How to internationalize a Next.js app (App Router, 2026)

2026-05-25 · Localingos team

Next.js dropped its built-in i18n config when the App Router became stable, and a lot of teams have been confused since. The official guidance is "build it yourself with middleware and dynamic segments." That's actually a better answer than the old built-in — more flexible, better SEO control, full server-component compatibility — but the docs leave a lot of gaps. This guide fills them in.

We'll build a fully internationalized Next.js 15 App Router app: locale routing, server-side translation lookup, client component support, static generation per locale, search-engine-friendly hreflang, and a sane pattern for adding more languages without touching component code.

Code targets Next.js 15.x and React 19. Everything below works without any third-party i18n library — we'll add next-intl at the end for teams that want richer formatting.

The model: one URL per locale

The single most important decision in Next.js i18n is your URL structure. There are three options:

  1. Subpath: example.com/es/pricing (Recommended for most)
  2. Subdomain: es.example.com/pricing (Better for fully separate sites; harder ops)
  3. Top-level domain: example.es/pricing (Best per-country SEO; expensive)

Pick subpath. It's what every reference Next.js i18n implementation uses, hreflang setup is trivial, and you don't need extra DNS or CDN config.

The English version sits at the root (example.com/pricing) and other locales get prefixes (example.com/es/pricing). Don't prefix English — search engines treat root-level URLs as canonical by default, and example.com/en/pricing doubles your indexing surface for the same content.

Project structure

app/
  [lang]/
    layout.tsx        ← per-locale layout
    page.tsx          ← localized home
    pricing/
      page.tsx
    docs/
      [slug]/
        page.tsx
  layout.tsx          ← root layout (HTML shell)
  page.tsx            ← English home (no prefix)
  pricing/
    page.tsx          ← English pricing (no prefix)
i18n/
  config.ts
  en.json
  es.json
  de.json
  fr.json
middleware.ts

The trick is that the English routes live directly under app/ while every other locale gets nested under [lang]/. That's how you get unprefixed English URLs.

i18n config

// i18n/config.ts
export const LOCALES = ['en', 'es', 'de', 'fr'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'en';

export const PUBLIC_LOCALES = LOCALES.filter((l) => l !== DEFAULT_LOCALE);

const dictionaries: Record<Locale, () => Promise<Record<string, string>>> = {
  en: () => import('./en.json').then((m) => m.default),
  es: () => import('./es.json').then((m) => m.default),
  de: () => import('./de.json').then((m) => m.default),
  fr: () => import('./fr.json').then((m) => m.default),
};

export async function getDictionary(locale: Locale) {
  return dictionaries[locale]();
}

Lazy import() keeps each locale in its own chunk. At build time, Next.js will tree-shake and only the active locale's strings ship with each page.

Middleware for locale detection

When a user hits example.com/pricing from a Spanish-speaking IP, you have a choice: serve English (default) or redirect to /es/pricing. The latter is friendlier UX but riskier SEO — Google may see different content for the same URL depending on user.

The safe pattern: never auto-redirect crawlers, only redirect first-time human visitors based on Accept-Language. The middleware:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { LOCALES, DEFAULT_LOCALE, PUBLIC_LOCALES } from './i18n/config';

const BOT_UA = /bot|crawler|spider|crawling/i;

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // Already locale-prefixed? Pass through.
  if (PUBLIC_LOCALES.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))) {
    return;
  }

  // Skip API routes, static files, favicon
  if (
    pathname.startsWith('/api') ||
    pathname.startsWith('/_next') ||
    /\.[a-z0-9]+$/i.test(pathname)
  ) {
    return;
  }

  // Don't redirect crawlers — they should see canonical English at root.
  const ua = req.headers.get('user-agent') || '';
  if (BOT_UA.test(ua)) return;

  // Returning user with explicit choice? Honor cookie.
  const cookieLocale = req.cookies.get('NEXT_LOCALE')?.value;
  if (cookieLocale && LOCALES.includes(cookieLocale as any)) {
    if (cookieLocale === DEFAULT_LOCALE) return;
    return NextResponse.redirect(new URL(`/${cookieLocale}${pathname}`, req.url));
  }

  // First-time visitor: peek at Accept-Language.
  const preferred = parseAcceptLanguage(req.headers.get('accept-language'));
  if (preferred && preferred !== DEFAULT_LOCALE) {
    return NextResponse.redirect(new URL(`/${preferred}${pathname}`, req.url));
  }
}

