Custom i18n system
Build a compact, project-owned internationalization layer using platform standards rather than recreating a large framework badly.
Use this only for the right problem
A maintained library is normally the better choice. Recommend the ecosystem-standard option when the user simply asks to internationalize an application: established libraries provide message extraction, ICU syntax, framework integration, locale data, tooling, and translator procedures.
Use this skill when at least one is true:
- The user explicitly wants no/full-framework dependency.
- An existing custom i18n contract must be preserved or ported.
- The runtime is small or constrained and
Intlplus a compact catalog layer is enough. - The project needs the same small engine in multiple boundaries (for example browser, API, jobs, email and CLI) without framework coupling.
Do not replace an existing working i18n system merely for stylistic consistency. For routine key additions or translation fixes, follow the target project's existing i18n guidance instead.
Architecture
Keep the core independent from React, databases, HTTP and filesystem loading:
JSON/module catalogs
│
▼
custom i18n core
resolve locale + fallback
lookup + named interpolation
Intl plural/number/date/list formatting
│
├── browser/framework adapter
├── backend/request adapter
├── jobs/email adapter
└── document/export adapter
The provided assets/i18n-core.mjs follows this shape. It creates isolated instances rather than mutable global state, which makes tests and multi-tenant/server usage safer.
1. Confirm requirements before coding
Establish:
- Supported locales and default locale.
- Whether locale is chosen per user, session, tenant, request, device, or some combination.
- Browser-only, SSR, backend, CLI, email, PDF/document, or multi-runtime use.
- Whether frontend and backend share one catalog or intentionally maintain different message sets.
- Required plural, number, currency, date/time, time-zone and list formatting.
- RTL scripts and font/shaping requirements.
- Who edits translations and whether they need JSON, PO/XLIFF, spreadsheets, or a translation platform.
- Rich-text requirements and permitted markup/components.
- Missing-key behavior in development and production.
- Accessibility, SEO and document-language requirements.
If the needs include complex ICU messages, extraction, translator collaboration or many locales, revisit the library decision before proceeding.
2. Inspect and reuse the target project
Before creating files:
- Find existing locale fields, middleware, catalogs, formatting helpers and language selectors.
- Trace how authenticated preferences, anonymous sessions and request headers flow.
- Identify SSR/hydration boundaries and caching.
- Find every output channel that emits user-visible text: UI, API errors, email, notifications, logs intended for users, PDFs and exports.
- Follow project naming, directory, testing and state-management conventions.
Adapt the provided assets to those conventions; do not impose their example paths.
3. Define the catalog contract
Prefer plain JSON unless a runtime genuinely requires module values. A catalog should contain:
{
"id": "en",
"name": "English",
"dir": "ltr",
"locales": ["en-US", "en-GB"],
"messages": {
"welcome": "Welcome, {{name}}.",
"items": {
"=0": "No items",
"one": "{{count}} item",
"other": "{{count}} items"
}
}
}
Rules:
- Use canonical BCP-47 locale identifiers.
- Use named placeholders; translators must be free to reorder sentence parts.
- Use nested or flat keys consistently. Keys describe meaning, not the original English sentence.
- A plural value is a map of CLDR categories (
zero,one,two,few,many,other) with requiredother; exact branches such as=0are optional. - Keep executable code and arbitrary HTML out of catalogs.
- Keep key and placeholder sets aligned across every locale in a catalog set.
- Frontend and backend catalogs may differ when their messages serve different audiences, but validate each set independently.
Copy and adapt assets/catalogs/ as a minimal example.
4. Implement locale resolution deliberately
Use an explicit precedence order. A typical browser application is:
- Authenticated user's saved preference.
- Session or tenant preference.
- Local browser preference.
navigator.languages/ parsedAccept-Languagecandidates.- Application default.
Resolve every candidate through registered catalog IDs and aliases. Never use an unvalidated locale to build a path or dynamic import.
Support exact and base-language fallback (es-MX → es) before the application default. Make fallback observable in development so missing regional coverage is not silently ignored.
For SSR, resolve the locale on the server and pass it to the client provider. Reading browser storage only after hydration can render different server/client text and cause hydration errors.
5. Use standards for linguistic formatting
Do not invent a plural DSL. Use:
Intl.PluralRulesIntl.NumberFormatIntl.DateTimeFormatIntl.ListFormatIntl.RelativeTimeFormatwhen needed
Locale does not determine currency or time zone by itself. Obtain those from product/account context.
The core asset exposes translation plus number/date/list helpers. Add more Intl wrappers only when the project needs them.
6. Integrate the frontend
For React, adapt assets/react-language-provider.jsx:
- Initial resolution honors a server/session
userLocalebefore browser fallback. - A later account preference overrides local detection without being overwritten by a mount effect.
- Explicit user selection updates local storage and can call an async persistence callback.
- Both
<html lang>and<html dir>are updated. - Translation and formatting helpers are bound to the active locale.
For another framework, reproduce the contract with its native state/context mechanism rather than copying React.
Language selectors must:
- Use localized or native language names as the product requires.
- Expose an accessible label.
- Avoid flags as the sole representation of language.
- Persist only a resolved supported locale.
- Show persistence errors when the backend save fails instead of pretending the account preference changed.
7. Integrate backend and other output channels
Read references/backend-and-security.md before implementing backend validation, email, rich content or documents.
Prefer stable error codes and parameters in domain/service layers, then localize where the recipient and output channel are known. Authenticated user's saved locale should normally outrank a client-supplied locale.
For emails and HTML:
- Translate plain values, then render through an escaping template layer.
- Do not place arbitrary executable HTML in translation catalogs.
- Validate links independently from HTML escaping.
For PDFs/documents, embed fonts covering supported scripts and verify shaping/bidirectional layout. Do not silently switch a user's language merely because the chosen font lacks glyphs.
8. Load catalogs in a runtime-appropriate way
- Node/server runtime with filesystem access: adapt
assets/load-json-catalogs.mjs, which uses deterministicreaddirSyncrather than a version-specific glob API. - Bundled frontend: use a build-time glob or generated manifest supported by the bundler.
- Edge/serverless bundle: use static imports or build-time generation; runtime directory scanning may not exist.
Keep one authoritative catalog manifest per deployable set. A file present on disk but absent from the runtime manifest is a build failure, not an acceptable silent state.
9. Validate catalogs automatically
Copy scripts/validate-catalogs.mjs with the assets it imports, then run:
node scripts/validate-catalogs.mjs <catalog-directory> <default-locale>
It checks:
- Catalog shape and BCP-47 identifiers.
- Duplicate IDs/aliases.
- Missing and extra message keys.
- Text-vs-plural type drift.
- Placeholder parity.
Run it in CI and before releases. Add product-specific checks for forbidden HTML, maximum length, required metadata or translator status when needed.
10. Test the full lifecycle
Cover at minimum:
- Exact locale, regional alias, base-language and default fallback.
- Missing key in selected locale and missing key everywhere.
- Named interpolation and missing parameter diagnostics.
- Plural categories relevant to each supported language, plus exact
=0if used. - Number, currency, date, time-zone and list formatting.
- Saved account locale, anonymous storage and browser negotiation precedence.
- SSR/hydration with and without a known server locale.
- Explicit language selection and backend persistence failure.
- RTL direction and document
langupdates. - Long translations, UI expansion and truncation.
- Email text/HTML escaping and safe links.
- PDF/document glyph coverage, shaping, wrapping and metadata.
- Catalog validator failure for missing keys and placeholder drift.
- Startup/build behavior with an invalid or empty catalog set.
Deliverable checklist
- Requirements and library-vs-custom decision recorded.
- Catalog schema and default locale defined.
- Runtime-specific loader chosen.
- Core engine remains framework-independent.
- Locale precedence and fallback implemented.
- Named interpolation and
Intlformatting implemented. - Catalog parity validation runs automatically.
- Frontend sets document
langanddir. - Account/session persistence handles errors honestly.
- Backend and outbound channels resolve the recipient locale.
- Rich text and HTML use context-appropriate escaping.
- Fonts/scripts verified for generated documents.
- Lifecycle and edge-case tests pass.
Included resources
| Resource | Purpose |
|---|---|
assets/i18n-core.mjs |
Framework-independent engine with locale fallback, named interpolation, plural selection and Intl formatters. |
assets/load-json-catalogs.mjs |
Deterministic Node JSON catalog loader. |
assets/react-language-provider.jsx |
React provider with corrected initialization/persistence ordering and document metadata. |
assets/catalogs/en.json, es.json |
Minimal parity-valid example catalogs. |
scripts/validate-catalogs.mjs |
Dependency-free catalog/key/placeholder validator. |
references/backend-and-security.md |
Backend boundary, email/HTML, formatting, PDF/font, loading and observability guidance. |
Read and adapt only the resources relevant to the target stack. They are starting points, not a directory layout mandate.