Internationalizing (i18n) a Next.js 13 app with React Server Components
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:
- A
?language=xx
query parameter has the highest priority. Unfortunately, we currently have to get this information from the props of allpage.tsx
files that want to use i18n. - If no query parameter is set, we check for a
language
cookie - If no language cookie is set, we check the
Accept-Language
HTTP header - 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.