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],
})}
/>
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
}
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"
}
}
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")),
{}
)
}
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
}
}
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: "แฅแแ แแฃแแ",
}
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))
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()
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
}
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)))
}
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
})
}
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)
})
}
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
}
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
}
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,
})
}
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",
}),
})
}
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()
}
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",
})
}
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")}
}`
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",
})
}
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,
})
}
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",
})
}
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")
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`
),
}
}
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
}
}
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>
)
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>
)
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>
)
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
}
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("/")
}
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
}
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,
},
}
}
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} />
}
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} />
))
}}
/>
)
}
Top comments (0)