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.
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-TW → zh-Hant → zh — 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-AR → es-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.submitin the UI. SetreturnEmptyString: falseand aparseMissingKeyHandler, and gate the visible marker behind a dev flag. Build the tiered chain properly via setting up graceful fallback chains for missing strings. 404on a valid region. Routing treats/es-AR/as unknown because onlyeshas a bundle. Resolve at the routing layer and redirect/rewrite; never404a string miss. See region-to-language fallback without a 404.- Un-canonicalized tags.
es_arorES-arsilently miss every bundle. Always runIntl.getCanonicalLocalesfirst. - Widening instead of narrowing. Appending subtags (
es→es-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-Languageserves 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-AR → es-419 → es → en), 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.
Related
- Locale Negotiation Strategies — produces the single resolved tag that feeds this chain.
- Setting up graceful fallback chains for missing strings — the tiered resolver and CI key-coverage linting in depth.
- Region-to-language fallback without a 404 — routing-layer redirect vs. rewrite for region tags.
- ICU Message Format Deep Dive — placeholder and plural parity that fallback strings must preserve.