DEV Community

Cover image for Custom Solution for Internationalization in Static Next.js App: A Practical Guide
Radzion Chachura
Radzion Chachura

Posted on • Originally published at radzion.com

Custom Solution for Internationalization in Static Next.js App: A Practical Guide

🐙 GitHub | 🎮 Demo

Developing Custom Internationalization in Static NextJS Applications

Today, we're tackling an exciting challenge: developing internationalization in a static NextJS application without external libraries. NextJS provides internationalized routing, but it relies on server-side rendering, requiring a server for the front-end. To host our app statically without a server, we must devise a custom solution. While there are effective React internationalization libraries, like react-i18next, for this project, I chose to explore creating this functionality independently. In this blog post, we'll explore the intricacies of this task. Our goal is to enable a static NextJS app to support multiple languages and offer users a seamless way to switch between them. Although the app in question is in a private repository, there's no need for concern. All essential code snippets are provided, and the ReactKit repository includes all reusable utilities and components we'll use.

Implementing Multilingual Support in a Georgian Citizenship Exam App

We're focusing on an app designed to assist individuals preparing for the Georgian citizenship exam. The final version is live at georgiancitizen.com. Unlike other projects I've worked on, where internalization often takes a backseat, it's critical here. The app's primary audience are Russian speakers likely to search for this information in Russian. Therefore, the app will support three languages: Georgian, English, and Russian. We'll generate each page in these languages, enhancing SEO as the site will appear in search results for non-English language queries. As mentioned, we're not using external libraries. Thus, we'll initially bypass complexities like pluralization. Language detection won't be automated; users will select their preferred language manually. However, since most users will likely arrive via Google search results, this shouldn't pose a significant issue.

Creating the useCopy Hook for Efficient Language Translation in NextJS

Approaching the translation challenge from a top-down perspective, the key component we need is a hook that supplies text in the correct language, irrespective of what that language might be. We'll name this hook useCopy, as it's responsible for providing the app's textual content. Here's how we'll implement it:

const copy = useCopy()

<Text height="large" color="contrast" as="h1" centered size={32}>
  {copy.homePageTitle}
</Text>

<MetaTags
  title={copy.categoryTestPageMetaTagTitle({
    category: copy[category],
  })}
  description={copy.categoryTestPageMetaTagDescription({
    category: copy[category],
  })}
/>
Enter fullscreen mode Exit fullscreen mode

The hook's design skillfully combines typed records, with each field being either a string or a function that returns a string with interpolated variables. This design provides developers with the benefits of autocomplete and type checking for their copy, significantly enhancing efficiency and code quality. Additionally, it generates a type error for any missing copy fields during the app's build process, ensuring robustness and completeness. The architecture also eliminates the need for manual searches for copy variables. Texts that require variables are derived from functions with typed arguments, thereby simplifying and streamlining the development workflow.

// This file is generated by app/copy/codegen/utils/generateCopyType.ts
export type Copy = {
  homePageMetaTagTitle: string
  homePageTitle: string
  getStarted: string
  createdBy: string
  language: string
  law: string
  history: string
  georgian: string
  restart: string
  testPageTitle: (variables: { category: string }) => string
  categoryPageTitle: (variables: { category: string }) => string
  startTest: string
  markAsLearned: string
  learned: string
  allTickets: string
  completedTickets: string
  testPassed: string
  testFailed: string
  testCongratulation: (variables: { percentage: string }) => string
  scoreToPass: (variables: { percentage: string }) => string
  completedTicketsTestMin: (variables: { count: string }) => string
  homePageMetaTagDescription: string
  categoryPageMetaTagTitle: (variables: { category: string }) => string
  categoryPageMetaTagDescription: (variables: { category: string }) => string
  categoryTestPageMetaTagTitle: (variables: { category: string }) => string
  categoryTestPageMetaTagDescription: (variables: {
    category: string
  }) => string
}
Enter fullscreen mode Exit fullscreen mode

Streamlining Multi-Language Support with syncCopy Script in NextJS

Maintaining a Copy type along with its implementations for three different languages can be quite labor-intensive. To streamline this process, we utilize code generation. Specifically, there's a syncCopy script located in the copy/codegen directory of our application. This script automates the translation of our English source of truth into other languages and generates the corresponding Copy implementations. For added convenience, we can incorporate a syncCopy command into our package.json file.

