How to internationalize a Nuxt 4 app with @nuxtjs/i18n and Locize
Nuxt is the dominant full-stack framework for Vue — file-based routing, Nitro on the server, Vite under the hood, and an opinionated module ecosystem. For internationalization, the canonical pick is @nuxtjs/i18n, which wraps vue-i18n with Nuxt-aware routing, SEO helpers, and locale-detection middleware.
This post walks through pairing that stack with Locize as a translation management backend: how to wire @nuxtjs/i18n with the locize package's new vue-i18n implementation (introduced in 4.1.0), use locize-cli for build-time sync, optionally push new keys to Locize at runtime via vue-i18n's missing hook, and edit translations in place via the Locize InContext editor.
If you're on React Router v7 (framework mode) instead, follow the React Router v7 walkthrough. If you're still on Remix v2, see the Remix v2 post.
If you'd rather use i18next directly on Vue (no
@nuxtjs/i18n, no vue-i18n), see i18next-vue for the alternative stack.
TL;DR
- Use
@nuxtjs/i18n10.x as your Nuxt i18n module. It owns routing, SEO, locale detection, and lazy-loading; vue-i18n underneath provides thet()API. - Bundle translations into the app at build time via
locize-cli'sdownloadcommand. Both server (Nitro SSR) and client read the same JSON — no runtime CDN hop, serverless-friendly. - Optionally push new keys back to Locize at runtime via vue-i18n's
missinghook (saveMissing). Or skip it and uselocize syncfrom CI — whichever fits your workflow. - Edit in context with the
locizepackage'sgetVueI18nImplementationhelper (shipped in 4.1.0) — append?incontext=trueto any URL. - Full working example: github.com/locize/locize-nuxt-example
How the pieces fit
Nuxt 4 runs the same Vue tree on the server (Nitro SSR) and on the client (after hydration). @nuxtjs/i18n mounts vue-i18n in both contexts and adds Nuxt-aware features around it — typed routes, the useLocalePath composable, browser-language detection, and so on.
Locize is the translation management layer. The integration here is deliberately bundle-only at runtime:
- Build time:
locize-clidownloads the latest published JSON from the Locize CDN intoi18n/locales/{lng}/{ns}.json. The committed JSON files are read by@nuxtjs/i18n's lazy-load on both server and client. - Optional runtime push (
saveMissing): when at()call references a key that isn't in the loaded JSON, a missing-handler POSTs the key to Locize so translators see it appear without a manual extract step. - Optional InContext editor (
?incontext=true): opens the Locize editor as an iframe overlay; translators can edit any string they click in the page and save.
A "published a translation → users see it" round trip therefore requires re-running downloadLocales and redeploying. This is by design — it keeps Nitro serverless-friendly (no per-request CDN hop) and the client free of an extra translation fetch. If you'd rather have live CDN-backed fetch (publish → next page view picks it up, no redeploy), the React Router v7 walkthrough covers that shape with i18next-locize-backend.
Let's get into it
Prerequisites
Node.js 20+, npm/pnpm/yarn, and basic familiarity with Vue 3 and Nuxt. The example below targets Nuxt 4.4.
Project layout
Scaffold a fresh Nuxt 4 app:
npx nuxi@latest init my-app
cd my-appThe relevant directory shape after we wire i18n + Locize:
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}.jsonA few things look unusual; the gotchas section below explains each.
Install dependencies
npm install @nuxtjs/i18n locize
npm install --save-dev locize-clivue-i18n comes transitively with @nuxtjs/i18n (currently 11.x).
Configure @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>',
// 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'
},
},
})Per-locale defineI18nLocale wrappers
@nuxtjs/i18n's lazy-load reads one file per locale. Locize emits one JSON per namespace per language, and locize sync preserves that layout — so for each locale, write a tiny .ts wrapper that imports the per-namespace JSON files and assembles them under their namespace key. This keeps the round-trip clean and lets t('common.headTitle') / t('index.title') resolve naturally:
// 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,
}))…and the same for de.ts.
Why the wrapper? vue-i18n doesn't have first-class namespaces (unlike i18next). Messages are just a nested JSON tree, and t('foo.bar') is a deep key lookup. The wrapper layers the namespace structure on top so the round-trip with Locize stays clean.
Download translations from Locize at build time
Add to 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"
}
}Run npm run downloadLocales before npm run build (manually, in CI, or as a prebuild hook) so the bundled JSON is always fresh.
Standard vs Pro CDN. Locize ships two CDN infrastructures (full comparison at CDN types: Standard vs. Pro): Standard at
api.lite.locize.appis BunnyCDN-backed, free for generous monthly download volumes, public-only (default for new projects); Pro atapi.locize.appis CloudFront-backed, paid, supports private downloads + custom cache control. Match--cdn-typeon the cli flag to whatever your project is on.
The vue-i18n config — i18n/i18n.config.ts
This file is loaded by @nuxtjs/i18n at vue-i18n init time and contributes the vue-i18n options. We use it to register the missing and postTranslation handlers — both written so they're safe to run during SSR (they no-op on the server) and only kick in on the client when the plugin below populates the runtime flag:
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,
}
}The runtime-state bridge — app/utils/locize-runtime.ts
The handlers above need access to runtime config (project id, api key, etc.) but i18n.config.ts doesn't have a Nuxt context. Bridge with a small shared mutable module:
export const locizeRuntime = {
projectId: '',
apiKey: '',
version: 'latest',
cdnHost: 'https://api.lite.locize.app',
isInContext: false,
saveMissing: false,
}The Nuxt client plugin — app/plugins/locize.client.ts
This populates the runtime state from runtimeConfig.public and starts the InContext editor when ?incontext=true is set:
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 })
})Using t() in pages
Standard vue-i18n usage, with one twist: pages must call useI18n({ useScope: 'global' }) to share the same composer that has the missing and postTranslation handlers on it. A plain useI18n() creates a per-component scoped composer that doesn't inherit those.
<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 — what burned me building this
A handful of non-obvious things worth knowing.
i18n/i18n.config.ts must live inside the i18n/ directory
@nuxtjs/i18n 10.x resolves the vue-i18n config file's path relative to layer.i18nDir (the i18n/ directory), not the project root. A root-level i18n.config.ts is silently not loaded. See findPath(layer.i18n.vueI18n || "i18n.config", { cwd: layer.i18nDir }) in @nuxtjs/i18n/dist/module.mjs.
Don't use the defineI18nConfig(...) macro
@nuxtjs/i18n's module post-processes any file containing the macro identifier with a greedy regex (DEFINE_I18N_FN_RE). With comments preserved by oxc-transform, a comment-text match yanks the closing paren of unrelated console.log / fetch(...) calls into the regex's capture group and corrupts the transformed output. Symptom: Expected ')' but found ';'. Plain export default function () { ... } (no macro wrapper) works just as well — @nuxtjs/i18n calls the default export at init time.
useI18n({ useScope: 'global' }) in pages
vue-i18n's useI18n() creates a per-component scoped composer by default in legacy: false mode, and scoped composers do NOT inherit missing / postTranslation from the global composer. If your pages call a plain useI18n(), saveMissing won't fire and the InContext editor won't see any segments. Use useScope: 'global' everywhere.
Composing translations with literal text in templates
This pattern works in JSX (React renders separate text nodes) but breaks in Vue:
<!-- DON'T -->
<NuxtLink to="/second">→ {{ t('index.goto.second') }}</NuxtLink>Vue's template compiler merges the → literal and {{ t(...) }} dynamic result into a single DOM text node — and the locize parser's text.startsWith(startMarker) check fails because the text now starts with → instead of the marker. (locize 4.1.0 added a fallback case in the parser that recovers most of these, but the cleanest answer is also better i18n practice: put the arrow into the translation value so translators can reorder it for RTL.)
<!-- DO -->
<NuxtLink to="/second">{{ t('index.goto.second') }}</NuxtLink>{ "goto": { "second": "→ Go to the second page" } }<i18n-t> slot interpolation passes an array of VNodes to postTranslation
vue-i18n's <i18n-t> component uses the translateVNode path, which calls postTranslation(messaged, key) — but messaged here is an array of Vue text VNodes (not raw strings), because vue-i18n's internal normalize() converts every string segment to createTextNode(...) before the hook runs. A plain typeof translated === 'string' check skips this case entirely. The handler above covers both shapes: wrap the whole string, OR rebuild the first + last text VNodes via h(Text, null, startMarker + children) and h(Text, null, children + endMarker).
Hydration mismatch warnings under ?incontext=true
Subliminal-wrapped text on the client doesn't match the unwrapped server output, so Vue logs [Vue warn] Hydration text content mismatch for every translated string when ?incontext=true is set. Vue's hydration is forgiving — it trusts the client, updates the DOM, and the editor's parser sees the wrapped strings. The warnings are noise, not failure, and they don't appear in normal (non-?incontext=true) page loads. If you want a clean console under ?incontext=true, you'd need to wrap on the server pass too (requires per-request SSR state for the runtime flag — outside the scope of this example).
Three ways to land new keys in Locize
The example wires up saveMissing because it's the most discoverable: every time a developer references a key that doesn't exist, it appears in Locize automatically. But it's optional, and which path you pick depends on your workflow:
saveMissingruntime push (the path the example shows by default). Convenient during dev. Requires shipping a dev-only apiKey in the browser bundle.locize sync(already wired inpackage.jsonassyncLocales, drop the--dry=trueflag).locize-clireads your locali18n/locales/{lng}/{ns}.jsonand uploads any keys not yet in Locize. Manual or CI-triggered, no write-key in the browser.- Static extraction via
i18next-clior similar — scans source, writes new keys into the local JSON, then sync up as above.
To disable saveMissing in this example, either delete the missing handler from i18n/i18n.config.ts or just leave NUXT_PUBLIC_LOCIZE_API_KEY empty so the handler no-ops at runtime.
InContext editing for translators
The locize plugin (just getVueI18nImplementation(...) + startStandalone({ implementation }) in the client plugin) gives translators an in-context editor: open any page of your running app with ?incontext=true appended and an iframe-based editor opens that highlights every translated string and lets translators edit them in place.
The editor saves land in your Locize project. To make those edits visible to end users you'll need to re-run npm run downloadLocales and redeploy (this example is bundle-only at runtime — see TL;DR for the reasoning and the cross-link to the live-CDN-fetch alternative).
🎉 Congratulations
A working Nuxt 4 + @nuxtjs/i18n + Locize setup gives you:
- SSR-friendly i18n with no flash of untranslated content
- Per-request locale detection via
@nuxtjs/i18n's middleware (cookie → URL → Accept-Language → fallback) - Build-time translation sync via
locize-cli— serverless-friendly, no per-request CDN hops - Optional
saveMissingto skip the manual key-add step during dev - InContext editing for non-developers via the
locizeplugin - A clean separation between bundled runtime translations and CMS-side editing
🧑💻 The complete code: github.com/locize/locize-nuxt-example.
The founders of Locize are also the creators of i18next — by using Locize you directly support the future of i18next.
If you want a bigger i18next overview, there's also an i18next crash course video:
See also
- How to internationalize a React Router v7 app with remix-i18next — same shape but with client-side live CDN fetch via
i18next-locize-backend - How to internationalize an Astro site with Locize — static-first counterpart: routing-only built-in i18n + build-time JSON sync, no runtime framework needed
- How to internationalize a Remix application (Part 1) and (Part 2) — Remix v2 walkthroughs
- Vue Localization with i18next-vue — alternative Vue stack using i18next directly (no
@nuxtjs/i18n, no vue-i18n) - Give vue-i18n more superpowers — the vanilla vue-i18n + locizer integration story
@nuxtjs/i18ndocumentation