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.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 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.