# i18n-keyless
> Ultimate DX for i18n. A keyless internationalization SaaS: write strings in your source language, get AI-powered translations at runtime — no JSON files, no translation keys, no manual upkeep.
i18n-keyless lets you ship multi-language apps without managing translation keys or files. You write text in your primary language (French or English), wrap it in a component or call, and the SDK fetches AI-generated translations on the fly, caches them locally, and lets you override them from a dashboard or directly in code.
Official SDKs: React, React Native (`i18n-keyless-react`), and Node.js (`i18n-keyless-node`).
This file is a single-page Markdown summary of the documentation, intended to be pasted into an LLM context window so the assistant has full coverage of the API surface and conventions.
## Core concepts
- **Keyless**: the source string itself is the translation key — no IDs to manage.
- **Primary language**: `fr` or `en` only. This is the language you write your code in.
- **Supported target languages**: `fr`, `en`, `nl`, `it`, `de`, `es`, `pl`, `pt`, `ro`, `hu`, `sv`, `tr`, `ja`, `cn`, `cz`, `ru`, `ko`, `ar`. Also exported as the runtime const `AVAILABLE_LANGS` from `i18n-keyless-react` and `i18n-keyless-node` (see Type reference below).
- **Storage** (React / React Native only): required, used to cache translations locally. Must implement `getItem`, `setItem`, `removeItem` (sync or async).
- **AI translations**: on cache miss, the server generates a translation with Mistral, stores it server-side and pushes it to the client cache.
- **Overrides**: edit a translation in the dashboard at https://i18n-keyless.com/dashboard, or use `forceTemporary` from code.
- **API key**: get one at https://i18n-keyless.com/#get-api-key.
## Installation
### React (web)
```bash
npm install i18n-keyless-react
```
Initialize once in `main.tsx` / `index.tsx` before rendering the app:
```ts
import * as I18nKeyless from "i18n-keyless-react";
I18nKeyless.init({
API_KEY: "YOUR_API_KEY",
storage: window.localStorage, // or any get/set/del-compatible adapter
languages: {
primary: "fr", // "fr" | "en"
supported: ["en", "fr"],
fallback: "en", // optional
initWithDefault: "fr", // optional
},
});
```
For IndexedDB, wrap `idb-keyval` in a `{ getItem, setItem, removeItem }` adapter.
### React Native
```bash
npx expo install i18n-keyless-react react-native-mmkv
npx expo prebuild
```
```ts
import * as I18nKeyless from "i18n-keyless-react";
import { MMKV } from "react-native-mmkv";
I18nKeyless.init({
API_KEY: "YOUR_API_KEY",
storage: new MMKV(),
languages: { primary: "fr", supported: ["en", "fr"] },
});
```
`AsyncStorage` (`@react-native-async-storage/async-storage`) is also supported.
### Node.js
```bash
npm install i18n-keyless-node
```
```ts
import * as I18nKeyless from "i18n-keyless-node";
I18nKeyless.init({
API_KEY: "YOUR_API_KEY",
languages: { primary: "fr", supported: ["en", "fr"] },
});
```
No storage parameter on Node.
## React / React Native API (`i18n-keyless-react`)
### `I18nKeylessText` — translation component
Use it inline to wrap any text. The text content is also the translation key.
```tsx
import { I18nKeylessText } from "i18n-keyless-react";
Bonjour le monde
```
Props:
- `children: ReactNode` — the source text.
- `context?: string` — disambiguation hint (e.g. `"This is a back button"`).
- `replace?: Record` — placeholder substitution (e.g. `{ "{name}": user.name }`).
- `forceTemporary?: Record` — override per language, e.g. `{ en: "Back" }`.
- `debug?: boolean` — log internals for this call.
### `getTranslation(text, options?)` — string-returning hook
Use when you need a translated string in a prop (placeholder, `aria-label`, tab label, etc.) instead of as JSX.
```tsx
import { getTranslation } from "i18n-keyless-react";
```
Same options as `I18nKeylessText`. Returns `string`. Must be called inside a React component (it's a hook).
### `useCurrentLanguage()` — current language hook
Returns `Lang | null` (null until set).
```ts
const current = useCurrentLanguage(); // "fr" | "en" | ... | null
```
### `setCurrentLanguage(lang)` — switch language
```ts
import { setCurrentLanguage } from "i18n-keyless-react";
setCurrentLanguage("es");
```
Triggers re-render of all consumers. Can be called from anywhere (not a hook).
### `getSupportedLanguages()` — configured languages
Returns the `Lang[]` you passed to `init`. Useful for building a language selector.
### `useI18nKeyless(selector)` — Zustand store accessor
Low-level access to the store. Common selectors:
```ts
const primary = useI18nKeyless((s) => s.config.languages.primary);
const supported = useI18nKeyless((s) => s.config.languages.supported);
const lastRefresh = useI18nKeyless((s) => s.lastRefresh); // for forcing re-renders
```
## Node.js API (`i18n-keyless-node`)
### `awaitForTranslation(text, language, options?)`
Returns a `Promise`. **Must be awaited** — calling without `await` will trigger 429 rate limit errors.
```ts
import { awaitForTranslation, type Lang } from "i18n-keyless-node";
const title = await awaitForTranslation("Viens voir l'application", user.lang as Lang);
const body = await awaitForTranslation(
"Bonjour {name} !",
user.lang as Lang,
{ replace: { "{name}": user.name }, context: "push notification" }
);
```
Options:
- `context?: string`
- `replace?: Record`
- `forceTemporary?: string` — note: a single string here, not a record (target language is already known).
- `debug?: boolean`
### `getSupportedLanguages()`
Same as in the React SDK.
## Common patterns
### Disambiguate ambiguous words
```tsx
RetourProche
```
### Replace dynamic values
Any pattern can be a placeholder: `{name}`, `{{name}}`, `[name]`, `%name%`, ``, plain `name`, `{user_name}`, etc.
```tsx
Hello {name} !
```
### Force a translation from code
When the AI gets it wrong and you can't (or won't) fix it from the dashboard:
```tsx
Retour
```
### Build a language selector
```tsx
import { useCurrentLanguage, setCurrentLanguage, useI18nKeyless } from "i18n-keyless-react";
const current = useCurrentLanguage();
const primary = useI18nKeyless((s) => s.config.languages.primary);
```
### Translate Markdown
`i18n-keyless` only translates strings, so use Markdown for inline styling. With `react-markdown`, force re-render with `lastRefresh`:
```tsx
import { useI18nKeyless, getTranslation } from "i18n-keyless-react";
import ReactMarkdown from "react-markdown";
const lastRefresh = useI18nKeyless((s) => s.lastRefresh);
{getTranslation(children)}
```
### Wrap in a `MyText` component
Recommended in React Native (for fonts/styles) and useful in React too. Forward `context`, `replace`, `forceTemporary`, `debug` through an `i18nProps` prop, and add a `skipTranslation` escape hatch for already-translated content.
### Debug a single call
```tsx
Hello world
getTranslation("Hello world", { debug: true });
await awaitForTranslation("Hello world", "fr", { debug: true });
```
## Server-Side Rendering (SSR)
Works under SSR (TanStack Start, Next, Remix, Astro, Expo Router server output, and any
Node/modern-edge runtime) since `i18n-keyless-react >= 2.0.0` (TanStack Start / Vite SSR
needs `>= 2.3.2`). v2 is a **drop-in for SPAs** — no code changes. The server is **read-only**: usage analytics are never sent
from the server, so SSR adds no API traffic (on a long-lived server it's reported once
per process boot, not once per user — fewer calls than a SPA). Translate-on-miss is
unchanged: a key with no translation yet is still requested once.
`storage` is **optional on the server** (defaults to an in-memory store, also exported as
`createMemoryStorage()`) and still required in the browser. Pass `ssr: true` to `init` to
force read-only mode explicitly (useful on serverless cold starts). Runtime: Node ≥ 20.10
or a modern bundler (Vite, esbuild, webpack 5, Rollup 3+). `runWithI18nKeyless` needs
`AsyncLocalStorage` (Node and most edge runtimes; Cloudflare Workers needs a flag — where
unavailable, use `` for `` and the scope degrades to a no-op).
The SSR additions are inert in the browser: `node:async_hooks` is loaded via a guarded
dynamic import, so it never enters browser/React Native bundles and the browser path is a
no-op. There are two modes.
### The two translation paths (the part most people get wrong)
Two paths resolve the language through two different mechanisms:
- **Component path**: `` / `` → ``
(React context). Reads context first, then the ALS, then the store.
- **Function path**: imperative `getTranslation(key)` → the ALS scope set by
`runWithI18nKeyless`. `getTranslation` is a plain function and **cannot read React
context**, so it depends on the ALS.
How much wiring you need depends on whether the component tree renders **inside** or
**outside** the ALS scope — which is framework-specific:
- **Remix / React Router 7** — `entry.server` renders the tree *inside* `runWithI18nKeyless`,
so on the server both paths work from the ALS alone — **no Provider needed**. For the
client, serialize a snapshot (`getUsedTranslationsSnapshot()` in a ``;
});
// client — seed synchronously, then init, then hydrate
import { hydrateFromServer, init } from "i18n-keyless-react";
const el = document.getElementById("i18n-keyless");
if (el) hydrateFromServer(JSON.parse(el.textContent)); // { lang, translations } — BEFORE first render
init({ languages: { primary: "fr", supported: ["fr", "en"] }, API_KEY, storage: window.localStorage });
hydrateRoot(document, );
```
`init`'s async hydrate treats the seed as authoritative (won't reset to primary on a cold
cache). `hydrateFromServer` is SSR-only and opt-in — SPA/non-SSR apps never call it.
Separately (`>= 2.2.0`), `getTranslation` defers its usage-analytics write to a
`queueMicrotask`, so calling it during render no longer triggers React's "Cannot update a
component while rendering a different component" warning; the server records nothing.
**Large translation sets (`>= 2.3.0`).** By default the snapshot embeds the full language
set in every page's HTML — fine for small/medium sets (it doubles as a warm cache for
instant client navigation). For thousands of keys, serialize only the keys the page
rendered: swap `getRequestScope()` for `getUsedTranslationsSnapshot()` at the
serialization site. During render, each `getTranslation(...)` / `` adds
its key to a per-request `Set` (no store write, no re-render); the snapshot returns just
those keys (intersected with available). The full set stays in scope so any key still
resolves mid-render — only the serialized payload narrows; concurrent requests are
isolated. Keep calling `init()` on the client: its background fetch loads the full set so
post-hydration navigation has every key (until then, misses resolve via translate-on-miss).
Measure first: `JSON.stringify(getRequestScope().translations).length`. **Not compatible
with TanStack Start** (or any setup whose body renders outside the ALS / uses code-split
routes): keys aren't recorded, so the subset misses body keys → mismatch. Use the full map
there.
### SSR exports
- `getServerTranslations(lang) => Promise` — fetch a language's
translations on the server, cached per process.
- `clearServerTranslationsCache(lang?) => void` — evict one/all languages from that cache.
- `runWithI18nKeyless(scope, fn) => Promise` — run a server render with
`{ lang, translations }` active so `getTranslation` and `` resolve in `lang`.
- `getRequestScope() => { lang, translations } | undefined` — read the active request
scope (advanced) — serialize for the client (full set).
- `getUsedTranslationsSnapshot() => { lang, translations } | undefined` (`>= 2.3.0`) — like
`getRequestScope` but `translations` is narrowed to only the keys this render touched —
for large sets. **Render-inside-ALS only** — incompatible with TanStack Start (and any
setup whose body renders outside the ALS or uses code-split routes): keys aren't recorded,
so the subset misses body keys → hydration mismatch; serialize the full map there.
`undefined` outside a `runWithI18nKeyless` render.
- `I18nKeylessProvider({ lang, translations, children })` — per-request context for ``;
seeds the store on the client for flash-free hydration of the component form.
- `hydrateFromServer({ lang, translations }) => void` (`>= 2.2.0`) — client-only; seed the
store *synchronously* before `hydrateRoot` so the `getTranslation` function form is
correct on first render. No-op without `lang`.
- `createMemoryStorage() => Storage` — in-memory storage adapter (server default).
- `init({ …, ssr? })` — `storage` optional on the server; `ssr: true` forces read-only.
### Migrating from an SSR workaround
- Remove `ssr: { noExternal: ['i18n-keyless-*'] }` from your bundler config — v2's build
is valid native Node ESM and externalizes cleanly.
- Remove any no-op server storage — omit `storage` on the server; it defaults to
in-memory.
- To render non-primary languages, adopt the localized-SSR flow above
(`getServerTranslations` + `runWithI18nKeyless` + ``).
### Framework recipes
Copy-paste integration guides per meta-framework live at:
- TanStack Start (**requires `>= 2.3.2`**) — https://i18n-keyless.com/docs/ssr/tanstack-start
(in `src/server.ts`: `createStartHandler(defaultStreamHandler)` + wrap the WHOLE `fetch`
in `runWithI18nKeyless`, export via `createServerEntry({ fetch })` — not just
`defaultStreamHandler`; `` from the root-route loader; call
`getTranslation` only in loaders/`head()`, never in a component body; client just
`initI18nClient()` — no `hydrateFromServer`; `?lang=` is the single source of truth;
serialize the full map, not `getUsedTranslationsSnapshot()`).
- Remix / React Router 7 framework mode — https://i18n-keyless.com/docs/ssr/remix (wrap
`renderToPipeableStream` in `runWithI18nKeyless` in `app/entry.server.tsx`; tree renders
inside the ALS so both paths resolve from it — **no Provider**; serialize via a tiny
`` (uses `getUsedTranslationsSnapshot()`) rendered after the app in
`root.tsx`; `entry.client.tsx` calls `hydrateFromServer(snapshot)` before `hydrateRoot`,
then `initI18nClient()`).
- Next.js App Router — https://i18n-keyless.com/docs/ssr/nextjs (no render hook to wrap, so
no `runWithI18nKeyless`: `app/[lang]/layout.tsx` (server) fetches `getServerTranslations`
→ a `"use client"` `Providers` boundary rendering `` and calling
`hydrateFromServer` + `initI18nClient` in an effect; imperative `getTranslation` renders
the primary language until that effect — prefer ``).
- Astro (React islands) — https://i18n-keyless.com/docs/ssr/astro (`.astro` page fetches
`getServerTranslations`, passes `lang` + `translations` as island props; island renders
`` and runs `hydrateFromServer` + `initI18nClient` in an effect;
prefer `` over imperative `getTranslation` for zero flash).
### Example apps
Runnable examples (one per framework), each with a passing test suite, running offline
against a bundled mock backend (no API key needed):
https://github.com/arnaudambro/i18n-keyless/tree/main/examples — docs at
https://i18n-keyless.com/docs/examples. Covers `vite-react` (SPA), `tanstack-start`
(SSR+SPA, needs `>= 2.3.2`), `remix-rr7` (SSR), `nextjs` (App Router SSR), `astro` (SSR
islands), `node` (`i18n-keyless-node` + `awaitForTranslation`), `react-native` (MMKV /
AsyncStorage), and `expo`. Run: `cd examples/ && cp .env.example .env && npm install
&& npm run dev` (leave the API key empty + run `examples/_mock-server` to try offline).
### Changelog
- `2.3.2` — fix SSR request scope under TanStack Start / Vite: the per-request
AsyncLocalStorage is now pinned to a single `globalThis` slot shared across V8 realms /
module graphs (was duplicated, so `?lang=en` rendered the primary language with a
hydration mismatch). SPA, Next.js, Remix, Astro, and Node were unaffected. No API change.
- `2.3.0` — `getUsedTranslationsSnapshot()` for per-page key subsets on large translation
sets.
- `2.2.0` — `hydrateFromServer()` synchronous client seed for the `getTranslation` function
form; `getTranslation` defers usage recording to a microtask (no setState-during-render
warning).
- `2.1.0` — AsyncLocalStorage request scope (`runWithI18nKeyless`, `getRequestScope`).
- `2.0.0` — SSR support (`getServerTranslations`, ``, read-only server,
optional server `storage`).
## Type reference
```ts
// Importable runtime const — exported from i18n-keyless-react and i18n-keyless-node
// (re-exported from i18n-keyless-core). Use it to support every available language
// without hardcoding the list, or to build language selectors / validation.
const AVAILABLE_LANGS = [
"fr", "en", "nl", "it", "de", "es", "pl", "pt", "ro",
"hu", "sv", "tr", "ja", "cn", "cz", "ru", "ko", "ar",
] as const;
type Lang = (typeof AVAILABLE_LANGS)[number];
// "fr" | "en" | "nl" | "it" | "de" | "es" | "pl" | "pt" | "ro"
// | "hu" | "sv" | "tr" | "ja" | "cn" | "cz" | "ru" | "ko" | "ar"
type PrimaryLang = "fr" | "en";
type LanguagesConfig = {
primary: PrimaryLang;
supported: Lang[];
fallback?: Lang;
initWithDefault?: Lang;
};
type TranslationOptions = {
context?: string;
debug?: boolean;
forceTemporary?: Partial>; // Node: string
replace?: Record;
};
```
### Using `AVAILABLE_LANGS`
```ts
import { AVAILABLE_LANGS } from "i18n-keyless-react"; // or "i18n-keyless-node"
// Support every available language without hardcoding the list
I18nKeyless.init({
API_KEY: "YOUR_API_KEY",
storage: window.localStorage,
languages: {
primary: "fr",
supported: [...AVAILABLE_LANGS],
},
});
// Validate a user-provided language code at runtime
const isLang = (l: string): l is Lang => (AVAILABLE_LANGS as readonly string[]).includes(l);
```
## Gotchas
- `awaitForTranslation` **must** be awaited. Fire-and-forget calls will hit rate limits.
- `init` must run before any translation call; in React, do it in your entry file before rendering.
- Storage adapter methods can be sync or async, but must all be present (`getItem`, `setItem`, `removeItem`).
- The `primary` language can only be `fr` or `en`. Source strings must be written in that language.
- Translations are cached on-device — if you change a translation in the dashboard, cached clients pick it up via the next refresh, not instantly.
- For `react-markdown` (and similar memoized renderers), pass `key={lastRefresh}` so the tree re-renders when translations refresh.
## Resources
- Website: https://i18n-keyless.com
- Docs: https://i18n-keyless.com/docs/quick-setup
- SSR guide: https://i18n-keyless.com/docs/ssr
- Dashboard: https://i18n-keyless.com/dashboard
- Get an API key: https://i18n-keyless.com/#get-api-key
- GitHub: https://github.com/arnaudambro/i18n-keyless