Internazionalizzare React Router v7 con remix-i18next
A maggio 2024 il team di Remix ha annunciato che Remix come framework autonomo viene integrato in React Router v7 — e la developer experience tipica di Remix diventa la «modalità Framework» di React Router. La libreria i18n usata dalla maggior parte delle app Remix, remix-i18next, ha seguito il passo: dalla versione 7.x punta alla modalità Framework di React Router v7 (e la linea 6.x continua a supportare Remix v2).
Questo articolo guida attraverso la nuova forma: come localizzare un'app React Router v7 con i18next, remix-i18next 7.x e la piattaforma di gestione delle traduzioni Locize — incluso il nuovo pattern middleware di React Router, il rendering server-side con traduzioni in bundle e il lazy loading lato client basato su CDN.
Se siete ancora su Remix v2, seguite invece l'articolo Remix v2 e restate su remix-i18next 6.x.
Se usate React Router v7 in modalità SPA (senza SSR), utilizzate direttamente react-i18next — questo articolo è focalizzato sulla modalità Framework + SSR.
TL;DR
- Usate il middleware di remix-i18next 7.x (
createI18nextMiddleware) per rilevare la locale per ogni richiesta e inizializzare un'istanza i18next per richiesta. - Mettete le traduzioni in bundle lato server tramite import statici di JSON (nessuna chiamata CDN a runtime — serverless-friendly).
- Usate
i18next-locize-backendsul client per fare lazy load delle traduzioni fresche dal CDN di Locize — gli aggiornamenti pubblicati compaiono senza redeploy. - Inviate automaticamente le chiavi mancanti a Locize con
saveMissing. - Modificate in-context con il plugin
locize(aggiungete?incontext=truea una URL). - Esempio completo: github.com/locize/locize-react-router-example
Cosa è cambiato rispetto a Remix v2
Tre parti dell'integrazione sono cambiate in modo sostanziale tra remix-i18next 6.x (Remix v2) e 7.x (React Router v7):
| Area | Remix v2 / remix-i18next 6 | React Router v7 / remix-i18next 7 |
|---|---|---|
| Rilevamento della locale | Una classe RemixI18Next istanziata una sola volta al caricamento del modulo, chiamata esplicitamente dai loader | Un middleware di rotta (createI18nextMiddleware) dichiarato in root.tsx che gira per ogni richiesta e inizializza il contesto |
| Accesso alle traduzioni nel loader | remixI18n.getLocale(request) / remixI18n.getFixedT(request, ns) | getLocale(context) / getInstance(context) dalla tupla restituita dal middleware |
| Sistema di build | Compiler classico di Remix o plugin Vite di Remix | Plugin Vite di React Router (@react-router/dev/vite) — Vite è l'unica via in avanti |
I plugin di traduzione (i18next-locize-backend, locize, locize-lastused, i18next-browser-languagedetector) restano invariati.
Iniziamo
Prerequisiti
Assicuratevi di avere Node.js 20+ e una versione recente di npm/pnpm/yarn. Dovreste avere familiarità con React e aver letto almeno una volta i docs della modalità Framework di React Router v7.
Layout del progetto
Potete generare una nuova app React Router v7 con lo starter ufficiale:
npx create-react-router@latest my-app
cd my-appPer il resto di questo articolo, le parti rilevanti del layout dell'app sono:
app/
├── root.tsx — dichiara il middleware i18next
├── entry.client.tsx — init i18next lato client (backend CDN di Locize)
├── entry.server.tsx — renderer lato server, riceve l'istanza i18next dal middleware
├── routes.ts — configurazione delle rotte
├── middleware/
│ └── i18next.ts — setup di createI18nextMiddleware
├── locales/ — traduzioni in bundle (JSON, sincronizzati da Locize)
│ ├── en/{common,index,second}.json
│ ├── de/{common,index,second}.json
│ └── index.ts — riesporta tutti i JSON come un unico oggetto `resources`
└── routes/
├── home.tsx
└── second.tsxNota: i file JSON di traduzione stanno sotto app/locales/ (non public/locales/), perché Vite non mette in bundle gli import statici da public/. È il posto più pulito per le traduzioni che devono essere importate come moduli ES lato server.
Installare i18next + remix-i18next + Locize
npm install remix-i18next i18next react-i18next \
i18next-browser-languagedetector i18next-locize-backend \
locize locize-lastused
npm install --save-dev locize-cliAbilitare il middleware in react-router.config.ts
Il supporto al middleware è attualmente dietro un future flag in React Router v7:
import type { Config } from '@react-router/dev/config'
export default {
ssr: true,
future: {
v8_middleware: true,
},
} satisfies ConfigMettere in bundle le traduzioni
Esportate ogni lingua × ogni namespace come un oggetto, così da poterlo passare a i18next lato server:
// app/locales/index.ts
import enCommon from './en/common.json'
import enIndex from './en/index.json'
import enSecond from './en/second.json'
import deCommon from './de/common.json'
import deIndex from './de/index.json'
import deSecond from './de/second.json'
export default {
en: {
common: enCommon,
index: enIndex,
second: enSecond,
},
de: {
common: deCommon,
index: deIndex,
second: deSecond,
},
}Il middleware — app/middleware/i18next.ts
Questo è il cuore del nuovo pattern. createI18nextMiddleware restituisce una tupla a 3: il middleware stesso più funzioni accessor per la locale e l'istanza i18next:
import { initReactI18next } from 'react-i18next'
import { createCookie } from 'react-router'
import { createI18nextMiddleware } from 'remix-i18next/middleware'
import resources from '~/locales'
export const localeCookie = createCookie('lng', {
path: '/',
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
})
export const [i18nextMiddleware, getLocale, getInstance] =
createI18nextMiddleware({
detection: {
supportedLanguages: ['en', 'de'],
fallbackLanguage: 'en',
cookie: localeCookie,
},
i18next: {
resources,
fallbackLng: 'en',
supportedLngs: ['en', 'de'],
defaultNS: 'common',
ns: ['common', 'index', 'second'],
},
plugins: [initReactI18next],
})Collegare il middleware in root.tsx
import { useEffect } from 'react'
import {
data,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from 'react-router'
import { useTranslation } from 'react-i18next'
import type { Route } from './+types/root'
import {
getLocale,
i18nextMiddleware,
localeCookie,
} from './middleware/i18next'
// Dichiarazione del middleware — gira per ogni richiesta attraverso la rotta root.
export const middleware = [i18nextMiddleware]
export async function loader({ context }: Route.LoaderArgs) {
const locale = getLocale(context)
return data(
{ locale },
{ headers: { 'Set-Cookie': await localeCookie.serialize(locale) } },
)
}
export function Layout({ children }: { children: React.ReactNode }) {
const { i18n } = useTranslation()
return (
<html lang={i18n.language} dir={i18n.dir(i18n.language)}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export default function App({ loaderData: { locale } }: Route.ComponentProps) {
const { i18n } = useTranslation()
useEffect(() => {
if (i18n.language !== locale) i18n.changeLanguage(locale)
}, [locale, i18n])
return <Outlet />
}Rendering lato server — entry.server.tsx
getInstance(routerContext) restituisce l'istanza i18next per richiesta che il middleware ha inizializzato. Avvolgete l'albero React in I18nextProvider, così che useTranslation() durante l'SSR risolva l'istanza corretta:
import { PassThrough } from 'node:stream'
import { createReadableStreamFromReadable } from '@react-router/node'
import type { EntryContext, RouterContextProvider } from 'react-router'
import { ServerRouter } from 'react-router'
import { isbot } from 'isbot'
import type { RenderToPipeableStreamOptions } from 'react-dom/server'
import { renderToPipeableStream } from 'react-dom/server'
import { I18nextProvider } from 'react-i18next'
import { getInstance } from './middleware/i18next'
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
entryContext: EntryContext,
routerContext: RouterContextProvider,
) {
const i18nInstance = getInstance(routerContext)
return new Promise((resolve, reject) => {
const userAgent = request.headers.get('user-agent')
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || entryContext.isSpaMode
? 'onAllReady'
: 'onShellReady'
const { pipe, abort } = renderToPipeableStream(
<I18nextProvider i18n={i18nInstance}>
<ServerRouter context={entryContext} url={request.url} />
</I18nextProvider>,
{
[readyOption]() {
const body = new PassThrough()
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(createReadableStreamFromReadable(body), {
headers: responseHeaders,
status: responseStatusCode,
}),
)
pipe(body)
},
onShellError(error) { reject(error) },
onError(error) {
responseStatusCode = 500
console.error(error)
},
},
)
setTimeout(abort, 6_000)
})
}Catch-all + fallback difensivo. Aggiungete
route('*', './routes/not-found.tsx')inroutes.tscosì il middleware gira sempre (incluse le probe come/.well-known/...che Chrome DevTools attiva). E avvolgetegetInstance(...)intry/catchcon un'istanza i18next minima di fallback se volete resilienza extra contro casi limite. Il repo di esempio mostra entrambi.
Inizializzazione lato client — entry.client.tsx
Qui Locize diventa la fonte delle traduzioni fresche. Sul client i18next-locize-backend preleva le traduzioni direttamente dal CDN di Locize — gli aggiornamenti pubblicati compaiono quindi alla prossima visualizzazione di pagina senza redeploy. Il plugin di editing in-context locize permette ai traduttori di aprire ?incontext=true e modificare inline.
import { startTransition, StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { HydratedRouter } from 'react-router/dom'
import i18next from 'i18next'
import { I18nextProvider, initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import Backend from 'i18next-locize-backend'
import LastUsed from 'locize-lastused'
import { locizePlugin } from 'locize'
const isProduction = import.meta.env.PROD
const locizeOptions = {
projectId: '<your locize project id>',
// Solo in dev — invia nuove chiavi via saveMissing. Non mettete mai
// una chiave con permessi di scrittura in bundle in produzione. In un progetto
// reale caricatela da un file env gitignorato.
apiKey: !isProduction ? '<your dev apiKey>' : undefined,
version: isProduction ? 'production' : 'latest',
// 'standard' → api.lite.locize.app (BunnyCDN, gratuito, default per i nuovi progetti)
// 'pro' → api.locize.app (CloudFront, supporta Private Downloads)
cdnType: 'pro' as const,
}
async function main() {
if (!isProduction) i18next.use(LastUsed)
await i18next
.use(locizePlugin)
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
supportedLngs: ['en', 'de'],
defaultNS: 'common',
ns: ['common', 'index', 'second'],
// Il server ha già deciso la locale e l'ha emessa via
// <html lang> — la rileggiamo, senza rilevarla di nuovo.
detection: { order: ['htmlTag'], caches: [] },
backend: locizeOptions,
locizeLastUsed: locizeOptions,
saveMissing: !isProduction,
react: { useSuspense: false },
})
startTransition(() => {
hydrateRoot(
document,
<I18nextProvider i18n={i18next}>
<StrictMode>
<HydratedRouter />
</StrictMode>
</I18nextProvider>,
)
})
}
main().catch((error) => console.error(error))Usare t() nelle rotte
Come in qualsiasi app react-i18next:
// app/routes/home.tsx
import { Link } from 'react-router'
import { useTranslation, Trans } from 'react-i18next'
export const handle = { i18n: ['index'] }
export default function Home() {
const { t, i18n } = useTranslation('index')
return (
<main>
<h1>{t('title')}</h1>
<p>
<Trans t={t} i18nKey="description.part1">
To get started, edit <code>app/routes/home.tsx</code> and save to reload.
</Trans>
</p>
<p>{t('description.part2')}</p>
<Link to="/second">{t('goto.second')}</Link>
</main>
)
}Endpoint del CDN di Locize
Locize offre due infrastrutture CDN (confronto completo in Tipi di CDN: Standard vs Pro):
- CDN Standard su
api.lite.locize.app— basato su BunnyCDN, gratuito per volumi mensili di download generosi, cache fissa a 1 ora, solo pubblico. Default per i progetti Locize appena creati. - CDN Pro su
api.locize.app— basato su CloudFront, a pagamento, supporta Private Downloads, controllo cache personalizzato, backup dei namespace.
Impostate cdnType: 'standard' o 'pro' nell'oggetto locizeOptions qui sopra in base al vostro progetto.
Localizzazione continua con Locize
Una volta cablata l'integrazione, la parte di workflow è ciò che dà valore a Locize:
Sincronizzare le traduzioni da Locize al build
Aggiungete script a package.json che invocano la locize-cli:
{
"scripts": {
"downloadLocales": "locize download --project-id=<your-id> --ver=latest --cdn-type=pro --clean=true --path=./app/locales",
"syncLocales": "locize sync --project-id=<your-id> --ver=latest --cdn-type=pro --api-key=<your-write-key> --path=./app/locales --dry=true"
}
}Eseguite npm run downloadLocales prima di npm run build (in locale, in CI o come hook prebuild) per garantire che le traduzioni messe in bundle lato server siano fresche.
saveMissing — le chiavi fluiscono automaticamente dal codice a Locize
Quando saveMissing: true è impostato lato client e un apiKey è configurato, ogni chiave di traduzione che la vostra app referenzia ma che il JSON caricato non contiene ancora viene inviata indietro al vostro progetto Locize. I traduttori vedono le nuove chiavi nella UI di Locize senza un passaggio manuale di estrazione.
In produzione omettete l'apiKey (come nello snippet sopra), così i tentativi di scrittura diventano no-op — il percorso di lettura dal CDN continua a funzionare per tutte le chiavi tradotte.
Editing in-context — ?incontext=true
Il plugin locize (semplicemente .use(locizePlugin) nella catena di init) dà ai traduttori un editor in-context: aprite una pagina della vostra app in esecuzione con ?incontext=true accodato e si apre un editor basato su iframe che evidenzia ogni stringa tradotta e permette ai traduttori di modificarla sul posto.
Reporting last-used + traduzione automatica
locize-lastused(solo in dev) marca ogni segmento di traduzione con il timestamp dell'ultimo accesso, così le chiavi inutilizzate possono essere ripulite in seguito.- La UI di Locize offre la traduzione automatica con un click per le chiavi mancanti via DeepL / OpenAI / Google Translate — così potete avere un build tedesco funzionante a pochi minuti dall'aver aggiunto il testo inglese.
🎉 Complimenti
Un setup funzionante React Router v7 + remix-i18next + Locize vi offre:
- i18n compatibile con SSR senza flash di contenuti non tradotti
- Rilevamento della locale per richiesta via middleware (Cookie → URL → Accept-Language → Fallback)
- Traduzioni fresche senza redeploy via CDN di Locize
saveMissingsalta il passaggio di estrazione — le nuove chiavi compaiono in Locize non appena gli sviluppatori le referenziano- Editing in-context per non sviluppatori via plugin
locize - Sincronizzazione delle traduzioni compatibile con CI via
locize-cli
🧑💻 Il codice completo: github.com/locize/locize-react-router-example.
I fondatori di Locize sono anche i creatori di i18next — usando Locize sostenete direttamente il futuro di i18next.
Se volete una panoramica più ampia di i18next, c'è anche un video crash course di i18next:
Vedi anche
- Come internazionalizzare un'applicazione Remix (Parte 1) — walkthrough di Remix v2 (ancora rilevante se non siete passati a RR v7)
- Come internazionalizzare un'applicazione Remix (Parte 2) — workflow di localizzazione continua con Locize su Remix v2
- remix-i18next — la libreria su cui si basa questo articolo (grazie a Sergio Xalambrí)