Skip to main content

Ship translations in the bundle

By default the SDK downloads a dictionary at boot and keeps the API in the loop: a string it has never seen is translated on the spot, and a change made in the dashboard reaches every client at its next refresh. That is the keyless model, and it makes the app depend on the API at runtime.

The precompiled bundle removes that dependency for everything the app already knows. You export the dictionaries at build time, commit them next to the source, and hand them to init. A language the bundle covers is read from the files, never downloaded. The API is only called for a string the bundle does not have — user generated content, a screen added since the last export — and for the delta that follows it, exactly as before. Nothing else changes: no mode, no switch, the same init.

Should you?​

Read this before deciding. A bundle is not free, and the total cost is about the same either way — it only moves.

  • The bundle sits on the critical path. Its bytes are downloaded before the first paint, by every visitor, on every deploy that changes them. The runtime dictionary is downloaded after the first paint, once, and then served from storage.
  • A bundle is a snapshot. A correction made in the dashboard after the export reaches a user only through the delta fetch that follows a miss, or at the next deploy. Without a bundle it reaches every client at its next refresh.
  • A bundle is one file per language. The size cost is only acceptable when the app ships the current language alone, with a dynamic import(). A bundle that inlines every language is a mistake on the web.

So a bundle is the right choice for an offline app, for a mobile app (the files are assets, nothing is downloaded), for an SSR server that must render a non-primary language at its very first request, and for a product that must not make a network request before it renders. It is the wrong choice for a web app with a good network and many languages: keep the default.

Export the files​

The files come from the API, from the same dictionaries the SDK would download.

With the MCP server (see Operate it from your agent): ask your agent to run the export_bundle tool. It returns every file to write under an i18n-keyless/ directory.

To export only specific namespaces, pass them to the tool:

export_bundle({ namespaces: ["default", "checkout"] })

Without that argument, every namespace of the project is exported.

The result is a set of files:

i18n-keyless/
manifest.json
default/en.json
default/es.json
default/fr.json
checkout/en.json
...

manifest.json lists the namespaces, the languages each one has a file for, and the cursor of each namespace. The cursor is what makes the first fetch after a miss a delta: the API answers it with an empty payload until a row of that namespace changes.

Commit the files. The build then needs no network and no key, and the diff of an export is reviewable like any other change.

Re-export before each release

A stale bundle is never wrong — a miss still resolves — it only makes more of them. Ask your agent to re-run export_bundle before you deploy.

Hand them to init​

Pass the manifest and a loader. The loader is called once per namespace and language, and may return a dynamic import() — that is what lets the bundler split the files so only the rendered language ships.

React​

import { init } from "i18n-keyless-react";
import manifest from "./i18n-keyless/manifest.json";

init({
API_KEY: "your-public-key",
languages: { primary: "fr", supported: ["fr", "en", "es"] },
storage: window.localStorage,
bundle: {
manifest,
load: (namespace, lang) => import(`./i18n-keyless/${namespace}/${lang}.json`),
},
});

Vue​

import { init } from "i18n-keyless-vue";
import manifest from "./i18n-keyless/manifest.json";

init({
API_KEY: "your-public-key",
languages: { primary: "fr", supported: ["fr", "en", "es"] },
storage: window.localStorage,
bundle: {
manifest,
load: (namespace, lang) => import(`./i18n-keyless/${namespace}/${lang}.json`),
},
});

Angular​

import { init } from "i18n-keyless-angular";
import manifest from "./i18n-keyless/manifest.json";

init({
API_KEY: "your-public-key",
languages: { primary: "fr", supported: ["fr", "en", "es"] },
storage: window.localStorage,
bundle: {
manifest,
load: (namespace, lang) => import(`./i18n-keyless/${namespace}/${lang}.json`),
},
});

Browser (vanilla JS)​

import { init } from "i18n-keyless-browser";
import manifest from "./i18n-keyless/manifest.json";

init({
API_KEY: "your-public-key",
languages: { primary: "fr", supported: ["fr", "en", "es"] },
storage: window.localStorage,
bundle: {
manifest,
load: (namespace, lang) => import(`./i18n-keyless/${namespace}/${lang}.json`),
},
});

Node​

On Node the files are read once at boot, and the boot download is skipped. There is no per-language code splitting to gain — every language is loaded either way — so the point is zero dictionary requests at boot.

import { readFile } from "node:fs/promises";
import { init } from "i18n-keyless-node";
import manifest from "./i18n-keyless/manifest.json" with { type: "json" };

await init({
API_KEY: "your-public-key",
languages: { primary: "fr", supported: ["fr", "en", "es"] },
bundle: {
manifest,
load: (namespace, lang) => readFile(`./i18n-keyless/${namespace}/${lang}.json`, "utf8").then(JSON.parse),
},
});

Other ports​

The Laravel, Rails, Python and Go ports take a directory path instead of a loader. The Flutter, Swift and Kotlin ports take the manifest and a loader that reads the app's assets. See each port's documentation for the exact signature.

The bundle option​

Every SDK accepts the same shape:

bundle?: {
manifest: BundleManifest;
load: (namespace: string, lang: string) => BundleFile | Promise<BundleFile>;
};
  • manifest — the contents of manifest.json. It lists the primary language, the supported languages, the export timestamp, and each namespace with its cursor and the languages it covers.
  • load — called once per (namespace, language) pair the manifest covers. It may return a plain Record<string, string>, an ES module default export ({ default: ... }), or undefined / null to fall back to the network.

The loader is only called for pairs the manifest lists. If the file is missing or the loader throws, the SDK falls back to a fetch — the same behaviour as having no bundle for that pair.

What happens at runtime​

  • Boot and language switch. For every namespace the manifest lists, the SDK reads the file for the current language and seeds its store with it, with the namespace's cursor. No dictionary request is made for it. A namespace the manifest does not cover in that language is fetched as before.
  • A miss. A string the store does not have goes to POST /translate as usual, and the delta fetch that follows starts from the bundle's cursor.
  • Storage. What the device already holds wins over the bundle only when it is newer and in the same language — a user who received a corrected translation after your export keeps it. Anything else, the bundle wins.
  • SSR. getServerTranslations(lang) reads the bundle before the network, so the first request of a fresh process renders a non-primary language with no fetch.

API reference​

The MCP export_bundle tool calls GET /translate/bundle internally. If you need to call the endpoint directly:

GET /translate/bundle
Authorization: Bearer <API_KEY>
Version: 3

Optional query parameter: ?namespace=default,checkout — comma-separated list of namespaces to export. Without it, every namespace of the project is returned.

The Version: 3 header selects the v3 language codes. Omit it or pass a lower major to receive v2 codes. Match it to the SDK version your app uses.

The response is Cache-Control: no-store and contains the full dictionaries, which the export script or MCP tool splits into one file per language.