Fixing the vue-i18n “Not found ‘key’ in ‘locale’ messages” warning

The vue-i18n console warning [intlify] Not found 'cart.title' key in 'en' locale messages means t('cart.title') reached the end of its lookup chain without resolving, so it returned the raw key string instead of translated text. The warning has four distinct causes that look identical in the console: the key is genuinely absent, the wrong useI18n() scope is reading the wrong store, the messages were registered lazily and have not loaded yet, or you are reaching for the removed v8 silentTranslationWarn flag and it no longer does anything. This page separates the four, gives a minimal repro for each, and shows how to silence only the legitimate noise — fallbackWarn versus missingWarn — without hiding real gaps.

The trap is treating every Not found line as a missing translation and “fixing” it by adding the key to a JSON file. Half the time the key already exists; the lookup just never reached the store it lives in. Diagnose the cause before you touch the catalog.

Four causes of the vue-i18n Not found key warning A Not found warning branches into four causes: key genuinely missing, wrong useI18n scope, lazy messages not loaded yet, or relying on the removed silentTranslationWarn flag. Not found 'key' warning t('key') returned 'key' 1 · Genuinely missing add to catalog 2 · Wrong scope local vs global useI18n() 3 · Lazy load messages not loaded yet 4 · Removed flag silentTranslation Warn is gone Only cause 1 is a real gap — the other three need config, not a new key missingWarn → exhausted · fallbackWarn → resolved via fallback
Diagnose which of the four causes you have before editing the catalog — three of them are not missing keys at all.

Root cause: missingWarn vs fallbackWarn, and the two warnings they emit

vue-i18n emits two different warnings that read almost identically, and conflating them sends you down the wrong fix path. The exact Not found '...' key in '...' locale messages text comes from missingWarn — the key resolved nowhere, not in the active locale and not anywhere in the fallbackLocale chain, so t() returned the key itself. A second, separate line — Fall back to translate '...' key with 'en' locale — comes from fallbackWarn and is informational: the key was missing in the active locale but the fallback chain rescued it, so the user saw real text. Both default to true (in development; vue-i18n suppresses them in production builds where __INTLIFY_PROD_DEVTOOLS__ is false).

That distinction is the whole diagnosis. A fallbackWarn line is usually benign — it just means a regional locale leans on its base language, which is the fallback chain working as designed. A missingWarn line is the one to chase. The four causes from the diagram resolve as follows:

  1. Genuinely missing. The key exists in no loaded catalog for any locale in the chain. This is the only cause that warrants adding the key to a JSON file. Confirm it by checking that the key is absent from all locale files, not just the active one.
  2. Wrong scope. A component called useI18n({ useScope: 'local' }), so t() reads the component’s private SFC <i18n> store, where the key does not exist — even though it sits in the global catalog. The fix is scope, not content. Scope behavior is laid out in full in the Vue i18n Composition API Guide.
  3. Lazy messages not loaded. With async locale loading (mergeLocaleMessage after a dynamic import), a component can render and call t() in the tick before the messages resolve. The key warns once, then the merge lands and the next render is fine — a race, not a gap.
  4. Removed silentTranslationWarn. Teams migrating from v8 reach for silentTranslationWarn: true to mute the noise. That option was removed in vue-i18n v9; it is silently ignored. The v9 replacements are missingWarn and fallbackWarn, which accept a boolean or a RegExp so you can mute by namespace.

The lookup order itself — local scope, then global scope, then each entry in the fallbackLocale chain — is the same resolver documented for the fallback chain configuration; the warning fires only after every link is exhausted.

Minimal reproducible example

Each cause has a tiny repro. The scope mismatch is the most common and the most confusing, because the key visibly exists in your JSON yet still warns:




The lazy-load race (cause 3) reproduces when a route component renders before its locale chunk has merged:

// Repro: cause 3 — messages registered after first render.
async function setLocale(locale: string) {
  const msgs = await import(`./locales/${locale}.json`)   // async: resolves next tick
  i18n.global.setLocaleMessage(locale, msgs.default)
  i18n.global.locale.value = locale                       // switched BEFORE merge on slow nets
}
// A component mounted between locale.value flip and setLocaleMessage warns once.

And cause 4 is the silent no-op — the flag is accepted by TypeScript-loose configs but does nothing:

// Repro: cause 4 — this option does not exist in v9 and is ignored.
createI18n({
  legacy: false,
  silentTranslationWarn: true, // ❌ removed in v9 — warnings still print
})

The fix

The fix depends on the cause, so handle them in order — cheapest diagnosis first.

// src/i18n.ts — corrected createI18n for causes 2 and 4
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
import de from './locales/de.json'

