npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

next-intl-localechain

v0.1.1

Published

Smart locale fallback chains for next-intl

Readme

next-intl-localechain

npm version license

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-localechain

Quick 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

  1. withLocaleChain wraps your getRequestConfig callback.
  2. When a request comes in, it resolves the fallback chain for the requested locale.
  3. It calls your loadMessages function for each locale in the chain.
  4. Messages are deep-merged in priority order: default locale (base) -> chain locales -> requested locale (highest priority).
  5. If loadMessages throws for any chain locale (e.g., file doesn't exist), it silently skips that locale and continues.
  6. 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 test before submitting.

License

MIT License - see LICENSE file.

Built by i18nagent.ai