{
  "scripts": {
    "syncCopy": "npx tsx copy/codegen/syncCopy"
  }
}
Enter fullscreen mode Exit fullscreen mode

Each language is associated with a corresponding JSON file located in the copy/sources directory. In the syncCopy function, we initially retrieve the primary copy, which is in English, using the getCopySource function. This function reads and parses a file into JSON format. In cases where the file doesn't exist, it returns an empty object.

import { Language } from "@georgian/languages/Language"
import { attempt } from "@georgian/utils/attempt"
import fs from "fs"
import path from "path"

export const copySourceDirectory = path.resolve(__dirname, "../../sources")

export const getCopySourceFilePath = (language: Language) =>
  path.resolve(copySourceDirectory, `${language}.json`)

export const getCopySource = (language: Language) => {
  return attempt(
    () => JSON.parse(fs.readFileSync(getCopySourceFilePath(language), "utf-8")),
    {}
  )
}
Enter fullscreen mode Exit fullscreen mode

When I need to handle a scenario without explicitly processing an error, I prefer to use an attempt function. This function attempts to execute a given function and returns a specified fallback value if an error occurs:

export const attempt = <T,>(func: () => T, fallback: T): T => {
  try {
    return func()
  } catch (error) {
    return fallback
  }
}
Enter fullscreen mode Exit fullscreen mode

After acquiring our initial text, we move forward with creating translations for other languages. Our application's supported languages are listed in the Language file, located in the languages package of our monorepo. This file includes the languages in their native names and specifies the primary countries associated with each language. These details are particularly useful for the language switcher feature in our app.

import { CountryCode } from "@georgian/utils/countries"

export const languages = ["en", "ru", "ka"] as const
export type Language = (typeof languages)[number]

export const primaryLanguage: Language = "en" as const

export const languagePrimaryCountry: Record<Language, CountryCode> = {
  en: "GB",
  ru: "RU",
  ka: "GE",
}

export const languageNativeName: Record<Language, string> = {
  en: "English",
  ru: "Русский",
  ka: "ქართული",
}
Enter fullscreen mode Exit fullscreen mode

To iterate over every language except english that doesn't need translation we first remove it from the list using the without function which is a more comfortable version of the filter function.

export const without = <T>(items: readonly T[], ...itemsToRemove: T[]) =>
  items.filter((item) => !itemsToRemove.includes(item))
Enter fullscreen mode Exit fullscreen mode

To create a translation for a new language, we begin with an existing JSON file and identify any missing keys. This is done by comparing every key from the English version and subtracting those already present in the target language's file. Next, we extract the values associated with these missing keys and translate them using the translateTexts function.

import { translateTexts } from "@georgian/languages/utils/translateTexts"
import { makeRecord } from "@georgian/utils/makeRecord"
import { createJsonFile } from "@georgian/codegen/utils/createJsonFile"
import { generateCopy } from "./utils/generateCopy"
import { generateCopyType } from "./utils/generateCopyType"
import { languages, primaryLanguage } from "@georgian/languages/Language"
import { without } from "@georgian/utils/array/without"
import { copySourceDirectory, getCopySource } from "./utils/getCopySource"
import { generateGetCopy } from "./utils/generateGetCopy"

const syncCopy = async () => {
  const sourceCopy = getCopySource(primaryLanguage)

  await Promise.all(
    without(languages, primaryLanguage).map(async (targetLanguage) => {
      const copy = getCopySource(targetLanguage)
      const sourceKeys = Object.keys(sourceCopy)
      const targetKeys = Object.keys(copy)
      const missingKeys = without(sourceKeys, ...targetKeys)

      const textsToTranslate = missingKeys.map((key) => sourceCopy[key])
      const translations = await translateTexts({
        texts: textsToTranslate,
        from: primaryLanguage,
        to: targetLanguage,
      })

      const result = makeRecord(sourceKeys, (key) =>
        missingKeys.includes(key)
          ? translations[missingKeys.indexOf(key)]
          : copy[key]
      )

      createJsonFile({
        directory: copySourceDirectory,
        fileName: targetLanguage,
        content: JSON.stringify(result),
      })
    })
  )

  await Promise.all([
    generateCopyType(sourceCopy),
    ...languages.map(generateCopy),
    generateGetCopy(),
  ])
}