export const i18n = createI18n({
  legacy: false,
  globalInjection: false,
  locale: 'en',
  fallbackLocale: { 'de-AT': ['de', 'en'], default: ['en'] }, // base-language hop first

  // CAUSE 4 FIX: silentTranslationWarn is gone. Use these instead.
  // RegExp form mutes ONLY known-dynamic namespaces, not the whole app.
  missingWarn: /^(?!chart\.|userGen\.)/,  // warn on everything EXCEPT chart.* / userGen.*
  fallbackWarn: false,                    // fallback hops are expected here — silence the info line

  messages: { en, de },
})



For cause 3, the lazy race, never flip locale.value until the merge has resolved — do the import and the setLocaleMessage before the locale switch, and gate routing on it:

// CAUSE 3 FIX: load and merge BEFORE switching, so t() never sees an empty store.
export async function loadLocale(locale: string) {
  if (!i18n.global.availableLocales.includes(locale)) {
    const msgs = await import(`./locales/${locale}.json`)
    i18n.global.setLocaleMessage(locale, msgs.default) // merge first
  }
  i18n.global.locale.value = locale                    // switch only after merge resolves
  return nextTick()                                    // let dependent components re-render
}
// Call loadLocale() inside router.beforeEach and await it before resolving navigation.

For cause 1 — a genuinely missing key — add it to every locale file, or let the fallback chain cover it and accept the (now silenced) fallbackWarn. A static parity check, below, stops cause 1 from ever reaching the console again.

Verification

Prove the scope fix in a unit test by mounting against a real instance and asserting t() returns text, not the raw key:

// not-found.spec.ts
import { mount } from '@vue/test-utils'
import { i18n } from '../src/i18n'
import CartHeader from '../src/components/CartHeader.vue'

test('cart.title resolves and does not echo the key', () => {
  i18n.global.locale.value = 'en'
  const wrapper = mount(CartHeader, { global: { plugins: [i18n] } })
  expect(wrapper.text()).toBe('Your Cart')        // real translation
  expect(wrapper.text()).not.toContain('cart.title') // never the raw key
})

Then close the loop on cause 1 in CI — a key present in en but absent elsewhere is the only thing that should fail the build:

# Fail CI if any locale is missing a key that en defines (catches genuine gaps).
npx vue-i18n-extract report \
  --vueFiles './src/**/*.vue' \
  --languageFiles './src/locales/*.json' \
  --add false
# Expected: missingKeys: [] — a non-empty array exits non-zero.

A passing test plus an empty missingKeys array means every remaining Not found line in development is a scope or lazy-load issue, not a real gap — and those are now config, not catalog, problems.

When to escalate

This four-cause split covers the standard SPA setup where messages live in JSON and load either eagerly or via a simple dynamic import. If you still see Not found after applying the matching fix, the problem usually sits one layer up: a build that strips SFC <i18n> blocks because the @intlify/unplugin-vue-i18n compiler is missing (the keys vanish silently at compile time, so they are “missing” for a reason that no runtime fix touches), or messages fetched from a remote API that arrive after hydration in an SSR app. Both are setup issues tracked through the broader Vue i18n Composition API Guide. If the warnings only began after a version bump, the injection model and compiler split changed too — chase that through migrating Vue 2 i18n to the Vue 3 Composition API. And if the right text appears but the plural branch is wrong, that is a CLDR-category issue rather than a missing key — see pluralization rules across languages.

FAQ

Why does the key exist in my en.json but still warn as not found?

Almost always a scope mismatch: the component called useI18n({ useScope: 'local' }), so t() reads only that component’s private SFC <i18n> store, where the key is absent. The key in your global en.json is invisible to a local-scope t(). Switch to useScope: 'global' (or drop the option, since global is the default) and the lookup reaches the catalog.

Why doesn’t silentTranslationWarn: true silence the warnings anymore?

silentTranslationWarn was a v8 option and was removed in vue-i18n v9, where it is silently ignored. The v9 replacements are missingWarn and fallbackWarn. Both accept a boolean or a RegExp, so prefer a RegExp like missingWarn: /^(?!chart\.)/ to mute only known-dynamic namespaces instead of hiding every real gap.

Should I just set missingWarn: false to clean up the console?

No. missingWarn: false hides genuinely missing keys, which is the one cause you actually want to catch. Silence the benign fallbackWarn (expected when regional locales lean on their base language), scope missingWarn to a RegExp for dynamic namespaces, and enforce key parity in CI with vue-i18n-extract so real gaps fail the build instead of scrolling past in the console.

Part of Vue i18n Composition API Guide.