All insights
Engineering7 min read

Internationalization in Next.js: Building for a Global Audience

Internationalization in the App Router works differently than in Pages Router. Here's how to implement locale routing, translations, and locale-aware formatting correctly.

NC

Nextcraft Engineering Team

The i18n Landscape in Next.js App Router

The App Router doesn't have built-in i18n routing (unlike Pages Router's i18n config). Instead, it provides the primitives — dynamic route segments and middleware — that you use to build your own routing layer. Most teams either:

  1. Use a dynamic [locale] segment for all routes
  2. Implement locale routing in middleware

The dynamic segment approach is more explicit and integrates cleanly with Next.js's file-system routing.

The Folder Structure

code
app/
├── [locale]/
│   ├── layout.tsx      — Locale-aware root layout
│   ├── page.tsx        — Homepage
│   ├── blog/
│   │   ├── page.tsx    — Blog index
│   │   └── [slug]/
│   │       └── page.tsx — Blog post
│   └── about/
│       └── page.tsx
├── api/                — API routes (no locale prefix)
└── middleware.ts       — Locale detection and redirect

All user-facing pages live under [locale]. API routes stay at the root.

Middleware: Auto-Detecting the Locale

code
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

const supportedLocales = ['en', 'de', 'fr', 'es'];
const defaultLocale = 'en';

function detectLocale(request: NextRequest): string {
  // 1. Check for explicit locale in cookie (user's preference)
  const cookieLocale = request.cookies.get('locale')?.value;
  if (cookieLocale && supportedLocales.includes(cookieLocale)) {
    return cookieLocale;
  }

  // 2. Check Accept-Language header
  const acceptLanguage = request.headers.get('accept-language') ?? '';
  const preferredLocale = acceptLanguage
    .split(',')
    .map(lang => lang.split(';')[0].trim().split('-')[0])
    .find(lang => supportedLocales.includes(lang));
  
  return preferredLocale ?? defaultLocale;
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  
  // Skip API routes, static files
  if (pathname.startsWith('/api') || pathname.includes('.')) {
    return NextResponse.next();
  }
  
  // Check if pathname already has a supported locale
  const hasLocale = supportedLocales.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );
  
  if (hasLocale) return NextResponse.next();
  
  // Redirect to locale-prefixed URL
  const locale = detectLocale(request);
  return NextResponse.redirect(
    new URL(`/${locale}${pathname}`, request.url)
  );
}

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

The Translation Layer

For translation management, next-intl is the most fully-featured library for App Router:

code
npm install next-intl
code
// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`../messages/${locale}.json`)).default,
}));
code
// messages/en.json
{
  "nav": {
    "home": "Home",
    "services": "Services",
    "pricing": "Pricing"
  },
  "hero": {
    "headline": "Build faster. Ship better.",
    "cta": "Get started"
  },
  "pricing": {
    "title": "Simple, transparent pricing",
    "monthly": "Monthly",
    "annual": "Annual",
    "save": "Save {percent}%"
  }
}
code
// messages/de.json
{
  "nav": {
    "home": "Startseite",
    "services": "Leistungen",
    "pricing": "Preise"
  },
  "hero": {
    "headline": "Schneller bauen. Besser liefern.",
    "cta": "Jetzt starten"
  }
}
code
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';

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

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}
code
// In Server Components — direct import
import { useTranslations } from 'next-intl';

export default function HeroSection() {
  const t = useTranslations('hero');
  return (
    <section>
      <h1>{t('headline')}</h1>
      <a href="/signup">{t('cta')}</a>
    </section>
  );
}

Locale-Aware Metadata

code
// app/[locale]/page.tsx
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: { locale: string };
}): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'meta' });

  return {
    title: t('homepageTitle'),
    description: t('homepageDescription'),
    alternates: {
      canonical: `/${locale}`,
      languages: {
        'en': '/en',
        'de': '/de',
        'fr': '/fr',
      },
    },
  };
}

The alternates.languages generates <link rel="alternate" hreflang="..."> tags — critical for international SEO. Search engines use these to serve the correct locale version to users in different countries.

Number and Date Formatting

Never hardcode number formats. Use the Intl API or next-intl's formatting utilities:

code
// Server Component
import { useFormatter } from 'next-intl';

export function PriceDisplay({ amount }: { amount: number }) {
  const format = useFormatter();
  
  return (
    <span>
      {format.number(amount, {
        style: 'currency',
        currency: 'USD',
      })}
    </span>
  );
}

Different locales format currency differently:

  • US: $1,234.56
  • Germany: 1.234,56 $ (dot for thousands, comma for decimal, currency after)
  • France: 1 234,56 $ (space for thousands)

The Intl.NumberFormat API handles all of this correctly by locale.

Static Generation for Localized Pages

Generate all locale variants at build time:

code
// app/[locale]/blog/[slug]/page.tsx

export async function generateStaticParams() {
  const locales = ['en', 'de', 'fr'];
  const slugs = await getAllPostSlugs();
  
  return locales.flatMap(locale =>
    slugs.map(slug => ({ locale, slug }))
  );
}

This generates /en/blog/my-post, /de/blog/my-post, and /fr/blog/my-post at build time — all statically cached, no per-request rendering needed.

The Translation Management Workflow

At scale, JSON files in the repo don't work — translators can't edit code, and tracking what needs translation becomes difficult. The solutions:

  • Crowdin / Phrase: Professional translation management platforms with Git integration
  • Tolgee: Open-source, self-hostable translation management
  • Lingo: Simpler, developer-friendly option with a good free tier

The workflow: export English strings to the platform, translators work there, a CI step pulls updated translations and commits them to the repo. Engineers never touch translation files directly.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.