I just shipped internationalization support for HazelJS — a TypeScript-first Node.js framework I've been building. The new @hazeljs/i18n package gives you locale detection, JSON-based translations, pluralization via the native Intl API, and currency/date formatting — all with zero external dependencies and the same decorator-driven API you already know from the rest of the framework.
Why build i18n from scratch?
Most i18n libraries for Node.js are either too heavy (pulling in ICU data bundles and parser machinery you may not need) or too loosely coupled to your framework (requiring you to wire up middleware, guards, and request context yourself). I wanted something that:
-
Fits the HazelJS module system — register once in your root module with
forRoot(), done. -
Uses only what Node.js ships —
fs/promisesfor loading JSON files and the nativeIntlAPI for formatting. No polyfills, no ICU bundles. -
Feels familiar —
@Lang()is just another parameter decorator, sitting alongside@Param(),@Query(), and@Body()in your controller signatures. -
Handles the hard parts — plural rules for Arabic, German, French (and the 200+ other CLDR locales) via
Intl.PluralRules, without a single extra package.
Features
-
I18nModule.forRoot()/forRootAsync()— loads all<locale>.jsonfiles at startup, wires the service and middleware into the DI container -
LocaleMiddleware— detects locale from query param (?lang=fr), cookie, orAccept-Languageheader in configurable priority order -
@Lang()parameter decorator — injects the request-scoped locale string directly into controller methods -
I18nService.t()— dot-notation key lookup,{placeholder}interpolation, andIntl.PluralRules-backed pluralization with transparent fallback locale -
I18nService.format.*—number(),date(),currency(),relative()via nativeIntl.*APIs -
I18nInterceptor— optional: auto-translates amessagefield in response objects using the request locale - Graceful degradation — missing translation directories and malformed JSON files are logged and skipped; the service starts cleanly without translations rather than crashing
Installation
npm install @hazeljs/i18n
npm: @hazeljs/i18n
Quick start
1. Create translation files
Put locale JSON files anywhere — ./translations/ by default:
// translations/en.json
{
"welcome": "Welcome, {name}!",
"products": {
"count": { "one": "{count} product", "other": "{count} products" },
"notFound": "Product \"{id}\" was not found."
}
}
// translations/fr.json
{
"welcome": "Bienvenue, {name} !",
"products": {
"count": { "one": "{count} produit", "other": "{count} produits" },
"notFound": "Le produit « {id} » est introuvable."
}
}
2. Register the module
import { HazelModule } from '@hazeljs/core';
import { I18nModule } from '@hazeljs/i18n';
@HazelModule({
imports: [
I18nModule.forRoot({
defaultLocale: 'en',
fallbackLocale: 'en',
translationsPath: './translations',
detection: ['query', 'cookie', 'header'],
}),
],
})
export class AppModule {}
3. Apply the locale middleware globally
// main.ts
const app = new HazelApp(AppModule);
const localeMw = app.getContainer().resolve(LocaleMiddleware);
app.use((req, res, next) => localeMw.handle(req, res, next));
await app.listen(3000);
4. Translate in a controller
import { Controller, Get } from '@hazeljs/core';
import { I18nService, Lang } from '@hazeljs/i18n';
@Controller('/greet')
export class GreetController {
constructor(private readonly i18n: I18nService) {}
@Get('/')
hello(@Lang() locale: string) {
return {
message: this.i18n.t('welcome', { locale, vars: { name: 'Alice' } }),
};
// GET /greet?lang=fr → { message: "Bienvenue, Alice !" }
// GET /greet → { message: "Welcome, Alice!" }
}
}
Real-world example: multilingual product catalog
I built a complete starter — hazeljs-i18n-starter — to show what this looks like in practice. It's a product catalog REST API with 5 seeded products, CRUD endpoints, category browsing, and pagination. Every response field — labels, prices, dates, counts, error messages — is locale-aware.
Here's what makes it interesting:
Locale-aware pricing
Different locales get different currencies and number formats. The service layer picks the right currency for the region and uses format.currency() to format it:
// src/products/products.service.ts
const LOCALE_CURRENCY: Record<string, string> = {
fr: 'EUR',
de: 'EUR',
ar: 'SAR',
en: 'USD',
};
private localize(product: Product, locale: string): LocalizedProduct {
const currency = LOCALE_CURRENCY[locale] ?? 'USD';
return {
...product,
// $299.99, 299,99 €, ٢٩٩٫٩٩ ر.س. — all from one call
price: this.i18n.format.currency(product.price, locale, currency),
// "Mar 4, 2026, 12:00 PM" vs "4 mars 2026 à 12:00"
createdAt: this.i18n.format.date(product.createdAt, locale, {
dateStyle: 'medium',
timeStyle: 'short',
}),
category: this.i18n.t(`categories.${product.category}`, { locale }),
stockLabel: this.buildStockLabel(product.stock, locale),
};
}
The format.currency() call handles everything: symbol position, decimal separator, thousands separator — all from the native Intl.NumberFormat. No conversion tables needed.
Pluralization that actually works across languages
English has two plural forms. Arabic has six. The package handles both transparently via Intl.PluralRules:
// translations/ar.json
{
"products": {
"count": {
"zero": "لم يتم العثور على منتجات.",
"one": "تم العثور على {count} منتج.",
"two": "تم العثور على {count} منتجَين.",
"few": "تم العثور على {count} منتجات.",
"many": "تم العثور على {count} منتجاً.",
"other": "تم العثور على {count} منتج."
}
}
}
this.i18n.t('products.count', { locale: 'ar', count: 2, vars: { count: '2' } })
// → "تم العثور على 2 منتجَين." (Arabic dual form — "two")
this.i18n.t('products.count', { locale: 'ar', count: 11, vars: { count: '11' } })
// → "تم العثور على 11 منتجاً." (Arabic "many" form)
this.i18n.t('products.count', { locale: 'en', count: 11, vars: { count: '11' } })
// → "11 products found." (English "other" form)
The same t() call works for all locales — you just write the plural forms in the JSON files for the languages that need them and omit the ones that don't.
Async configuration from environment variables
The starter uses forRootAsync() so locale settings come from the environment, not hardcoded values:
// src/app.module.ts
I18nModule.forRootAsync({
useFactory: (config: ConfigService) => ({
defaultLocale: config.get<string>('DEFAULT_LOCALE') ?? 'en',
fallbackLocale: config.get<string>('FALLBACK_LOCALE') ?? 'en',
translationsPath: config.get<string>('TRANSLATIONS_PATH') ?? './translations',
detection: ['query', 'cookie', 'header'],
}),
inject: [ConfigService],
})
Swap DEFAULT_LOCALE=fr in your staging environment and every undetected request defaults to French — no redeploy required.
Localized error responses
A custom exception filter reads the locale from the request (set by LocaleMiddleware earlier in the pipeline) and translates error messages before sending them to the client:
// src/common/i18n-exception.filter.ts
@Catch(HttpError)
export class I18nExceptionFilter implements ExceptionFilter<HttpError> {
constructor(private readonly i18n: I18nService) {}
catch(exception: HttpError, host: ArgumentsHost): void {
const locale = getLocaleFromRequest(host.switchToHttp().getRequest()) ?? 'en';
const rawMessage = exception.message ?? 'Internal server error';
// Translate if the message is an i18n key; otherwise use as-is
const message = this.i18n.has(rawMessage, locale)
? this.i18n.t(rawMessage, { locale })
: rawMessage;
host.switchToHttp().getResponse().status(exception.statusCode ?? 500).json({
statusCode: exception.statusCode ?? 500,
message,
locale,
});
}
}
A 404 for a missing product now looks like this depending on the request locale:
GET /products/999?lang=fr
# → { "statusCode": 404, "message": "Aucun produit avec l'identifiant « 999 » n'a été trouvé.", "locale": "fr" }
GET /products/999?lang=de
# → { "statusCode": 404, "message": "Produkt mit der ID \"999\" wurde nicht gefunden.", "locale": "de" }
The full request flow
Here's how a request travels through the i18n layer:
Incoming request
→ LocaleMiddleware (detects locale from query / cookie / Accept-Language)
→ sets __hazel_locale__ on the request object
→ sets Content-Language response header
→ Controller method
→ @Lang() injects the locale string
→ I18nService.t() resolves the key, applies interpolation + pluralization
→ I18nService.format.currency() / .date() formats the output
→ Response
Nothing magical — just a middleware writing a value to the request object that a decorator reads later. Simple to debug, easy to test.
Trying the starter
git clone https://github.com/hazel-js/hazeljs-i18n-starter
cd hazeljs-i18n-starter
cp .env.example .env
npm install
npm run start:dev
Then try a few requests:
# English — USD prices
curl http://localhost:3000/products
# French — EUR prices, French dates
curl "http://localhost:3000/products?lang=fr"
# German — EUR prices, German number format
curl "http://localhost:3000/products?lang=de"
# Arabic — SAR prices, Arabic plural forms, RTL text
curl "http://localhost:3000/products?lang=ar"
# Localized 404
curl "http://localhost:3000/products/999?lang=fr"
You can also send Accept-Language: de as a header or set Cookie: locale=ar — the middleware picks up whichever comes first in the configured detection order.
Adding a new locale
- Create
translations/<locale>.jsonusing the same key structure asen.json. - Restart the server.
That's it. TranslationLoader reads all *.json files from the directory at startup. No code changes, no imports, no registration. The new locale appears in i18n.getLocales() automatically.
What's next
A few things I'm looking at for the next iteration:
- Namespaced loaders — lazy-load translation namespaces on demand for large apps instead of loading everything upfront
-
@I18nKey()DTO decorator — mark response DTO fields as translation keys so the interceptor can translate entire objects automatically - Hot reload in development — watch the translations directory and reload without restarting
Learn more
- Documentation: HazelJS i18n Package Docs
- npm: @hazeljs/i18n
- Starter repo: hazeljs-i18n-starter
- HazelJS: hazeljs.com
Top comments (0)