Un build multilingua è un problema di copy prima che di routing. Il routing, l'hreflang, lo scaffold del dizionario sono tutti risolti per la terza settimana. Quello che resta da fare è rendere il prodotto naturale a un madrelingua, e quel lavoro dipende da una copy sorgente chiara, da un brief dettagliato, e da un reviewer madrelingua che sa distinguere tra una traduzione e una riscrittura.
I cinque file qui sotto sono l'ossatura che l'ingaggio lascia in piedi. Il caricatore di dizionari tipizzati, il middleware di locale routing, l'helper di hreflang e canonical, gli helper di formattazione, e il guardrail di traduzione in CI.
1. Il caricatore di dizionari tipizzati
Il dizionario inglese è la fonte di verità. Le altre lingue importano il tipo inglese e devono implementare ogni chiave; le chiavi mancanti sono errori di compilazione, non bug a runtime che vengono fuori in produzione.
// 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. Il middleware di locale routing
Il middleware legge URL, header Accept-Language e cookie. Le prime visite vengono reindirizzate alla lingua migliore; le visite successive rispettano il cookie. Il cookie viene settato quando l'utente cambia lingua dal 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. L'helper di hreflang e canonical
L'helper produce i tag <link> che ogni pagina emette. Il canonical punta alla pagina nella lingua corrente; l'hreflang elenca ogni lingua supportata; x-default punta alla versione inglese.
// 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. Gli helper di formattazione
Gli helper di formattazione avvolgono Intl.*. Le format string hardcoded vengono bloccate dal lint; ogni valore formattato passa dall'helper, che prende una lingua e (per la valuta) il paese di fatturazione.
// 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. Il guardrail di traduzione in CI
Uno step CI fa diff dei dizionari e fa fallire la build quando un dizionario non-inglese ha una chiave mancante, ha testo inglese dove dovrebbe esserci una traduzione, oppure ha una chiave che non esiste più in inglese. I dizionari restano sincronizzati e il team lo sa prima del deploy, non dopo.
// 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. Cosa compone questo
I dizionari tipizzati tengono ogni stringa sotto un unico tetto e fanno fallire la build quando una si perde. Il middleware spedisce ogni visitatore alla lingua giusta e rispetta la sua scelta. L'helper di hreflang dice a Google che ogni mercato esiste e quale pagina appartiene a quale mercato. Gli helper di formato impediscono che il simbolo del dollaro appaia in Spagna. Il guardrail in CI impedisce al prossimo sviluppatore di spedire una pagina a metà.
Il prodotto si legge nativo a un cliente spagnolo, a un cliente italiano, a un cliente francese che entra dopo. La landing italiana rankerà in Italia perché Google vede una pagina italiana vera, non la traduzione di una pagina inglese. La pagina prezzi mostra euro a Madrid e dollari a San Francisco senza che nessuno tocchi uno switch. Il team che pensava al lancio in un mercato nuovo come a un trimestre di lavoro lo pensa adesso come a uno sprint di traduzione e a un sitemap submit.