Skip to content

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

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-US.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
// Use the full BCP 47 codes Localingos uses — the CLI names each file after the
// project's target-locale code, so these keys, the filenames and your URLs all match.
export const LOCALES = ['en-US', 'es-ES', 'de-DE', 'fr-FR'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'en-US';

// Nested source files produce nested dictionaries, which is the CLI default
// (`format: "json-nested"`). Type the value as a tree, not Record<string, string>,
// and resolve dotted keys through it.
type Dictionary = { [key: string]: string | Dictionary };

const dictionaries: Record<Locale, () => Promise<Dictionary>> = {
  'en-US': () => import('./en-US.json').then(m => m.default),
  'es-ES': () => import('./es-ES.json').then(m => m.default),
  'de-DE': () => import('./de-DE.json').then(m => m.default),
  'fr-FR': () => import('./fr-FR.json').then(m => m.default),
};

export const getDictionary = (locale: Locale) => dictionaries[locale]();

export function translate(dict: Dictionary, key: string): string {
  const hit = key.split('.').reduce<string | Dictionary | undefined>(
    (node, part) => (typeof node === 'object' ? node?.[part] : undefined),
    dict,
  );
  return typeof hit === 'string' ? hit : key;
}

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

Step 4 — Configure Localingos

Run localingos init and answer its prompts. It writes two files.

localingos.config.json — commit this. Project settings shared with your team and CI:

{
  "projectId": "your-project-id",
  "sourceLocale": "en-US",
  "format": "json-nested",
  "sourceFile": "i18n/en-US.json",
  "outputDir": "i18n",
  "outputPattern": "{locale}.json"
}

.localingos.json — add this to .gitignore. Your API key for local development:

{ "apiKey": "your-api-key" }

In CI, set LOCALINGOS_API_KEY instead; it takes precedence over both files. init is interactive, so for containers, provisioning scripts or AI agents, write these two files yourself — the CLI reads nothing else.

Three things worth knowing:

  • format decides your key shape. json-nested maps home.title to { "home": { "title": … } }; json-flat keeps "home.title" as one top-level key. Those are the two supported values.
  • Target locales are not configured here. They belong to the project itself — set them in the dashboard under ProjectsEditLocalesUpdate Project. The CLI writes one file per target locale the project has, so you add a language without touching your repo. Codes are full BCP 47, e.g. es-ES, pt-BR, zh-TW.
  • Placeholder preservation is automatic. Localingos extracts placeholders from the source string — {{name}}, {name}, ${count}, %s, %d, ICU fragments — and validates each one survives translation, retrying with a corrective prompt when it doesn't. There is nothing to configure.

Then push your source strings and pull back translations:

localingos sync

Translation is asynchronous

The first sync of a new key pushes it and usually has nothing to pull back yet:

Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation: home.title, home.subtitle, …
No new translations. Run "localingos sync" again later.

That's expected. Run localingos sync (or localingos pull) again shortly to collect results. Unchanged strings are never re-translated or re-billed.

Verify completeness before committing. pull writes whatever is ready and exits 0, so a file pulled mid-translation can be missing keys with no warning and no error — at runtime that surfaces as a silent fallback to your source language. Check key counts per locale before you commit, and gate on it in CI.

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-US.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
      - 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-US.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 change on the project in the dashboard (ProjectsEditLocales) plus one entry in i18n/config.ts.

Start with the free tier: 5,000 words, no credit card. Usage counts your source words once per target locale, so that covers a small app in a handful of languages rather than a full corpus in all 56 — which plan do I need? works it out for your own numbers.