Web Design and Engineering18 luglio 20269 min di lettura

i18n in Next.js senza librerie: il pattern dell’App Router

L’App Router di Next.js non ha i18n integrato. Facciamo girare tre lingue su routing nativo, zero dipendenze. Ecco il pattern che usiamo in produzione.

a signpost with several arrows

Alla fine di questa guida hai un sito Next.js 16 che serve tre lingue su routing nativo, senza nessun pacchetto i18n tra le dipendenze. Inglese su /en, italiano su /it, spagnolo su /es, URL localizzati dove contano e tag hreflang corretti, cosí Google mostra a ogni mercato la sua versione. È il pattern che usiamo in produzione su un sito a tre lingue.

L'App Router ha tolto il routing i18n integrato che il Pages Router aveva dalla versione 10 di Next.js. In next.config.js la chiave i18n non esiste piú. Sembra una mancanza. È piú una tela pulita: il routing delle lingue ora sta in codice tuo e, per un insieme fisso di lingue, non serve una libreria per gestirlo. Una libreria conviene per un altro motivo, e lo vediamo alla fine.

Cosa ti serve prima di partire

  • Next.js 16 su App Router, React 19.
  • Un insieme di lingue deciso e piccolo. Questo pattern regge da 2 a 6 lingue. Oltre, uno strumento di gestione delle traduzioni inizia a ripagare.
  • Traduzioni esprimibili come stringhe chiave-valore. Testi di marketing, label, metadati SEO: sí. Contratti legali con clausole per giurisdizione: quelli trattali come documenti a parte, non come chiavi di dizionario.
  • Una scelta fatta all'inizio: quale lingua è quella di default e se porta un prefisso nell'URL. Noi prefissiamo tutte e tre (/en, /it, /es) perché URL simmetrici sono piú facili da ragionare di un default a radice nuda.

Passo 1: metti ogni pagina sotto un segmento app/[lang]

Tutto il pattern regge su un solo segmento dinamico. Sposta ogni rotta pubblica sotto app/[lang]/. Next.js passa il parametro lang a ogni layout e pagina piú in basso.

app/
  [lang]/
    layout.tsx      // legge lang, imposta il provider
    page.tsx        // home
    blog/
      page.tsx
      [slug]/page.tsx
  layout.tsx        // root: html, font, niente di specifico per lingua

Tieni i nomi delle cartelle nella lingua di default (inglese): blog, projects, about. Gli URL tradotti arrivano dopo, nel Passo 5, con una rewrite: cosí non ti ritrovi tre copie dello stesso albero di rotte.

Passo 2: rileva la lingua in proxy.ts, il file che una volta era il middleware

Next.js 16 ha rinominato middleware.ts in proxy.ts e la funzione esportata da middleware a proxy. La logica non cambia, il runtime è solo Node, il vecchio file funziona ancora ma è deprecato. Su un progetto esistente lancia npx @next/codemod middleware-to-proxy .. I progetti nuovi partono direttamente da proxy.ts.

Il proxy fa tre cose: manda la / nuda alla lingua piú probabile del visitatore, lascia passare le richieste che hanno già un prefisso valido, salta gli asset statici e /api. Il rilevamento legge l'header Accept-Language.

// proxy.ts
import { NextRequest, NextResponse } from 'next/server'

const locales = ['en', 'it', 'es']
const defaultLocale = 'en'

function pickLocale(req: NextRequest) {
  const header = req.headers.get('accept-language') ?? ''
  const wanted = header.split(',').map(p => p.split(';')[0].trim().slice(0, 2))
  return wanted.find(l => locales.includes(l)) ?? defaultLocale
}

export function proxy(req: NextRequest) {
  const { pathname } = req.nextUrl
  const hasLocale = locales.some(l => pathname === '/' + l || pathname.startsWith('/' + l + '/'))
  if (hasLocale) return NextResponse.next()
  const locale = pickLocale(req)
  return NextResponse.redirect(new URL('/' + locale + pathname, req.url))
}

export const config = { matcher: ['/((?!api|_next|.*\..*).*)'] }

Il match a due lettere fatto a mano va bene per poche lingue. La guida ufficiale di Next.js usa Negotiator e @formatjs/intl-localematcher per un match best-fit vero, che tiene conto dei valori di qualità. Sono entrambi minuscoli. Servono nel momento in cui gestisci varianti regionali come pt-BR contro pt-PT, dove il taglio ingenuo a due lettere si rompe.

Passo 3: i dizionari sono stringhe, mai funzioni

È la regola che ti evita un crash in build. Un dizionario è un oggetto annidato di stringhe, un file per lingua, con la stessa forma in tutte e tre.

