Skip to main content

Runtime vs. inline mode

The plugin has two production modes. Pick based on how you ship your app.

Runtime mode

neokapi({ mode: "runtime" });

One bundle. Translations live in per-locale JSON files loaded at runtime via fetch.

Every translatable JSX site gets rewritten to a __t(hash, fallback, params) call:

// Source
<h1>Welcome</h1>

// Output (runtime mode)
<h1>{__t("aB3", "Welcome")}</h1>

The runtime is ~2 kB gzipped; it holds the active dict and a subscriber set. When loadTranslations(locale, url) resolves, every subscribed component re-renders.

When runtime mode fits

  • You ship a single JS bundle to a CDN and flip locales based on user preference.
  • You want to add new locales without re-deploying your JS.
  • You have more than a handful of locales — the per-locale JSON is a small download compared to your JS bundle.
  • You care about hot-swapping locale in-page (language picker, A/B).

Load once, subscribe to changes

import { loadTranslations, setTranslations, useNeokapi } from "@neokapi/kapi-react/runtime";

async function bootstrap() {
const locale = navigator.language.split("-")[0];
if (locale !== "en") {
await loadTranslations(locale, `/translations/${locale}.json`).catch(() => {});
}
ReactDOM.createRoot(root).render(<App />);
}

// Somewhere in the tree that should re-render on locale change
function AppRoot() {
useNeokapi(); // subscribe
return <Routes />;
}

useNeokapi() subscribes to the translation store via useSyncExternalStore. When the dict updates, any component that called the hook re-renders, and the cascade takes care of the rest.

For a locale switcher UI: call loadTranslations or setTranslations("en", {}) on change, and make sure useNeokapi() is called high enough in the tree for the whole visible surface to re-render.

Both also push the new locale onto <html lang> and <html dir> automatically — handy for screen readers, fonts, hyphenation, and RTL support. Opt out with { syncDocumentLocale: false } if your app owns those attributes. Details: Configuration → HTML lang and dir.

Lazy loading per route (code splitting)

For larger apps, the single-catalog-per-locale model downloads every string even for routes the user never visits. The plugin + runtime can split translations along the same lines the bundler splits code:

  1. In runtime mode, the Vite/Rollup plugin emits translations-manifest.json next to your JS chunks — a {chunkName: hashes[]} map of which strings each chunk needs.
  2. kapi-react split slices each master {locale}.json into per-chunk subsets ({locale}/{chunkName}.json), duplicating strings shared across chunks so each file is independently loadable.
  3. The runtime's loadTranslationChunk(locale, url) fetches one subset and merges it into the active dict. Concurrent requests for the same (locale, url) pair share a single fetch.

Wire it into a React Router lazy route:

import { loadTranslationChunk } from "@neokapi/kapi-react/runtime";

const routes = [
{
path: "/settings",
lazy: async () => {
const [mod] = await Promise.all([
import("./SettingsPage"),
loadTranslationChunk(locale, `/translations/${locale}/SettingsPage.json`),
]);
return { Component: mod.default };
},
},
];

Build pipeline:

vite build # emits dist/translations-manifest.json
kapi-react compile i18n/ --out public/translations
kapi-react split \
--manifest dist/translations-manifest.json \
--locales public/translations \
--out dist/translations

Missing hashes fall back to the source text baked into each __t / __tx call at build time — a late-arriving chunk is never fatal. Users see English for ~100ms while the chunk streams in, not a broken render.

If merge: true is passed to setTranslations or loadTranslations, the incoming entries OR into the existing dict instead of replacing it. loadTranslationChunk uses this internally. Switching locale (without merge) drops any in-flight chunk loads for the previous locale so their payloads can't poison the new dict.

Runtime pseudo-translation

Runtime mode can apply pseudo-translation on the fly, no build step, no catalog — useful for dev ergonomics, layout QA, and debugging which strings flow through the translation system:

import { setPseudoMode } from "@neokapi/kapi-react/runtime/pseudo";

// Turn on with defaults (▒-wrapped, accented)
setPseudoMode({});

// Tune
setPseudoMode({
prefix: "« ",
suffix: " »",
expansion: 30, // +30% padding to test layout
alphabet: "accented", // ASCII → accented variants (the default)
});

// Off
setPseudoMode(null);

