Un SaaS che si legge nativo in ogni lingua che spedisce, non tre riscritture della stessa copy inglese
Dizionari di stringhe tipizzati per lingua, locale routing sotto `/[lang]` con redirect dagli URL legacy, hreflang che Google legge bene, una pipeline di review nativa così la copy spagnola non suona inglese travestita da spagnolo. Formattazione di prezzi, date e numeri legata alla lingua. Un repo, un design system, quattro lingue.
Il problema
Tre riscritture della stessa copy inglese non sono un prodotto multilingua
Il pattern fallisce sempre allo stesso modo. Il founder ingaggia un traduttore, esporta le stringhe su un Google Sheet, le riceve indietro in tre lingue, le butta in un file JSON e spedisce. Sei mesi dopo il team ha il 40% delle stringhe tradotte (le altre dicono `[en] sign up`), i clienti spagnoli si lamentano che la copy sembra Google Translate del 2014, Google indicizza solo le route inglesi perché gli hreflang sono sbagliati, e la pagina prezzi mostra euro ai clienti americani perché la valuta è hardcoded. Ricostruiamo la superficie i18n dall'interno. Dizionari tipizzati con una sola fonte di verità per lingua. Routing sotto `/[lang]` che rispetta la lingua del browser e resta saldo dopo la scelta dell'utente. Review nativa di ogni traduzione, mai uno scarico AI. SEO che fa rankare ogni lingua nel proprio mercato. Il prodotto si legge nativo in ogni lingua che il founder è disposto a sostenere.
Come lo affrontiamo
Sei passi da una lingua a quattro che si leggono native
Audit della copy prima della struttura. Struttura prima del routing. Routing prima della SEO. SEO prima della review. Review prima del lancio. Saltare l'audit produce un codebase dove il 30% delle stringhe non è nel dizionario; saltare la review nativa produce copy che perde il deal alla prima frase.
- 01
Audit della copy
Facciamo grep su ogni JSX, MDX, email, push notification e seed di database alla ricerca di stringhe utente hardcoded. Output: un CSV con file, riga e la stringa. L'audit di un SaaS vero torna 800-2.500 stringhe; metà nei componenti, un terzo nelle pagine marketing, il resto in email, messaggi d'errore e dati seed. L'audit è il brief; nulla si muove prima che il founder veda il numero.
- 02
Costruire la struttura del dizionario
Un dizionario tipizzato per lingua, organizzato per superficie (`nav`, `home`, `pricing`, `account`, `emails`). Il dizionario è un modulo TypeScript, non un file JSON, così le chiavi mancanti falliscono in compile time. Ogni chiave ha un commento di una riga che spiega il contesto (dove appare, quale azione precede, che tono porta). I traduttori leggono i commenti; il risultato è una copy che sta nel momento, non nella traduzione letterale dell'inglese.
- 03
Locale routing
La struttura URL diventa `/[lang]/...`. Il middleware legge l'header `Accept-Language` alla prima visita, sceglie la lingua migliore dalla lista supportata, e fa redirect. Un cookie ricorda la scelta quando l'utente la cambia. Gli URL legacy (quelli usciti prima dell'i18n) ricevono redirect 301 ai nuovi URL prefissati per lingua. La mappa di redirect viene generata una volta dall'inventario delle route; i link in ingresso e le campagne email esistenti continuano a funzionare.
- 04
hreflang e canonical
Ogni pagina emette `<link rel="alternate" hreflang="...">` per ogni lingua supportata più `x-default`. I canonical URL sono settati per lingua, non collassati su quello inglese. La sitemap elenca ogni lingua di ogni pagina. Google indicizza ogni mercato bene; la landing spagnola rankerà in Spagna, quella italiana in Italia, nessuna delle due fa concorrenza a quella inglese.
- 05
Pipeline di review nativa
Le traduzioni passano da una review di un madrelingua prima di atterrare nel repo. Lavoriamo con i traduttori del founder dove possibile; abbiamo una rotazione di tre per inglese, italiano e spagnolo dove il founder non ce li ha. Ogni traduzione riceve una nota di contesto (dove appare, quale azione precede) e un SLA di 24 ore. Le prime bozze AI-assistite sono concesse; le traduzioni solo-AI no. La review segnala le stringhe che richiedono una riscrittura culturale, non solo letterale.
- 06
Formattazione e valute
Date, numeri, valute e ordinali vengono da `Intl.*`. La lingua porta il formato; nulla è hardcoded. I clienti americani vedono dollari e MM/DD/YYYY; i tedeschi vedono euro e DD.MM.YYYY; i giapponesi vedono yen e un calendario che rispetta la lingua. Le valute che cambiano col paese di fatturazione del cliente (non con la lingua della pagina) leggono dal record di fatturazione, non dalla URL. I due casi sono espliciti nell'API.
Cosa consegniamo
Audit della copy
Un CSV che elenca ogni stringa utente hardcoded nel codebase, con file, riga e un tag tra `component`, `page`, `email`, `error`, `seed`. Il manufatto che il resto dell'ingaggio legge. Ri-eseguibile; una regola di lint impedisce che nuove stringhe hardcoded rientrino dalla finestra.
Scaffold del dizionario tipizzato
Un modulo TypeScript per lingua, organizzato per superficie. Strict type-check; le chiavi mancanti sono errori di compilazione. Commenti su ogni chiave che spiegano il contesto. La lingua inglese è la fonte di verità; i dizionari non-inglesi ereditano le chiavi.
Brief al traduttore
Un documento che il traduttore legge prima di partire. Brand voice (con le parole del founder, non un template marketing), registro di tono per superficie (formale per il legale, conversazionale per il prodotto, diretto per il marketing), un glossario dei termini che devono restare consistenti. Il documento che il founder scrive una volta e i traduttori leggono ogni volta.
Middleware di locale routing
Un middleware Next.js che legge `Accept-Language`, sceglie la lingua migliore tra quelle supportate, fa redirect alla prima visita e rispetta il cookie nelle visite successive. Documentato. Il path `app.x/it` serve sempre italiano; il path `app.x/` fa redirect alla lingua dell'utente.
Mappa di redirect URL legacy
Un file di redirect che mappa ogni URL pre-i18n al corrispondente URL localizzato con un 301. Generata una volta dall'inventario delle route; copre campagne email, link in ingresso, pagine indicizzate nella sitemap. Esce nella stessa PR del cambio di routing, così non si rompe nessun link durante il cutover.
Setup di hreflang e canonical
Un helper che emette `<link rel="alternate" hreflang="...">` per ogni lingua su ogni pagina, più `x-default`, più canonical. Fa uscire una sitemap che elenca ogni lingua di ogni pagina. Verificato contro Google Search Console dopo il cutover; spediamo i fix che la console segnala nelle prime due settimane.
Workflow di review delle traduzioni
Un workflow Linear-o-simili con un ticket per superficie per lingua. I traduttori prendono i ticket, i reviewer (madrelingua, a volte il founder) approvano o chiedono modifiche. Il workflow vive fuori dal codebase; le stringhe atterrano nel repo solo dopo la review.
Helper di formattazione
Una piccola libreria di wrapper `formatDate`, `formatCurrency`, `formatNumber`, `formatOrdinal` che prendono la lingua (e, per la valuta, il paese di fatturazione) e tornano la stringa formattata. Ogni posto nel codebase che formatta valori per l'utente passa da qui. Le format string hardcoded vengono bloccate dal lint.
Traduzioni di email e notifiche
Ogni email transazionale e corpo di push notification vive nello stesso dizionario della copy in-product. L'email di reset password si legge naturale in italiano perché il traduttore italiano l'ha rivista; quella detettata come AI no.
SEO per lingua
`<title>` e `<meta description>` localizzati per pagina per lingua. Tag Open Graph localizzati. Dati strutturati localizzati dove portano contenuto (FAQ, Breadcrumb, Article). Chi cerca il prodotto italiano in italiano trova la pagina italiana, non il fallback inglese.
Guardrail i18n in CI
Uno step CI che segnala chiavi mancanti, chiavi non tradotte (testo inglese in un dizionario non-inglese) e chiavi presenti nei dizionari non-inglesi ma non più nell'inglese. I dizionari restano sincronizzati; lo sviluppatore che aggiunge una chiave nuova la aggiunge per ogni lingua (oppure la marca come `pending_translation` con il link a un issue).
Checklist di lancio e audit post-lancio
Una checklist da 20 righe che il team passa prima di ogni lancio di una nuova lingua (sitemap inviata, hreflang verificato, valuta testata, mappa di redirect confermata, OG image rigenerate). Un audit a 30 giorni dal lancio misura il rank in ricerca per lingua e il volume di ticket da ogni mercato; il risultato guida il giro di traduzione successivo.
Cinque file che compongono un prodotto Next.js multilingua
I cinque file qui sotto compongono la superficie i18n: 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.
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.
Domande frequenti
Si può aggiungere una lingua dopo il lancio senza ricostruire la superficie i18n?
Sì, di proposito. Lo scaffold del dizionario e il routing gestiscono N lingue; aggiungerne una quinta è una PR più il lavoro di traduzione. Il costo della costruzione è soprattutto l'audit e la struttura; una volta che esistono, ogni lingua in più prende una-tre settimane di review di traduzione, non una ricostruzione.
Ci serve un translation management system (Lokalise, Crowdin, Phrase)?
Spesso no, soprattutto sotto le 1.500 stringhe e le 4 lingue. I dizionari TypeScript più un workflow Linear gestiscono bene quella scala; il TMS aggiunge costi e indirezione di cui i team piccoli non hanno bisogno. Sopra quella scala, o quando i traduttori chiedono glossari e translation memory, cabliamo Lokalise o simili e teniamo i dizionari tipizzati come fonte di verità.
E le traduzioni AI-assistite?
Le prime bozze AI-assistite vanno bene e le usiamo. Le traduzioni solo-AI no. Il reviewer madrelingua intercetta il calco, il mismatch culturale, la brand voice che deriva verso il default del modello. La pipeline impone un occhio nativo su ogni stringa; l'AI velocizza il primo passaggio.
Come gestite i prezzi in valute diverse?
Due pattern, entrambi supportati. Pattern A: il pricing dipende dalla lingua (il prezzo EUR sulla pagina spagnola, il prezzo USD sulla pagina USA). Pattern B: il pricing segue il paese di fatturazione del cliente (il prezzo EUR a un cliente spagnolo a prescindere dalla lingua di navigazione). La maggior parte dei SaaS fa B; l'API distingue lingua e paese di fatturazione esplicitamente.
E se la nostra app è ibrida (alcune pagine tradotte, altre no)?
Il dizionario supporta la copertura parziale. Le pagine che hanno una chiave tradotta servono la traduzione; quelle che non l'hanno cadono in fallback sull'inglese con un warning in console. L'audit fa emergere il gap di copertura così il founder decide cosa tradurre dopo. Non blocchiamo il lancio perché tre messaggi d'errore sono ancora in inglese.
Quanto ci vuole?
Sei-dieci settimane per la prima lingua nuova dopo il kickoff, a seconda del numero di stringhe. L'audit della copy prende una settimana. Lo scaffold del dizionario e il routing prendono una settimana. La review di traduzione su 800-2.500 stringhe prende tre-sei settimane (parallelizzabile tra traduttori). hreflang, valute e traduzioni email escono nelle ultime due settimane. La lingua successiva prende due-tre settimane ciascuna.
E le lingue right-to-left (arabo, ebraico)?
In scope quando il founder lo chiede; facciamo audit del CSS per logical property (il design system usa già `padding-inline` ecc.) e spediamo una variante RTL. RTL raddoppia il lavoro di QA sulle pagine coinvolte; segnaliamo il lavoro nella call di scoping così la timeline combacia con lo scope.
Sostituite il nostro copywriter o il team marketing?
No. La pipeline rende load-bearing il loro lavoro in ogni lingua. Il copywriter inglese è la fonte; i traduttori nativi sono le fonti seconde; il founder verifica la brand voice in ogni lingua. L'ingaggio lascia al team la capacità di spedire una campagna nuova in quattro lingue in una settimana, non in quattro mesi.
Definisci lo scope del tuo build i18n
Una call di scoping, un audit della copy nella prima settimana, uno scope fisso e un numero che teniamo. Sei-dieci settimane dal kickoff a un prodotto che si legge nativo nelle lingue che spedisci, con la SEO e la formattazione che vanno con loro.