Next.js Internationalization
Add multiple languages to Next.js with locale routing, translation dictionaries, and automatic locale detection.
TL;DR
- Put routes under
app/[lang]so every URL carries its locale. - Load translations from JSON dictionaries with a
getDictionary()helper. - Redirect visitors to their preferred locale from
proxy.ts.
Locale Routing
app/[lang]Nest every route under a dynamic segment so the URL includes the locale.
app/
[lang]/
layout.tsx
page.tsx // /en, /es
about/page.tsx // /en/aboutparams.langRead the current locale in a page by awaiting the route params.
export default async function Page(
props: PageProps<'/[lang]'>
) {
const { lang } = await props.params;
return <p>Current locale: {lang}</p>;
}generateStaticParams()List the supported locales so each one is prebuilt as a static page.
export async function generateStaticParams() {
return [{ lang: 'en' }, { lang: 'es' }];
}html langSet the document language from the route so browsers and crawlers can read it.
export default async function Layout(
props: LayoutProps<'/[lang]'>
) {
const { lang } = await props.params;
return (
<html lang={lang}>
<body>{props.children}</body>
</html>
);
}Translations
Dictionary FilesKeep the text for each language in its own JSON file.
// dictionaries/es.json
{
"nav": { "home": "Inicio" },
"cart": { "add": "Agregar al carrito" }
}getDictionary()Load only the dictionary for the requested locale, on the server.
import 'server-only';
const dictionaries = {
en: () =>
import('./en.json').then((m) => m.default),
es: () =>
import('./es.json').then((m) => m.default),
};
export const getDictionary = (lang: 'en' | 'es') =>
dictionaries[lang]();Use a TranslationFetch the dictionary in a Server Component and read keys from it.
const dict = await getDictionary(lang);
return <button>{dict.cart.add}</button>;Locale Detection
localesDefine the supported locales and a default in one shared place.
export const locales = ['en', 'es'];
export const defaultLocale = 'en';Accept-LanguagePick the visitor's preferred language from the request header.
function getLocale(request: NextRequest) {
const h = request.headers.get('accept-language');
const preferred = h?.split(',')[0].slice(0, 2);
return locales.includes(preferred ?? '')
? preferred
: defaultLocale;
}proxy.tsRedirect requests without a locale prefix to the detected locale.
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasLocale = locales.some((l) =>
pathname.startsWith(`/${l}`)
);
if (hasLocale) return;
const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}Formatting and Switching
Intl.DateTimeFormatFormat a date for the current locale without any extra library.
const date = new Intl.DateTimeFormat(lang, {
dateStyle: 'long',
}).format(new Date());Intl.NumberFormatFormat prices and numbers with the right separators and currency symbol.
const price = new Intl.NumberFormat(lang, {
style: 'currency',
currency: 'EUR',
}).format(19.99);Language SwitcherLink to the same page in another language by swapping the locale prefix.
import Link from 'next/link';
export function Switcher({ path }: { path: string }) {
return (
<nav>
<Link href={`/en${path}`}>English</Link>
<Link href={`/es${path}`}>Español</Link>
</nav>
);
}alternates.languagesTell search engines which URL serves each language version of a page.
export const metadata: Metadata = {
alternates: {
languages: {
en: '/en',
es: '/es',
},
},
};Tips
- Prebuild every language with
generateStaticParams(), so each locale is served as a fast static page. - Set the
langattribute on<html>from the route param, so screen readers and search engines know the language.
Warnings
- Importing every dictionary into a Client Component ships all languages to the browser, so load them on the server with
getDictionary(). - A locale redirect in
proxy.tsmust skip paths that already have a locale, or it will redirect in a loop.
In Practice
Render the home page in the language from the URL, with a formatted date.
- generateStaticParams prebuilds one copy of the page for each locale.
- The page awaits params to learn which language was requested.
- getDictionary loads only that language, so no extra text is shipped.
- Intl formats the date in the visitor's own style.
// app/[lang]/page.tsx
import { getDictionary } from './dictionaries';
export async function generateStaticParams() {
return [{ lang: 'en' }, { lang: 'es' }];
}
export default async function Page(
props: PageProps<'/[lang]'>
) {
const { lang } = await props.params;
const dict = await getDictionary(lang as 'en' | 'es');
const today = new Intl.DateTimeFormat(lang, {
dateStyle: 'long',
}).format(new Date());
return (
<main>
<h1>{dict.home.title}</h1>
<p>{today}</p>
</main>
);
}FAQ
Move your routes into app/[lang]. Store translations in one JSON file per language. Load the right file on the server and pass the text to your components.
Read the Accept-Language header in proxy.ts. Match it against your supported locales and redirect to the best one, such as /es.
Not for simple sites. Dictionaries and a [lang] route cover the basics. Libraries such as next-intl add plural rules, rich formatting, and typed message keys.
Use the built-in Intl APIs. Intl.DateTimeFormat and Intl.NumberFormat take a locale code and need no extra library.