Internationalization
Set up multilingual support in Next.js using sub-path routing and localized content with a clean, type-safe approach.
Next.js supports multiple languages through content localization (i18n) and internationalized routing. We'll explore how to set it up using the sub-path approach. That is, we prepend the language to every route (e.g., /en/products).
Walkthrough
-
Install the dependencies:
pnpm add negotiator @formatjs/intl-localematcher -
Install the development-only dependencies:
pnpm add -D @types/negotiator -
Create a
config.tsas the source of truth for you supported localesi18n/config.ts export const i18n = { defaultLocale: "en", locales: ["en", "da"], } as const; export type Locale = (typeof i18n.locales)[number]; -
Now we can create a function to check the if the incoming request has a supported locale in the URL
i18n/verify-locale.ts import "server-only"; import type { NextRequest } from "next/server"; import { match } from "@formatjs/intl-localematcher"; import Negotiator from "negotiator"; import { i18n } from "@/i18n/config.ts"; const getLocale = (request: NextRequest) => { const languages = new Negotiator({ headers: { "accept-language": request.headers.get("accept-language") ?? "", }, }).languages(); return match(languages, i18n.locales, i18n.defaultLocale); }; const isLocaleSupported = (input: string) => { if (input.length !== 2) return false; const isNotSupported = i18n.locales.every((locale) => locale !== input); return !isNotSupported; }; export const verifyLocale = (request: NextRequest) => { const { pathname } = request.nextUrl; const [firstSegment, ...segments] = pathname.split("/").toSpliced(0, 1); return isLocaleSupported(firstSegment) ? { needsRedirect: false, redirectPath: "" } : { needsRedirect: true, redirectPath: `/${getLocale(request)}/${firstSegment}/${segments.join("/")}`, }; }; -
You will then use the function inside the
middleware.tsfile to check every URL for the locale:(root) middleware.ts import { NextRequest, NextResponse } from "next/server"; import { verifyLocale } from "@/i18n/verify-locale.ts"; export const middleware = async (request: NextRequest) => { const { needsRedirect, redirectPath } = verifyLocale(request); if (needsRedirect) { request.nextUrl.pathname = redirectPath; return NextResponse.redirect(request.nextUrl); } }; export const config = { matcher: [ "/((?!api|_next).*)", // Optional: only run on root (/) URL // "/", ], }; -
Move all the routes from
app/toapp/[lang]. -
For the localization, you need to create an English dictionary:
i18n/dictionaries/en.json { "products": { "cart": "Add to Cart" } } -
And mirror it across the other supported languages (Danish in this case):
i18n/dictionaries/da.json { "products": { "cart": "Tilføj til kurv" } } -
Finally, create a function to load the translations for the requested locale:
i18n/get-dictionary.ts import "server-only"; import type { Locale } from "@/i18n/config.ts"; type Dictionary = { landing: { hello: string } }; const dictionaries: { [key in Locale]: () => Promise<Dictionary> } = { en: () => import("@/i18n/dictionaries/en.json").then((module) => module.default), da: () => import("@/i18n/dictionaries/da.json").then((module) => module.default), }; export const getDictionary = async (locale: Locale) => dictionaries[locale]() ?? dictionaries.en(); -
Starting now, you can use the
getDictionaryfunction (preferably inside server components):app/[lang]/page.tsx import { getDictionary } from "@/i18n/get-dictionary.ts"; import { type Locale } from "@/i18n/config.ts"; export default async function Home({ params, }: { params: Promise<{ lang: Locale; }>; }) { const locale = (await params).lang; const { marketing } = await getDictionary(locale); return ( <main> <h1>{marketing.title}</h1> ... </main> ); }