Vai al contenuto
19 maggio 20266 min readTutorials

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-backend sul 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=true a 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):

AreaRemix v2 / remix-i18next 6React Router v7 / remix-i18next 7
Rilevamento della localeUna classe RemixI18Next istanziata una sola volta al caricamento del modulo, chiamata esplicitamente dai loaderUn middleware di rotta (createI18nextMiddleware) dichiarato in root.tsx che gira per ogni richiesta e inizializza il contesto
Accesso alle traduzioni nel loaderremixI18n.getLocale(request) / remixI18n.getFixedT(request, ns)getLocale(context) / getInstance(context) dalla tupla restituita dal middleware
Sistema di buildCompiler classico di Remix o plugin Vite di RemixPlugin 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-app

Per 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.tsx

Nota: 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-cli

Abilitare 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 Config

Mettere 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') in routes.ts così il middleware gira sempre (incluse le probe come /.well-known/... che Chrome DevTools attiva). E avvolgete getInstance(...) in try/catch con 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
  • saveMissing salta 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:

smart_display
YouTube Video
This video is hosted on YouTube. Accept YouTube cookies to watch it here.
Watch on YouTube

Vedi anche