Next.js internationalization

Internationalizing a Next.js App Router app: routing by locale segment, server and client translation, metadata, hreflang and keeping message files in sync.

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:

ts
// 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

ts
// 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,
  };
});
tsx
// 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:

tsx
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:

tsx
"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:

tsx
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:

json
{
  "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:

tsx
t.rich("terms", {
  link: (chunks) => <Link href="/tos">{chunks}</Link>,
});
json
{ "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:

tsx
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:

tsx
export function generateStaticParams() {
  return ["en", "fr", "de"].map((locale) => ({ locale }));
}

Formatting

tsx
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:

bash
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:

ts
// 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:

tsx
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 i18n key in next.config.js is 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 metadata objects, which cannot be localized.
  • hreflang for locales without real translations.
  • Missing lang on <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

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.