syncCopy()
Enter fullscreen mode Exit fullscreen mode

Enhancing Language Translation Accuracy in NextJS with Google Translate API

The translateTexts function resides within the languages package. You have the flexibility to choose any translation service. Initially, I attempted using the ChatGPT API. Although it is generally superior for translating popular languages, I encountered subpar results when translating from Georgian to English. Consequently, I switched to the Google Translate API for better accuracy.

import { Language } from "@georgian/languages/Language"
import { toBatches } from "@georgian/utils/array/toBatches"
import { TranslationServiceClient } from "@google-cloud/translate"
import { getEnvVar } from "../getEnvVar"
import { extractTemplateVariables } from "@georgian/utils/template/extractTemplateVariables"
import { withoutDuplicates } from "@georgian/utils/array/withoutDuplicates"
import { injectVariables } from "@georgian/utils/template/injectVariables"
import { makeRecord } from "@georgian/utils/makeRecord"
import { toTemplateVariable } from "@georgian/utils/template/toTemplateVariable"

const batchSize = 600

interface TranslateTextsParams {
  texts: string[]
  from: Language
  to: Language
}

export const translateTexts = async ({
  texts,
  from,
  to,
}: TranslateTextsParams): Promise<string[]> => {
  if (texts.length === 0) {
    return []
  }

  const variables = withoutDuplicates(
    texts.map(extractTemplateVariables).flat()
  )

  const translationClient = new TranslationServiceClient()

  const batches = toBatches(texts, batchSize)

  const result = []
  for (const batch of batches) {
    const contents = batch.map((text) =>
      injectVariables(
        text,
        makeRecord(extractTemplateVariables(text), (text) =>
          toTemplateVariable(`var_${variables.indexOf(text)}`)
        )
      )
    )

    const request = {
      parent: `projects/${getEnvVar(
        "GOOGLE_TRANSLATE_PROJECT_ID"
      )}/locations/global`,
      contents,
      mimeType: "text/plain",
      sourceLanguageCode: from,
      targetLanguageCode: to,
    }

    const [{ translations }] = await translationClient.translateText(request)
    if (!translations) {
      throw new Error("No translations")
    }

    result.push(
      ...translations.map((translation) => {
        const { translatedText } = translation
        if (!translatedText) {
          throw new Error("No translatedText")
        }

        return injectVariables(
          translatedText,
          makeRecord(extractTemplateVariables(translatedText), (variable) =>
            toTemplateVariable(variables[Number(variable.split("_")[1])])
          )
        )
      })
    )
  }

  return result
}
Enter fullscreen mode Exit fullscreen mode

The function itself is straightforward, yet handling template variables requires additional steps. The challenge arises when the API inadvertently translates variables like "category" into other languages. To prevent this, we temporarily rename variables to "var_0", "var_1", etc., before translation and revert them to their original names afterward. The extractTemplateVariables function, utilizing a regular expression, identifies all variables within a string. Subsequently, the withoutDuplicates function eliminates any duplicates, and the .slice method removes the curly braces.

import { withoutDuplicates } from "../array/withoutDuplicates"

export const extractTemplateVariables = (str: string): string[] => {
  const variableRegex = /\{\{(\w+)\}\}/g
  const matches = str.match(variableRegex)
  if (!matches) return []

  return withoutDuplicates(matches.map((match) => match.slice(2, -2)))
}
Enter fullscreen mode Exit fullscreen mode

The injectVariables function plays a crucial role in reintegrating variables into the string. This function takes a string and a record of variable-value pairs. It employs a regular expression to methodically replace each placeholder variable with its respective value.

export const injectVariables = (
  template: string,
  variables: Record<string, string>
): string => {
  return template.replace(/\{\{(\w+)\}\}/g, (match, variableName) => {
    if (variableName in variables) {
      return variables[variableName]
    }
    return match
  })
}
Enter fullscreen mode Exit fullscreen mode

Translation of texts in batches is necessary, and I selected 600 as the batch size based on its proximity to the API's limit during testing. The toBatches function divides an array into smaller segments, each containing a specified number of elements.

