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

najm-i18n

v2.1.3

Published

I18n .

Readme

najm-i18n

Internationalization (i18n) plugin for the Najm framework with automatic language detection, translation management, and decorator-based injection.

Features

  • 🌍 Multi-language support - Define translations for multiple languages
  • 🔍 Automatic language detection - From headers, cookies, query strings, or URL path
  • 💉 Decorator-based injection - Clean, type-safe translation functions
  • 🎯 Prefixed translations - Organize translations with automatic key prefixes
  • 🔄 Parameter interpolation - Dynamic values in translations with {{variable}} syntax
  • 🍪 Cookie persistence - Remembers user language preference
  • ⚡ AsyncLocalStorage - Request-scoped language detection
  • 🎨 Flexible configuration - Multiple detection strategies and fallback options

Installation

bun add najm-i18n

Quick Start

import { Server, Controller, Get } from 'najm-core';
import { i18n, I18n, type TFn } from 'najm-i18n';

// Define translations
const translations = {
  en: {
    hello: 'Hello',
    welcome: 'Welcome {{name}}!',
    errors: {
      notFound: 'Not found',
    },
  },
  fr: {
    hello: 'Bonjour',
    welcome: 'Bienvenue {{name}}!',
    errors: {
      notFound: 'Non trouvé',
    },
  },
};

@Controller('/api')
class ApiController {
  @I18n()
  t!: TFn;

  @Get('/greet')
  greet() {
    return { message: this.t('hello') };
  }

  @Get('/welcome/:name')
  welcome(@Params('name') name: string) {
    return { message: this.t('welcome', { name }) };
  }
}

// Initialize server with i18n plugin
new Server()
  .use(i18n({
    translations,
    defaultLanguage: 'en',
    supportedLanguages: ['en', 'fr'],
  }))
  .load(ApiController)
  .listen(3000);

Configuration

Plugin Options

interface I18nOptions {
  // Translation data (key-value pairs per language)
  translations: Record<string, Record<string, any>>;

  // Default language to use when detection fails
  defaultLanguage?: string;  // Default: 'en'

  // List of supported languages
  supportedLanguages?: string[];  // Default: ['en']

  // Language detection order
  order?: ('querystring' | 'header' | 'cookie' | 'path')[];  // Default: ['cookie', 'querystring', 'header']

  // Query string parameter name
  lookupQueryString?: string;  // Default: 'lang'

  // Cookie name for language storage
  lookupCookie?: string;  // Default: 'language'

  // Header key for language detection
  lookupFromHeaderKey?: string;  // Default: 'language'

  // URL path index for language detection
  lookupFromPathIndex?: number;  // Default: 0

  // Cache detected language in cookie
  caches?: ('cookie')[];  // Default: ['cookie']

  // Cookie configuration
  cookieOptions?: {
    path?: string;
    domain?: string;
    sameSite?: 'Strict' | 'Lax' | 'None';
    secure?: boolean;
    maxAge?: number;
    httpOnly?: boolean;
  };

  // Case-insensitive language matching
  ignoreCase?: boolean;  // Default: true

  // Convert detected language code
  convertDetectedLanguage?: (lang: string) => string;

  // Enable debug logging
  debug?: boolean;  // Default: false

  // Resolve a key missing from the detected language against defaultLanguage
  // instead of echoing the key. See "Missing-key fallback".
  fallbackToDefaultLanguage?: boolean;  // Default: false
}

Missing-key fallback

By default a key that is absent from the active language renders as the key itself. That is deliberate: an untranslated string stays visible in the UI and in najm-kit's label diagnostics instead of being papered over.

An application that ships an incomplete locale beside a complete one usually wants the opposite — a key added to the base catalog should render readable text everywhere until its translation lands. Set fallbackToDefaultLanguage: true and the lookup falls through to defaultLanguage before echoing.

The option never affects an entirely absent language. That already resolves against defaultLanguage, with or without it:

