Fallback Chain Configuration

A fallback chain resolves a requested locale to the best available translation by walking from exact match down to a guaranteed default — so a request for es-AR lands on es-419, then es, then en, instead of rendering the raw key checkout.cta.submit to a user. Get the ordering wrong and you ship empty strings, mismatched plurals, or a 404 on /es-AR/checkout; get it right and every locale degrades gracefully to readable text.

This is the resolver that sits between locale negotiation and bundle hydration. Once Locale Negotiation Strategies have produced a resolved tag, the fallback chain decides which actual resource file backs each key. The ordering is fixed and deterministic: exact locale → regional variant → base language → default. Everything below makes that chain explicit, cache-safe, and impossible to leak raw keys through.

Fallback cascade for a missing key A request for es-AR checks the exact bundle, falls to the es-419 regional variant, then the es base language, then the en default; each step is tried only when the key is absent in the prior bundle. resolve("checkout.cta", "es-AR") Exact locale es-AR Regional variant es-419 Base language es Default en miss miss RFC 4647 "lookup" rule Truncate at the last "-" each step; never widen. Stop at the first bundle that owns the key. If the default also misses → return the key marker, emit telemetry, never render raw key to the user.
The cascade tries each narrower-to-broader bundle in order and terminates at a guaranteed default.

Prerequisites

Concept & spec — what “fallback” actually means

The chain is the lookup algorithm defined in RFC 4647 §3.4. Given a requested tag, lookup produces an ordered list of candidate tags by progressively truncating the rightmost subtag at each hyphen — zh-Hant-TWzh-Hantzh — and never adding subtags back. The first candidate that names an available bundle wins. This is distinct from RFC 4647’s “filtering” mode, which returns all matching tags; for runtime string resolution you always want lookup, because you need exactly one source per key.

Two refinements matter in practice. First, a regional variant like es-419 (Latin American Spanish, a UN M.49 region code) often sits between es-AR and es and is a far better fallback than the Iberian es-ES default. RFC 4647 truncation alone won’t insert it — you supply that link explicitly via a fallbackLng map. Second, the chain must terminate at a verified default; per the parent Core i18n Architecture & Locale Negotiation model, the default bundle is the one hard guarantee in the whole pipeline, so it is the only bundle whose 100% key coverage is enforced at build time.

Canonicalize before you resolve. Intl.getCanonicalLocales('es-419') returns ['es-419'], and new Intl.Locale('zh-hant-tw').baseName normalizes casing. Resolving against un-normalized tags is the single most common cause of a “bundle exists but never matches” bug.

Step-by-step implementation

1. Canonicalize the requested tag

Normalize casing and structure before the chain runs. A mixed-case es-ar must become es-AR or it will miss every bundle keyed in canonical form.

export function canonical(tag: string): string {
  try {
    return Intl.getCanonicalLocales(tag)[0] ?? tag;
  } catch {
    return tag; // malformed tag — let the chain fall through to default
  }
}

2. Build the RFC 4647 lookup chain

Generate the ordered candidate list by truncating right-to-left, then append explicit fallbackLng links and the default. De-duplicate while preserving order so a regional variant injected via the map isn’t visited twice.

type FallbackMap = Record<string, string[]>;

export function buildChain(
  tag: string,
  fallbackLng: FallbackMap,
  defaultLocale: string,
): string[] {
  const chain: string[] = [];
  let cur = canonical(tag);

  // RFC 4647 lookup: truncate at each hyphen.
  while (cur) {
    chain.push(cur);
    for (const extra of fallbackLng[cur] ?? []) chain.push(extra);
    const i = cur.lastIndexOf('-');
    if (i < 0) break;
    cur = cur.slice(0, i);
  }
  chain.push(...(fallbackLng['*'] ?? []), defaultLocale);

  return [...new Set(chain)]; // order-preserving de-dupe
}
// buildChain('es-AR', { 'es-AR': ['es-419'] }, 'en')
// → ['es-AR', 'es-419', 'es', 'en']

