Fixing the “[React Intl] Missing message” / MISSING_TRANSLATION warning

The [React Intl] Missing message: "app.cart.title" for locale "de", using default message warning means <FormattedMessage> looked up an id that does not exist in the catalog you passed to IntlProvider for the active locale, so React Intl fell back to defaultMessage — or, in production where defaultMessage was stripped, rendered the raw id. This page walks the five real causes (id absent from the compiled catalog, defaultMessage removed by the Babel plugin in prod, the wrong namespace or locale bundle loaded, a permissive onError swallowing the signal, and an id-hashing mismatch between extract-time and runtime) and the verification steps that prove the fix.

The diagnosis below assumes react-intl v6+ with the @formatjs/cli extract/compile pipeline and babel-plugin-formatjs (or the SWC equivalent) in the build. Every snippet runs against a standard Vite or Next.js client tree.

React Intl missing-message diagnosis flow FormattedMessage resolves an id against the active-locale catalog. A hit renders the translated string. A miss fires onError with MISSING_TRANSLATION and falls back to defaultMessage, which is shown in development but stripped to the raw id in production. A second band lists the five reasons the id is absent from the active catalog: never extracted, defaultMessage stripped in prod, wrong locale catalog, namespace not loaded, and an extract-versus-runtime hash mismatch. FormattedMessage id="app.cart.title" Catalog lookup messages[locale][id] Hit renders translated string Miss onError(MISSING_TRANSLATION) defaultMessage? dev → shows English copy prod → stripped, renders id A miss always means the active-locale catalog lacks this id — five ways that happens: never extracted absent from de.json defaultMessage stripped in prod wrong locale catalog not loaded onError swallows signal hidden hash mismatch extract ≠ runtime
A catalog miss fires onError, then falls back to defaultMessage — shown in dev, stripped to the raw id in prod. The bottom band lists the five reasons the id never reached the active catalog.

Root cause analysis

React Intl resolves a message in formatMessage/<FormattedMessage> by reading messages[id] from the object you handed IntlProvider for the currently active locale. There is exactly one lookup: a flat string key into a flat object. If that key is absent, the library calls your onError handler with a MISSING_TRANSLATION error code and then renders defaultMessage if one is present at runtime. The warning is therefore never about a “namespace” inside React Intl itself — React Intl has no namespaces — but about which catalog object reached the provider. The five observed failure modes all reduce to “the active catalog does not contain this id”:

Four checks behind one missing-message warning The warning means the identifier was not found for the active locale. Checking whether the identifier exists in the compiled catalogue separates a build problem from a translation gap. Comparing the extraction pattern against the Babel plugin pattern catches id drift. Logging the active locale catches a wrong-locale render. If all three pass, the string genuinely has not been translated yet. Reading a MISSING_TRANSLATION warning correctly 1 Is the id in the compiled catalogue? grep the built lang file for the id 2 Do the extract and Babel patterns match? byte-identical idInterpolationPattern 3 Is the locale the one you think? log intl.locale at the boundary 4 Is it simply untranslated yet? then the warning is doing its job
Only the fourth outcome is a translation problem; the first three are pipeline problems.
  • Not in the compiled catalog. The id was authored in JSX but the extract step (formatjs extract) never saw it (dynamic id, source path excluded, or the file added after the last extract), so the compiled de.json has no entry.
  • defaultMessage stripped in production. babel-plugin-formatjs with removeDefaultMessage: true (or ast: true) drops defaultMessage from the bundle to save bytes. In dev you saw the English copy; in prod the same missing id now renders the bare id because there is nothing to fall back to.
  • Wrong locale / catalog not loaded. IntlProvider got locale="de" but messages still points at the English object, or the per-locale dynamic import() resolved after the first render, so the provider mounted with {}.
  • onError swallowing the signal. A handler that does if (err.code === 'MISSING_TRANSLATION') return; hides the warning, so a real catalog gap ships silently and surfaces later as raw ids in the UI.
  • Id-hashing mismatch. Extraction used idInterpolationPattern: '[sha512:contenthash:base64:6]' but the runtime <FormattedMessage> either has a different defaultMessage (changing the hash) or runs without the Babel transform, so the runtime id and the compiled id differ by a hash and never match.