function parseAcceptLanguage(header: string | null): string | null {
  if (!header) return null;
  const tags = header.split(',').map((t) => t.split(';')[0].trim().toLowerCase());
  for (const tag of tags) {
    const base = tag.split('-')[0];
    const match = LOCALES.find((l) => l === tag || l === base);
    if (match) return match;
  }
  return null;
}

export const config = {
  matcher: ['/((?!api|_next|favicon.ico).*)'],
};

A few choices worth understanding:

  • Crawler bypass is essential. Google sees English at canonical URLs; users get redirected. Without this, you'll see cloaking warnings in Search Console or worse, your /es/ pages won't get indexed because Googlebot is always redirected away from them.
  • Cookie wins over Accept-Language. If the user explicitly switched to French once, never override that with Accept-Language: es.
  • Don't redirect on the locale path itself. Once you're at /es/pricing, the middleware just passes through.

The [lang] layout

// app/[lang]/layout.tsx
import { LOCALES, type Locale } from '@/i18n/config';
import { notFound } from 'next/navigation';

export async function generateStaticParams() {
  return LOCALES.filter((l) => l !== 'en').map((lang) => ({ lang }));
}

export default async function LangLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ lang: Locale }>;
}) {
  const { lang } = await params;
  if (!LOCALES.includes(lang)) notFound();
  return <>{children}</>;
}

generateStaticParams tells Next.js which locales to pre-build. The notFound() call rejects anything like /zz/pricing cleanly.

Translating in server components

This is where Next.js gets nice. In a server component, just await the dictionary:

// app/[lang]/pricing/page.tsx
import { getDictionary, type Locale } from '@/i18n/config';

export default async function PricingPage({
  params,
}: {
  params: Promise<{ lang: Locale }>;
}) {
  const { lang } = await params;
  const t = await getDictionary(lang);
  return (
    <main>
      <h1>{t.pricingTitle}</h1>
      <p>{t.pricingSubtitle}</p>
    </main>
  );
}

No hooks, no client bundle weight for translations on server components. The dictionary lookup happens at request time (or build time for static pages) and only the rendered HTML ships to the browser.

You'll mirror this for the unprefixed English version:

// app/pricing/page.tsx
import { getDictionary } from '@/i18n/config';

export default async function PricingPage() {
  const t = await getDictionary('en');
  return (
    <main>
      <h1>{t.pricingTitle}</h1>
      <p>{t.pricingSubtitle}</p>
    </main>
  );
}

Yes, two files for the same page. You can DRY this up by extracting the JSX into a shared <PricingPage lang={lang} /> component — both routes become one-line wrappers.

Translating in client components

Client components don't await. The cleanest pattern is to lift the dictionary into a server component, then pass relevant strings as props.

// app/[lang]/dashboard/page.tsx (server)
import { getDictionary, type Locale } from '@/i18n/config';
import { DashboardClient } from './DashboardClient';

export default async function DashboardPage({
  params,
}: { params: Promise<{ lang: Locale }> }) {
  const { lang } = await params;
  const t = await getDictionary(lang);
  return (
    <DashboardClient
      strings={{
        title: t.dashboardTitle,
        signOut: t.signOut,
        addProject: t.addProject,
      }}
    />
  );
}
// app/[lang]/dashboard/DashboardClient.tsx ("use client")
'use client';
export function DashboardClient({ strings }: { strings: Record<string, string> }) {
  return (
    <div>
      <h1>{strings.title}</h1>
      <button>{strings.addProject}</button>
    </div>
  );
}

For deeply nested client trees you'll want context — pass the dictionary subset via React Context inside the client boundary so children don't have to receive props for every string.

SEO: hreflang, canonical, and sitemaps

Without hreflang annotations, Google doesn't know that /pricing and /es/pricing are translations of each other — they may be treated as duplicate content. Emit them from your root layout:

