Next.js internationalization changed substantially with the App Router, and most of the confusion comes from advice written for the Pages Router still being the top search result.
The built-in i18n config in next.config.js does nothing in the App Router. It was a Pages Router feature. In the App Router you build routing yourself with a dynamic segment, which is more work and considerably more flexible.
This guide covers the App Router with next-intl, the most widely used library for it.
Routing
Put a [locale] segment at the top of the app directory:
app/
[locale]/
layout.tsx
page.tsx
dashboard/
page.tsx
So /en/dashboard and /fr/dashboard resolve to the same component with a different locale param.
Middleware handles detection and redirects a bare path to a prefixed one:
// middleware.ts
import createMiddleware from "next-intl/middleware";
export default createMiddleware({
locales: ["en", "fr", "de"],
defaultLocale: "en",
localePrefix: "as-needed",
});
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};
localePrefix: "as-needed" serves the default locale unprefixed (/dashboard) and others prefixed (/fr/dashboard). This keeps your canonical English URLs stable, which matters if the site already ranks.
Get the matcher right. A matcher that catches /api routes or static assets will redirect them into a locale prefix and break them, and the symptom — images 404ing after adding i18n — does not obviously point at middleware.
Setup
// i18n/request.ts
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async ({ requestLocale }) => {
const locale = (await requestLocale) ?? "en";
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Note <html lang={locale}>. It drives screen reader pronunciation, browser translation prompts and font selection for CJK — not decoration.
Server and client components
This is the part worth getting right, because it determines your bundle size.
Server components read messages directly, and nothing ships to the browser:
import { getTranslations } from "next-intl/server";
export default async function Dashboard() {
const t = await getTranslations("dashboard");
return <h1>{t("title")}</h1>;
}
Client components use the hook, and their messages must be provided:
"use client";
import { useTranslations } from "next-intl";
export function SaveButton() {
const t = useTranslations("actions");
return <button>{t("save")}</button>;
}
The default NextIntlClientProvider above passes every message to the client, which defeats the point. Narrow it to what client components actually need:
import { pick } from "lodash";
<NextIntlClientProvider messages={pick(messages, ["actions", "forms"])}>
Translate on the server wherever you can. A page that is entirely server components ships no message JSON at all.
Messages
Nested JSON, namespaced by feature:
{
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back, {name}"
},
"messages": {
"count": "{count, plural, =0 {No messages} one {# message} other {# messages}}"
}
}
next-intl uses ICU MessageFormat — single braces, named CLDR plural categories, select for gender, and skeleton-based number and date formatting. This is a genuine advantage over i18next’s simpler custom syntax, particularly for languages needing four or six plural forms.
Rich text keeps markup out of the translation:
t.rich("terms", {
link: (chunks) => <Link href="/tos">{chunks}</Link>,
});
{ "terms": "Read our <link>terms of service</link> before continuing." }
The translator moves <link>…</link> where the grammar needs it; the href stays in code.
Metadata and SEO
Static metadata objects cannot be localized — they are evaluated without params. Use the async form:
export async function generateMetadata({ params }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
return {
title: t("title"),
description: t("description"),
alternates: {
canonical: `/${locale}`,
languages: { en: "/en", fr: "/fr", "x-default": "/en" },
},
};
}
The alternates.languages block emits hreflang tags. Declare only locales that genuinely have translated content — pointing hreflang="fr" at a page serving English is worse than emitting nothing, because it tells search engines a translation exists when it does not.
Add generateStaticParams so locale routes are statically generated:
export function generateStaticParams() {
return ["en", "fr", "de"].map((locale) => ({ locale }));
}
Formatting
const format = useFormatter();
format.number(total, { style: "currency", currency: "EUR" });
format.dateTime(createdAt, { dateStyle: "medium" });
format.relativeTime(updatedAt);
These wrap Intl, so the output follows the active locale. Never pre-format a number or date into a string and interpolate it.
Watch for hydration mismatches on relative times and anything derived from the current clock: the server renders one value, the client another. Pass an explicit reference time, or render those on the client only.
Syncing translations
Message files are plain nested JSON:
wti push # messages/en.json up
wti pull # translated message files back
wti diff # what a push would change
Push on merge to your designated branch, pull nightly into a pull request. See Git-based localization workflows.
Type safety
Derive the key type from your English messages so a typo is a build error:
// global.d.ts
import type en from "./messages/en.json";
declare module "next-intl" {
interface AppConfig {
Messages: typeof en;
}
}
Autocomplete then lists available keys, and removing a key from the JSON surfaces every call site using it. On a project where translations are edited outside the repository this is more valuable than usual — it means a translator’s file change cannot silently break the build’s assumptions without the type check catching it.
Static generation and revalidation
Locale routes are ordinary routes, so they statically generate. The consequence worth planning for is that translations are baked in at build time: pulling new translations from your platform does not change a deployed static page until it is rebuilt.
Two ways to handle it. Rebuild on translation change, by having your nightly wti pull pull request trigger a deploy when it lands — simple, and the delay is at most a day. Or use revalidate on the affected routes so pages refresh on a timer without a full rebuild.
Which you want depends on how fast translations need to appear. For most products the pull request path is right: translations arriving through code review is a feature, not latency to be engineered away.
Testing
Render with a real provider rather than mocking useTranslations — mocking hides the failures worth catching:
import { NextIntlClientProvider } from "next-intl";
import messages from "../messages/en.json";
render(
<NextIntlClientProvider locale="en" messages={messages}>
<Dashboard />
</NextIntlClientProvider>
);
Assert on behaviour rather than translated copy. Then add a CI check for completeness across locales, and one that every route in generateStaticParams has a corresponding message file — a locale listed in middleware but missing its JSON produces a runtime error on a page nobody visits until a customer does.
Common mistakes
- Following Pages Router advice. The
i18nkey innext.config.jsis inert in the App Router. - A middleware matcher that catches API routes or static files.
- Passing every message to
NextIntlClientProvider, shipping the whole catalogue to the browser. - Static
metadataobjects, which cannot be localized. hreflangfor locales without real translations.- Missing
langon<html>. - Hydration mismatches from clock-derived formatting.
Frequently asked questions
- Does the Next.js App Router have built-in i18n?
- No. The built-in i18n routing configuration in next.config.js only ever applied to the Pages Router and does nothing in the App Router. App Router internationalization is done with a dynamic [locale] route segment plus a library such as next-intl.
- How do I structure locale routes in Next.js?
- Put a dynamic [locale] segment at the top of the app directory, so app/[locale]/page.tsx serves /en and /fr. Use middleware to detect the visitor's locale and redirect a bare path to the prefixed one.
- How do I translate metadata in Next.js?
- Export an async generateMetadata that receives the locale param, load the messages for that locale, and return the translated title and description. Static metadata objects cannot be localized because they are evaluated without params.
- Should translations be loaded on the server or the client?
- On the server wherever possible. Server components can read messages directly without shipping them to the browser, so only the subset needed by interactive client components crosses the wire — which is the main bundle-size advantage of the App Router for i18n.
Keep reading
-
React i18next: setup and workflow
Setting up i18next in a React app: namespaces, plurals, interpolation, Trans for embedded markup, lazy loading, and keeping translation files in sync.
-
What is a locale? Language tags explained (BCP 47)
A locale is more than a language. Here is what it controls — dates, numbers, currency, sorting, plurals — and how BCP 47 language tags are built and matched.
-
ICU MessageFormat: a practical guide
The syntax for plurals, gender selection, number and date formatting in one message string — with the escaping rules and the mistakes translators reliably make.
-
Translate JSON files (documentation)
How WebTranslateIt parses the nested JSON message files Next.js i18n libraries use.
Translate your app without the spreadsheet round-trip
WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.