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 string. A miss checks for a defaultMessage; present in dev it renders, stripped in prod it renders the raw id and fires onError. Five root causes branch off the miss path. FormattedMessage id="app.cart.title" Catalog lookup messages[locale][id] Hit render string Miss onError fires defaultMessage? dev: shown / prod: id 5 causes not extracted dm stripped wrong locale ns not loaded hash mismatch
A catalog miss fires onError, then falls back to defaultMessage — shown in dev, stripped to the raw id in prod.

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”:

  • 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,
    }],
  ],
};

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.