import { range } from "./range"

export const toBatches = <T>(array: T[], batchSize: number): T[][] => {
  const batchesCount = Math.ceil(array.length / batchSize)

  return range(batchesCount).map((batchIndex) => {
    const startIndex = batchIndex * batchSize
    const endIndex = startIndex + batchSize
    return array.slice(startIndex, endIndex)
  })
}
Enter fullscreen mode Exit fullscreen mode

Setting up the Google Translate API can be somewhat cumbersome. It requires downloading a configuration JSON file and setting the GOOGLE_APPLICATION_CREDENTIALS environment variable with its file path. Additionally, the GOOGLE_TRANSLATE_PROJECT_ID is necessary, which is retrieved using the getEnvVar function. This function ensures typed access to environment variables and throws an error if they are not correctly set.

type VariableName = "GOOGLE_TRANSLATE_PROJECT_ID"

export const getEnvVar = (name: VariableName): string => {
  const value = process.env[name]
  if (!value) {
    throw new Error(`Missing ${name} environment variable`)
  }

  return value
}
Enter fullscreen mode Exit fullscreen mode

Automating JSON File Generation for Multi-Language Support in NextJS

Once the translation of the missing content is complete, we can proceed to assemble the final JSON file. For transforming a list of keys into an object, I prefer using a custom makeRecord function. This approach offers a more user-friendly alternative to the standard reduce function. It takes an array of keys and a function that assigns a value to each key.

export const makeRecord = <T extends string, V>(
  keys: T[],
  getValue: (key: T) => V
) => {
  const record: Record<T, V> = {} as Record<T, V>

  keys.forEach((key) => {
    record[key] = getValue(key)
  })

  return record
}
Enter fullscreen mode Exit fullscreen mode

To create and save a JSON file, I utilize the createJsonFile helper function from our monorepo's codegen package. This function employs formatCode for code formatting, followed by createFile for writing the formatted code to the file system.

import { formatCode } from "./formatCode"
import { createFile } from "./createFile"

interface CreateJsonFileParams {
  directory: string
  fileName: string
  content: string
}

export const createJsonFile = async ({
  directory,
  fileName,
  content,
}: CreateJsonFileParams) => {
  const extension = "json"

  const code = await formatCode({
    extension,
    content,
  })

  createFile({
    directory,
    fileName,
    content: code,
    extension,
  })
}
Enter fullscreen mode Exit fullscreen mode

To format our code, we use Prettier. We first obtain the configuration from the monorepo's root and then apply the format function, selecting the appropriate parser based on the file extension.

import { match } from "@georgian/utils/match"
import path from "path"
import { format, resolveConfig } from "prettier"

interface FormatCodeParams {
  extension: "ts" | "tsx" | "json"
  content: string
}

export const formatCode = async ({ extension, content }: FormatCodeParams) => {
  const configPath = path.resolve(__dirname, "../../.prettierrc")

  const config = await resolveConfig(configPath)

  return format(content, {
    ...config,
    parser: match(extension, {
      ts: () => "typescript",
      tsx: () => "typescript",
      json: () => "json",
    }),
  })
}
Enter fullscreen mode Exit fullscreen mode

The match function serves as a functional alternative to the traditional switch statement. It accepts a value and a record of handlers, where each handler corresponds to a specific value. The function then executes and returns the result from the handler matching the provided value.

export function match<T extends string | number | symbol, V>(
  value: T,
  handlers: { [key in T]: () => V }
): V {
  const handler = handlers[value]

  return handler()
}
Enter fullscreen mode Exit fullscreen mode

Generating TypeScript Types for Multi-Language Copy in NextJS

Once we have an up-to-date JSON copy for each language, we can proceed with creating a robust copy implementation in TypeScript. Let's start by creating a Copy type using the generateCopyType function.

import { createTsFile } from "@georgian/codegen/utils/createTsFile"
import { makeRecord } from "@georgian/utils/makeRecord"
import path from "path"
import { toRecordTypeBody } from "@georgian/codegen/utils/ts/toRecordTypeBody"
import { extractTemplateVariables } from "@georgian/utils/template/extractTemplateVariables"