3. Resolve a key against the chain

Walk the chain and return the first bundle that owns the key. Crucially, never return the raw key to the renderer — return a typed miss so the caller can decide on a visible marker only in non-production.

const MISS = Symbol('i18n.miss');

export function resolveKey(
  key: string,
  chain: string[],
  bundles: Record<string, Record<string, string>>,
): string | typeof MISS {
  for (const loc of chain) {
    const v = bundles[loc]?.[key];
    if (v != null) return v;
  }
  return MISS;
}

4. Guard the render boundary

Convert a miss into safe output exactly once, at the edge of your render layer, and emit telemetry. This is the only place the raw key may surface, and only behind a dev flag.

export function render(key: string, chain: string[], bundles: any): string {
  const out = resolveKey(key, chain, bundles);
  if (out === MISS) {
    track('i18n.miss', { key, requested: chain[0] });
    return process.env.NODE_ENV === 'production' ? '' : `${key}`;
  }
  return out;
}

5. Validate ICU parity across the chain

A fallback string must keep the same placeholders as the source, or interpolation breaks at runtime. Compare parsed ICU structure before trusting a fallback — covered in depth in the ICU Message Format Deep Dive.

import { parse } from '@formatjs/icu-messageformat-parser';

export function placeholders(msg: string): Set<string> {
  const out = new Set<string>();
  for (const el of parse(msg)) if ('value' in el && el.type !== 0) out.add(String(el.value));
  return out;
}
// In CI: assert placeholders(fallback).size matches the source for every shared key.

Configuration reference

Option Type Description / default
defaultLocale string Terminal locale with enforced 100% coverage. Default 'en'.
fallbackLng Record<string, string[]> Explicit links inserted into the lookup chain (e.g. { 'es-AR': ['es-419'] }). Key '*' applies to all.
maxDepth number Hard cap on chain length; cycle/runaway guard. Default 4.
strictMode boolean Throw on ICU parity mismatch instead of warning. Default false in prod, true in CI.
lowerCaseLng boolean Canonicalize tags before lookup. Default true. Disable only if bundles are keyed in raw form.
returnNull boolean If true, missing keys return null (typed miss) instead of ''. Default true.
nonExplicitSupportedLngs boolean Treat de as covering de-AT/de-CH without explicit map entries. Default true.

Framework variants

React / i18next

i18next ships this chain natively. fallbackLng accepts a map, and nonExplicitSupportedLngs enables base-language coverage. Keep returnEmptyString: false and parseMissingKeyHandler to avoid raw-key leakage.

i18n.init({
  fallbackLng: { 'es-AR': ['es-419', 'es'], default: ['en'] },
  nonExplicitSupportedLngs: true,
  returnEmptyString: false,
  parseMissingKeyHandler: (key) => (import.meta.env.DEV ? `${key}` : ''),
});

Next.js (App Router)

Resolve the chain in middleware so the server component receives a single concrete locale, and serve 404 only for tags outside supportedLngs — never for a missing string. Region-to-language redirects belong here; see region-to-language fallback without a 404 for the redirect-vs-rewrite decision.

Vue i18n

Vue i18n exposes fallbackLocale, which accepts the same map shape ({ 'es-AR': ['es-419', 'es'], default: ['en'] }) plus fallbackWarn to surface gaps in dev. Set missingWarn: false in production builds.

Node.js backend / SSR

On the server, build the chain once per request from the negotiated tag and memoize per-locale chains (they’re pure functions of tag + config). Pass the resolved chain — not the raw header — to the template layer so SSR and client hydration agree.

Cache-aware SWR headers

A fallback resolver that changes which bundle backs a key must not be cached as if it were locale-agnostic. Vary the edge cache on the resolved locale and serve stale-while-revalidate so a cold variant bundle never blocks render:

Vary: Accept-Language, Cookie
Cache-Control: public, max-age=600, stale-while-revalidate=86400