// dictionaries/en.ts
export const en = {
  nav: { home: 'Home', blog: 'Blog', contact: 'Contatti' },
  blog: { readingTime: '{n} min di lettura' },
} as const

export type Dictionary = typeof en
// it.ts ed es.ts dichiarano le stesse chiavi, o TypeScript protesta

Mai mettere una funzione in un dizionario. React 19 e Next 16 lanciano "Functions cannot be passed directly to Client Components" in build, appena un valore-funzione attraversa il confine Server-Client. Quindi niente readingTime: (n) => `${n} min`. Salva il segnaposto come stringa e interpola dove lo usi: t.blog.readingTime.replace('{n}', String(minuti)). Con Dictionary = typeof en l'inglese diventa la fonte di verità: ogni chiave che aggiungi in inglese e dimentichi in italiano è un errore di tipo, non uno spazio vuoto in produzione.

Passo 4: leggi il dizionario nei Server Component, passalo ai Client Component

Una funzione getDictionary mappa una lingua al suo oggetto. I Server Component la chiamano direttamente dopo aver atteso params. In Next 16 params è una promise.

// dictionaries/index.ts
import { en } from './en'; import { it } from './it'; import { es } from './es'
const dicts = { en, it, es }
export const getDictionary = (lang: string) => dicts[lang] ?? en

// app/[lang]/page.tsx (Server Component)
export default async function Home({ params }: { params: Promise<{ lang: string }> }) {
  const { lang } = await params
  const t = getDictionary(lang)
  return <h1>{t.nav.home}</h1>
}

Un Client Component non può chiamare una funzione server asincrona, quindi avvolgi l'albero in un context provider che riceve dal layout il dizionario già risolto. Un hook useDictionary() lo legge ovunque piú sotto. Cosí il dizionario resta fuori dal bundle client, tranne le stringhe che una data pagina rende davvero. Se non sai bene quali componenti devono stare lato client, il nostro pezzo sull'architettura del sito spiega dove tiriamo quella linea.

Passo 5: dai a ogni lingua i suoi slug

Un utente italiano cerca progetti, non projects. Servire /it/projects lascia posizionamento sul tavolo. Invece di duplicare l'albero delle rotte, tieni un solo albero interno con slug inglesi e nel proxy riscrivi verso di esso gli URL localizzati.

// config/routes.ts
export const ROUTE_SLUGS = {
  it: { projects: 'progetti', about: 'chi-siamo' },
  es: { projects: 'proyectos', about: 'nosotros' },
}
// il proxy riscrive /it/progetti -> interno /it/projects, in modo trasparente

Il visitatore vede /it/progetti. Next.js rende il file in app/[lang]/projects/. Un solo albero di rotte, tre superfici di URL. Un helper translatePathname fa il contrario per il tuo selettore di lingua, cosí chi è su /it/progetti atterra su /es/proyectos, non su /es/progetti.

Passo 6: emetti canonical e hreflang da generateMetadata

I motori di ricerca devono sapere che i tre URL sono la stessa pagina in lingue diverse. Emetti gli alternate hreflang e un canonical auto-referenziale da generateMetadata. Se sbagli qui, Google tratta le pagine italiana e spagnola come duplicati sottili di quella inglese.

export async function generateMetadata({ params }) {
  const { lang } = await params
  return {
    alternates: {
      canonical: `https://example.com/${lang}`,
      languages: { en: '/en', it: '/it', es: '/es', 'x-default': '/en' },
    },
  }
}

La <html lang> alla radice può restare statica sul default, mentre l'hreflang per pagina porta il segnale vero, e ai crawler basta. Rendere dinamica la <html lang> vuol dire spostare l'elemento in [lang]/layout.tsx: un cambio che puoi rimandare a quando serve.

Come verificare che funzioni

  • Chiama / con un header Accept-Language: it e verifica che il redirect atterri su /it.
  • Apri /it/progetti e verifica che renda la pagina progetti in italiano, con l'URL invariato nella barra.
  • Guarda il sorgente di una pagina e controlla che ci siano tre tag <link rel="alternate" hreflang> piú un canonical.
  • Aggiungi una chiave solo a en.ts e lancia tsc. Un errore di tipo conferma che i dizionari sono allineati.
  • Fai la build di produzione. Nessun errore "Functions cannot be passed" vuol dire dizionari puliti.

Errori frequenti e come risolverli

Il proxy va in loop di redirect. Il matcher intercetta /_next o i file statici, quindi ogni asset viene rediretto e richiesto di nuovo. Stringi il config.matcher per escludere api, _next e tutto ciò che ha un'estensione.

La build fallisce con "Functions cannot be passed directly to Client Components". Un valore del dizionario è una funzione, oppure passi un helper server come prop a un componente client. Trasforma le stringhe dinamiche in segnaposto e interpola dove le usi.

