Zum Inhalt springen
20. Mai 20268 min readTutorials

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/i18n 10.x als Ihr Nuxt-i18n-Modul. Es besitzt Routing, SEO, Locale-Erkennung und Lazy Loading; vue-i18n darunter liefert die t()-API.
  • Bündeln Sie Übersetzungen zur Build-Zeit in die App via locize-cli-Befehl download. 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 nutzen locize sync aus CI — was zu Ihrem Workflow passt.
  • Bearbeiten Sie im Kontext mit dem getVueI18nImplementation-Helper des locize-Pakets (ausgeliefert in 4.1.0) — hängen Sie ?incontext=true an 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-cli lädt das aktuelle veröffentlichte JSON vom Locize-CDN nach i18n/locales/{lng}/{ns}.json herunter. Die committeten JSON-Dateien werden von @nuxtjs/i18n's Lazy Load auf Server und Client gelesen.
  • Optionaler Runtime-Push (saveMissing): wenn ein t()-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-app

Die 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}.json

Einige 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-cli

vue-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.app ist BunnyCDN-gestützt, kostenlos für großzügige monatliche Download-Volumen, nur öffentlich (Default für neue Projekte); Pro unter api.locize.app ist CloudFront-gestützt, kostenpflichtig, unterstützt Private Downloads + individuelle Cache-Steuerung. Setzen Sie --cdn-type im 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:

  1. 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.
  2. locize sync (bereits in package.json als syncLocales verdrahtet, das --dry=true-Flag entfernen). locize-cli liest Ihre lokalen i18n/locales/{lng}/{ns}.json und lädt alle Schlüssel hoch, die noch nicht in Locize sind. Manuell oder CI-getrieben, kein Write-Key im Browser.
  3. Statische Extraktion via i18next-cli oder Ä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:

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

Siehe auch