export const generateCopyType = async (copy: Record<string, string>) => {
  const type = toRecordTypeBody(
    makeRecord(Object.keys(copy), (key) => {
      const value = copy[key]
      const variables = extractTemplateVariables(value)
      if (variables.length === 0) {
        return "string"
      }

      return `(variables: {${variables
        .map((variable) => `${variable}: string`)
        .join(", ")}}) => string`
    })
  )
  const content = `export type Copy = ${type}`

  return createTsFile({
    fileName: "Copy",
    directory: path.resolve(__dirname, "../../"),
    content,
    generatedBy: "app/copy/codegen/utils/generateCopyType.ts",
  })
}
Enter fullscreen mode Exit fullscreen mode

This function takes a record of copy and generates a type with the appropriate fields. The toRecordTypeBody function is responsible for converting a record into a type body, by iterating over each key-value pair, converting it into a string, and wrapping it in curly braces.

export const toRecordTypeBody = (record: Record<string, string>) =>
  `{
    ${Object.entries(record)
      .map(([key, value]) => `${key}: ${value}`)
      .join(",\n")}
  }`
Enter fullscreen mode Exit fullscreen mode

To determine whether a variable is of string or function type, we first extract the variable and then check if it is empty. If the variable is empty, we return a string type. If it is not, we return a function type, listing all required variables as arguments. Ultimately, we invoke the createTsFile function to generate a file with the specified type.

With the Copy type established, we can iterate over each language and implement the copy for every one of them. During this process, we utilize the toRecordTypeBody function to transform the record into a type body. Additionally, we import the injectVariables function from the @georgian/utils/template/injectVariables package, which is essential for handling template variables.

import { createTsFile } from "@georgian/codegen/utils/createTsFile"
import path from "path"
import { toRecordTypeBody } from "@georgian/codegen/utils/ts/toRecordTypeBody"
import { makeRecord } from "@georgian/utils/makeRecord"
import { extractTemplateVariables } from "@georgian/utils/template/extractTemplateVariables"
import { Language } from "@georgian/languages/Language"
import { getCopySource } from "./getCopySource"

export const generateCopy = (language: Language) => {
  const copy = getCopySource(language)

  const copyCode = toRecordTypeBody(
    makeRecord(Object.keys(copy), (key) => {
      const value = copy[key]
      const variables = extractTemplateVariables(value)
      if (variables.length === 0) {
        return `\`${value}\``
      }

      return `(variables: {${variables
        .map((variable) => `${variable}: string`)
        .join(", ")}}) => injectVariables(\`${value}\`, variables)`
    })
  )

  const content = [
    `import { Copy } from './Copy'`,
    `import { injectVariables } from '@georgian/utils/template/injectVariables'`,

    `const ${language}Copy: Copy = ${copyCode}`,
    `export default ${language}Copy`,
  ].join("\n\n")

  return createTsFile({
    fileName: language,
    directory: path.resolve(__dirname, "../../"),
    content,
    generatedBy: "app/copy/codegen/utils/generateCopy.ts",
  })
}
Enter fullscreen mode Exit fullscreen mode

When creating a TypeScript file, we include a comment at the top with the generatedBy field. This indicates that the file is auto-generated and should not be manually modified.

import { formatCode } from "./formatCode"
import { createFile } from "./createFile"

interface CreateTsFileParams {
  extension?: "ts" | "tsx"
  directory: string
  fileName: string
  generatedBy: string
  content: string
}

export const createTsFile = async ({
  extension = "ts",
  directory,
  fileName,
  generatedBy,
  content,
}: CreateTsFileParams) => {
  const code = await formatCode({
    content: [`// This file is generated by ${generatedBy}`, content].join(
      "\n"
    ),
    extension,
  })

  createFile({
    directory,
    fileName,
    content: code,
    extension,
  })
}
Enter fullscreen mode Exit fullscreen mode

The final piece of code we need to generate is the getCopy function, tasked with returning the relevant copy according to the specified language. Initially, we import all copies, then create a record of copies for each language, and finally, we export our helper function. To update the content, alterations are made in one of the JSON files, followed by executing the syncCopy command to generate all necessary code.

import { createTsFile } from "@georgian/codegen/utils/createTsFile"
import path from "path"
import { languages } from "@georgian/languages/Language"

