Internationalizing (i18n) a Next.js 13 app with React Server Components

Niklas Klein
5 min readFeb 3, 2023

--

With the introduction of Next.js 13’s app/ directory (as opposed to pages/), internationalization is no longer a first-class citizen of the popular framework. Therefore, it is not immediately obvious how to implement i18n support, so let’s walk through it step by step.

The goal of this tutorial is not to replicate the behavior of Next.js with the pages/ directory, which forced a URL segment-based approach for i18n. Instead, we will work on these requirements:

  • The page is always displayed in the user’s preferred language, initially suggested by the Accept-Language header value
  • The user can explicitly override this behavior by selecting a different language, which is then stored in a cookie
  • A page can be loaded in a specific language by appending a ?language=xx query parameter to the URL
  • The ?language query parameter is removed from the URL in a client component, to keep the URL clean and sharable for real users, while the page remains static and accessible for crawlers
  • We are doubling down on React Server Components, if you rely heavily on Client Components (use client), this approach will not work for you, yet, it should be possible to pass translations as props to Client Components

We will keep it simple and won’t use an i18n library like i18next. While generally possible, the design of the library does not do well with Server Components. We’ll take a look at how we can do better in a follow-up post.

Setting up the project

Let’s keep this section short and sweet and set up a simple Next.js project with all the necessary dependencies.

> npx create-next-app@latest --experimental-app
...
> npm install accept-language-parser
...
> npm install --save-dev @types/accept-language-parser

Preparing translations

Now, we create a translations/ folder in our project root, where we put our i18n files and define some utilities.

translations/en.json

{
"title": "What's your name?",
"greeting": "Nice to meet you,",
"placeholder": "Donald Duck",
"languageSwitcher": {
"code": "de",
"label": "Auf Deutsch"
}
}

translations/de.json

{
"title": "Wie heißt du?",
"greeting": "Schön, Dich kennenzulernen,",
"placeholder": "Donald Duck",
"languageSwitcher": {
"code": "en",
"label": "In English"
}
}

Resolving the preferred language

We continue by defining some utilities in translations/index.ts that help us identify the visitor’s preferred language. The strategy being:

  1. A ?language=xx query parameter has the highest priority. Unfortunately, we currently have to get this information from the props of all page.tsx files that want to use i18n.
  2. If no query parameter is set, we check for a language cookie
  3. If no language cookie is set, we check the Accept-Language HTTP header
  4. If all of the above fails or contains an invalid language setting, we fall back to a default language
import { parse } from "accept-language-parser"
import { cookies, headers } from "next/headers"
import de from "./de.json"
import en from "./en.json"

export const fallbackLanguage = "en"
export const supportedLanguages = ["de", fallbackLanguage] as const
export type SupportedLanguages = typeof supportedLanguages[number]

export type Translations = typeof en
export const translations: Record<SupportedLanguages, Translations> = {
en,
de,
}

export const preferredRequestLanguage = (
languageQuery: string | null
): SupportedLanguages => {
// Start by checking the `?language=xx` query parameter, that takes precedence
if (languageQuery !== null)
return (
supportedLanguages.find((language) => language === languageQuery) ||
fallbackLanguage
)

// Next, check for a `language` cookie
const languageCookie = cookies().get("language")

if (languageCookie !== undefined)
return (
supportedLanguages.find(
(language) => language === languageCookie.value
) || fallbackLanguage
)

// No luck so far? Continue by checking the `Accept-Language` header
const acceptLanguageHeader = headers().get("Accept-Language")

if (acceptLanguageHeader === null) return fallbackLanguage

const acceptedLanguages = parse(acceptLanguageHeader)
for (const acceptedLanguage of acceptedLanguages) {
const preferredLanguage = supportedLanguages.find(
(language) => language === acceptedLanguage.code
)
if (preferredLanguage !== undefined) return preferredLanguage
}

return fallbackLanguage
}

export const preferredTranslations = (
languageQuery: string | null
): Translations => translations[preferredRequestLanguage(languageQuery)]

Cleaning up the URL

That’s already enough to get going, but in order to keep the URL clean for our visitors, we’ll first continue by define a headless Client Component that removes the ?language query parameter. This has the advantage, that the user can share the URL, and the visitors to that URL have the page displayed in their preferred language.

We do so by creating a new component in components/I18nUrlManager.tsx.

"use client"

import { useRouter, useSearchParams } from "next/navigation"
import { useEffect } from "react"

export const I18nUrlManager: React.FC = () => {
const router = useRouter()
const searchParams = useSearchParams()

useEffect(() => {
const language = searchParams.get("language")

if (language !== null && navigator.cookieEnabled) {
const url = new URL(window.location.href)
url.searchParams.delete("language")
const target = `${url.pathname}${url.search}${url.hash}`
document.cookie = `language=${language}; max-age=${365 * 24 * 60 * 60}`
router.replace(target)
}
}, [router, searchParams])

return <></>
}

And mount it in app/layout.tsx as show below.

import { ReactNode } from "react"
import { I18nUrlManager } from "../components/I18nUrlManager"

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<>
<html lang="en">
<head />
<body>
<I18nUrlManager />
{children}
</body>
</html>
</>
)
}

Putting it all together

Now we are ready to build the UI as shown in the video above. We start with a Client Component that receives translations as props in app/Form.tsx.

"use client"

import React, { useState } from "react"

export const Form: React.FC<{
placeholder: string
greeting: string
}> = ({ placeholder, greeting }) => {
const [name, setName] = useState("")

return (
<>
<input
onChange={(event) => setName(event.currentTarget.value)}
placeholder={placeholder}
/>
{name && (
<p>
{greeting} {name}
</p>
)}
</>
)
}

And finally we bring life to our app/page.tsx file where we extract the ?language query parameter and resolve the user’s preferred language.

import { preferredTranslations } from "@/translations"
import Link from "next/link"
import { Form } from "./Form"

export default async function RootPage({
searchParams,
}: {
searchParams?: { language?: string }
}) {
const i18n = preferredTranslations(searchParams?.language || null)

return (
<>
<h1>{i18n.title}</h1>
<Form placeholder={i18n.placeholder} greeting={i18n.greeting} />
<Link
href={`/?language=${i18n.languageSwitcher.code}`}
style={{ display: "block", marginTop: "24px" }}
>
{i18n.languageSwitcher.label}
</Link>
</>
)
}

Final remarks

And that’s really all we need for a basic i18n setup. You can now run and test the application via npm run dev or download the full source code of this tutorial from taig/nextjs-i18n-simple.

In the next article, we’ll take a look at available i18n libraries and learn how to integrate them with Next.js 13 Server Components.

--

--