The fix differs per cause, but all of them are confirmed the same way: the compiled catalog for the active locale must contain the exact id the runtime requests.

Minimal reproducible example

A single component plus a stale catalog reproduces the warning. The catalog has an English entry but no de entry, and production has stripped defaultMessage:

// App.tsx — reproduces the warning
import { IntlProvider, FormattedMessage } from 'react-intl';

// Compiled de.json is missing "app.cart.title" entirely.
const messages: Record<string, Record<string, string>> = {
  en: { 'app.cart.title': 'Your cart' },
  de: {}, // <-- gap: id never extracted into the German catalog
};

export function App() {
  const locale = 'de';
  return (
    <IntlProvider locale={locale} messages={messages[locale]} defaultLocale="en">
      {/* In prod, babel-plugin-formatjs has stripped this defaultMessage */}
      <h1>
        <FormattedMessage id="app.cart.title" defaultMessage="Your cart" />
      </h1>
    </IntlProvider>
  );
}

Rendered under locale="de", the console logs [React Intl] Missing message: "app.cart.title" for locale "de". In dev it still shows “Your cart” (the inline defaultMessage); after a production build that ran the plugin with removeDefaultMessage, the <h1> renders the literal string app.cart.title.

The fix, annotated

The durable fix is to make extract → compile authoritative and to keep defaultMessage available as the source of truth, so a missing id is impossible by construction rather than patched per-string. The id-stability work overlaps directly with the extraction pipeline trade-offs you choose between formatjs and Lingui.

First, pin extraction and compilation so every authored id reaches every locale catalog:

# 1. Extract every id (with defaultMessage) from source into a source-of-truth file.
#    --id-interpolation-pattern MUST match the Babel plugin config exactly (see below).
npx formatjs extract 'src/**/*.{ts,tsx}' \
  --out-file lang/en.json \
  --id-interpolation-pattern '[sha512:contenthash:base64:6]'

# 2. Compile the per-locale source (translated by your TMS) into the runtime catalog.
#    Compilation drops defaultMessage but keeps the id->translation map intact.
npx formatjs compile lang/de.json --out-file src/compiled/de.json

Second, keep the Babel/SWC plugin config and the extract command in lockstep — the single most common hash-mismatch source is these two drifting apart:

// babel.config.js — runtime id generation
module.exports = {
  plugins: [
    ['formatjs', {
      // MUST be byte-identical to the extract flag above, or runtime ids
      // won't match compiled ids and every message "misses".
      idInterpolationPattern: '[sha512:contenthash:base64:6]',
      // Keep defaultMessage in dev so the inline fallback works.
      // Set removeDefaultMessage only in the production env, never globally.
      removeDefaultMessage: process.env.NODE_ENV === 'production',
      ast: true,
    }],
  ],
};
Why the extract flag and Babel plugin pattern must be byte-identical formatjs extract hashes each message id with an id-interpolation pattern to build the compiled catalog. The Babel plugin hashes the same message at runtime with its own pattern. A comparison node checks the two patterns: identical patterns produce equal ids so the lookup hits, but a drifted pattern produces a different hash so every auto-id message misses. formatjs extract --id-interpolation-pattern babel-plugin-formatjs idInterpolationPattern compare pattern strings Identical → hit runtime id == catalog id Drift → miss every auto-id "misses" equal differ
Extraction and the runtime transform hash each id independently; unless the two interpolation patterns are byte-identical, the ids never match and every auto-generated message misses.

Third, make IntlProvider load the right catalog for the active locale and make onError loud about real gaps while staying quiet about formatting noise:

