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/i18n10.x come modulo i18n di Nuxt. Si occupa di routing, SEO, rilevamento della locale e lazy loading; vue-i18n sottostante fornisce l'APIt(). - Mettete in bundle le traduzioni nell'app al build tramite il comando
downloaddilocize-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
missingdi vue-i18n (saveMissing). Oppure saltatelo e usatelocize syncda CI — a seconda del workflow che preferite. - Editing in-context con l'helper
getVueI18nImplementationdel pacchettolocize(rilasciato in 4.1.0) — accodate?incontext=truea 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-cliscarica gli ultimi JSON pubblicati dal CDN di Locize ini18n/locales/{lng}/{ns}.json. I file JSON committati vengono letti dal lazy load di@nuxtjs/i18nsia sul server sia sul client. - Push opzionale a runtime (
saveMissing): quando una chiamatat()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-appLa 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}.jsonAlcune cose sembrano inusuali; la sezione gotchas più sotto spiega ciascuna.
Installare le dipendenze
npm install @nuxtjs/i18n locize
npm install --save-dev locize-clivue-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 suapi.locize.appè basato su CloudFront, a pagamento, supporta Private Downloads + controllo cache personalizzato. Allineate--cdn-typenel 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:
- 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. locize sync(già cablato inpackage.jsoncomesyncLocales, togliete il flag--dry=true).locize-clilegge i vostrii18n/locales/{lng}/{ns}.jsonlocali e carica qualsiasi chiave non ancora in Locize. Manuale o triggerato da CI, nessuna write-key nel browser.- Estrazione statica via
i18next-clio 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 saveMissingopzionale 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:
Vedi anche
- Come internazionalizzare un'app React Router v7 con remix-i18next — stessa forma ma con fetch live tramite CDN lato client via
i18next-locize-backend - Come internazionalizzare un'applicazione Remix (Parte 1) e (Parte 2) — walkthrough di Remix v2
- Localizzazione Vue con i18next-vue — stack Vue alternativo che usa i18next direttamente (niente
@nuxtjs/i18n, niente vue-i18n) - Date a vue-i18n più superpoteri — la storia dell'integrazione vue-i18n puro + locizer
- Documentazione di
@nuxtjs/i18n