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. Two ways.

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

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

With a script, when no agent is around. One request with the project's public key:

// docs-check: skip a build script, not an SDK call
// scripts/export-i18n.mjs — run it before a release, commit the result
import { mkdir, writeFile } from "node:fs/promises";

const API_KEY = process.env.I18N_KEYLESS_API_KEY;
const res = await fetch("https://api.i18n-keyless.com/translate/bundle", {
headers: { Authorization: `Bearer ${API_KEY}`, Version: "3" }, // any major >= 3: the v3 language codes
});
const { data } = await res.json();

const manifest = { ...data, namespaces: {} };
for (const [namespace, entry] of Object.entries(data.namespaces)) {
manifest.namespaces[namespace] = { lastRefresh: entry.lastRefresh, languages: Object.keys(entry.translations) };
await mkdir(`i18n-keyless/${namespace}`, { recursive: true });
for (const [lang, translations] of Object.entries(entry.translations)) {
await writeFile(`i18n-keyless/${namespace}/${lang}.json`, JSON.stringify(translations));
}
}
await writeFile("i18n-keyless/manifest.json", JSON.stringify(manifest, null, 2));

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.

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.

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`),
},
});

On Node the files are read once at boot, and the boot download is skipped:

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),
},
});

The same option exists in the Vue, Angular and browser packages, and in every port: the Laravel, Rails, Python and Go ports take a directory path; the Flutter, Swift and Kotlin ports take the manifest and a loader that reads the app's assets.

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.

Re-run the export before each release. A stale bundle is never wrong — a miss still resolves — it only makes more of them.