export const generateGetCopy = async () => {
  const imports = [
    `import { Language } from '@georgian/languages/Language'`,
    ...languages.map((language) => `import ${language} from './${language}'`),
  ].join("\n")

  const copyRecord = `const copy = {${languages.join(", ")}} as const`

  const getCopy = `export const getCopy = (language: Language) => copy[language]`

  return createTsFile({
    fileName: "getCopy",
    directory: path.resolve(__dirname, "../../"),
    content: [imports, copyRecord, getCopy].join("\n\n"),
    generatedBy: "app/copy/codegen/utils/generateGetCopy.ts",
  })
}
Enter fullscreen mode Exit fullscreen mode

Integrating useCopy Hook with React Context for Language Management in NextJS

To connect the useCopy hook with the generated copy, we'll utilize a standard React Context, setting the Copy type as its value. For straightforward providers managing a single value, I employ the getValueProviderSetup function from ReactKit. This function facilitates the creation of both a provider and a corresponding hook for accessing the value.

import { getValueProviderSetup } from "@georgian/ui/state/getValueProviderSetup"
import { Copy } from "./Copy"

export const { useValue: useCopy, provider: CopyProvider } =
  getValueProviderSetup<Copy>("Copy")
Enter fullscreen mode Exit fullscreen mode

The process simply involves creating a Context, defining Props for the provider—which should include children and the value—and then returning a hook to access the value.

import { createContext } from "react"

import { createContextHook } from "./createContextHook"
import { ComponentWithChildrenProps } from "../props"
import { capitalizeFirstLetter } from "@georgian/utils/capitalizeFirstLetter"

export function getValueProviderSetup<T>(name: string) {
  const ValueContext = createContext<T | undefined>(undefined)

  type Props = ComponentWithChildrenProps & { value: T }

  const ValueProvider = ({ children, value }: Props) => {
    return (
      <ValueContext.Provider value={value}>{children}</ValueContext.Provider>
    )
  }

  return {
    provider: ValueProvider,
    useValue: createContextHook(
      ValueContext,
      `${capitalizeFirstLetter(name)}Context`
    ),
  }
}
Enter fullscreen mode Exit fullscreen mode

The createContextHook function is tasked with generating a hook for accessing the value from the context. It takes two arguments: the context itself and its name. The returned hook throws an error in cases where the context is not supplied.

import { Context as ReactContext, useContext } from "react"

export function createContextHook<T>(
  Context: ReactContext<T | undefined>,
  contextName: string
) {
  return () => {
    const context = useContext(Context)

    if (!context) {
      throw new Error(`${contextName} is not provided`)
    }

    return context
  }
}
Enter fullscreen mode Exit fullscreen mode

To facilitate server-side rendering of static pages with the correct language, each page should be wrapped with the CopyProvider, where the corresponding copy is passed as a value. We'll begin with the root page, a simpler case where the language isn't indicated in the pathname. In this instance, we default to the primary language of the application, which is English.

import { LandingPage } from "landing/LandingPage"
import Head from "next/head"
import { primaryLanguage } from "@georgian/languages/Language"
import { PageContainer } from "components/PageContainer"

export default () => (
  <PageContainer language={primaryLanguage}>
    <Head>
      <link
        rel="canonical"
        href={`${process.env.NEXT_PUBLIC_BASE_URL}/${primaryLanguage}`}
      />
    </Head>
    <LandingPage />
  </PageContainer>
)
Enter fullscreen mode Exit fullscreen mode

Leveraging PageContainer for Language Selection in NextJS Static Sites

To minimize code duplication, our application utilizes a PageContainer component. This component encompasses providers and a layout common to all pages.

import { LanguageProvider } from "@georgian/languages-ui/components/LanguageProvider"
import { PageMetaTags } from "@georgian/ui/metadata/PageMetaTags"
import { ComponentWithChildrenProps } from "@georgian/ui/props"
import { CopyProvider } from "copy/CopyProvider"
import { LocalizedPageProps } from "copy/LocalizedPageProps"
import { getCopy } from "copy/getCopy"
import { WebsiteLayout } from "layout/components/WebsiteLayout"

interface PageContainerProps
  extends LocalizedPageProps,
    ComponentWithChildrenProps {}

