Internationalization (i18n)

EmberKit ships a lightweight, tree-shakeable i18n core β€” no third-party runtime required. Use TypeScript catalogs or JSON translation files.

Overview

EmberKit i18n is built on four ideas:

  • Message catalogs β€” flat or nested key/value maps per locale
  • {placeholder} interpolation β€” t('greeting', { name: 'Ada' })
  • Pipe plural syntax β€” one {count} item | other {count} items
  • Intl formatters β€” dates, numbers, and relative time with zero extra bundle cost

Locale flows through context (createI18nContext) and can be resolved per request on the server.

src/
β”œβ”€β”€ lib/
β”‚   └── i18n.ts              # createI18nFromGlob + provider export
β”œβ”€β”€ locales/
β”‚   β”œβ”€β”€ en.json
β”‚   └── es.json
└── routes/
    β”œβ”€β”€ [locale]/
    β”‚   β”œβ”€β”€ _layout.tsx      # I18nProvider + locale from loader
    β”‚   └── index.tsx
    └── index.tsx            # optional redirect to default locale
public/
└── locales/                 # only when lazy-loading via fetch
    β”œβ”€β”€ en.json
    └── es.json

src/locales/en.json

{
  "greeting": "Hello {name}",
  "nav": {
    "home": "Home",
    "about": "About"
  },
  "items": "one {count} item | other {count} items"
}

Nested objects are flattened to dot keys β€” nav.home in code.

src/lib/i18n.ts

import { createI18nFromGlob, createI18nContext, resolveLocaleFromRequest } from '@emberkit/core';

const modules = import.meta.glob('../locales/*.json', { eager: true });

export const i18n = createI18nFromGlob(modules, {
  locales: ['en', 'es'] as const,
  defaultLocale: 'en',
  fallbackLocale: 'en',
});

export const { Provider: I18nProvider, useI18n } = createI18nContext();

export function resolveRequestLocale(request: Request): string {
  return resolveLocaleFromRequest(request, {
    locales: i18n.locales,
    defaultLocale: i18n.defaultLocale,
    strategy: ['path-prefix', 'header', 'cookie'],
  });
}

File names become locale codes: en.json β†’ en, es-MX.json β†’ es-MX.

Loading translation files

MethodAPIWhen to use
Vite globcreateI18nFromGlobDefault β€” bundles all locales at build time
Static importcreateI18nFromJsonSingle locale or explicit imports
Runtime fetchcreateI18nFromUrlsLazy-load from public/locales/
Node filesystemcreateI18nFromDirectorySSR scripts, CLI, Node-only tooling

Static import

import en from './locales/en.json';
import es from './locales/es.json';
import { createI18nFromJson } from '@emberkit/core';

export const i18n = createI18nFromJson({
  locales: ['en', 'es'] as const,
  defaultLocale: 'en',
  messages: { en, es },
});

Vite and TypeScript resolve JSON imports when resolveJsonModule is enabled in tsconfig.json.

Lazy-load at runtime (fetch)

import { createI18nFromUrls } from '@emberkit/core';

export const i18n = await createI18nFromUrls(
  {
    en: '/locales/en.json',
    es: '/locales/es.json',
  },
  { locales: ['en', 'es'] as const, defaultLocale: 'en' },
);

Place JSON under public/locales/ so the dev server and production host serve them as static files.

Node / SSR (filesystem)

import { createI18nFromDirectory } from '@emberkit/core/i18n/node';

export const i18n = createI18nFromDirectory('./src/locales', {
  locales: ['en', 'es'] as const,
  defaultLocale: 'en',
});

Also available from @emberkit/core/i18n/node:

  • readLocaleCatalog(filePath) β€” read and parse one JSON file
  • loadLocalesFromDirectory(dir) β€” load every *.json in a folder

TypeScript catalogs

Use defineMessages when you want catalogs colocated in TypeScript with full key inference:

import { createI18n, defineMessages } from '@emberkit/core';

const en = defineMessages({
  'nav.home': 'Home',
  greeting: 'Hello {name}',
  items: 'one {count} item | other {count} items',
});

const es = defineMessages({
  'nav.home': 'Inicio',
  greeting: 'Hola {name}',
  items: 'one {count} artΓ­culo | other {count} artΓ­culos',
});