The transform stacks on top of whatever's in the runtime dict — so you can load a real French catalog and THEN flip pseudo on to see what French looks like at +30% length, with markers showing which strings got translated vs. which fell through to source. {param} / {=m0} tokens are preserved verbatim so param substitution still works.

Works without a catalog. The source string lands in the __t / __tx call as the fallback argument at build time. When the dict is empty the runtime uses the fallback, and pseudo transforms it. Edit <h1>Welcome</h1> → save → HMR replaces the module → React re-renders → "▒ Ŵéļçöḿé ▒". No extract step, no compile step, no rebuild — just your source text flowing through the transform. A plain neokapi({ mode: "runtime" }) in vite.config.ts is the only prerequisite; without runtime mode the plugin no-ops and there's no __t wrapper for pseudo to hook into.

The panel below runs the real kapi-react runtime in your browser — no catalog loaded. Toggle pseudo mode and the same strings flip to accented, expanded text; {name} stays literal because {param} tokens are preserved through the transform:

If you want pseudo to be the default in dev, wire it at the top of main.tsx guarded on import.meta.env.DEV, then keep the dev console handle available for tuning:

import { setPseudoMode } from "@neokapi/kapi-react/runtime/pseudo";

if (import.meta.env.DEV) {
setPseudoMode({ expansion: 30 });
// @ts-expect-error — dev-only global so you can re-tune from the console
window.setPseudoMode = setPseudoMode;
}

The pseudo module lives at a separate subpath (@neokapi/kapi-react/runtime/pseudo) so importing it is opt-in — the main runtime stays ~2 kB. Internally it uses setStringTransform, a general post-lookup hook also exported from the main runtime for custom transforms (debug markers, letter-spacing audits, etc.).

Inline mode

neokapi({
mode: "inline",
locale: "fr",
translationsDir: "./translations",
});

One bundle per locale. Every JSX text node is replaced at build time with the translated literal. No runtime dict lookup, no subscription, no loadTranslations():

// Source
<h1>Welcome</h1>

// Output (inline mode, locale=fr)
<h1>Bienvenue</h1>

When inline mode fits

  • You ship per-locale builds (www-fr.example.com, www-de.example.com).
  • You care about first-paint bundle size — no runtime, no dict fetch.
  • You have SSR / SSG and want pre-rendered HTML in the target locale.
  • Your locale set is small and rarely changes.

Typical inline setup

# Build one app per locale in CI
for locale in en fr de ja; do
LOCALE=$locale vite build --outDir dist/$locale
done
vite.config.ts
export default defineConfig({
plugins: [
neokapi({
mode: "inline",
locale: process.env.LOCALE ?? "en",
translationsDir: "./translations",
strict: "error", // fail the build on missing translations
}),
react(),
],
});

strict: "error" turns missing translations into a build error — nothing untranslated ships. For markets-by-market rollouts you'd keep strict: "warn" (default) during development, flip to "error" before the final release build.

Fallback chain

When a translation is missing in the primary locale, inline mode can consult fallback locales before giving up:

neokapi({
mode: "inline",
locale: "de-AT",
fallbackLocales: ["de", "en"],
});

For the Austrian-German build, a missing de-AT entry falls back to de, then to en, then to the source text.

Hybrid: inline core, runtime optional locales

A common pattern is to inline the primary locale at build time (so the default market loads instantly) and make secondary locales available via the runtime dict:

// Primary build
neokapi({ mode: "inline", locale: "en" });

// Secondary locales still get an OTA dict loaded via loadTranslations
// when the user switches, compiled from the same KLF directory.

Mixing modes within a single build is not supported — you pick one per deploy.

Mode comparison

runtimeinline
Number of builds11 per locale
Runtime bundle cost~2 kB0
Dict fetch at runtimeyes (per locale)no
Missing translationfallback to source textwarn or error at build
Hot-swap locale in-pageyesfull page reload / swap bundle
Best forapp shells, SaaS dashboardsmarketing sites, SSR, SSG, locale-per-domain

What doesn't change between modes

  • The extractor (kapi-react extract) produces the same .klf regardless of mode.
  • Hashes are mode-independent.
  • <Plural> / <Select> / t() all work the same in authoring.
  • Unmapped-component warnings fire identically.

The mode decision is purely about how the translated output lands in the user's browser.

Next