next-intl-localechain
v0.1.1
Published
Smart locale fallback chains for next-intl
Maintainers
Readme
next-intl-localechain
Smart locale fallback chains for next-intl — because pt-BR users deserve pt-PT, not English.
The Problem
next-intl falls back directly to defaultLocale when a translation key is missing. There is no intermediate fallback.
Example: Your app has pt-PT translations but no pt-BR messages file. A Brazilian Portuguese user sees English (or whatever your default locale is) instead of the perfectly good pt-PT translations.
The same thing happens with es-MX -> es, fr-CA -> fr, de-AT -> de, and every other regional variant.
Your users see English when a perfectly good translation exists in a sibling locale.
The Solution
One-line setup. Zero changes to your existing next-intl code.
withLocaleChain wraps your getRequestConfig callback and automatically deep-merges messages from a configurable fallback chain before passing them to next-intl.
Installation
npm install next-intl-localechainQuick Start
// src/i18n/request.ts (or wherever your next-intl config lives)
import { getRequestConfig } from 'next-intl/server';
import { withLocaleChain } from 'next-intl-localechain';
export default getRequestConfig(withLocaleChain({
loadMessages: (locale) => import(`../../messages/${locale}.json`).then(m => m.default),
defaultLocale: 'en'
}));All default fallback chains are active. A pt-BR user will now see pt-PT translations when pt-BR keys are missing.
Custom Configuration
Default (zero config)
withLocaleChain({
loadMessages: (locale) => import(`../../messages/${locale}.json`).then(m => m.default),
defaultLocale: 'en'
})Uses all built-in fallback chains. Covers Chinese, Portuguese, Spanish, French, German, Italian, Dutch, English, Arabic, Norwegian, and Malay regional variants.
With overrides (merge with defaults)
// Override specific chains while keeping all defaults
withLocaleChain({
loadMessages: (locale) => import(`../../messages/${locale}.json`).then(m => m.default),
defaultLocale: 'en',
overrides: { 'pt-BR': ['pt'] } // skip pt-PT, go straight to pt
})Your overrides replace matching keys in the default map. All other defaults remain.
Full custom (replace defaults)
// Full control — only use your chains
withLocaleChain({
loadMessages: (locale) => import(`../../messages/${locale}.json`).then(m => m.default),
defaultLocale: 'en',
fallbacks: {
'pt-BR': ['pt-PT', 'pt'],
'es-MX': ['es-419', 'es']
},
mergeDefaults: false
})Only the chains you specify will be active. No defaults.
API Reference
withLocaleChain(config)
Returns a function compatible with next-intl's getRequestConfig. Wraps message loading with fallback chain resolution.
export default getRequestConfig(withLocaleChain({
loadMessages: (locale) => import(`../../messages/${locale}.json`).then(m => m.default),
defaultLocale: 'en',
}))Config options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| loadMessages | (locale: string) => Messages \| Promise<Messages> | required | Function to load messages for a locale |
| defaultLocale | string | required | Base locale loaded first (lowest priority) |
| overrides | FallbackMap | undefined | Custom fallback chains merged with defaults |
| fallbacks | FallbackMap | undefined | Custom fallback chains (use with mergeDefaults) |
| mergeDefaults | boolean | true | Whether to merge custom fallbacks with built-in defaults |
deepMerge(target, source)
Recursively deep-merges two message objects. Source values override target values.
defaultFallbacks
The built-in FallbackMap constant containing all default locale chains.
mergeFallbacks(defaults, overrides)
Utility function that merges two FallbackMap objects. Overrides replace matching keys from defaults.
Default Fallback Map
Chinese
| Locale | Fallback Chain | |--------|---------------| | zh-Hant-HK | zh-Hant-TW -> zh-Hant -> (default locale) | | zh-Hant-MO | zh-Hant-HK -> zh-Hant-TW -> zh-Hant -> (default locale) | | zh-Hant-TW | zh-Hant -> (default locale) | | zh-Hans-SG | zh-Hans -> (default locale) | | zh-Hans-MY | zh-Hans -> (default locale) |
Portuguese
| Locale | Fallback Chain | |--------|---------------| | pt-BR | pt-PT -> pt -> (default locale) | | pt-PT | pt -> (default locale) | | pt-AO | pt-PT -> pt -> (default locale) | | pt-MZ | pt-PT -> pt -> (default locale) |
Spanish
| Locale | Fallback Chain | |--------|---------------| | es-419 | es -> (default locale) | | es-MX | es-419 -> es -> (default locale) | | es-AR | es-419 -> es -> (default locale) | | es-CO | es-419 -> es -> (default locale) | | es-CL | es-419 -> es -> (default locale) | | es-PE | es-419 -> es -> (default locale) | | es-VE | es-419 -> es -> (default locale) | | es-EC | es-419 -> es -> (default locale) | | es-GT | es-419 -> es -> (default locale) | | es-CU | es-419 -> es -> (default locale) | | es-BO | es-419 -> es -> (default locale) | | es-DO | es-419 -> es -> (default locale) | | es-HN | es-419 -> es -> (default locale) | | es-PY | es-419 -> es -> (default locale) | | es-SV | es-419 -> es -> (default locale) | | es-NI | es-419 -> es -> (default locale) | | es-CR | es-419 -> es -> (default locale) | | es-PA | es-419 -> es -> (default locale) | | es-UY | es-419 -> es -> (default locale) | | es-PR | es-419 -> es -> (default locale) |
French
| Locale | Fallback Chain | |--------|---------------| | fr-CA | fr -> (default locale) | | fr-BE | fr -> (default locale) | | fr-CH | fr -> (default locale) | | fr-LU | fr -> (default locale) | | fr-MC | fr -> (default locale) | | fr-SN | fr -> (default locale) | | fr-CI | fr -> (default locale) | | fr-ML | fr -> (default locale) | | fr-CM | fr -> (default locale) | | fr-MG | fr -> (default locale) | | fr-CD | fr -> (default locale) |
German
| Locale | Fallback Chain | |--------|---------------| | de-AT | de -> (default locale) | | de-CH | de -> (default locale) | | de-LU | de -> (default locale) | | de-LI | de -> (default locale) |
Italian
| Locale | Fallback Chain | |--------|---------------| | it-CH | it -> (default locale) |
Dutch
| Locale | Fallback Chain | |--------|---------------| | nl-BE | nl -> (default locale) |
English
| Locale | Fallback Chain | |--------|---------------| | en-GB | en -> (default locale) | | en-AU | en-GB -> en -> (default locale) | | en-NZ | en-AU -> en-GB -> en -> (default locale) | | en-IN | en-GB -> en -> (default locale) | | en-CA | en -> (default locale) | | en-ZA | en-GB -> en -> (default locale) | | en-IE | en-GB -> en -> (default locale) | | en-SG | en-GB -> en -> (default locale) |
Arabic
| Locale | Fallback Chain | |--------|---------------| | ar-SA | ar -> (default locale) | | ar-EG | ar -> (default locale) | | ar-AE | ar -> (default locale) | | ar-MA | ar -> (default locale) | | ar-DZ | ar -> (default locale) | | ar-IQ | ar -> (default locale) | | ar-KW | ar -> (default locale) | | ar-QA | ar -> (default locale) | | ar-BH | ar -> (default locale) | | ar-OM | ar -> (default locale) | | ar-JO | ar -> (default locale) | | ar-LB | ar -> (default locale) | | ar-TN | ar -> (default locale) | | ar-LY | ar -> (default locale) | | ar-SD | ar -> (default locale) | | ar-YE | ar -> (default locale) |
Norwegian
| Locale | Fallback Chain | |--------|---------------| | nb | no -> (default locale) | | nn | nb -> no -> (default locale) |
Malay
| Locale | Fallback Chain | |--------|---------------| | ms-MY | ms -> (default locale) | | ms-SG | ms -> (default locale) | | ms-BN | ms -> (default locale) |
How It Works
withLocaleChainwraps yourgetRequestConfigcallback.- When a request comes in, it resolves the fallback chain for the requested locale.
- It calls your
loadMessagesfunction for each locale in the chain. - Messages are deep-merged in priority order: default locale (base) -> chain locales -> requested locale (highest priority).
- If
loadMessagesthrows for any chain locale (e.g., file doesn't exist), it silently skips that locale and continues. - The merged messages are returned to next-intl, which sees a complete message object with no missing keys.
FAQ
Performance impact? Minimal. The fallback map is resolved once at config time. Message loading happens per-request, but only for locales in the chain. Deep merge is fast for typical message objects.
Does it work with namespaces?
Yes. Deep merge is recursive — it walks all nesting levels. If pt-BR has common.save but not common.cancel, common.cancel will be filled from the next locale in the chain.
What if my loadMessages is async?
Fully supported. loadMessages can return Messages or Promise<Messages>. Dynamic import(), fetch(), fs.readFile() — all work.
Can I load messages from a CMS or API?
Yes. loadMessages is just a function — it can load from anywhere:
withLocaleChain({
loadMessages: async (locale) => {
const res = await fetch(`https://my-cms.com/messages/${locale}`)
return res.json()
},
defaultLocale: 'en'
})What if a chain locale doesn't have a messages file? It's silently skipped. The chain continues to the next locale. This is by design — you don't need message files for every locale in every chain.
next-intl version compatibility? Works with next-intl v3+ and v4+.
Contributing
- Open issues for bugs or feature requests.
- PRs welcome, especially for adding new locale fallback chains.
- Run
npm testbefore submitting.
License
MIT License - see LICENSE file.
Built by i18nagent.ai
