A multi-locale build is a copy problem before it is a routing problem. The routing, the hreflang, the dictionary scaffold are all solved by week three. The work that remains is making the product read naturally to a native speaker, and that work depends on the source copy being clear, the brief being detailed, and the reviewer being a native speaker who can tell the difference between a translation and a rewrite.
The five files below are the scaffolding the engagement leaves behind. The typed dictionary loader, the locale routing middleware, the hreflang and canonical helper, the format helpers, and the translation CI guard.
1. The typed dictionary loader
The English dictionary is the source of truth. Other locales import the English type and must implement every key; missing keys are compile errors, not runtime bugs that surface in production.
// src/config/i18n/en.ts
export const en = {
nav: {
home: 'Home',
pricing: 'Pricing',
signIn: 'Sign in',
},
pricing: {
title: 'Pricing that scales with you',
subtitle: 'No setup fee. Cancel anytime.',
cta: 'Start free trial',
},
emails: {
welcome: {
subject: 'Welcome to {productName}',
body: 'You are in. The next step is …',
},
},
} as const
export type Dictionary = typeof en
// src/config/i18n/it.ts
import type { Dictionary } from './en'
export const it: Dictionary = {
nav: {
home: 'Home',
pricing: 'Prezzi',
signIn: 'Accedi',
},
pricing: {
title: 'Prezzi che crescono con te',
subtitle: 'Nessun costo di attivazione. Cancelli quando vuoi.',
cta: 'Prova gratis',
},
emails: {
welcome: {
subject: 'Benvenuto su {productName}',
body: 'Sei dentro. Il prossimo passo è …',
},
},
}
// src/config/i18n/index.ts
import { en } from './en'
import { it } from './it'
import { es } from './es'
export const locales = ['en', 'it', 'es'] as const
export type Locale = (typeof locales)[number]
const dictionaries: Record<Locale, typeof en> = { en, it, es }
export function getDictionary(locale: Locale): typeof en {
return dictionaries[locale] ?? en
}
2. The locale routing middleware
The middleware reads the URL, the Accept-Language header, and a cookie. First visits get redirected to the best locale; subsequent visits respect the cookie. The cookie is set when the user changes locale through a language picker.
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { locales, type Locale } from '@/config/i18n'
const DEFAULT_LOCALE: Locale = 'en'
function pickLocale(req: NextRequest): Locale {
const cookie = req.cookies.get('NEXT_LOCALE')?.value
if (cookie && locales.includes(cookie as Locale)) return cookie as Locale
const accept = req.headers.get('accept-language') ?? ''
for (const part of accept.split(',')) {
const tag = part.split(';')[0].trim().toLowerCase().split('-')[0]
if (locales.includes(tag as Locale)) return tag as Locale
}
return DEFAULT_LOCALE
}
export function middleware(req: NextRequest): NextResponse {
const { pathname } = req.nextUrl
if (locales.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))) {
return NextResponse.next()
}
const locale = pickLocale(req)
const url = req.nextUrl.clone()
url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`
return NextResponse.redirect(url)
}
export const config = { matcher: ['/((?!_next|api|.*\\..*).*)'] }
3. The hreflang and canonical helper
The helper produces the <link> tags every page emits. Canonical points to the page in the current locale; hreflang lists every supported locale; x-default points to the English version.
// src/lib/seo/alternates.ts
import { locales, type Locale } from '@/config/i18n'
const BASE = process.env.PUBLIC_APP_URL ?? 'https://adamarant.com'
export function hreflang(pathByLocale: Record<Locale, string>, current: Locale) {
const alternates: Record<string, string> = {}
for (const locale of locales) {
alternates[locale] = `${BASE}${pathByLocale[locale]}`
}
alternates['x-default'] = `${BASE}${pathByLocale.en}`
return {
canonical: alternates[current],
languages: alternates,
}
}
// app/[lang]/pricing/page.tsx
import type { Metadata } from 'next'
import { hreflang } from '@/lib/seo/alternates'
export async function generateMetadata({ params }: { params: Promise<{ lang: 'en' | 'it' | 'es' }> }): Promise<Metadata> {
const { lang } = await params
return {
alternates: hreflang(
{
en: '/en/pricing',
it: '/it/prezzi',
es: '/es/precios',
},
lang,
),
}
}
4. The format helpers
Format helpers wrap Intl.*. Hardcoded format strings are blocked by lint; every formatted value goes through the helper, which takes a locale and (for currency) the billing country.
// src/lib/format.ts
import type { Locale } from '@/config/i18n'
const LOCALE_TAG: Record<Locale, string> = {
en: 'en-US',
it: 'it-IT',
es: 'es-ES',
}
export function formatDate(date: Date, locale: Locale): string {
return new Intl.DateTimeFormat(LOCALE_TAG[locale], { dateStyle: 'long' }).format(date)
}
export function formatNumber(value: number, locale: Locale): string {
return new Intl.NumberFormat(LOCALE_TAG[locale]).format(value)
}
export function formatCurrency(amountCents: number, currency: string, locale: Locale): string {
return new Intl.NumberFormat(LOCALE_TAG[locale], {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(amountCents / 100)
}
5. The translation CI guard
A CI step diffs the dictionaries and fails the build when a non-English dictionary is missing a key, has English text where it should be translated, or has a key that no longer exists in English. The dictionaries stay in sync, and the team is told before the deploy, not after.
// scripts/i18n/check.ts
import { en } from '../../src/config/i18n/en'
import { it } from '../../src/config/i18n/it'
import { es } from '../../src/config/i18n/es'
type Dictionary = typeof en
function diffKeys(a: Dictionary, b: Dictionary, path = ''): string[] {
const issues: string[] = []
for (const key of Object.keys(a) as Array<keyof Dictionary>) {
const here = path ? `${path}.${String(key)}` : String(key)
if (!(key in b)) {
issues.push(`MISSING in target: ${here}`)
continue
}
const aValue = a[key] as unknown
const bValue = (b as Record<string, unknown>)[String(key)]
if (typeof aValue === 'object' && aValue && !Array.isArray(aValue)) {
issues.push(...diffKeys(aValue as Dictionary, bValue as Dictionary, here))
} else if (typeof aValue === 'string' && typeof bValue === 'string') {
if (aValue === bValue && a !== b) {
issues.push(`UNTRANSLATED in target (matches English): ${here}`)
}
}
}
for (const key of Object.keys(b) as Array<keyof Dictionary>) {
if (!(key in a)) {
const here = path ? `${path}.${String(key)}` : String(key)
issues.push(`STALE in target (no longer in English): ${here}`)
}
}
return issues
}
const issues = [...diffKeys(en, it as Dictionary, 'it'), ...diffKeys(en, es as Dictionary, 'es')]
if (issues.length > 0) {
console.error('i18n issues:\n' + issues.join('\n'))
process.exit(1)
}
console.log('i18n: all dictionaries in sync')
6. What this composes
The typed dictionaries keep every string under one roof and fail the build when one goes missing. The middleware sends every visitor to the right locale and respects their choice. The hreflang helper tells Google each market exists and which page belongs to which. The format helpers stop the dollar sign from showing up in Spain. The CI guard stops the next developer from shipping a half-translated page.
The product reads natively to a Spanish customer, to an Italian customer, to a French customer who joins next. The Italian landing page ranks in Italy because Google sees a real Italian page, not a translation of an English page. The pricing page shows euros in Madrid and dollars in San Francisco without anyone editing a switch statement. The team that used to think launching in a new market was a quarter of work now thinks of it as a translation sprint and a sitemap submission.