How to Translate a Next.js App: A Complete i18n Guide
Routing, message loading and translation workflow for Next.js App Router with next-intl – including how to keep locale files in sync as the app grows.
Next.js removed its built-in i18n routing when the App Router landed, so localisation is now something you assemble yourself out of a library, a middleware and a dynamic [locale] segment. This guide walks the whole path: choosing a library, setting up routing and middleware, keeping pages statically rendered, formatting numbers and plurals, emitting correct hreflang tags, and – the part most tutorials skip – keeping your message files in sync once several people are adding strings every week.
Code here targets Next.js 15/16 with the App Router and next-intl 4.x. If you are on the Pages Router, the built-in i18n routing still works and most of this article does not apply.
Choosing a library: next-intl vs react-i18next
Both are good. They optimise for different things.
| Concern | next-intl | react-i18next |
|---|---|---|
| App Router model | Built around Server Components; async message loading per request | Works, but needs extra glue for RSC |
| Message syntax | ICU MessageFormat | i18next interpolation, ICU via a plugin |
| Routing | Ships locale middleware + navigation helpers | Bring your own |
| Ecosystem | Focused on Next.js | Huge: React Native, Vue, plain JS, backends, many plugins |
| Runtime detection | Middleware-based | Rich detector plugin chain |
Pick next-intl if this is a Next.js App Router app and nothing else – the Server Component integration and the routing helpers save you a day of plumbing. Pick react-i18next if you already use i18next elsewhere (a React Native app, an Express backend, a Vue admin panel) and want one message format and one plugin ecosystem across all of them, or if you depend on i18next features like language detection chains and backend connectors. We cover the i18next side in React i18n and localization.
The rest of this guide uses next-intl.
Routing: locale prefix or domain
Two workable strategies:
Path prefix – example.com/en/pricing, example.com/de/pricing. One deployment, one domain, cheap to add locales. This is the default and the right choice for most teams.
Domain routing – example.com serves English, example.de serves German. Worth it when locales are genuinely different markets with different legal entities, pricing or content, or when local domains matter for your SEO strategy. It costs you domains, certificates and a more complex preview environment.
Within path prefixing you also choose whether the default locale is visible. localePrefix: 'as-needed' serves English at /pricing and German at /de/pricing; 'always' puts every locale in the URL. Prefer 'always' unless you are retrofitting i18n onto an existing site whose English URLs are already indexed – mixed shapes make canonical tags and analytics harder to reason about.
The wiring: routing, request config, middleware
Four files do almost all the work.
src/i18n/routing.ts is the single source of truth for which locales exist:
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'de', 'fr'],
defaultLocale: 'en',
localePrefix: 'always',
});
src/i18n/navigation.ts exports locale-aware replacements for the Next.js navigation primitives. Import these instead of the built-ins and every link keeps the current locale automatically:
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
src/i18n/request.ts resolves the locale for the current request and loads its messages:
import { getRequestConfig } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});
src/middleware.ts handles locale negotiation and redirects:
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: '/((?!api|_next|_vercel|.*\\..*).*)',
};
That matcher deliberately skips API routes, Next.js internals and anything with a file extension. Get it wrong and you will spend an afternoon wondering why /favicon.ico redirects to /en/favicon.ico.
Finally, register the plugin in next.config.ts so the request config is picked up:
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
export default withNextIntl({});
Static rendering: generateStaticParams and setRequestLocale
Move your app under src/app/[locale]/ and give the layout a generateStaticParams:
import { notFound } from 'next/navigation';
import { hasLocale, NextIntlClientProvider } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { routing } from '@/i18n/routing';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) notFound();
setRequestLocale(locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}
Note that params is a Promise in Next.js 15 and later – awaiting it is not optional.
The setRequestLocale gotcha
This is the one that bites everyone. Reading the locale from the request normally requires request-time APIs, which silently opts the whole route into dynamic rendering. Your build output shows every localised page as a server-rendered route instead of static HTML, you pay a render on every single request, and nothing warns you.
setRequestLocale breaks that dependency: it tells next-intl the locale up front, so nothing has to inspect the incoming request. The rules:
- Call it in the locale layout and in every page that should be static. Layouts do not cover their children here.
- Call it before any
useTranslations,getTranslationsor formatter call in that file. - It must come after you have validated the locale, so an unknown segment still 404s.
import { setRequestLocale, getTranslations } from 'next-intl/server';
export default async function PricingPage({ params }) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('Pricing');
return <h2>{t('heading')}</h2>;
}
Verify it worked: run next build and confirm the route table marks your localised routes as static (the legend calls these prerendered as static content) rather than dynamic. Do this check once per sprint – a single new page that forgets the call will quietly go dynamic.
Server components vs client components
The mental model is simple once stated: useTranslations is a hook, but next-intl makes it work in both worlds. In a Server Component it reads from the request config synchronously; in a Client Component it reads from NextIntlClientProvider. The async variant getTranslations is for Server Components only – use it when you need to call it outside the render body, for example inside generateMetadata.
Keep translation as close to the leaf as possible. Do not pass translated strings down as props from a server parent into a client child "so the client stays small" – it turns your components into prop-threading spaghetti and breaks reuse. Just call useTranslations in the client component.
Left alone, NextIntlClientProvider inherits the messages from the server request config, which means the whole message file can end up in the client payload. If that gets heavy, mount a provider lower in the tree and hand it only the namespaces that subtree needs:
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
export default async function CartLayout({ children }) {
const messages = await getMessages();
return (
<NextIntlClientProvider messages={{ Cart: messages.Cart }}>
{children}
</NextIntlClientProvider>
);
}
Organising message files
One file per locale in messages/, namespaced by feature rather than by page URL:
{
"Nav": { "pricing": "Pricing", "docs": "Docs" },
"Pricing": {
"heading": "Simple pricing",
"cta": "Start free"
},
"Cart": {
"items": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}",
"total": "Total: {amount, number, ::currency/EUR}"
}
}
Namespaces map to useTranslations('Cart'). Two rules that pay off later: keys describe meaning, not appearance (checkout.submit, never blueButton), and you never build a sentence by concatenating two keys – word order differs between languages, and translators cannot see what they are translating. If a string needs a variable, put the variable in the string.
Split into multiple files per locale once a single file passes a few hundred keys – merge them in request.ts. Whatever you do, keep the shape identical across locales; that is what makes automated checks possible. If you also ship non-JSON formats elsewhere in your stack, translating YAML files covers the same discipline for that format.
Numbers, dates and plurals with ICU
ICU MessageFormat handles plural categories properly, which matters because English has two – one and other – while Russian and Polish have four and Arabic has six. A naive count === 1 check is wrong in most of the world. Use useFormatter for standalone values:
const format = useFormatter();
format.number(1234.5, { style: 'currency', currency: 'EUR' });
format.dateTime(publishedAt, { dateStyle: 'long' });
format.relativeTime(publishedAt, now);
Pass now explicitly for relative times rather than letting it default, or the server-rendered HTML and the first client render can disagree and you get a hydration mismatch.
Metadata and hreflang alternates
Search engines need to know the localised variants of each page. Build them from routing.locales so a new locale cannot be forgotten:
import { getTranslations } from 'next-intl/server';
import { routing } from '@/i18n/routing';
export async function generateMetadata({ params }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Pricing' });
const languages = Object.fromEntries(
routing.locales.map((l) => [l, `/${l}/pricing`])
);
return {
title: t('metaTitle'),
description: t('metaDescription'),
alternates: {
canonical: `/${locale}/pricing`,
languages: { ...languages, 'x-default': '/en/pricing' },
},
};
}
Set metadataBase in your root layout so Next.js expands those into absolute URLs. Three things to check afterwards: every locale variant links to every other one including itself, the lang attribute on html matches the served locale, and your sitemap lists all locale URLs.
Keeping messages in sync
The engineering setup above is a one-day job. The recurring cost is that English gains fifteen keys during a feature and the other locales do not – and next-intl will happily render the raw key path in production.
Two defences. First, make TypeScript check the source locale. next-intl 4 reads an AppConfig interface from global scope, so declare one in a global.d.ts at the project root:
import type messages from './messages/en.json';
import type { routing } from './src/i18n/routing';
declare global {
interface AppConfig {
Locale: (typeof routing.locales)[number];
Messages: typeof messages;
}
}
Now t('typoedKey') is a compile error, locale strings are checked against the configured list, and next build catches both.
Second, check the other locales in CI. TypeScript only validates against the source file, so add a script that compares key sets:
// scripts/check-messages.mjs
import { readFileSync, readdirSync } from 'node:fs';
const flatten = (obj, prefix = '') =>
Object.entries(obj).flatMap(([k, v]) =>
typeof v === 'object' && v !== null
? flatten(v, `${prefix}${k}.`)
: [`${prefix}${k}`]
);
const load = (f) => flatten(JSON.parse(readFileSync(`messages/${f}`, 'utf8')));
const source = new Set(load('en.json'));
let failed = false;
for (const file of readdirSync('messages')) {
if (file === 'en.json' || !file.endsWith('.json')) continue;
const keys = new Set(load(file));
const missing = [...source].filter((k) => !keys.has(k));
const extra = [...keys].filter((k) => !source.has(k));
if (missing.length || extra.length) {
failed = true;
console.error(`${file}: ${missing.length} missing, ${extra.length} stale`);
missing.slice(0, 20).forEach((k) => console.error(` - ${k}`));
}
}
process.exit(failed ? 1 : 0);
Run it in the same CI job as lint. Decide deliberately whether missing keys fail the pipeline or only warn – failing is right for a small team with fast turnaround, warning is right when translation runs on a slower cycle and you have a sensible fallback locale configured.
Automating the round trip
Once a human translator or a translation platform is involved, wire the exchange into CI rather than emailing JSON around. With the Translatize CLI the shape looks like this – a config file records the project and the file layout once, and the commands take it from there:
# once, committed to the repo: records project, file pattern and on-disk shape
npx translatize init --files "messages/{lang}.json" --format json-nested
# in CI, with a branch-bound token in TRANSLATIZE_API_TOKEN
npx translatize push # upload new and changed source keys
npx translatize pull # rewrite messages/<lang>.json
npx translatize status --fail-on-missing # gate the build on completeness
push never deletes keys on the server, and pull writes sorted files with a trailing newline, so re-running it produces a clean diff instead of noise. status reports per-language completeness and exits non-zero with --fail-on-missing, which makes the hand-rolled script above optional once the platform is in place.
The CLI moves files; it does not decide where the strings live between push and pull, who translates them, or who is allowed to approve them. That is the platform's job, and the thing worth checking is whether its model matches the way your team branches – because messages/en.json is a single file that every feature in flight wants to edit at the same time.
Where Translatize fits
Translatize is a translation management platform organised around Git-like branching, which maps onto how messages/*.json actually changes. Two features in the same sprint both add keys to messages/en.json; on separate branches each set is translated in isolation and merged when the feature ships, so neither blocks the other and unreleased strings stay out of main. Merges are permission-guarded – developer and above create branches, only admin or owner merges to main – with four strategies: overwrite (non-destructive union, source wins), replace, keep-newer, and manual key-by-key resolution. Integration tokens are bound to one project and one branch, so CI on a feature branch cannot write to another. Import and export cover nested or flat JSON – the shape next-intl already reads – plus YAML, CSV, XLIFF 1.2, gettext PO, ARB, RESX, Android XML and iOS .strings. Labels carry draft, review, approved and rejected statuses. The docs cover token setup; AI localization workflow covers machine translation in front of human review.
Wrapping up
The Next.js-specific parts of this are small and finite: a [locale] segment, a middleware, a request config, and setRequestLocale on every page you want static. Get those right and check the build output once to confirm nothing went dynamic.
The part that keeps costing you is the message files. Type the source locale, diff the rest in CI, and pick one automated path for getting translations back into the repo. Do that early, while you have three hundred keys instead of three thousand.