Vary: Accept-Language tells the CDN that two requests differing only by language are distinct cache entries — without it, the first visitor’s resolved locale gets served to everyone. stale-while-revalidate=86400 lets the edge return a slightly stale localized page instantly while it refetches in the background, so a translation update never forces a synchronous origin fetch on the user’s critical path. When you redirect a region tag to its language (e.g. es-ARes-419), use a 302 with Vary, not a 301, so the mapping stays revisable.

Verification

Assert the chain shape and the no-raw-key guarantee in CI. The first test pins the RFC 4647 ordering; the second proves every key in every locale resolves to non-empty text through the default.

import { test, expect } from 'vitest';
import { buildChain, render } from './fallback';

test('chain follows exact → regional → base → default', () => {
  expect(buildChain('es-AR', { 'es-AR': ['es-419'] }, 'en'))
    .toEqual(['es-AR', 'es-419', 'es', 'en']);
});

test('no key ever renders raw to a user', () => {
  const bundles = load(); // all locale json
  for (const loc of supportedLngs) {
    const chain = buildChain(loc, fallbackLng, 'en');
    for (const key of Object.keys(bundles.en)) {
      expect(render(key, chain, bundles)).not.toBe(`${key}`);
    }
  }
});
# Expected output
✓ chain follows exact → regional → base → default
✓ no key ever renders raw to a user

Wire this as a build gate so a PR that adds a key to the default bundle without coverage — or breaks the ordering — fails before merge.

Common pitfalls

  • Raw key leaking to users. A missing string renders checkout.cta.submit in the UI. Set returnEmptyString: false and a parseMissingKeyHandler, and gate the visible marker behind a dev flag. Build the tiered chain properly via setting up graceful fallback chains for missing strings.
  • 404 on a valid region. Routing treats /es-AR/ as unknown because only es has a bundle. Resolve at the routing layer and redirect/rewrite; never 404 a string miss. See region-to-language fallback without a 404.
  • Un-canonicalized tags. es_ar or ES-ar silently miss every bundle. Always run Intl.getCanonicalLocales first.
  • Widening instead of narrowing. Appending subtags (eses-ES) is filtering, not lookup, and picks the wrong regional default. Only ever truncate.
  • ICU placeholder drift. A fallback string drops {count} and the plural breaks at runtime. Gate on ICU parity in CI.
  • Stale edge cache. Missing Vary: Accept-Language serves one visitor’s locale to all. Always vary on the locale signal.

FAQ

What is the correct fallback order for locales?

Exact locale → regional variant → base language → default, following RFC 4647 lookup: truncate the requested tag at each hyphen (es-ARes-419esen), inserting any explicit regional variant via a fallbackLng map, and terminate at a default bundle with guaranteed 100% key coverage.

How do I stop raw translation keys from showing in the UI?

Make missing keys return a typed miss (null or empty string) rather than the key itself, and convert that miss to safe output at the render boundary. Set returnEmptyString: false plus a parseMissingKeyHandler that only emits a visible ⟦key⟧ marker in development, never in production.

What is the difference between RFC 4647 lookup and filtering?

Lookup returns exactly one best-matching tag by progressively truncating the request, which is what runtime string resolution needs. Filtering returns the full set of tags matching a range and is meant for content negotiation lists, not for picking a single source bundle per key.

Should a missing locale return a 404?

No. A 404 is correct only when the requested language is entirely unsupported. A string miss should fall through the chain to the default; a region miss should redirect or rewrite to the base language. Resolve this at the routing layer with a 302, not by returning a 404.

How do edge caches interact with fallback chains?

Because the resolver changes which bundle backs a page per locale, the CDN must Vary: Accept-Language (and Cookie if you use a locale cookie) so each resolved locale is a distinct cache entry. Add stale-while-revalidate so a cold variant bundle is served instantly from cache while it revalidates in the background.

Part of Core i18n Architecture & Locale Negotiation.