| Selected language | Selected key | fallbackToDefaultLanguage | Result | | --- | --- | --- | --- | | exists | exists | either | selected-language value | | absent | default has the key | either | default-language value | | exists | absent | false / omitted | key echo | | exists | absent | true | default-language value | | exists | absent in both | true | key echo |

The same option is accepted by translate(), createTranslator(), the server plugin, defineI18n(), and I18nProvider, so an application that uses one catalog on both sides configures the policy once and both paths agree.

translate(translations, 'fr', 'orders.title', undefined, {
  defaultLanguage: 'en',
  fallbackToDefaultLanguage: true,
});

Basic Configuration

new Server()
  .use(i18n({
    translations: {
      en: { hello: 'Hello' },
      fr: { hello: 'Bonjour' },
    },
    defaultLanguage: 'en',
    supportedLanguages: ['en', 'fr'],
  }))
  .load(/* controllers */)
  .listen(3000);

Production Configuration

new Server()
  .use(i18n({
    translations: {
      en: require('./locales/en.json'),
      fr: require('./locales/fr.json'),
      es: require('./locales/es.json'),
      de: require('./locales/de.json'),
    },
    defaultLanguage: 'en',
    supportedLanguages: ['en', 'fr', 'es', 'de'],
    order: ['cookie', 'header', 'querystring'],
    lookupCookie: 'user_lang',
    cookieOptions: {
      secure: true,
      sameSite: 'Strict',
      maxAge: 365 * 24 * 60 * 60, // 1 year
    },
  }))
  .load(/* controllers */)
  .listen(3000);

React Usage

Use the optional najm-i18n/react entry point in client-rendered React trees. The provider updates subscribed components immediately; persistence can happen asynchronously through onLanguageChange.

import { I18nProvider, useTranslation } from 'najm-i18n/react';

function LanguageButton() {
  const { t, changeLanguage } = useTranslation();

  return (
    <button onClick={() => void changeLanguage('fr')}>
      {t('language.french')}
    </button>
  );
}

export function App() {
  return (
    <I18nProvider
      translations={translations}
      initialLanguage="en"
      onLanguageChange={(language) => saveLanguage(language)}
    >
      <LanguageButton />
    </I18nProvider>
  );
}

The root najm-i18n entry remains server-safe and exposes the imperative t, translate, and createTranslator APIs. React is an optional peer and is loaded only through najm-i18n/react.

Typed keys without a wrapper hook

useTranslation<Key, Language>() accepts explicit generics, but repeating them at every call site is what pushes applications into writing a wrapper hook whose only job is to bind them once. Register the two unions instead, in one ambient declaration per TypeScript program:

// najm-i18n.d.ts
import type { TranslationKeys } from 'najm-i18n';
import type en from '@acme/server/locales/en.json';

declare module 'najm-i18n/react' {
  interface NajmI18nRegistry {
    key: TranslationKeys<(typeof en)['ui']>;
    language: 'ar' | 'en' | 'es' | 'fr';
  }
}

Every direct import is then typed with no call-site generics and no app hook:

import { useTranslation } from 'najm-i18n/react';

const { t, language, changeLanguage } = useTranslation();
t('operator.orders.title');   // checked against the catalog
t('operator.orders.titel');   // compile error
changeLanguage('de');         // compile error

TranslationKeys<Catalog> is exported from the root entry and produces every dotted path in a catalog that ends at a string. On a 1,700-key catalog it adds under 0.1s to a tsc check.

One registration per TypeScript program. React cannot infer these unions from the provider — the hook and the provider are different call sites — so the registry is program-wide rather than per-provider. A program with a second, independent catalog keeps using explicit generics, which still override the registry:

const { t } = useTranslation<'only.this', 'de'>();

A program that registers nothing behaves exactly as before: both parameters default to string.

Defining a catalog once

defineI18n is a server-safe, framework-neutral helper that binds a catalog to its language union, its fallback policy, and the per-language facts an application would otherwise re-declare in three places. It imports no React, no Next.js, and no najm-core.

import { defineI18n } from 'najm-i18n';
import translations from '@acme/server/locales';