import { IntlProvider, type OnErrorFn } from 'react-intl';
import de from './compiled/de.json';

const onError: OnErrorFn = (err) => {
  // Do NOT blanket-suppress MISSING_TRANSLATION — that hides catalog gaps.
  // Only downgrade it to a warning in dev; let it surface in CI/QA.
  if (err.code === 'MISSING_TRANSLATION') {
    if (process.env.NODE_ENV !== 'production') console.warn(err.message);
    return; // production: render fallback, but the CI gate below already failed the build
  }
  throw err; // genuine format errors must not be swallowed
};

<IntlProvider locale="de" messages={de} defaultLocale="en" onError={onError}>
  {/* ... */}
</IntlProvider>;

The key non-obvious line is that onError must not be where you “fix” missing translations — it is a last-resort renderer. The actual gate is at build time: a missing id is a CI failure, not a runtime warning to tolerate.

Verification snippet

Prove the fix two ways. First, a CI check that fails the build when any compiled locale catalog is missing an id present in the source-of-truth English catalog:

# Fails (exit 1) if any id in en.json is absent from de.json after compile.
node -e '
  const en = require("./lang/en.json");
  const de = require("./src/compiled/de.json");
  const missing = Object.keys(en).filter((id) => !(id in de));
  if (missing.length) { console.error("Missing ids in de:", missing); process.exit(1); }
  console.log("All", Object.keys(en).length, "ids present in de catalog");
'

Second, a runtime assertion that onError is never called with MISSING_TRANSLATION for a known id:

import { render } from '@testing-library/react';
import { IntlProvider, FormattedMessage } from 'react-intl';
import de from '../compiled/de.json';

test('no missing-message error for a compiled id', () => {
  const onError = jest.fn();
  render(
    <IntlProvider locale="de" messages={de} onError={onError}>
      <FormattedMessage id="app.cart.title" defaultMessage="Your cart" />
    </IntlProvider>,
  );
  // Assert React Intl never reported the id as missing.
  const missing = onError.mock.calls.find(([e]) => e.code === 'MISSING_TRANSLATION');
  expect(missing).toBeUndefined();
});

A green CI check plus a passing assertion confirms the id reaches the active-locale catalog with the same hash the runtime computes.

When to escalate

If ids still mismatch after pinning idInterpolationPattern on both sides, the problem is almost always that part of your tree renders <FormattedMessage> without the Babel/SWC transform applied — a third-party package shipping its own React Intl messages, or a route bundled by a different toolchain. In that case stop chasing per-string hashes and standardize the extraction toolchain across the whole app, which is the broader decision covered in the formatjs vs Lingui extraction pipeline comparison. If the warning is intermittent and tied to a locale switch rather than a missing id, it is a load-order race in the provider, and the component-mount patterns in the parent React i18next component patterns guide address it. Either way, treat a MISSING_TRANSLATION that reaches production as a failed build gate, not an acceptable warning.

FAQ

Why does the message show in development but render the raw id in production?

Because babel-plugin-formatjs is configured with removeDefaultMessage (or ast: true) for production builds, which strips the inline defaultMessage from the bundle. In dev the fallback string still exists, so a catalog miss is invisible; in prod there is nothing to fall back to, so React Intl renders the bare id. The id was missing in both environments — production just makes it visible.

How do I make a missing translation fail the build instead of warning at runtime?

Add a CI step that diffs the source-of-truth catalog (en.json) against each compiled locale catalog and exits non-zero on any id present in the source but absent from a target. Keep onError as a renderer only; do not rely on it to catch gaps, because by the time it fires the broken build has already shipped.

Why do my ids mismatch between extract time and runtime?

The --id-interpolation-pattern passed to formatjs extract must be byte-identical to the idInterpolationPattern in your Babel/SWC plugin config. If they differ, or if part of the tree renders without the transform, the runtime computes a different hash than the compiled catalog uses, so every auto-id message “misses” even though the translation exists.

Part of React i18next Component Patterns.