Intl-T

Dynamic Import

Load locales on demand instead of bundling them all

Give locales loaders instead of objects and Intl-T fetches messages when they are needed — the server can preload everything, while the client downloads only the language it is actually showing.

Loaders

import { createTranslation } from "intl-t";

export const t = createTranslation({
  locales: {
    en: () => import("./messages/en.json"),
    es: () => import("./messages/es.json"),
  },
});

Types are inferred from each loader's return, and the module record is unwrapped for you — returning import("./en.json") directly is fine, { default } never becomes part of your tree. values defaults declared inside a loaded tree apply when it arrives, with anything you already set kept on top.

import { createTranslation } from "intl-t";

export const t = createTranslation({
  locales: locale => import(`./messages/${locale}.json`) as Promise<typeof import("./messages/en.json")>,
  allowedLocales: ["en", "es"] as const, //  required — keys can't be inferred from a function
});

The as Promise<typeof import("./messages/en.json")> assertion gives every locale the shape of your main one. With a template-string import the bundler creates a chunk per matching file automatically.

Keep each loader a literal import("...") expression. Bundlers include a JSON in the bundle only when they can see the import — which is also what makes loaders work on Edge.

The await contract

ExpressionLoads
await t — preload onall allowedLocales at once (later awaits are instant)
await t — preload offonly the current locale
await t.esonly es
reading an unloaded messagenothing yet — it renders undefined and the load starts in the background
t.setLocale("es")es, on demand
const t = createTranslation({
  locales: { en: () => import("./en.json"), es: () => import("./es.json") },
});

t.es.hello; //  undefined — not loaded yet
await t.es; //  loads only `es`
t.es.hello; //  "¡Hola Mundo!"
await t.es; //  instant — already resolved, never re-fetched

Once loaded, a locale stays cached — awaiting it again is instant and never re-fetches.

Lazy by default

Nothing loads until something is accessed or awaited — in every environment, with either loader form. preload only decides what await t means: the current locale (default) or all of them.

export const { t } = await createTranslation({
  locales: {
    en: () => import("./messages/en.json"),
    es: () => import("./messages/es.json"),
  },
  preload: true, //  `await t` now resolves with every allowed locale loaded
});

Turn it on for servers — one await at boot, then any request renders any language with zero per-request loads. Leave it off for clients — the visitor downloads only the language on screen, and switching fetches the next one on demand. Enabling preload never blocks rendering by itself: the all-locales fetch happens on the first await t, and if you never await, the tree simply stays lazy.

Perfect types: .ts locale files

JSON imports widen strings ("Welcome, {user}!" becomes string), which costs the variable inference. Two ways to keep the full literal types with loaders:

messages/es.ts
export default {
  homepage: {
    title: "Inicio",
    welcome: "¡Bienvenido, {user}!",
  },
} as const;
i18n/translation.ts
import { createTranslation } from "intl-t";
import en from "./messages/en";

//  static: instant first paint, and the source of types

export const t = createTranslation({
  locales: {
    en,
    es: () => import("./messages/es"), //  lazy — typeof import resolves the `as const` statically
  },
  mainLocale: "en",
});

mainLocale is the source of the tree's type — keep it static for a zero-fetch primary language, or make it a loader too: the types stay perfect either way, since typeof import never costs runtime bytes.

Keep your JSON and generate literal declarations next to it — autocomplete and variable inference come from the generated .d.json.ts files. See TypeScript → Declarations.

npx @intl-t/declarations ./messages --watch

Failure and retry

A loader that rejects makes the await reject — it does not hang or resolve with missing messages. The failed locale is dropped from the cache, and the next access retries the loader, so a transient network failure heals itself:

await t.en; //  rejects — the import failed
await t.en; //  retries the loader; resolves once it succeeds

Resolving up front

getLocales runs every loader immediately and returns a plain messages record — useful when you want the resolved objects themselves rather than lazy behavior:

import { createTranslation, getLocales } from "intl-t";

const locales = await getLocales(locale => import(`./messages/${locale}.json`), ["en", "es"] as const);

export const { t } = createTranslation({ locales });

Next.js and Suspense

With the Next.js integration, loading is handled for you: await getTranslation() resolves the request locale and its messages, and the provider sends the loaded messages to the client for hydration — the client never re-downloads what the server already rendered.

Static rendering: preload at the page

With static rendering, a page can render before its layout. Add await getTranslation() at the top of the page so the locale's messages are loaded before anything reads them — it keeps the page static, and after that first await the rest of your components don't need to await anything.

On this page