Vai al contenuto
20 maggio 20269 min readTutorials

Internazionalizzare un'app Nuxt 4 con @nuxtjs/i18n e Locize

Nuxt è il framework full-stack dominante per Vue — routing file-based, Nitro sul server, Vite sotto il cofano e un ecosistema di moduli con scelte opinionate. Per l'internazionalizzazione, la scelta canonica è @nuxtjs/i18n, che avvolge vue-i18n con routing Nuxt-aware, helper SEO e middleware di rilevamento della locale.

Questo articolo guida nell'accoppiamento di quello stack con Locize come backend di gestione delle traduzioni: come collegare @nuxtjs/i18n con la nuova implementazione vue-i18n del pacchetto locize (introdotta in 4.1.0), usare locize-cli per la sincronizzazione al build, opzionalmente inviare nuove chiavi a Locize a runtime via l'hook missing di vue-i18n e modificare le traduzioni sul posto tramite l'editor InContext di Locize.

Se siete su React Router v7 (modalità Framework), seguite il walkthrough di React Router v7. Se siete ancora su Remix v2, vedete l'articolo Remix v2.

Se preferite usare i18next direttamente con Vue (niente @nuxtjs/i18n, niente vue-i18n), vedete i18next-vue per lo stack alternativo.

TL;DR

  • Usate @nuxtjs/i18n 10.x come modulo i18n di Nuxt. Si occupa di routing, SEO, rilevamento della locale e lazy loading; vue-i18n sottostante fornisce l'API t().
  • Mettete in bundle le traduzioni nell'app al build tramite il comando download di locize-cli. Sia il server (Nitro SSR) sia il client leggono gli stessi JSON — nessun hop CDN a runtime, serverless-friendly.
  • Opzionalmente inviate nuove chiavi a Locize a runtime tramite l'hook missing di vue-i18n (saveMissing). Oppure saltatelo e usate locize sync da CI — a seconda del workflow che preferite.
  • Editing in-context con l'helper getVueI18nImplementation del pacchetto locize (rilasciato in 4.1.0) — accodate ?incontext=true a una URL.
  • Esempio completo funzionante: github.com/locize/locize-nuxt-example

Come si incastrano i pezzi