Il selettore di lingua manda gli utenti su un 404. Cambia solo il prefisso della lingua e tiene lo slug di partenza, cosí /it/progetti diventa /es/progetti, che non esiste. Fai passare il cambio dalla tua mappa slug inversa, o usa gli alternate hreflang già presenti nel DOM.

Le pagine italiane si posizionano sotto l'URL inglese. hreflang mancante o sbagliato. Verifica che ogni lingua elenchi tutti gli alternate, incluso x-default, e che ogni canonical punti a sé stesso, non alla versione inglese.

Quando una libreria conviene davvero

Il routing qui sopra non ha bisogno di nessuna dipendenza. Il limite onesto è la grammatica. Appena il testo ha bisogno di plurali veri, una libreria si ripaga. L'inglese ha 2 forme plurali, l'arabo ne ha 6, e polacco e russo stanno in mezzo con regole che un ternario ?: non esprime in modo pulito. È quello che risolve la sintassi ICU dei messaggi, ed è la vera ragione per prendere qualcosa come next-intl, che pesa circa 2KB e sa precompilare i messaggi ICU sotto 1KB, appoggiandosi all'API nativa Intl.

Prendi una libreria quando è vero uno di questi punti: ti servono plurali o generi ICU, affidi le traduzioni a non sviluppatori tramite un sistema di gestione delle traduzioni, oppure il numero di lingue cresce oltre la manciata. Resta nativo quando le lingue sono poche, le stringhe sono coppie chiave-valore piatte e il team le modifica nel repo. Noi siamo rimasti nativi. Per il solo oggetto Intl, che formatta date, numeri e valute per lingua, non serve nessun pacchetto. Se stai valutando la questione piú ampia delle dipendenze, la nostra lettura su cosa cambia in Next.js 16 e React 19 spiega perché teniamo l'albero piccolo.

Foto di Ian Ward su Unsplash

Domande frequenti

Serve una libreria i18n per Next.js nel 2026?
Non per un insieme fisso e piccolo di lingue con traduzioni fatte di stringhe chiave-valore piatte. L’App Router ti dà segmenti di rotta dinamici, rilevamento della lingua nel proxy e l’API nativa Intl, che coprono routing, formattazione e metadati senza dipendenze. Una libreria serve quando il testo ha bisogno di plurali o generi ICU, quando le traduzioni le modificano non sviluppatori tramite uno strumento di gestione, o quando le lingue superano la manciata. Per due-sei lingue modificate nel repo, il nativo è meno codice da mantenere e niente da tenere aggiornato.
Cosa ha sostituito middleware.ts per il routing i18n in Next.js 16?
Next.js 16 ha rinominato middleware.ts in proxy.ts e la funzione esportata da middleware a proxy. La logica i18n è identica: rileva la lingua, redirige la radice nuda, riscrivi gli slug localizzati. Il runtime è solo Node, quindi il runtime Edge non è piú disponibile per questo file, e il vecchio middleware.ts funziona ancora ma è deprecato. Lancia npx @next/codemod middleware-to-proxy . per migrare in modo meccanico un progetto esistente.
Come gestisco i plurali senza una libreria i18n?
Per i casi semplici, l’API nativa Intl.PluralRules ti dice in quale categoria di plurale cade un numero per una data lingua, e tu ti ramifichi su quella. Funziona bene per inglese, italiano e spagnolo, che hanno due forme ciascuno. Diventa faticoso per lingue con tre-sei forme, come polacco, russo o arabo, dove le regole non sono ovvie ed è facile sbagliarle. È il punto in cui la sintassi ICU dei messaggi, e una libreria che la implementa, smette di essere opzionale. Se tutte le tue lingue hanno regole di plurale semplici, resta nativo; se una ha regole complesse, adotta ICU invece di scriverle a mano.
Posso aggiungere una quarta lingua dopo senza rifare tutto?
Sí, se fin dall’inizio hai preso l’elenco delle lingue da un solo array di config. Aggiungere una lingua vuol dire tre cose: aggiungi il codice all’array locales, aggiungi il suo file di dizionario con le stesse chiavi dell’inglese, e riempi le sue voci nella mappa degli slug localizzati. Nessun file di rotta cambia, perché ogni pagina sta già sotto l’unico segmento app/[lang]. Il costo vero è la traduzione, non il codice. È il vantaggio di tenere l’inglese come fonte di verità tipizzata: appena a un nuovo dizionario manca una chiave, TypeScript te la segnala prima del rilascio.

Articoli correlati

Studio

Inizia un progetto.

Un partner unico per il prodotto digitale che devi costruire. Produzione più veloce, tecnologie moderne, costi ridotti. Un team, una fattura.