Nuxt i18n lazy messages missing after a locale switch
The language switcher works, the URL changes to /de/produkte, and the page renders every key as its raw path: products.title, products.cta.add. The German file exists, the German translations are correct, and switching back to English works perfectly.
With lazy: true the message catalogue is a chunk fetched at navigation time. When keys render raw after a switch, either the chunk never arrived or it arrived somewhere the renderer is not looking.
Root cause: loading is asynchronous, resolution is not
Non-lazy loading bundles every locale’s messages into the application, so they are present before the first render. Lazy loading replaces that with a dynamic import performed during navigation, which means there is a window — usually a few milliseconds, occasionally much longer — in which the route has changed and the catalogue for the new locale has not been installed.
Anything that resolves a key inside that window resolves against an empty catalogue. Because most i18n resolvers return the key itself when nothing matches, the result is a page of dotted paths rather than an error, and nothing is logged.
The module handles this correctly for the ordinary render path: it awaits the import before completing navigation. What it cannot handle is code that reads a message outside that path — a value computed in setup at module scope, a store initialised at import time, or a composable that caches a translated string on first call.
Minimal reproducible example
// A store initialised at import time — evaluated before any locale chunk is loaded.
import { useI18n } from 'vue-i18n';
export const useLabels = defineStore('labels', () => {
const { t } = useI18n();
// Evaluated once, at module scope: whatever the catalogue holds right now.
const addToCart = t('products.cta.add');
return { addToCart };
});
The value is captured at the moment the module is evaluated and never recomputed, so it is wrong after any locale switch — and raw on the first load of a lazily loaded locale.
The fix: keep resolution reactive and read it at render time
export const useLabels = defineStore('labels', () => {
const { t } = useI18n();
// Computed: re-evaluates when the locale changes and the catalogue is installed.
const addToCart = computed(() => t('products.cta.add'));
return { addToCart };
});
The rule generalises beyond stores: a translated string should be a computed value or resolved inside the template, never a constant captured at setup time. Anything that holds a resolved string across a locale change is holding a stale value by construction.
Where a value genuinely must be resolved imperatively — building a document title, formatting an export file name — await the locale before reading it:
const { setLocale } = useI18n();
await setLocale('de'); // resolves once the chunk is installed
const title = t('products.title'); // now safe
When the chunk itself never arrives
If keys stay raw even on a full reload of the localized URL, the message file is not reaching the client at all, and the cause is one of two configuration mismatches.
The file field in each locale entry must match a real file inside langDir, and langDir is resolved relative to the project root rather than to nuxt.config.ts. A path that looks right and points one directory too high produces no request at all, because the module has nothing to import — as opposed to a wrong file name, which produces a visible 404 for the chunk.
The second cause is a second vue-i18n instance. A project that migrated from a manual createI18n setup and left the old call in a plugin ends up with two instances: the module installs messages into its own, and the application renders with the other. Everything looks correct in the configuration, the chunk is fetched successfully, and none of the keys resolve. Deleting the manual instance is the fix — the module owns the instance, and its runtime options belong in i18n.config.ts as described in Nuxt i18n module setup.
The pattern behind all four causes
The four causes look unrelated in a bug report and are the same mistake in four costumes: something assumed the catalogue was available at a moment when it was not.
A captured constant assumes it at module evaluation. A store initialised at import time assumes it before any route has been matched. A manual second instance assumes that whichever instance is in scope is the one holding messages. Even the langDir misconfiguration is a version of it — the code assumes a catalogue that never existed.
That framing is useful because it produces a single rule that prevents all four: treat the catalogue as data that arrives, not as data that is present. In practice that means three habits. Resolved strings live in computed values, never in constants. Anything that must read imperatively awaits setLocale first. And there is exactly one vue-i18n instance in the application, owned by the module.
Applying the rule has a pleasant side effect: code written this way keeps working when a locale is added, when lazy loading is switched on or off, and when a page moves between server and client rendering. Code that captures strings breaks on all three, and each break looks like a different bug.
The one case the rule does not cover is server-side generation of non-HTML artifacts — a PDF, an email, a CSV export — where the code path is genuinely imperative and there is no render to be reactive about. Those need the explicit await, and they need it in a place that is easy to forget: usually a job runner that does not go through the router at all, and therefore never triggers the module’s own loading.
Verification
# 1. The chunk is requested and served on a cold load of the localized URL
curl -s https://example.com/de/produkte | grep -o '/_nuxt/[^"]*de[^"]*\.js' | head -1
# 2. No raw keys survive in the rendered HTML
curl -s https://example.com/de/produkte | grep -oE '\b[a-z]+(\.[a-z-]+){2,}\b' | sort -u
# (empty — any dotted path here is an unresolved key)
The second check is worth keeping in CI for every locale. It is the same assertion that catches missing keys generally, and on a lazily loaded setup it doubles as proof that the chunk for that locale is reachable in production, which a local build cannot confirm.
When to escalate
If keys resolve on the server but not after client-side navigation, the server has all locales available and the client does not — usually because the client chunk failed to load behind a content-security policy or a CDN path rewrite. Look at the network response for the chunk rather than at the i18n configuration.
If keys resolve everywhere except one locale, compare that locale’s file against the others for a JSON parse error. A malformed catalogue fails the import silently in some bundler configurations, leaving that one locale empty while every other works.
If everything resolves but the wrong language renders, the problem is detection rather than loading, and the precedence rules in locale negotiation strategies apply.
FAQ
Should I just turn lazy loading off?
Only as a diagnostic. Turning it off proves the problem is loading rather than the catalogue, which is useful for ten minutes. Leaving it off means every reader downloads every language, and that cost grows with each market you add.
Why does the first visit work and the switch fail?
Because the first visit renders on the server, where every locale is available without a fetch. The switch happens on the client, where the catalogue for the new locale genuinely has to arrive over the network first — so a value captured before that arrival is stale in a way the server render never exposes.
Can I preload the next locale?
Yes, and it is worth doing when the switcher is prominent. Prefetching the chunk when the language menu opens removes the visible delay without giving up the bundle saving, since the fetch happens while the reader is choosing rather than after.
Does this affect date and number formatting too?
It can. datetimeFormats and numberFormats declared per locale are installed alongside the messages, so a formatter used during the same window falls back to defaults. Reading them through a computed value has the same effect as it does for messages.
How large does a locale chunk have to be before the delay is visible?
On a fast connection almost nothing is visible below about fifty kilobytes; on a throttled 3G profile the same chunk costs a noticeable beat. The useful measure is not the size but whether the fetch happens before or after the reader commits to the switch — prefetching on menu open moves it into time the reader was spending anyway, which makes the size largely irrelevant.
Related
- Nuxt i18n Module Setup — where lazy loading and langDir are configured.
- Vue i18n Composition API Guide — why a resolved string must stay reactive.
- vue-i18n fallback warning: Not found key — the warning an empty catalogue produces in development.
- Fallback Chain Configuration — what should happen when a key genuinely is missing.
Part of Nuxt i18n Module Setup.