export const appI18n = defineI18n({
  translations,
  defaultLanguage: 'en',
  fallbackToDefaultLanguage: true,
  languageMetadata: {
    en: { locale: 'en-MA', direction: 'ltr' },
    fr: { locale: 'fr-MA', direction: 'ltr' },
    ar: { locale: 'ar-MA', direction: 'rtl' },
  },
});

export const appUiI18n = appI18n.scope('ui');

The definition exposes:

| Member | Purpose | | --- | --- | | translations, defaultLanguage, supportedLanguages | the catalog and its inferred, frozen language union | | isLanguage(v), normalizeLanguage(v) | a type guard and a total coercion to the default | | translate(lang, key, params), createTranslator(lang) | translation with the fallback policy already bound | | locale(lang), direction(lang) | BCP 47 tag and writing direction from languageMetadata | | scope(prefix) | the same definition projected onto a nested branch | | options | config to hand straight to the i18n() server plugin |

scope reuses the nested objects by reference — a UI scope of a 100 KB catalog is read on every render, so it is never cloned. A language missing the branch is dropped from the scope and covered by the fallback policy; a branch missing from the default language throws, because every lookup through such a scope would silently echo keys.

direction is declared per language rather than derived. Arabic is not the only RTL language, and an application adding Hebrew or Persian should not have to discover a hard-coded list.

Usage

1. Direct Translation Function

Inject the translation function directly:

@Injectable()
class UserService {
  @I18n()
  t!: TFn;

  greet() {
    return this.t('hello');
  }

  welcomeUser(name: string) {
    return this.t('welcome', { name });
  }
}

2. Prefixed Translation Function

Automatically prefix all translation keys:

@Injectable()
class ErrorService {
  @I18n('errors')  // All keys prefixed with 'errors.'
  t!: TFn;

  notFound() {
    return this.t('notFound');  // Translates 'errors.notFound'
  }

  forbidden() {
    return this.t('forbidden');  // Translates 'errors.forbidden'
  }
}

3. Resolved Key (Static Translation)

Get a specific translated value as a property:

@Injectable()
class HomeService {
  @I18n('welcome.title', true)  // Resolve 'welcome.title' key
  title!: string;  // Returns translated value

  getTitle() {
    return this.title;  // No function call needed
  }
}

4. Nested Translation Keys

Access deeply nested translations using dot notation:

const translations = {
  en: {
    user: {
      profile: {
        title: 'User Profile',
        settings: {
          privacy: 'Privacy Settings',
        },
      },
    },
  },
};

@Injectable()
class UserService {
  @I18n() t!: TFn;

  getProfileTitle() {
    return this.t('user.profile.title');
  }

  getPrivacySettings() {
    return this.t('user.profile.settings.privacy');
  }
}

5. Parameter Interpolation

Use {{variable}} syntax for dynamic values:

const translations = {
  en: {
    greeting: 'Hello {{name}}, you have {{count}} messages',
    itemCount: '{{count}} item(s) found',
  },
};

@Injectable()
class MessageService {
  @I18n()
  t!: TFn;

  greet(name: string, count: number) {
    return this.t('greeting', { name, count });
  }

  showCount(count: number) {
    return this.t('itemCount', { count });
  }
}

Language Detection

The plugin detects language from multiple sources in configurable order:

1. From Cookie

# Cookie: language=fr
GET /api/greet

2. From Query String

GET /api/greet?lang=fr

3. From Header

GET /api/greet
Accept-Language: fr-FR,fr;q=0.9,en;q=0.8

4. From URL Path

// With lookupFromPathIndex: 0
GET /fr/api/greet  // Language: fr
GET /en/api/greet  // Language: en

Language Management API

The I18nService provides methods to manage languages programmatically:

import { I18nService } from 'najm-i18n';

@Injectable()
class LanguageController {
  constructor(private i18nService: I18nService) {}

  // Get current language
  getCurrentLang() {
    return this.i18nService.getCurrentLanguage();  // e.g., 'en'
  }

