Nuxt 4 mit @nuxtjs/i18n und Locize internationalisieren
Nuxt ist das dominierende Full-Stack-Framework für Vue — dateibasiertes Routing, Nitro auf dem Server, Vite im Hintergrund und ein meinungsstarkes Modul-Ökosystem. Für Internationalisierung ist die kanonische Wahl @nuxtjs/i18n, das vue-i18n mit Nuxt-bewusstem Routing, SEO-Helpern und Locale-Erkennungs-Middleware umschließt.
Dieser Beitrag führt durch die Kombination dieses Stacks mit Locize als Übersetzungsmanagement-Backend: wie Sie @nuxtjs/i18n mit der neuen vue-i18n-Implementierung des locize-Pakets (eingeführt in 4.1.0) verdrahten, locize-cli für Build-Zeit-Sync nutzen, optional neue Schlüssel zur Runtime über den missing-Hook von vue-i18n an Locize pushen und Übersetzungen direkt im Locize-InContext-Editor bearbeiten.
Wenn Sie auf React Router v7 (Framework-Modus) sind, folgen Sie stattdessen dem React-Router-v7-Walkthrough. Wenn Sie noch auf Remix v2 sind, lesen Sie den Remix-v2-Beitrag.
Wenn Sie lieber i18next direkt auf Vue verwenden möchten (ohne
@nuxtjs/i18n, ohne vue-i18n), schauen Sie sich i18next-vue als alternativen Stack an.
TL;DR
- Nutzen Sie
@nuxtjs/i18n10.x als Ihr Nuxt-i18n-Modul. Es besitzt Routing, SEO, Locale-Erkennung und Lazy Loading; vue-i18n darunter liefert diet()-API. - Bündeln Sie Übersetzungen zur Build-Zeit in die App via
locize-cli-Befehldownload. Server (Nitro SSR) und Client lesen dasselbe JSON — kein Runtime-CDN-Hop, serverless-freundlich. - Optional pushen Sie neue Schlüssel zur Runtime über den
missing-Hook von vue-i18n (saveMissing) zurück an Locize. Oder lassen Sie es weg und nutzenlocize syncaus CI — was zu Ihrem Workflow passt. - Bearbeiten Sie im Kontext mit dem
getVueI18nImplementation-Helper deslocize-Pakets (ausgeliefert in 4.1.0) — hängen Sie?incontext=truean eine beliebige URL an. - Vollständiges Beispiel: github.com/locize/locize-nuxt-example
Wie die Teile zusammenpassen
Nuxt 4 lässt denselben Vue-Baum auf dem Server (Nitro SSR) und auf dem Client (nach Hydration) laufen. @nuxtjs/i18n mountet vue-i18n in beiden Kontexten und ergänzt rundherum Nuxt-bewusste Features — typisierte Routes, das useLocalePath-Composable, Browser-Sprach-Erkennung und so weiter.
Locize ist die Übersetzungsmanagement-Schicht. Die Integration hier ist bewusst rein bundle-basiert zur Runtime:
- Build-Zeit:
locize-clilädt das aktuelle veröffentlichte JSON vom Locize-CDN nachi18n/locales/{lng}/{ns}.jsonherunter. Die committeten JSON-Dateien werden von@nuxtjs/i18n's Lazy Load auf Server und Client gelesen. - Optionaler Runtime-Push (
saveMissing): wenn eint()-Aufruf einen Schlüssel referenziert, der nicht im geladenen JSON ist, POSTet ein Missing-Handler den Schlüssel an Locize, sodass Übersetzer ihn ohne manuellen Extraktions-Schritt sehen. - Optionaler InContext-Editor (
?incontext=true): öffnet den Locize-Editor als Iframe-Overlay; Übersetzer können jeden String, den sie auf der Seite anklicken, bearbeiten und speichern.
Ein „Übersetzung veröffentlicht → Nutzer sehen sie"-Round-Trip erfordert daher ein erneutes Ausführen von downloadLocales und ein Redeployment. Das ist Absicht — es hält Nitro serverless-freundlich (kein per-Request-CDN-Hop) und den Client frei von einem zusätzlichen Übersetzungs-Fetch. Wenn Sie stattdessen einen Live-CDN-gestützten Fetch wünschen (veröffentlichen → nächster Seitenaufruf nimmt es auf, kein Redeployment), behandelt der React-Router-v7-Walkthrough diese Form mit i18next-locize-backend.
Legen wir los
Voraussetzungen
Node.js 20+, npm/pnpm/yarn und grundlegende Vertrautheit mit Vue 3 und Nuxt. Das Beispiel unten zielt auf Nuxt 4.4.
Projekt-Layout
Eine frische Nuxt-4-App erzeugen:
npx nuxi@latest init my-app
cd my-appDie relevante Verzeichnisstruktur, nachdem wir i18n + Locize verdrahtet haben:
my-app/
├── nuxt.config.ts — @nuxtjs/i18n module config + runtimeConfig.public.locize*
├── app/ — Nuxt 4's default srcDir
│ ├── app.vue — root component
│ ├── pages/
│ │ ├── index.vue
│ │ └── second.vue
│ ├── plugins/
│ │ └── locize.client.ts — populates locize runtime state + InContext editor init
│ └── utils/
│ └── locize-runtime.ts — shared mutable runtime state for the i18n config handlers
└── i18n/
├── i18n.config.ts — vue-i18n options (missing + postTranslation handlers)
└── locales/
├── en.ts — defineI18nLocale wrapper assembling en/*.json
├── de.ts — same, for de/*.json
├── en/{common,index,second}.json
└── de/{common,index,second}.jsonEinige Dinge sehen ungewöhnlich aus; der Abschnitt zu den Stolpersteinen unten erklärt jedes.
Abhängigkeiten installieren
npm install @nuxtjs/i18n locize
npm install --save-dev locize-clivue-i18n kommt transitiv mit @nuxtjs/i18n (aktuell 11.x).
@nuxtjs/i18n in nuxt.config.ts konfigurieren
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>',
// Dev-only — leave empty in production so saveMissing no-ops.
// Override at deploy via NUXT_PUBLIC_LOCIZE_API_KEY=''.
locizeApiKey: '<your dev apiKey>',
locizeVersion: 'latest',
locizeCdnType: 'standard', // or 'pro'
},
},
})Pro-Locale defineI18nLocale-Wrapper
@nuxtjs/i18n's Lazy Load liest eine Datei pro Locale. Locize emittiert ein JSON pro Namespace pro Sprache, und locize sync bewahrt diese Struktur — schreiben Sie also pro Locale einen kleinen .ts-Wrapper, der die per-Namespace-JSON-Dateien importiert und sie unter ihrem Namespace-Schlüssel zusammenführt. Das hält den Round-Trip sauber und lässt t('common.headTitle') / t('index.title') natürlich auflösen:
// 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,
}))…und dasselbe für de.ts.
Warum der Wrapper? vue-i18n hat keine erstklassigen Namespaces (anders als i18next). Nachrichten sind einfach ein verschachtelter JSON-Baum, und t('foo.bar') ist ein Deep-Key-Lookup. Der Wrapper legt die Namespace-Struktur darüber, damit der Round-Trip mit Locize sauber bleibt.
Übersetzungen zur Build-Zeit von Locize herunterladen
Zu package.json hinzufügen:
{
"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"
}
}Führen Sie npm run downloadLocales vor npm run build aus (manuell, in CI oder als prebuild-Hook), damit das gebündelte JSON immer frisch ist.
Standard vs. Pro CDN. Locize liefert zwei CDN-Infrastrukturen (vollständiger Vergleich unter CDN-Typen: Standard vs. Pro): Standard unter
api.lite.locize.appist BunnyCDN-gestützt, kostenlos für großzügige monatliche Download-Volumen, nur öffentlich (Default für neue Projekte); Pro unterapi.locize.appist CloudFront-gestützt, kostenpflichtig, unterstützt Private Downloads + individuelle Cache-Steuerung. Setzen Sie--cdn-typeim CLI-Flag passend zu Ihrem Projekt.
Die vue-i18n-Konfiguration — i18n/i18n.config.ts
Diese Datei wird von @nuxtjs/i18n zum vue-i18n-Init-Zeitpunkt geladen und steuert die vue-i18n-Optionen bei. Wir nutzen sie, um die Handler missing und postTranslation zu registrieren — beide so geschrieben, dass sie während SSR sicher laufen (sie sind auf dem Server No-Ops) und nur auf dem Client greifen, wenn das Plugin unten das Runtime-Flag setzt:
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)) {
// vue-i18n's `<i18n-t>` slot path passes us an array of Text VNodes —
// wrap the first + last text VNodes with subliminal markers, leave
// slot VNodes untouched.
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,
}
}Die Runtime-State-Brücke — app/utils/locize-runtime.ts
Die Handler oben benötigen Zugriff auf Runtime-Config (Project ID, API Key usw.), aber i18n.config.ts hat keinen Nuxt-Kontext. Überbrücken Sie das mit einem kleinen gemeinsamen veränderbaren Modul:
export const locizeRuntime = {
projectId: '',
apiKey: '',
version: 'latest',
cdnHost: 'https://api.lite.locize.app',
isInContext: false,
saveMissing: false,
}Das Nuxt-Client-Plugin — app/plugins/locize.client.ts
Es füllt den Runtime-State aus runtimeConfig.public und startet den InContext-Editor, wenn ?incontext=true gesetzt ist:
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'
// Populate the shared runtime so the i18n.config.ts handlers can do work.
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
// Force a re-render so vue-i18n re-evaluates every cached t() output
// against the now-populated runtime.
const cur = i18n.locale.value
i18n.setLocaleMessage(cur, { ...(i18n.getLocaleMessage(cur) || {}) })
if (!locizeRuntime.isInContext) return
// locize 4.1.0 ships a vue-i18n implementation alongside the i18next one.
// We supply Vue's `watch` so the editor observes locale switches — locize
// itself stays free of a `vue` peer dep.
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 })
})t() in Pages verwenden
Standard-vue-i18n-Nutzung, mit einer Eigenheit: Pages müssen useI18n({ useScope: 'global' }) aufrufen, um denselben Composer zu teilen, an dem die Handler missing und postTranslation hängen. Ein einfaches useI18n() erzeugt einen pro-Komponente scoped Composer, der diese nicht erbt.
<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>Stolpersteine — was mich beim Bauen geärgert hat
Eine Handvoll nicht offensichtlicher Dinge, die man wissen sollte.
i18n/i18n.config.ts muss innerhalb des i18n/-Verzeichnisses liegen
@nuxtjs/i18n 10.x löst den Pfad der vue-i18n-Config-Datei relativ zu layer.i18nDir (dem i18n/-Verzeichnis) auf, nicht zum Projekt-Root. Eine i18n.config.ts auf Root-Ebene wird stillschweigend nicht geladen. Siehe findPath(layer.i18n.vueI18n || "i18n.config", { cwd: layer.i18nDir }) in @nuxtjs/i18n/dist/module.mjs.
Verwenden Sie nicht das defineI18nConfig(...)-Makro
Das Modul @nuxtjs/i18n verarbeitet jede Datei nach, die den Makro-Identifier enthält, mit einer gierigen Regex (DEFINE_I18N_FN_RE). Bei von oxc-transform erhaltenen Kommentaren zieht ein Treffer in einem Kommentar-Text die schließende Klammer unzusammenhängender console.log / fetch(...)-Aufrufe in die Capture-Group der Regex und korrumpiert die transformierte Ausgabe. Symptom: Expected ')' but found ';'. Ein schlichtes export default function () { ... } (ohne Makro-Wrapper) funktioniert genauso gut — @nuxtjs/i18n ruft den Default-Export beim Init auf.
useI18n({ useScope: 'global' }) in Pages
vue-i18n's useI18n() erzeugt im legacy: false-Modus standardmäßig einen pro-Komponente scoped Composer, und scoped Composer erben missing / postTranslation NICHT vom globalen Composer. Wenn Ihre Pages ein schlichtes useI18n() aufrufen, feuert saveMissing nicht, und der InContext-Editor sieht keine Segmente. Verwenden Sie useScope: 'global' überall.
Übersetzungen mit literalem Text in Templates komponieren
Dieses Muster funktioniert in JSX (React rendert separate Textknoten), bricht aber in Vue:
<!-- DON'T -->
<NuxtLink to="/second">→ {{ t('index.goto.second') }}</NuxtLink>Vues Template-Compiler verschmilzt den → -Literal und das {{ t(...) }}-Dynamik-Ergebnis zu einem einzigen DOM-Textknoten — und der text.startsWith(startMarker)-Check des Locize-Parsers schlägt fehl, weil der Text jetzt mit → statt mit dem Marker beginnt. (locize 4.1.0 hat im Parser einen Fallback-Pfad ergänzt, der die meisten dieser Fälle abfängt, aber die sauberste Antwort ist gleichzeitig auch besseres i18n: legen Sie den Pfeil in den Übersetzungswert, damit Übersetzer ihn für RTL umordnen können.)
<!-- DO -->
<NuxtLink to="/second">{{ t('index.goto.second') }}</NuxtLink>{ "goto": { "second": "→ Go to the second page" } }<i18n-t> Slot-Interpolation übergibt ein Array von VNodes an postTranslation
vue-i18n's <i18n-t>-Komponente nutzt den translateVNode-Pfad, der postTranslation(messaged, key) aufruft — aber messaged ist hier ein Array von Vue-Text-VNodes (keine rohen Strings), weil vue-i18n's internes normalize() jedes String-Segment via createTextNode(...) umwandelt, bevor der Hook läuft. Ein schlichter typeof translated === 'string'-Check überspringt diesen Fall vollständig. Der Handler oben deckt beide Formen ab: den gesamten String umhüllen ODER die ersten + letzten Text-VNodes via h(Text, null, startMarker + children) und h(Text, null, children + endMarker) neu aufbauen.
Hydration-Mismatch-Warnungen unter ?incontext=true
Subliminal umhüllter Text auf dem Client entspricht nicht der unverpackten Server-Ausgabe, daher loggt Vue [Vue warn] Hydration text content mismatch für jeden übersetzten String, wenn ?incontext=true gesetzt ist. Vues Hydration ist nachsichtig — sie vertraut dem Client, aktualisiert das DOM, und der Parser des Editors sieht die umhüllten Strings. Die Warnungen sind Rauschen, kein Fehler, und sie erscheinen nicht bei normalen (Nicht-?incontext=true-)Seitenaufrufen. Wenn Sie eine saubere Konsole unter ?incontext=true wollen, müssten Sie auch im Server-Durchgang umhüllen (erfordert per-Request-SSR-State für das Runtime-Flag — außerhalb des Umfangs dieses Beispiels).
Drei Wege, neue Schlüssel in Locize zu bekommen
Das Beispiel verdrahtet saveMissing, weil es am leichtesten zu entdecken ist: jedes Mal, wenn ein Entwickler einen Schlüssel referenziert, der nicht existiert, taucht er automatisch in Locize auf. Aber es ist optional, und welcher Pfad sich anbietet, hängt von Ihrem Workflow ab:
saveMissing-Runtime-Push (der Pfad, den das Beispiel standardmäßig zeigt). Praktisch während Dev. Erfordert das Ausliefern eines Dev-only-apiKey im Browser-Bundle.locize sync(bereits inpackage.jsonalssyncLocalesverdrahtet, das--dry=true-Flag entfernen).locize-cliliest Ihre lokaleni18n/locales/{lng}/{ns}.jsonund lädt alle Schlüssel hoch, die noch nicht in Locize sind. Manuell oder CI-getrieben, kein Write-Key im Browser.- Statische Extraktion via
i18next-clioder Ähnlichem — scannt den Quellcode, schreibt neue Schlüssel in das lokale JSON und synchronisiert dann wie oben hoch.
Um saveMissing in diesem Beispiel zu deaktivieren, entweder den missing-Handler aus i18n/i18n.config.ts löschen oder NUXT_PUBLIC_LOCIZE_API_KEY einfach leer lassen, damit der Handler zur Runtime ein No-Op ist.
InContext-Editing für Übersetzer
Das locize-Plugin (lediglich getVueI18nImplementation(...) + startStandalone({ implementation }) im Client-Plugin) bietet Übersetzern einen In-Context-Editor: öffnen Sie eine beliebige Seite Ihrer laufenden App mit angehängtem ?incontext=true, und ein iframe-basierter Editor öffnet sich, der jeden übersetzten String hervorhebt und Übersetzern erlaubt, sie an Ort und Stelle zu bearbeiten.
Speicherungen aus dem Editor landen in Ihrem Locize-Projekt. Damit diese Änderungen für Endnutzer sichtbar werden, müssen Sie npm run downloadLocales erneut ausführen und redeployen (dieses Beispiel ist zur Runtime rein bundle-basiert — siehe TL;DR für die Begründung und den Querverweis auf die Live-CDN-Fetch-Alternative).
🎉 Glückwunsch
Ein funktionierendes Nuxt 4 + @nuxtjs/i18n + Locize-Setup gibt Ihnen:
- SSR-taugliches i18n ohne Flash unübersetzter Inhalte
- Pro-Request-Locale-Erkennung via
@nuxtjs/i18n-Middleware (Cookie → URL → Accept-Language → Fallback) - Build-Zeit-Übersetzungs-Sync via
locize-cli— serverless-freundlich, keine per-Request-CDN-Hops - Optionales
saveMissing, um den manuellen Key-Add-Schritt während Dev zu überspringen - InContext-Editing für Nicht-Entwickler via
locize-Plugin - Eine saubere Trennung zwischen gebündelten Runtime-Übersetzungen und CMS-seitiger Bearbeitung
🧑💻 Der vollständige Code: github.com/locize/locize-nuxt-example.
Die Gründer von Locize sind auch die Macher von i18next — durch die Nutzung von Locize unterstützen Sie direkt die Zukunft von i18next.
Wenn Sie eine größere i18next-Übersicht möchten, gibt es auch ein i18next-Crash-Kurs-Video:
Siehe auch
- Wie man eine React-Router-v7-App mit remix-i18next internationalisiert — gleiche Form, aber mit client-seitigem Live-CDN-Fetch via
i18next-locize-backend - Wie man eine Remix-Anwendung internationalisiert (Teil 1) und (Teil 2) — Remix-v2-Walkthroughs
- Vue-Lokalisierung mit i18next-vue — alternativer Vue-Stack, der i18next direkt nutzt (ohne
@nuxtjs/i18n, ohne vue-i18n) - Gib vue-i18n mehr Superkräfte — die Geschichte der vanilla-vue-i18n + locizer-Integration
@nuxtjs/i18nDokumentation