All posts

Next.js Internationalization with the App Router

How to set up internationalization in Next.js App Router using next-intl, including middleware locale detection, route prefixing, and automating translations.

Next.js App Router changed how i18n works. The old next.config.js i18n config doesn't apply anymore. Here's how to set up proper internationalization with the App Router using next-intl, the library that's become the de facto standard.

Install next-intl

npm install next-intl

File Structure

app/
  [locale]/
    layout.tsx
    page.tsx
    settings/
      page.tsx
messages/
  en.json
  es.json
  de.json
middleware.ts
i18n/
  request.ts
  routing.ts

The key insight: your entire route tree lives under [locale]. Every page is automatically prefixed with the language code: /en/settings, /es/settings, /de/settings.

Step 1: Define Routing

Create your routing configuration:

// i18n/routing.ts
import { defineRouting } from "next-intl/routing";

export const routing = defineRouting({ locales: ["en", "es", "de", "fr", "ja"], defaultLocale: "en", });

Step 2: Middleware

The middleware detects the user's locale from their browser headers, cookies, or URL prefix, and redirects accordingly.

// middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";

export default createMiddleware(routing);

export const config = { matcher: [ // Match all pathnames except API routes, static files, etc. "/((?!api|_next|_vercel|.\\..).*)", ], };

When a user visits /settings, the middleware checks their Accept-Language header. If it's es, they get redirected to /es/settings. The default locale (en) can optionally be prefix-free — /settings serves English content directly.

Step 3: Request Configuration

// i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";

export default getRequestConfig(async ({ requestLocale }) => { let locale = await requestLocale;

if ( !locale || !routing.locales.includes(locale as (typeof routing.locales)[number]) ) { locale = routing.defaultLocale; }

return { locale, messages: (await import(../messages/${locale}.json)).default, }; });

Step 4: Layout

Wrap your app with NextIntlClientProvider:

// app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";

export default async function LocaleLayout({ children, params, }: { children: React.ReactNode; params: Promise<{ locale: string }>; }) { const { locale } = await params;

if (!routing.locales.includes(locale as typeof routing.locales[number])) { notFound(); }

const messages = await getMessages();

return ( <html lang={locale}> <body> <NextIntlClientProvider messages={messages}> {children} </NextIntlClientProvider> </body> </html> ); }

Step 5: Message Files

Create your translation files:

// messages/en.json
{
  "nav": {
    "home": "Home",
    "settings": "Settings",
    "logout": "Log out"
  },
  "dashboard": {
    "title": "Dashboard",
    "welcome": "Welcome back, {name}",
    "projects": "You have {count, plural, one {# project} other {# projects}}"
  }
}
// messages/es.json
{
  "nav": {
    "home": "Inicio",
    "settings": "Configuración",
    "logout": "Cerrar sesión"
  },
  "dashboard": {
    "title": "Panel",
    "welcome": "Bienvenido, {name}",
    "projects": "Tienes {count, plural, one {# proyecto} other {# proyectos}}"
  }
}

Step 6: Using Translations

In server components:

// app/[locale]/page.tsx
import { useTranslations } from "next-intl";

export default function HomePage() { const t = useTranslations("dashboard");

return ( <div> <h1>{t("title")}</h1> <p>{t("welcome", { name: "Mike" })}</p> <p>{t("projects", { count: 5 })}</p> </div> ); }

In client components:

"use client";
import { useTranslations } from "next-intl";

export function NavMenu() { const t = useTranslations("nav");

return ( <nav> <a href="/">{t("home")}</a> <a href="/settings">{t("settings")}</a> <button>{t("logout")}</button> </nav> ); }

The Problem: Keeping Translation Files in Sync

The setup above works great. The hard part is maintaining es.json, de.json, fr.json, and ja.json as your app grows.

Every time you add a new string to en.json, you need to add it to every other locale file. Miss one, and next-intl falls back to the default locale — which means your Spanish users randomly see English strings.

Option 1: Manual Translation

Hire translators. They update the JSON files. This is accurate but slow. A new feature with 20 strings takes days to get translated, and you can't ship to non-English users until it's done.

Option 2: Automated Translation in CI

Use your CI pipeline to detect new keys in en.json and translate them automatically:

// scripts/sync-translations.ts
import { readFileSync, writeFileSync } from "fs";

const LANGS = ["es", "de", "fr", "ja"]; const API_KEY = process.env.AUTO18N_API_KEY!;

async function sync() { const en = JSON.parse(readFileSync("messages/en.json", "utf-8"));

for (const lang of LANGS) { const target = JSON.parse(readFileSync(messages/${lang}.json, "utf-8")); const missing = findMissingKeys(en, target);

if (missing.length === 0) continue;

console.log(${lang}: translating ${missing.length} missing keys);

for (const { path, value } of missing) { const res = await fetch("https://api.auto18n.com/translate", { method: "POST", headers: { Authorization: Bearer ${API_KEY}, "Content-Type": "application/json", }, body: JSON.stringify({ text: value, to: lang, context: UI string for a web app. Key path: ${path}, }), }); const data = await res.json(); setNestedValue(target, path, data.translation); }

writeFileSync( messages/${lang}.json, JSON.stringify(target, null, 2) + "\n", ); } }

function findMissingKeys( source: Record<string, unknown>, target: Record<string, unknown>, prefix = "", ): { path: string; value: string }[] { const missing: { path: string; value: string }[] = [];

for (const [key, value] of Object.entries(source)) { const path = prefix ? ${prefix}.${key} : key; if (typeof value === "object" && value !== null) { const targetObj = (target[key] as Record<string, unknown>) ?? {}; missing.push( ...findMissingKeys(value as Record<string, unknown>, targetObj, path), ); } else if (!(key in (target as Record<string, unknown>))) { missing.push({ path, value: String(value) }); } }

return missing; }

Hook this into your CI so translations are generated on every push. New strings get translated automatically, and the JSON files stay in sync.

SEO: hreflang Tags

For proper multilingual SEO, add hreflang tags:

// app/[locale]/layout.tsx
import { routing } from "@/i18n/routing";

export function generateMetadata({ params }: { params: { locale: string } }) { const alternates: Record<string, string> = {}; for (const locale of routing.locales) { alternates[locale] = https://yoursite.com/${locale}; }

return { alternates: { languages: alternates, }, }; }

Common Pitfalls

Forgetting to update the matcher. If you add new top-level routes, make sure the middleware matcher still covers them.

Large message files. If your messages/en.json grows past 100KB, split it into namespaces and load them per-page using next-intl's getMessages with specific namespaces. This keeps your client bundle small.

Date and number formatting. next-intl handles this too — use useFormatter() for dates, numbers, and relative time. Don't roll your own.

Testing. Wrap your test components with NextIntlClientProvider and pass the messages object directly. Don't mock the hook — use the real provider with test messages.

The setup takes about 30 minutes. Keeping translations in sync is the ongoing work. Automate that part, and i18n becomes a solved problem in your codebase.