React
i18n nell’App Router: URL, dizionari e metadata
Un segmento [lang], dizionari sul server e metadata alternates: come impostare i18n nell’App Router senza pacchetti extra e senza duplicare le route.
In sintesi
- `app/[lang]` è l’unica cartella che serve: il resto sono dizionari e metadata.
- Il locale arriva dal segmento, non da un cookie: così ogni pagina resta indicizzabile.
- La generazione statica di tutte le lingue moltiplica le pagine e va misurata.
L’i18n nell’App Router sta in un segmento dinamico, non in una libreria: app/[lang] più un dizionario caricato sul server. Se il locale vive solo in un cookie, quella pagina non è indicizzabile.
Un segmento, non un pacchetto
Il locale è parte dell’URL. app/[lang]/page.tsx serve tutte le lingue, e generateStaticParams dice a Next quali valori generare in build. Nessun wrapper, nessun provider globale, nessun file di configurazione.
app/
[lang]/
layout.tsx
page.tsx
dizionari/
it.json
en.json
layout.tsx
middleware.ts
Il dizionario si carica sul server
Un import statico di JSON dentro il server component è la forma più semplice e la più veloce: il file entra nel bundle del server, non in quello del client. Passa al client solo le stringhe che servono a un componente interattivo.
// app/[lang]/page.tsx
import { notFound } from "next/navigation";
const lingue = ["it", "en"] as const;
type Lingua = (typeof lingue)[number];
const dizionari: Record Promise }>> = {
it: () => import("../dizionari/it.json"),
en: () => import("../dizionari/en.json"),
};
export function generateStaticParams() {
return lingue.map((lang) => ({ lang }));
}
export default async function Page({ params }: { params: Promise }) {
const { lang } = await params;
if (!lingue.includes(lang as Lingua)) notFound();
const dizionario = (await dizionari[lang as Lingua]()).default;
return {dizionario.titolo};
}
Dove finisce la traduzione e dove il codice
| Cosa | Lingua o codice | Nota |
|---|---|---|
| Titoli, testi, label | Dizionario | Un JSON per lingua, caricato sul server |
| Metadata e hreflang | Codice | generateMetadata con alternates.languages |
| Formato di date e numeri | Codice | Intl.DateTimeFormat con il locale corrente |
| Slug delle pagine | Codice | Un solo schema, tradotto solo se serve alla SEO |
Metadata: la parte che si sbaglia
generateMetadata deve dichiarare alternates.canonical e alternates.languages per ogni lingua pubblicata, più openGraph.locale. Un canonical che punta sempre all’italiano annulla il lavoro fatto sul resto della pagina. Gli errori ricorrenti sono raccolti in hreflang: gli errori che costano.
EneUI indicizza 111.985 componenti da 1.472 librerie e 1.556 blocchi: il numero è misurato il 1 novembre 2026 su ui.eneawork.it, non stimato. Un selettore di lingua cambia con il design system, non con il locale.
Componenti del catalogo utili qui
Tre record da cui partire: il selettore language selector dropdown, la famiglia blocchi navbar con spazio per lo switch, e i blocchi header per una testata bilingue.
Il pattern di redirect nel middleware è lo stesso che uso per l’autenticazione, descritto in middleware Next.js per l’autenticazione. Per il confronto tra framework resta valido Astro vs Next.js sulla documentazione. La struttura delle cartelle è in struttura di un progetto App Router.
Il limite da mettere in conto
generateStaticParams moltiplica le pagine per lingua. Con 400 articoli e due lingue sono 800 file HTML per build, e ogni lingua nuova aggiunge un fattore. Misura il costo prima di aprire il terzo mercato, non dopo.
Domande frequenti
Serve una libreria come next-intl?
No. Un segmento <code>[lang]</code> e un dizionario JSON coprono il caso normale. Una libreria aggiunge plurali, interpolazione e controllo delle chiavi mancanti: adottala quando ti servono davvero.
Meglio il locale nel cookie o nell’URL?
Nell’URL. Il cookie produce una sola versione indicizzabile e un redirect che i crawler non seguono. Il cookie resta utile solo come preferenza per il primo accesso.
Come traduco i metadata?
Con <code>generateMetadata</code> che legge lo stesso dizionario e restituisce <code>title</code>, <code>description</code> e <code>alternates</code> per la lingua corrente. Nessun testo tradotto nel componente.
Posso tradurre solo una parte del sito?
Sì. Dichiara in <code>alternates.languages</code> solo le lingue che esistono davvero. Un hreflang verso una pagina non pubblicata è un errore, non un segnale.
Come gestisco i plurali?
Con <code>Intl.PluralRules</code> sulla lingua corrente, oppure con una libreria che lo faccia. Concatenare suffissi produce testo sbagliato in quasi tutte le lingue.