Localize a Next.js app: complete guide with Localingos

Next.js dropped its built-in i18n config when the App Router stabilized, leaving teams to wire localization themselves. The good news: the DIY pattern is more flexible and SEO-friendly than the old built-in. This guide walks you from a fresh Next.js 15 App Router app to a fully localized build with locale-prefixed routes, server-side translation lookup, proper hreflang annotations, and a CI step that keeps translations in sync.

The model

URL structure: English at root (example.com/pricing), other locales prefixed (example.com/es/pricing). This is what every reference Next.js i18n implementation uses — search engines treat root-level URLs as canonical, hreflang annotations are trivial, and you don't need extra DNS configuration.

Step 1 — Install

npm install
npm install -g @localingos/cli
localingos login

Optionally add next-intl if you want richer ICU MessageFormat support — but the patterns below work without it.

Step 2 — Project structure

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

English routes live directly under app/ while other locales nest under [lang]/. This is how you get unprefixed English URLs.

Step 3 — 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';

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 const getDictionary = (locale: Locale) => dictionaries[locale]();

Each locale ends up in its own chunk thanks to lazy import() — only the active locale ships with each page.

Step 4 — Configure Localingos

localingos.json in project root:

{
  "projectId": "your-project-id",
  "source": { "path": "i18n/en.json", "locale": "en" },
  "targets": {
    "path": "i18n/{locale}.json",
    "locales": ["es", "de", "fr", "ja", "pt-BR", "zh-CN", "ko"]
  },
  "placeholders": ["{{variable}}", "${variable}"]
}
localingos sync

All 7 target locale files appear in i18n/. Commit them.

Step 5 — Locale detection middleware

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

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

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;
  if (LOCALES.some(l => l !== DEFAULT_LOCALE && (pathname === `/${l}` || pathname.startsWith(`/${l}/`)))) return;
  if (pathname.startsWith('/api') || pathname.startsWith('/_next') || /\.[a-z0-9]+$/i.test(pathname)) return;
  if (BOT_UA.test(req.headers.get('user-agent') || '')) return;

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

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

Critical: never redirect crawlers. Google must see canonical English at root URLs. Cookie-based redirect for returning users is fine — auto-detection on Accept-Language for first-time visitors is risky for SEO (skip it).

Step 6 — Translate in server components

// 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>
  );
}
// app/pricing/page.tsx (English version, no prefix)
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>;
}

DRY it up by extracting JSX into a shared <PricingView lang={lang} /> component — both routes become one-line wrappers.

Step 7 — Client components

Client components don't await. Lift the dictionary into a server parent and pass strings as props:

// server
const t = await getDictionary(lang);
return <DashboardClient strings={{ title: t.dashTitle, signOut: t.signOut }} />;

For deeply nested trees, push the dictionary subset through React Context inside the client boundary.

Step 8 — hreflang and sitemap

// app/sitemap.ts
import { MetadataRoute } from 'next';
import { LOCALES } from '@/i18n/config';

const ROUTES = ['/', '/pricing', '/docs'];
const BASE = 'https://example.com';

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

Next.js serves this at /sitemap.xml with proper hreflang. Submit to Google Search Console once.

Step 9 — Automate sync in CI

# .github/workflows/i18n.yml
name: i18n-sync
on:
  push: { branches: [main], paths: ['i18n/en.json'] }
jobs:
  sync:
    runs-on: ubuntu-latest
    permissions: { contents: write, pull-requests: write }
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm install -g @localingos/cli
      - run: localingos sync
        env: { LOCALINGOS_API_KEY: '${{ secrets.LOCALINGOS_API_KEY }}' }
      - uses: peter-evans/create-pull-request@v6
        with:
          branch: i18n/auto-sync
          title: 'chore(i18n): sync translations'
          commit-message: 'chore(i18n): sync translations'

Every push that touches en.json triggers a translation sync; the bot opens a PR with the result. Reviewer approves, merges, ships globally.

Production checklist

  • Static export compatible — works with output: 'export' if you deploy to S3/CloudFront. generateStaticParams pre-renders per locale.
  • ISR works per-locale — server components inside [lang] cache per locale automatically.
  • Use next-intl for ICU plurals if you need rich plural/ordinal/gendered string formatting. The setup above is enough for ~80% of apps.

Wrap up

Your Next.js 15 App Router app now serves English at root, localized URLs at /[lang]/..., with proper hreflang for SEO and a CI pipeline that keeps translations current. Adding the 8th locale is one entry in i18n/config.ts plus one entry in localingos.json — about 30 seconds of work.

Start with the free tier — 5,000 words across all locales covers most Next.js app string corpora.