Dynamic loading
Load locale catalogues lazily to keep bundles small
SayKit gives you two ways to wire messages into a Say instance:
- Inline, pass
messagesdirectly. Every locale is bundled, ready to use. - Loader, pass a
loaderfunction. 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:
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
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:
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
- Runtime, full Say API
- Locale detection, deciding what to load