export const i18n = createI18n({
  locales: ['en', 'es'] as const,
  defaultLocale: 'en',
  fallbackLocale: 'en',
  messages: { en, es },
});

Translating

i18n.t('greeting', { name: 'World' }); // Hello World
i18n.setLocale('es');
i18n.t('greeting', { name: 'Mundo' }); // Hola Mundo

// Plural pipe syntax
i18n.tp('items', 1, { count: 1 });
i18n.tp('items', 5, { count: 5 });

// Intl helpers (respect current locale)
i18n.formatDate(new Date(), { dateStyle: 'medium' });
i18n.formatNumber(1234.5);
i18n.formatRelativeTime(-1, 'day'); // "1 day ago"

Pass { strict: true } as the second argument to createI18n / createI18nFromJson to throw MissingTranslationError on missing keys (useful in CI).

Provider & hooks

import { I18nProvider, useI18n, i18n } from '../lib/i18n';

function App({ locale, children }: { locale: string; children?: unknown }) {
  return (
    <I18nProvider i18n={i18n} locale={locale}>
      {children}
    </I18nProvider>
  );
}

function Header() {
  const { t, setLocale, formatDate } = useI18n();
  return (
    <header>
      <span>{t('nav.home')}</span>
      <button type="button" onClick={() => setLocale('es')}>ES</button>
      <time>{formatDate(new Date(), { dateStyle: 'medium' })}</time>
    </header>
  );
}

Locale-prefixed routes

Pair i18n with a [locale] dynamic segment:

src/routes/[locale]/index.tsx   β†’  /en, /es
src/routes/[locale]/about.tsx   β†’  /en/about, /es/about
// src/routes/[locale]/_layout.tsx
import type { RouteComponent } from '@emberkit/core';
import { I18nProvider, i18n, resolveRequestLocale } from '../../lib/i18n';

export async function loader({ request }: { request: Request }) {
  const locale = resolveRequestLocale(request);
  return { data: { locale } };
}

const LocaleLayout: RouteComponent<{ locale: string }> = ({ locale, children }) => (
  <I18nProvider i18n={i18n} locale={locale}>
    {children}
  </I18nProvider>
);

export default LocaleLayout;

Path helpers for links and redirects:

import { extractLocaleFromPath, localizePath } from '@emberkit/core';

const { locale, pathnameWithoutLocale } = extractLocaleFromPath('/es/blog/post', ['en', 'es']);
// locale: 'es', pathnameWithoutLocale: '/blog/post'

localizePath('/en/about', 'es', ['en', 'es']); // '/es/about'

On Cloudflare Workers, use beforeAssets in createCloudflareWorker to redirect visitors to their preferred locale prefix. See Edge Deployment.

SSR locale detection

resolveLocaleFromRequest checks strategies in order and falls back to defaultLocale:

StrategySource
path-prefixFirst URL segment (/es/about β†’ es)
headerAccept-Language
cookielocale cookie (name configurable)
query?lang=es (param configurable)
import { resolveLocaleFromRequest } from '@emberkit/core';

export async function loader({ request }: RouteParams) {
  const locale = resolveLocaleFromRequest(request, {
    locales: ['en', 'es'],
    defaultLocale: 'en',
    strategy: ['path-prefix', 'header', 'cookie'],
    cookieName: 'locale',
    queryParam: 'lang',
  });
  i18n.setLocale(locale);
  return { data: { locale } };
}

Parsing & validation

Low-level helpers for custom pipelines:

import { parseMessageCatalog, parseMessageCatalogJson } from '@emberkit/core';

const catalog = parseMessageCatalog({ nav: { home: 'Home' } });
// { 'nav.home': 'Home' }

const fromFile = parseMessageCatalogJson('{"hello":"world"}');

Invalid catalogs throw InvalidMessageCatalogError (non-string leaf values, malformed JSON).

CLI scaffold

emberkit generate i18n app --path src/lib/i18n.ts

The generated file uses createI18nFromGlob and expects JSON files in a locales/ folder next to it.

API reference

See API Reference β€” Internationalization for the full function list.

  • Context β€” underlying provider mechanism
  • Routing β€” dynamic segments and loaders
  • SSR β€” server rendering and request in loaders
  • Edge Deployment β€” Workers and locale redirects