export const PageContainer = ({ children, language }: PageContainerProps) => (
  <LanguageProvider value={language}>
    <PageMetaTags language={language} />
    <CopyProvider value={getCopy(language)}>
      <WebsiteLayout>{children}</WebsiteLayout>
    </CopyProvider>
  </LanguageProvider>
)
Enter fullscreen mode Exit fullscreen mode

Although modifying the html's lang attribute on individual pages using NextJS's Head component in Static Site Generation (SSG) apps isn't possible, we ensure to specify the "Content-Language" meta tags. For more insights on meta tags in NextJS, refer to my other blog post.

import Head from "next/head"

interface PageMetaTags {
  title?: string
  description?: string
  image?: string
  language?: string
}

export const PageMetaTags = ({
  title,
  description,
  image,
  language,
}: PageMetaTags) => (
  <Head>
    {title && (
      <>
        <title>{title}</title>
        <meta name="application-name" content={title} />
        <meta name="apple-mobile-web-app-title" content={title} />
        <meta property="og:title" content={title} />
        <meta name="twitter:title" content={title} />
      </>
    )}

    {description && (
      <>
        <meta name="description" content={description} />
        <meta property="og:description" content={description} />
        <meta name="twitter:description" content={description} />
        <meta property="og:image:alt" content={description} />
        <meta name="twitter:image:alt" content={description} />
      </>
    )}

    {image && (
      <>
        <meta property="og:image" content={image} />
        <meta name="twitter:image:src" content={image} />
      </>
    )}

    {language && <meta httpEquiv="Content-Language" content={language} />}
  </Head>
)
Enter fullscreen mode Exit fullscreen mode

We select the copy corresponding to English and supply it to the CopyProvider. The LanguageProvider is responsible for monitoring the current language, which in our setup, is primarily used for showcasing the selected language in the language selector. Owing to the existence of individual pages for each language, there are no language state alterations. Instead, users are redirected to the relevant page specific to their selected language.

import { Language } from "@georgian/languages/Language"
import { getValueProviderSetup } from "@georgian/ui/state/getValueProviderSetup"
import { useRouter } from "next/router"
import { updateLanguageInPathname } from "../utils/updateLanguageInPathname"

const { useValue: useLanguageValue, provider: LanguageProvider } =
  getValueProviderSetup<Language>("Copy")

export { LanguageProvider }

export const useLanguage = () => {
  const language = useLanguageValue()

  const { asPath, push } = useRouter()

  const setLanguage = (newLanguage: Language) => {
    const newPathname = updateLanguageInPathname({
      pathname: asPath,
      newLanguage,
      oldLanguage: language,
    })

    push(newPathname)
  }

  return [language, setLanguage] as const
}
Enter fullscreen mode Exit fullscreen mode

The useLanguage hook demonstrates this concept. It retrieves the value from the LanguageProvider and returns it, along with a setLanguage function. This function is tasked with updating the pathname to reflect the new language and invoking the push function from NextJS's useRouter hook.

import { Language } from "@georgian/languages/Language"

interface UpdateLanguageInPathnameParams {
  pathname: string
  oldLanguage: Language
  newLanguage: Language
}

export const updateLanguageInPathname = ({
  pathname,
  oldLanguage,
  newLanguage,
}: UpdateLanguageInPathnameParams) => {
  const parths = pathname.split("/")

  if (parths[1] === oldLanguage) {
    parths[1] = newLanguage
  } else {
    parths.splice(1, 0, newLanguage)
  }

  return parths.join("/")
}
Enter fullscreen mode Exit fullscreen mode

To update the language in the pathname, we first split it using /. Then, we replace the element in the second position (which represents the language) with the new language. In cases where the language isn't already present in the pathname, we modify the pathname to start with the new language.

Implementing Language-Specific Routing and SEO in NextJS Static Sites

Given that the root page is identical to the /en page, we can repurpose the same component. However, we must include a canonical link on the root page directing to the /en page. In our scenario, NEXT_PUBLIC_BASE_URL is set to https://georgiancitizen.com. This step is crucial for avoiding duplicate content issues with search engines. It's important to note a limitation of our approach: the inability to reuse the layout component across pages. This is due to the necessity of having the copy in the navigation, which leads to re-rendering when navigating between pages.

