SayKit
Guides

Dynamic loading

Load locale catalogues lazily to keep bundles small

SayKit gives you two ways to wire messages into a Say instance:

  • Inline, pass messages directly. Every locale is bundled, ready to use.
  • Loader, pass a loader function. Locales load on demand.

Most production apps land somewhere in between, preload one locale, lazy-load the rest. This guide covers the patterns.

The trade-off

Inline

Pros: zero loading state, always-available, simpler runtime.
Cons: every locale ships in the bundle (or initial chunk).
Best for: small numbers of locales, server-rendered apps, CLIs.

Loader

Pros: only the active locale lives in memory, smaller initial load.
Cons: async transitions on locale switch, requires bundler code-splitting.
Best for: many locales, large catalogues, browser-only apps.

Inline pattern

The simplest setup:

src/i18n.ts
import { Say } from 'saykit';
import en from './locales/en.po';
import fr from './locales/fr.po';

const say = new Say({
  locales: ['en', 'fr'],
  messages: { en, fr },
});

export default say;

Both catalogues are part of the bundle. activate('fr') is synchronous, no loading state ever.

Loader pattern

src/i18n.ts
import { Say } from 'saykit';

const say = new Say({
  locales: ['en', 'fr', 'ja', 'de'],
  loader: async (locale) => {
    const mod = await import(`./locales/${locale}.po`);
    return mod.default;
  },
});

export default say;

Bundlers turn the dynamic import() into a separate chunk per locale. The catalogue for the active locale loads on first use:

await say.load('fr');
say.activate('fr');

The shape of the dynamic import (./locales/${locale}.po) matters, most bundlers (Vite, Webpack, Rolldown, esbuild) can statically analyse this pattern and produce one chunk per literal locale.

babel-plugin-saykit currently rewrites static import en from './locales/en.po' only. For dynamic imports, the bundler does the work, the formatter still parses the file, but through the bundler's loader pipeline rather than SayKit's. This works fine for unplugin-saykit (Vite, Rolldown, Rollup, Webpack, …).

Hybrid pattern

Preload the source locale, lazy-load everything else:

src/i18n.ts
import { Say } from 'saykit';
import en from './locales/en.po';

const say = new Say({
  locales: ['en', 'fr', 'ja', 'de'],
  messages: { en },
  loader: async (locale) => {
    const mod = await import(`./locales/${locale}.po`);
    return mod.default;
  },
});

say.activate('en');

export default say;

Initial render is synchronous, the source locale is always there. Switching locales triggers a single network fetch (or chunk load), and you control when:

async function switchTo(locale: string) {
  await say.load(locale);
  say.activate(locale);
}

Preloading on hover

In a locale switcher, prefetch the catalogue on hover so the actual switch is instant:

function LocaleOption({ locale }: { locale: string }) {
  const say = useSay();
  return (
    <a href={`/${locale}`} onMouseEnter={() => say.load(locale)}>
      {locale}
    </a>
  );
}

Calling load() on an already-loaded locale is a no-op, so this is safe to call repeatedly.

Server-side

On the server, you usually load only the locale this request needs:

import { Say } from 'saykit';

const baseSay = new Say({
  locales: ['en', 'fr', 'ja', 'de'],
  loader: (l) => import(`./locales/${l}.po`).then((m) => m.default),
});

export async function getSayFor(locale: string) {
  const request = baseSay.clone();
  const matched = request.match(locale);
  await request.load(matched);
  return request.activate(matched).freeze();
}

Cloning isolates per-request state; freezing makes the result safe to pass to multiple components.

Sync loaders

Loaders don't have to return a promise. If you have everything available synchronously, say you build catalogues at request start, or you're in a CLI, return the object directly:

const say = new Say({
  locales: ['en', 'fr'],
  loader: (locale) => buildCatalogueFor(locale), // sync
});

say.load('fr'); // returns the Say (not a promise)

When all loaders are synchronous, load() returns synchronously too.

Next

On this page