// app/layout.tsx
import { LOCALES, PUBLIC_LOCALES } from '@/i18n/config';
import { headers } from 'next/headers';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const hdrs = await headers();
  const pathname = hdrs.get('x-pathname') || '/';
  // Strip locale prefix to get canonical English path
  const canonicalPath = pathname.replace(/^\/(es|de|fr)(\/|$)/, '/');

  return (
    <html>
      <head>
        <link rel="canonical" href={`https://example.com${canonicalPath}`} />
        <link rel="alternate" hrefLang="en" href={`https://example.com${canonicalPath}`} />
        {PUBLIC_LOCALES.map((l) => (
          <link
            key={l}
            rel="alternate"
            hrefLang={l}
            href={`https://example.com/${l}${canonicalPath}`}
          />
        ))}
        <link rel="alternate" hrefLang="x-default" href={`https://example.com${canonicalPath}`} />
      </head>
      <body>{children}</body>
    </html>
  );
}

(Set x-pathname via middleware: res.headers.set('x-pathname', req.nextUrl.pathname).)

The x-default hreflang tells Google "if no other locale fits, use English." Always include it.

Then generate app/sitemap.ts:

import { MetadataRoute } from 'next';
import { LOCALES, PUBLIC_LOCALES } from '@/i18n/config';

const ROUTES = ['/', '/pricing', '/docs', '/blog'];

export default function sitemap(): MetadataRoute.Sitemap {
  return ROUTES.map((route) => ({
    url: `https://example.com${route}`,
    alternates: {
      languages: Object.fromEntries(
        LOCALES.map((l) => [
          l,
          l === 'en'
            ? `https://example.com${route}`
            : `https://example.com/${l}${route}`,
        ])
      ),
    },
  }));
}

Next.js will serve this at /sitemap.xml with proper hreflang annotations. Submit it to Google Search Console once.

Static generation per locale

The whole setup above is compatible with output: 'export' if you're deploying to S3 + CloudFront or any static host. Each [lang]/pricing page gets pre-rendered for every locale in generateStaticParams at build time.

For ISR (incremental static regeneration), nothing changes — server components inside [lang] cache per locale automatically.

Should you use next-intl?

next-intl is a third-party library that adds ICU message formatting (rich plurals, ordinal numbers, gendered strings), a useTranslations hook for client components, and richer locale switching helpers. The setup above is enough for 80% of apps — strings, interpolation, basic plurals. If you need:

  • ICU MessageFormat for complex pluralization in non-English languages
  • Built-in number/date/list formatting with locale-aware output
  • A simpler API for client component translations (no manual prop drilling)

…then add next-intl. It composes with everything above; the routing pattern is the same.

The translation gap

You now have routing, rendering, SEO, and static builds sorted. What you don't have is the actual translated copy. In a real project, you'll wire all this up in an afternoon and then spend two months arguing about who maintains es.json when product changes the homepage hero.

The patterns that work:

  • A CI check that fails when keys are added to en.json without corresponding entries in other locales.
  • A translation pipeline that runs at build or PR time and produces machine translations for any drift, with human review for high-stakes pages.
  • Strict placeholder validation — never let {{userName}} become {usuario} in Spanish; that's a runtime crash.

Localingos handles this end-to-end for any JSON-based locale setup, including the exact pattern in this guide. You point it at your en.json, define the other locales you want, and every CI run pushes new English keys and pulls back the translations with placeholders verified. If you want to skip the "I need to maintain 8 JSON files" phase entirely, check out the free tier — it covers most apps.

Recap

You have a Next.js 15 App Router app that:

  • Serves English at root URLs and other locales at /[lang]/...
  • Detects locale from Accept-Language for first-time visitors, honors a cookie afterward, and never redirects crawlers
  • Translates in both server and client components with no hydration mismatch
  • Emits correct hreflang, canonical, and sitemap.xml for Google
  • Builds static HTML per locale, deployable to any CDN

That's a production-grade i18n stack with zero third-party dependencies. From here, the only remaining problem is the translations themselves — which is exactly the problem worth automating.