import { Language } from "@georgian/languages/Language"

export interface LocalizedPageProps {
  language: Language
}
Enter fullscreen mode Exit fullscreen mode

Now, let's examine the same home page, but under the /[language] route. In this scenario, the page is provided with LocalizedPageProps.

import { LandingPage } from "landing/LandingPage"
import { GetStaticPaths, GetStaticProps } from "next"
import { Params } from "next/dist/shared/lib/router/utils/route-matcher"
import { languages } from "@georgian/languages/Language"
import { LocalizedPageProps } from "copy/LocalizedPageProps"
import { PageContainer } from "components/PageContainer"

export default ({ language }: LocalizedPageProps) => (
  <PageContainer language={language}>
    <LandingPage />
  </PageContainer>
)

export const getStaticPaths: GetStaticPaths<Params> = async () => {
  return {
    paths: languages.map((language) => ({
      params: { language },
    })),
    fallback: false,
  }
}

export const getStaticProps: GetStaticProps<
  LocalizedPageProps,
  Params
> = async ({ params }) => {
  if (!params) {
    return {
      notFound: true,
    }
  }

  return {
    props: {
      language: params.language,
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

In the getStaticPaths function, it's necessary to generate paths for every language. On the other hand, the getStaticProps function is responsible for returning the specified language in the props. It's important to note that this implementation is required for every page in our application.

Additionally, when navigating between pages, the language must be included in the pathname. So we can implement wrappers to abstract away the need to manually handle language in links. For instance, consider a custom Link component that retrieves the language from the context and appends it to the pathname provided in the props.

import NextLink from "next/link"
import { ComponentProps } from "react"
import { useLanguage } from "./LanguageProvider"

export const Link = ({ href, ...props }: ComponentProps<typeof NextLink>) => {
  const [language] = useLanguage()

  const path = `/${language}${href}`

  return <NextLink {...props} href={path} />
}
Enter fullscreen mode Exit fullscreen mode

Finally, let's examine the LanguageSelector, located in the languages-ui package. It's displayed in the topbar navigation of every page, represented by a country flag and a two-letter language abbreviation. Upon clicking, a popover menu appears, offering options for each language, accompanied by their respective country flags. For those interested in the country flag component, I recommend reading this blog post. Selecting a language triggers a redirection to the corresponding page.

import { Menu } from "@georgian/ui/Menu"
import { useLanguage } from "./LanguageProvider"
import styled from "styled-components"
import { IconWrapper } from "@georgian/ui/icons/IconWrapper"
import { HStack } from "@georgian/ui/layout/Stack"
import { MenuOptionProps, MenuOption } from "@georgian/ui/menu/MenuOption"
import {
  languageNativeName,
  languagePrimaryCountry,
  languages,
} from "@georgian/languages/Language"
import CountryFlag from "@georgian/ui/countries/flags/CountryFlag"
import { Text } from "@georgian/ui/text"
import { Button } from "@georgian/ui/buttons/Button"

const FlagWrapper = styled(IconWrapper)`
  border-radius: 2px;
  font-size: 18px;
`

export const LanguageSelector = () => {
  const [language, setLanguage] = useLanguage()

  return (
    <Menu
      title="Select language"
      renderOpener={({ ref, ...props }) => (
        <div ref={ref} {...props}>
          <Button size="s" kind="ghost">
            <HStack alignItems="center" gap={8}>
              <FlagWrapper>
                <CountryFlag code={languagePrimaryCountry[language]} />
              </FlagWrapper>
              <Text size={12} weight="semibold" height="small">
                {language.toUpperCase()}
              </Text>
            </HStack>
          </Button>
        </div>
      )}
      renderContent={({ view, onClose }) => {
        const options: MenuOptionProps[] = languages.map((option) => ({
          icon: (
            <FlagWrapper>
              <CountryFlag code={languagePrimaryCountry[option]} />
            </FlagWrapper>
          ),
          text: languageNativeName[option],
          onSelect: () => {
            if (language !== option) {
              setLanguage(option)
            }
            onClose()
          },
        }))

        return options.map((props, index) => (
          <MenuOption view={view} key={index} {...props} />
        ))
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)