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

CosaLingua o codiceNota
Titoli, testi, labelDizionarioUn JSON per lingua, caricato sul server
Metadata e hreflangCodicegenerateMetadata con alternates.languages
Formato di date e numeriCodiceIntl.DateTimeFormat con il locale corrente
Slug delle pagineCodiceUn 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.