DEV Community

Cover image for Building Multilingual APIs with @hazeljs/i18n
Muhammad Arslan
Muhammad Arslan

Posted on

Building Multilingual APIs with @hazeljs/i18n

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 shipsfs/promises for loading JSON files and the native Intl API 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>.json files at startup, wires the service and middleware into the DI container
  • LocaleMiddleware — detects locale from query param (?lang=fr), cookie, or Accept-Language header 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, and Intl.PluralRules-backed pluralization with transparent fallback locale
  • I18nService.format.*number(), date(), currency(), relative() via native Intl.* APIs
  • I18nInterceptor — optional: auto-translates a message field 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
Enter fullscreen mode Exit fullscreen mode

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."
  }
}
Enter fullscreen mode Exit fullscreen mode
// translations/fr.json
{
  "welcome": "Bienvenue, {name} !",
  "products": {
    "count": { "one": "{count} produit", "other": "{count} produits" },
    "notFound": "Le produit « {id} » est introuvable."
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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!" }
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
  };
}
Enter fullscreen mode Exit fullscreen mode

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} منتج."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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],
})
Enter fullscreen mode Exit fullscreen mode

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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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

  1. Create translations/<locale>.json using the same key structure as en.json.
  2. 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

Top comments (0)