Nuxt 4 esegue lo stesso albero Vue sul server (Nitro SSR) e sul client (dopo l'hydration). @nuxtjs/i18n monta vue-i18n in entrambi i contesti e aggiunge intorno feature Nuxt-aware — rotte tipizzate, il composable useLocalePath, il rilevamento della lingua del browser e così via.

Locize è il livello di gestione delle traduzioni. L'integrazione qui è deliberatamente solo in bundle a runtime:

  • Al build: locize-cli scarica gli ultimi JSON pubblicati dal CDN di Locize in i18n/locales/{lng}/{ns}.json. I file JSON committati vengono letti dal lazy load di @nuxtjs/i18n sia sul server sia sul client.
  • Push opzionale a runtime (saveMissing): quando una chiamata t() referenzia una chiave non presente nei JSON caricati, un missing-handler invia in POST la chiave a Locize così i traduttori la vedono apparire senza un passaggio manuale di estrazione.
  • Editor InContext opzionale (?incontext=true): apre l'editor di Locize come overlay in iframe; i traduttori possono modificare qualsiasi stringa cliccandola sulla pagina e salvare.

Un giro completo «pubblicata una traduzione → gli utenti la vedono» richiede quindi di rieseguire downloadLocales e fare il redeploy. È una scelta di design — mantiene Nitro serverless-friendly (nessun hop CDN per richiesta) e il client privo di un fetch di traduzione extra. Se preferite un fetch live tramite CDN (pubblichi → la pagina successiva lo prende, senza redeploy), il walkthrough di React Router v7 copre quella forma con i18next-locize-backend.

Iniziamo

Prerequisiti

Node.js 20+, npm/pnpm/yarn e una familiarità di base con Vue 3 e Nuxt. L'esempio sotto punta a Nuxt 4.4.

Layout del progetto

Generate una nuova app Nuxt 4:

npx nuxi@latest init my-app
cd my-app

La forma della directory rilevante dopo aver cablato i18n + Locize:

my-app/
├── nuxt.config.ts          — config del modulo @nuxtjs/i18n + runtimeConfig.public.locize*
├── app/                    — srcDir di default di Nuxt 4
│   ├── app.vue             — componente root
│   ├── pages/
│   │   ├── index.vue
│   │   └── second.vue
│   ├── plugins/
│   │   └── locize.client.ts — popola lo stato runtime locize + init dell'editor InContext
│   └── utils/
│       └── locize-runtime.ts — stato runtime mutabile condiviso per gli handler della config i18n
└── i18n/
    ├── i18n.config.ts      — opzioni vue-i18n (handler missing + postTranslation)
    └── locales/
        ├── en.ts           — wrapper defineI18nLocale che assembla en/*.json
        ├── de.ts           — idem, per de/*.json
        ├── en/{common,index,second}.json
        └── de/{common,index,second}.json

Alcune cose sembrano inusuali; la sezione gotchas più sotto spiega ciascuna.

Installare le dipendenze

npm install @nuxtjs/i18n locize
npm install --save-dev locize-cli

vue-i18n arriva transitivamente con @nuxtjs/i18n (attualmente 11.x).

Configurare @nuxtjs/i18n in nuxt.config.ts

export default defineNuxtConfig({
  compatibilityDate: '2026-05-20',
  modules: ['@nuxtjs/i18n'],
  i18n: {
    defaultLocale: 'en',
    strategy: 'no_prefix',
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: 'i18n_redirected',
      redirectOn: 'root',
    },
    locales: [
      { code: 'en', language: 'en-US', name: 'English', file: 'en.ts' },
      { code: 'de', language: 'de-DE', name: 'Deutsch', file: 'de.ts' },
    ],
    lazy: true,
  },
  runtimeConfig: {
    public: {
      locizeProjectId: '<your locize project id>',
      // Solo in dev — lasciate vuoto in produzione così saveMissing diventa no-op.
      // Sovrascrivete al deploy con NUXT_PUBLIC_LOCIZE_API_KEY=''.
      locizeApiKey: '<your dev apiKey>',
      locizeVersion: 'latest',
      locizeCdnType: 'standard',  // o 'pro'
    },
  },
})

Wrapper defineI18nLocale per locale

Il lazy load di @nuxtjs/i18n legge un file per locale. Locize emette un JSON per namespace per lingua, e locize sync preserva quel layout — quindi per ogni locale scrivete un piccolo wrapper .ts che importa i file JSON per-namespace e li assembla sotto la rispettiva chiave di namespace. Questo mantiene pulito il giro round-trip e permette a t('common.headTitle') / t('index.title') di risolversi naturalmente:

// i18n/locales/en.ts
import common from './en/common.json'
import index from './en/index.json'
import second from './en/second.json'

export default defineI18nLocale(() => ({
  common,
  index,
  second,
}))

…e idem per de.ts.

Perché il wrapper? vue-i18n non ha namespace di prima classe (a differenza di i18next). I messaggi sono solo un albero JSON annidato, e t('foo.bar') è una lookup profonda di chiave. Il wrapper sovrappone la struttura a namespace così il round-trip con Locize resta pulito.

Scaricare le traduzioni da Locize al build

Aggiungete a package.json:

{
  "scripts": {
    "downloadLocales": "locize download --project-id=<your-id> --ver=latest --cdn-type=standard --clean=true --path=./i18n/locales",
    "syncLocales": "locize sync --project-id=<your-id> --ver=latest --cdn-type=standard --api-key=<your-write-key> --path=./i18n/locales --dry=true"
  }
}

Eseguite npm run downloadLocales prima di npm run build (manualmente, in CI o come hook prebuild) così che i JSON in bundle siano sempre freschi.

Standard vs Pro CDN. Locize offre due infrastrutture CDN (confronto completo in Tipi di CDN: Standard vs Pro): Standard su api.lite.locize.app è basato su BunnyCDN, gratuito per volumi mensili di download generosi, solo pubblico (default per i nuovi progetti); Pro su api.locize.app è basato su CloudFront, a pagamento, supporta Private Downloads + controllo cache personalizzato. Allineate --cdn-type nel flag CLI a ciò che è configurato nel vostro progetto.

La config di vue-i18n — i18n/i18n.config.ts

Questo file viene caricato da @nuxtjs/i18n al momento dell'init di vue-i18n e contribuisce le opzioni di vue-i18n. Lo usiamo per registrare gli handler missing e postTranslation — entrambi scritti in modo da essere sicuri durante l'SSR (sul server diventano no-op) e si attivano sul client solo quando il plugin più sotto popola il flag runtime:

import { h, Text } from 'vue'
import { wrap } from 'locize'
import { locizeRuntime } from '../app/utils/locize-runtime'

const pendingMissing = new Set()

function handleMissing(locale, key) {
  if (!import.meta.client) return
  if (!locizeRuntime.saveMissing || !locizeRuntime.apiKey) return

  const dot = key.indexOf('.')
  const ns = dot >= 0 ? key.slice(0, dot) : 'common'
  const actualKey = dot >= 0 ? key.slice(dot + 1) : key
  const dedupe = `${locale}/${ns}/${actualKey}`
  if (pendingMissing.has(dedupe)) return
  pendingMissing.add(dedupe)

  fetch(`${locizeRuntime.cdnHost}/missing/${locizeRuntime.projectId}/${locizeRuntime.version}/${locale}/${ns}`, {
    method: 'POST',
    headers: { Authorization: locizeRuntime.apiKey, 'Content-Type': 'application/json' },
    body: JSON.stringify({ [actualKey]: actualKey }),
  }).finally(() => pendingMissing.delete(dedupe))
}

const MARKER_SENTINEL = ''

function handlePostTranslation(translated, key) {
  if (!import.meta.client) return translated
  if (!locizeRuntime.isInContext) return translated

  const dot = key.indexOf('.')
  const ns = dot >= 0 ? key.slice(0, dot) : 'common'
  const actualKey = dot >= 0 ? key.slice(dot + 1) : key
  const meta = { key: actualKey, ns }

  if (typeof translated === 'string') {
    try { return wrap(translated, meta) } catch (_) { return translated }
  }

  if (Array.isArray(translated)) {
    // Il percorso slot di `<i18n-t>` di vue-i18n ci passa un array di VNode Text —
    // avvolgiamo il primo + l'ultimo VNode di testo con marker subliminali, lasciando
    // intatti i VNode degli slot.
    const isText = (v) => v && v.__v_isVNode && typeof v.children === 'string'
    const first = translated.findIndex(isText)
    if (first === -1) return translated
    let last = first
    for (let i = translated.length - 1; i >= 0; i--) {
      if (isText(translated[i])) { last = i; break }
    }
    let startMarker, endMarker
    try {
      const sample = wrap(MARKER_SENTINEL, meta)
      const parts = sample.split(MARKER_SENTINEL)
      if (parts.length !== 2) return translated
      startMarker = parts[0]
      endMarker = parts[1]
    } catch (_) { return translated }
    const result = translated.slice()
    if (first === last) {
      result[first] = h(Text, null, startMarker + result[first].children + endMarker)
    } else {
      result[first] = h(Text, null, startMarker + result[first].children)
      result[last] = h(Text, null, result[last].children + endMarker)
    }
    return result
  }

  return translated
}

export default function () {
  return {
    legacy: false,
    fallbackLocale: 'en',
    missingWarn: false,
    fallbackWarn: false,
    missing: handleMissing,
    postTranslation: handlePostTranslation,
  }
}

Il bridge dello stato runtime — app/utils/locize-runtime.ts

Gli handler sopra hanno bisogno di accedere alla config runtime (project id, api key, ecc.) ma i18n.config.ts non dispone di un contesto Nuxt. Si fa da ponte con un piccolo modulo mutabile condiviso:

export const locizeRuntime = {
  projectId: '',
  apiKey: '',
  version: 'latest',
  cdnHost: 'https://api.lite.locize.app',
  isInContext: false,
  saveMissing: false,
}

Il plugin client di Nuxt — app/plugins/locize.client.ts

Questo popola lo stato runtime dal runtimeConfig.public e avvia l'editor InContext quando ?incontext=true è impostato:

import { watch } from 'vue'
import { startStandalone, getVueI18nImplementation } from 'locize'
import { locizeRuntime } from '~/utils/locize-runtime'

export default defineNuxtPlugin((nuxtApp) => {
  const config = useRuntimeConfig()
  const projectId = config.public.locizeProjectId as string
  const apiKey = config.public.locizeApiKey as string
  const version = config.public.locizeVersion as string
  const cdnType = config.public.locizeCdnType as 'standard' | 'pro'
  const cdnHost =
    cdnType === 'pro' ? 'https://api.locize.app' : 'https://api.lite.locize.app'

  const isProduction = !import.meta.dev
  const isInIframe = (() => { try { return self !== top } catch { return true } })()
  const showInContext =
    new URLSearchParams(window.location.search).get('incontext') === 'true'

  // Popola il runtime condiviso così che gli handler di i18n.config.ts possano lavorare.
  locizeRuntime.projectId = projectId
  locizeRuntime.apiKey = apiKey
  locizeRuntime.version = version
  locizeRuntime.cdnHost = cdnHost
  locizeRuntime.isInContext = isInIframe || showInContext
  locizeRuntime.saveMissing = !isProduction && !!apiKey

  const i18n = nuxtApp.$i18n as any

  // Forza un re-render così vue-i18n rivaluta ogni output t() in cache
  // rispetto al runtime ora popolato.
  const cur = i18n.locale.value
  i18n.setLocaleMessage(cur, { ...(i18n.getLocaleMessage(cur) || {}) })

  if (!locizeRuntime.isInContext) return

  // locize 4.1.0 fornisce un'implementazione per vue-i18n affiancata a quella per i18next.
  // Forniamo il `watch` di Vue così che l'editor osservi i cambi di locale — locize
  // stesso resta senza una peer dependency su `vue`.
  const impl = getVueI18nImplementation(i18n, {
    projectId,
    version,
    sourceLng: 'en',
    defaultNS: 'common',
    ns: ['common', 'index', 'second'],
    targetLngs: (i18n.availableLocales as string[]) || [],
    backendName: 'locize-cli',
    watch,
  })

  impl.triggerRerender?.()
  startStandalone({ implementation: impl, show: true })
})

Usare t() nelle pagine

Uso standard di vue-i18n, con una sola sfumatura: le pagine devono chiamare useI18n({ useScope: 'global' }) per condividere lo stesso composer su cui sono registrati gli handler missing e postTranslation. Un useI18n() semplice crea un composer scoped per componente che non li eredita.

<script setup lang="ts">
const { t, locale, locales, setLocale } = useI18n({ useScope: 'global' })
</script>

<template>
  <main>
    <h1>{{ t('index.title') }}</h1>
    <p>
      <i18n-t keypath="index.description.part1" scope="global">
        <template #file>
          <code>app/pages/index.vue</code>
        </template>
      </i18n-t>
    </p>
    <NuxtLink to="/second">{{ t('index.goto.second') }}</NuxtLink>
  </main>
</template>

Gotchas — cose che mi hanno bruciato costruendo questo

Una manciata di cose non ovvie che vale la pena sapere.

i18n/i18n.config.ts deve stare dentro la directory i18n/

@nuxtjs/i18n 10.x risolve il percorso del file di config di vue-i18n in modo relativo a layer.i18nDir (la directory i18n/), non alla root del progetto. Un i18n.config.ts alla root viene silenziosamente non caricato. Vedi findPath(layer.i18n.vueI18n || "i18n.config", { cwd: layer.i18nDir }) in @nuxtjs/i18n/dist/module.mjs.

Non usate la macro defineI18nConfig(...)

Il modulo @nuxtjs/i18n post-processa ogni file contenente l'identificatore della macro con una regex greedy (DEFINE_I18N_FN_RE). Con i commenti preservati da oxc-transform, un match nel testo di un commento risucchia la parentesi di chiusura di chiamate console.log / fetch(...) non correlate nel capture group della regex e corrompe l'output trasformato. Sintomo: Expected ')' but found ';'. Una semplice export default function () { ... } (senza il wrapper macro) funziona altrettanto bene — @nuxtjs/i18n invoca l'export default al momento dell'init.

useI18n({ useScope: 'global' }) nelle pagine

Il useI18n() di vue-i18n crea per default un composer scoped per componente in modalità legacy: false, e i composer scoped NON ereditano missing / postTranslation dal composer globale. Se le vostre pagine chiamano un useI18n() semplice, saveMissing non scatterà e l'editor InContext non vedrà alcun segmento. Usate useScope: 'global' ovunque.

Comporre traduzioni con testo letterale nei template

Questo pattern funziona in JSX (React renderizza nodi di testo separati) ma si rompe in Vue:

<!-- DON'T -->
<NuxtLink to="/second">→ {{ t('index.goto.second') }}</NuxtLink>

Il compilatore di template di Vue fonde il letterale e il risultato dinamico di {{ t(...) }} in un singolo nodo DOM di testo — e il controllo text.startsWith(startMarker) del parser di locize fallisce perché il testo ora inizia con invece che con il marker. (locize 4.1.0 ha aggiunto un caso di fallback nel parser che recupera la maggior parte di questi, ma la risposta più pulita è anche miglior pratica i18n: mettete la freccia dentro il valore della traduzione così i traduttori possono riordinarla per RTL.)

<!-- DO -->
<NuxtLink to="/second">{{ t('index.goto.second') }}</NuxtLink>
{ "goto": { "second": "→ Go to the second page" } }

L'interpolazione slot di <i18n-t> passa un array di VNode a postTranslation

Il componente <i18n-t> di vue-i18n usa il percorso translateVNode, che chiama postTranslation(messaged, key) — ma messaged qui è un array di VNode di testo di Vue (non stringhe grezze), perché il normalize() interno di vue-i18n converte ogni segmento stringa in createTextNode(...) prima che l'hook giri. Un semplice controllo typeof translated === 'string' salta interamente questo caso. L'handler sopra copre entrambe le forme: avvolge l'intera stringa, OPPURE ricostruisce il primo + l'ultimo VNode di testo via h(Text, null, startMarker + children) e h(Text, null, children + endMarker).

Warning di hydration mismatch sotto ?incontext=true

Il testo avvolto in modo subliminale sul client non corrisponde all'output non avvolto del server, quindi Vue logga [Vue warn] Hydration text content mismatch per ogni stringa tradotta quando ?incontext=true è impostato. L'hydration di Vue è tollerante — si fida del client, aggiorna il DOM e il parser dell'editor vede le stringhe avvolte. I warning sono rumore, non un fallimento, e non compaiono nei normali caricamenti di pagina (senza ?incontext=true). Se volete una console pulita sotto ?incontext=true, dovreste avvolgere anche nel pass del server (richiede uno stato SSR per richiesta per il flag runtime — fuori dallo scope di questo esempio).

Tre modi per far arrivare nuove chiavi in Locize

L'esempio cabla saveMissing perché è il più discoverabile: ogni volta che uno sviluppatore referenzia una chiave inesistente, compare in Locize automaticamente. Ma è opzionale, e quale strada scegliete dipende dal vostro workflow:

  1. Push a runtime via saveMissing (la strada che l'esempio mostra per default). Comoda in dev. Richiede di spedire un apiKey dev nel bundle del browser.
  2. locize sync (già cablato in package.json come syncLocales, togliete il flag --dry=true). locize-cli legge i vostri i18n/locales/{lng}/{ns}.json locali e carica qualsiasi chiave non ancora in Locize. Manuale o triggerato da CI, nessuna write-key nel browser.
  3. Estrazione statica via i18next-cli o simili — scansiona il sorgente, scrive nuove chiavi nei JSON locali, poi sync verso l'alto come sopra.

Per disabilitare saveMissing in questo esempio, cancellate l'handler missing da i18n/i18n.config.ts oppure lasciate semplicemente vuoto NUXT_PUBLIC_LOCIZE_API_KEY così l'handler diventa no-op a runtime.

Editing InContext per i traduttori

Il plugin locize (semplicemente getVueI18nImplementation(...) + startStandalone({ implementation }) nel plugin client) offre ai traduttori un editor in-context: aprite una qualsiasi 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.

Le modifiche dell'editor finiscono nel vostro progetto Locize. Per rendere quelle modifiche visibili agli utenti finali dovrete rieseguire npm run downloadLocales e fare il redeploy (questo esempio è solo in bundle a runtime — vedete TL;DR per il ragionamento e il link incrociato all'alternativa con fetch live tramite CDN).

🎉 Complimenti

Un setup funzionante Nuxt 4 + @nuxtjs/i18n + Locize vi offre:

  • i18n compatibile con SSR senza flash di contenuti non tradotti
  • Rilevamento della locale per richiesta via middleware di @nuxtjs/i18n (Cookie → URL → Accept-Language → Fallback)
  • Sincronizzazione delle traduzioni al build via locize-cli — serverless-friendly, nessun hop CDN per richiesta
  • saveMissing opzionale per saltare il passaggio manuale di aggiunta chiavi durante lo sviluppo
  • Editing InContext per non sviluppatori via il plugin locize
  • Una separazione pulita tra traduzioni runtime in bundle ed editing lato CMS

🧑‍💻 Il codice completo: github.com/locize/locize-nuxt-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