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:
formatdecides your key shape.json-nestedmapshome.titleto{ "home": { "title": … } };json-flatkeeps"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 Projects → Edit → Locales → Update 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.generateStaticParamspre-renders per locale. - ISR works per-locale — server components inside
[lang]cache per locale automatically. - Use
next-intlfor 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 (Projects → Edit → Locales) 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.