  // Set language (updates cookie)
  setLanguage(lang: string) {
    const success = this.i18nService.setLanguage(lang);
    return { success };
  }

  // Get available languages
  getAvailableLanguages() {
    return this.i18nService.getAvailableLanguages();  // e.g., ['en', 'fr', 'es']
  }

  // Check if language is supported
  isSupported(lang: string) {
    return this.i18nService.isLanguageSupported(lang);
  }

  // Get default language
  getDefaultLang() {
    return this.i18nService.getDefaultLanguage();
  }
}

Translation File Organization

Flat Structure

const translations = {
  en: {
    'user.name': 'Name',
    'user.email': 'Email',
    'button.submit': 'Submit',
  },
};

Nested Structure (Recommended)

const translations = {
  en: {
    user: {
      name: 'Name',
      email: 'Email',
      profile: {
        title: 'User Profile',
        edit: 'Edit Profile',
      },
    },
    button: {
      submit: 'Submit',
      cancel: 'Cancel',
    },
  },
};

External JSON Files

// locales/en.json
{
  "hello": "Hello",
  "welcome": "Welcome {{name}}",
  "errors": {
    "notFound": "Not found",
    "serverError": "Server error"
  }
}

// Load in server
import en from './locales/en.json';
import fr from './locales/fr.json';

new Server()
  .use(i18n({
    translations: { en, fr },
    defaultLanguage: 'en',
    supportedLanguages: ['en', 'fr'],
  }))
  .load(/* controllers */)
  .listen(3000);

Examples

Multi-Language API

import { Server, Controller, Get, Injectable } from 'najm-core';
import { i18n, I18n, type TFn } from 'najm-i18n';

const translations = {
  en: {
    welcome: 'Welcome to our API',
    user: {
      notFound: 'User not found',
      created: 'User {{name}} created successfully',
    },
  },
  fr: {
    welcome: 'Bienvenue sur notre API',
    user: {
      notFound: 'Utilisateur non trouvé',
      created: 'Utilisateur {{name}} créé avec succès',
    },
  },
};

@Injectable()
class UserService {
  @I18n('user')
  t!: TFn;

  createUser(name: string) {
    return this.t('created', { name });
  }

  notFound() {
    return this.t('notFound');
  }
}

@Controller('/api')
class ApiController {
  @I18n()
  t!: TFn;

  constructor(private userService: UserService) {}

  @Get('/welcome')
  welcome() {
    return { message: this.t('welcome') };
  }

  @Post('/users')
  createUser(@Body() data: { name: string }) {
    return { message: this.userService.createUser(data.name) };
  }

  @Get('/users/:id')
  getUser(@Params('id') id: string) {
    // Simulate user not found
    throw new HttpError(404, this.userService.notFound());
  }
}

new Server()
  .use(i18n({
    translations,
    defaultLanguage: 'en',
    supportedLanguages: ['en', 'fr'],
  }))
  .load(ApiController, UserService)
  .listen(3000);

// Test:
// curl http://localhost:3000/api/welcome
// { "message": "Welcome to our API" }

// curl -H "Accept-Language: fr" http://localhost:3000/api/welcome
// { "message": "Bienvenue sur notre API" }

Best Practices

  1. Organize by domain: Group related translations together
  2. Use nested keys: Makes translations more maintainable
  3. Consistent naming: Use dot notation consistently
  4. Parameter naming: Use descriptive parameter names in interpolations
  5. Fallback values: Always provide default language translations
  6. Prefix decorators: Use prefixed @I18n('domain') to reduce repetition
  7. Type safety: Use TFn type for translation functions

Migration from Core

If you're migrating from najm-core with built-in i18n:

// Before (core)
import { Server } from 'najm-core';

new Server({
  i18n: {
    translations: { /* ... */ },
    defaultLanguage: 'en',
  },
}).listen(3000);

// After (plugin)
import { Server } from 'najm-core';
import { i18n } from 'najm-i18n';

new Server()
  .use(i18n({
    translations: { /* ... */ },
    defaultLanguage: 'en',
  }))
  .listen(3000);

License

MIT