Welcome to i18n-keyless! π This package provides a seamless way to handle translations without the need for cumbersome key management. This README will guide you through the setup and usage of the library.
Try it by yourself in this Stackblitz
- Using an AI coding agent?
- How it works
- Installation
- Quick Start
- Usage
- Namespaces
- User-Generated Content
- Server-Side Rendering
- Supported Languages
- Setup
- Protocol and ports
- Custom Component Example
- What pains does it solve?
- Contact
i18n-keyless is built to be installed by an agent in one step. Point yours at whichever of these fits your tool:
| What | Where | For |
|---|---|---|
| Agent Skill | skills/i18n-keyless/SKILL.md |
Claude Code, Claude.ai, and any tool that reads SKILL.md. Copy the folder into .claude/skills/ of your project. |
| llms.txt | llms.txt, also served at docs.i18n-keyless.com/llms.txt |
The whole documentation as one pasteable Markdown file β Cursor, ChatGPT, Windsurf, Copilot. |
| Context7 | use context7 in your prompt |
Live docs injected into the context window through the Context7 MCP server. |
| MCP server | claude mcp add --transport http i18n-keyless https://api.i18n-keyless.com/mcp |
Your agent operates the project: list missing translations, fix one, change languages, create a project. OAuth, no key to paste. Guide. |
The skill is short on purpose: install, initialise, the two ways to render a string, the
per-translation options, the SSR traps, and the gotchas. It links to llms.txt for the rest.
First, you should read the What pains does it solve? section to understand the pains you have with the current i18n solutions.
i18n-keyless is a library, combined with an API service (I provide one, but you can use your own) that allows you to translate your text without the need to use keys.
By calling I18nKeyless.init you initialize an object that will be used to translate your text.
If your primary language is en and the user's language is fr, the object would look like this:
{
"Hello!": "Bonjour !",
"Welcome to our website": "Bienvenue sur notre site web",
...
}If the user's language is en, i18n-keyless won't use such an object and will use the default translations.
If the translation is not found, there will be an asynchronous fetch to I18nKeyless' API (or your own if you prefer) to get the translation by an AI API call. Then the translation is returned and stored in the object. This operation is only made once ever per key, for all the users all over the world. The operation can be made in dev mode if you encounter that key, but it can also be made in production if the key is dynamic.
At the first opening of the app ever in a new language, there is an API call to the server where all your translations are stored. Then it stores all those translations in the object and the storage you provide (localStorage, AsyncStorage, MMKV, etc.). No translations are stored in the app initially.
At each opening of the app, the newest translations are fetched from the storage and the object is updated.
Runnable example apps for every major framework live in examples/ β each a
two-page app showing init, the <I18nKeylessText> (<T>) component, the
getTranslation() function, context, replace, and a language switcher. The SSR ones
also show getServerTranslations + runWithI18nKeyless + getUsedTranslationsSnapshot +
hydrateFromServer.
| Example | Mode | Example | Mode | |
|---|---|---|---|---|
| vite-react | SPA | astro | SSR (islands) | |
| tanstack-start | SSR | react-native | native | |
| remix-rr7 | SSR | expo | native | |
| nextjs | SSR | node | server | |
| vue-vite | SPA (Vue) | angular | SPA (Angular) | |
| browser | script tag, no framework | laravel | server (PHP) |
See examples/README.md to run them (real service via an API key, or
offline against the bundled mock backend). Primary language is fr throughout.
Install the package via npm or yarn:
npm install i18n-keyless-reactInstall the package via npm or yarn:
npm install i18n-keyless-nodeOne protocol, one dashboard, one API key. Pick the package for your stack; each README is the full reference for that package.
| Target | Package | Install | README |
|---|---|---|---|
| React, React Native, Expo, Next.js, Remix, TanStack Start, Astro | i18n-keyless-react |
npm install i18n-keyless-react |
React usage below |
| Node.js backend (emails, push, cron) | i18n-keyless-node |
npm install i18n-keyless-node |
Node usage below |
| Vue 3, Nuxt, Vite SSR | i18n-keyless-vue |
npm install i18n-keyless-vue |
packages/vue |
| Angular >= 17.1 (standalone, signals, Angular SSR) | i18n-keyless-angular |
npm install i18n-keyless-angular |
packages/angular |
| Plain HTML, Svelte, Alpine, htmx, jQuery, legacy sites | i18n-keyless-browser |
npm install i18n-keyless-browser, or one script tag |
packages/browser |
| Laravel 11, 12, 13 | i18n-keyless/laravel (Composer) |
composer require i18n-keyless/laravel |
ports/laravel |
| Flutter, Dart | i18n_keyless (pub.dev) |
flutter pub add i18n_keyless |
ports/flutter |
| Any stack, shared engine | i18n-keyless-core |
npm install i18n-keyless-core |
packages/core, docs/PROTOCOL.md |
Get up and running in minutes!
-
Install:
npm install i18n-keyless-react
-
Initialize: Call
initonce at the root of your app (e.g.,App.jsorindex.js).import { init } from "i18n-keyless-react"; import myStorage from "./src/services/storage"; // Use your preferred storage solution init({ API_KEY: "<YOUR_API_KEY>", // Get your key from i18n-keyless.com /** * the storage to use for the translations - any storage that has a getItem, setItem, removeItem, or get, set, and remove method * * in React Native you can use react-native-mmkv, @react-native-async-storage/async-storage, and in Web window.localStorage, or idb-keyval for IndexedDB, or any storage whose methods are compatible */ storage: myStorage, languages: { primary: "en", // Your app's primary language supported: ["en", "fr", "es"], // Languages your app supports }, });
Note: You'll need an
API_KEYfrom i18n-keyless.com or configure your own API. -
Use: Wrap text with the
I18nKeylessTextcomponent.import { I18nKeylessText } from "i18n-keyless-react"; // `import { T } from "i18n-keyless-react"` also works import { setCurrentLanguage } from "i18n-keyless-react"; // Optional: for changing language // Example Component function MyComponent() { return ( <div> <button onClick={() => setCurrentLanguage("fr")}>Set FR</button> <button onClick={() => setCurrentLanguage("es")}>Set ES</button> <h1> <I18nKeylessText>Welcome to our app!</I18nKeylessText> </h1> <p> <I18nKeylessText>This text will be automatically translated.</I18nKeylessText> </p> {/* Example with context for disambiguation */} <button> <I18nKeylessText context="this is a back button">Back</I18nKeylessText> </button> </div> ); }
-
Install:
npm install i18n-keyless-node
-
Initialize: Call
initat the start of your application.import { init } from "i18n-keyless-node"; (async () => { await init({ API_KEY: "<YOUR_API_KEY>", // Get your key from i18n-keyless.com languages: { primary: "en", // Your primary language supported: ["en", "fr", "es"], // Languages you need translations for }, }); console.log("i18n-keyless initialized!"); })();
Note: You'll need an
API_KEYfrom i18n-keyless.com or configure your own API. -
Use: Two functions fetch and retrieve translations. Use
awaitForTranslationOrThrowin a script or a build step (an ignored rejection crashes the process on purpose). UseawaitForTranslationOrFallbackToOriginalin a request handler (it never rejects: a failed POST falls back to the key, with the failure still logged).import { awaitForTranslationOrThrow } from "i18n-keyless-node"; // Assuming init has completed (async () => { // Fetch and get the French translation for "Hello world" const greeting = await awaitForTranslationOrThrow("Hello world", "fr"); // Target language 'fr' console.log(greeting); // Output: "Bonjour le monde" (or similar) // Fetch and get the Spanish translation for "Processing complete." const message = await awaitForTranslationOrThrow("Processing complete.", "es"); // Target language 'es' console.log(message); // Output: "Procesamiento completo." (or similar) // Example with context const backButtonText = await awaitForTranslationOrThrow("Back", "es", { context: "this is a back button" }); console.log(backButtonText); // Output: Spanish translation for "Back" (e.g., "AtrΓ‘s") // β οΈ IMPORTANT: Always await translations to avoid API rate limiting // Bad - could get rate limited: awaitForTranslationOrThrow("Hello", "fr"); awaitForTranslationOrThrow("World", "fr"); // Good - await each translation: await awaitForTranslationOrThrow("Hello", "fr"); await awaitForTranslationOrThrow("World", "fr"); // Even better - await in parallel if possible: await Promise.all([ awaitForTranslationOrThrow("Hello", "fr"), awaitForTranslationOrThrow("World", "fr") ]); })();
In a request handler (an HTTP server, an API route), prefer the fallback variant so one failed translation doesn't fail the whole request:
import { awaitForTranslationOrFallbackToOriginal } from "i18n-keyless-node"; async function handleRequest(lang) { // Still MUST be awaited (rate limiting) β it just never rejects. const greeting = await awaitForTranslationOrFallbackToOriginal("Hello world", lang); return greeting; // falls back to "Hello world" if the POST fails; the failure is logged }
-
Install:
npm install i18n-keyless-vue
-
Initialize: Call
initonce before the app mounts, then install the plugin. It registers<T>globally.import { createApp } from "vue"; import { init, I18nKeyless } from "i18n-keyless-vue"; init({ API_KEY: "<YOUR_API_KEY>", storage: window.localStorage, languages: { primary: "en", supported: ["en", "fr", "es"] }, }); createApp(App).use(I18nKeyless).mount("#app");
-
Use: Wrap text with
<T>, or callt()for an attribute.<script setup> import { useI18nKeyless, setCurrentLanguage } from "i18n-keyless-vue"; const { t } = useI18nKeyless(); </script> <template> <button @click="setCurrentLanguage('fr')">Set FR</button> <h1><T>Welcome to our app!</T></h1> <input :placeholder="t('Your email')" /> <button><T context="this is a back button">Back</T></button> </template>
Full reference, SSR and Nuxt: packages/vue/README.md.
-
Install:
npm install i18n-keyless-angular
-
Initialize: Add the provider once, in
app.config.ts.storagedefaults tolocalStoragein the browser.import { provideI18nKeyless } from "i18n-keyless-angular"; export const appConfig = { providers: [ provideI18nKeyless({ API_KEY: "<YOUR_API_KEY>", languages: { primary: "en", supported: ["en", "fr", "es"] }, }), ], };
-
Use: The
<i18n-t>component, or thetpipe where an element cannot go.import { Component, inject } from "@angular/core"; import { I18nKeylessTextComponent, I18nKeylessTranslatePipe, I18nKeylessService } from "i18n-keyless-angular"; @Component({ standalone: true, imports: [I18nKeylessTextComponent, I18nKeylessTranslatePipe], template: ` <button (click)="i18n.setCurrentLanguage('fr')">Set FR</button> <h1><i18n-t>Welcome to our app!</i18n-t></h1> <input [placeholder]="'Your email' | t" /> <button><i18n-t context="this is a back button">Back</i18n-t></button> `, }) export class MyComponent { readonly i18n = inject(I18nKeylessService); }
Full reference and Angular SSR: packages/angular/README.md.
No framework, no build step: one script tag does init, translates every data-i18n
element and every <i18n-t>, and exposes the JS API as window.i18nKeyless.
<script type="module" src="https://esm.sh/i18n-keyless-browser/auto"
data-api-key="<YOUR_API_KEY>" data-primary="en" data-supported="en,fr,es"></script>
<button onclick="i18nKeyless.setCurrentLanguage('fr')">Set FR</button>
<h1 data-i18n>Welcome to our app!</h1>
<button><i18n-t context="this is a back button">Back</i18n-t></button>With a bundler (Svelte, Alpine, htmx, jQuery, vanilla): npm install i18n-keyless-browser,
then init, defineI18nT(), translateDom() and watchTranslation() from JS. Full
reference: packages/browser/README.md.
Your existing __('Welcome to our app') calls (Laravel's JSON keyless mode) resolve through
the API. One composer require, two .env lines, zero code change.
composer require i18n-keyless/laravelI18N_KEYLESS_API_KEY=<YOUR_API_KEY>
I18N_KEYLESS_LANGUAGES=en,fr,es # every language your app serves__('Welcome to our app'); // translated for App::getLocale()
__('Welcome :name', ['name' => $user->name]); // placeholders stay Laravel's job
i18nk('8 heures', context: 'duration'); // context, when one string has two meaningsFull reference (cache, queue, locales, limitations): ports/laravel/README.md.
No ARB files, no flutter gen-l10n: write T('Welcome') and ship 48 languages at runtime.
flutter pub add i18n_keylessfinal i18n = I18nKeylessClient();
await i18n.init(I18nKeylessConfig(
apiKey: '<YOUR_API_KEY>',
languages: LanguagesConfig(primary: Lang.en, supported: [Lang.en, Lang.fr, Lang.es]),
storage: SharedPreferencesStorage(),
));
runApp(I18nKeylessScope(client: i18n, child: const MyApp()));
// in a widget
T('Welcome to our app!');
TextField(decoration: InputDecoration(hintText: context.t('Your email')));
I18nKeyless.of(context).setCurrentLanguage(Lang.fr);Full reference: ports/flutter/README.md.
Use the I18nKeylessText component to wrap your text in any supported language:
import { I18nKeylessText } from "i18n-keyless-react";
<I18nKeylessText>Je mets mon texte dans ma langue, finies les clΓ©s !</I18nKeylessText>For text with dynamic content, use the I18nKeylessText component:
import { I18nKeylessText } from "i18n-keyless-react";
// Replace specific text patterns with dynamic values
<I18nKeylessText
replace={{
"{name}": user.name,
"{date}": formattedDate
}}
>
Bonjour {name}, votre rendez-vous est confirmΓ© pour le {date}
</I18nKeylessText>
// This will first translate the entire text, then replace the placeholders with their respective values. It's perfect for dynamic content like usernames, dates, or counts.We didn't build any complicated internal system for plural and gender management. For now, you need to use JavaScript to switch between cases, and maybe context to specify plural and gender.
For translating text outside of a <I18nKeylessText> component β a prop, an alt, a
placeholder, a string you pass to another library β use the useTranslation hook. It is
the hook behind <I18nKeylessText>, so it takes the same options and resolves the same way:
import { useTranslation } from "i18n-keyless-react";
export default function Home() {
const welcome = useTranslation("Welcome");
const search = useTranslation("Search {what}", { replace: { "{what}": "products" } });
return (
<HomeTabs.Navigator>
<HomeTabs.Screen options={{ tabBarLabel: welcome }} name="WELCOME" />
<input placeholder={search} />
</HomeTabs.Navigator>
);
}It is a hook, so the component re-renders when the translation arrives and when the user
switches language β and under SSR it reads the request's language from
<I18nKeylessProvider>, like <I18nKeylessText> does.
Outside a component β a route loader, a utility, a head() β there is no hook to call.
Use the plain getTranslation function there:
import { getTranslation } from "i18n-keyless-react";
export const loader = async () => ({ title: getTranslation("Welcome") });Important
getTranslation is a plain function, not a hook. It reads the store one time and does
not subscribe to it. A component that calls it in render never re-renders when the
user switches language, and the text stays in the previous language. In a component, use
useTranslation instead. If you must keep getTranslation in a component, call
useCurrentLanguage() once at the top of that component to subscribe it (a
useCurrentLanguage() in a child does not help the parent).
For setting a new current language, use the setCurrentLanguage method wherever you want:
import { setCurrentLanguage } from "i18n-keyless-react";
setCurrentLanguage("en");To retrieve the current language, use the useCurrentLanguage hook:
import { useCurrentLanguage } from "i18n-keyless-react";
const currentLanguage = useCurrentLanguage();It has a second job: it subscribes the component to language changes. Call it in every
component that calls getTranslation(), even if you ignore the return value. See the
note above.
Clear the i18n-keyless storage:
import { clearI18nKeylessStorage, clearI18nKeylessStorageAndStore } from "i18n-keyless-react";
// Clear the cached translations from the storage you passed to init()
clearI18nKeylessStorage(window.localStorage);
// Note: the device id (`i18n-keyless-user-id`) is deliberately kept. It identifies the
// install, not the cache β dropping it would count this device as a new monthly active
// user on its next launch.
// Same, and also resets the in-memory store, so the UI drops back to the source strings
await clearI18nKeylessStorageAndStore();Initialize the i18n system with your configuration (usually done once at startup):
import { init } from "i18n-keyless-node";
await init({
API_KEY: "<YOUR_API_KEY>",
languages: {
primary: "fr",
supported: ["fr", "en"]
}
});awaitForTranslationOrThrow / awaitForTranslationOrFallbackToOriginal (Asynchronous - MANDATORY AWAIT)
Two functions retrieve a translation, automatically fetching it from the backend via API or
custom handler if it's missing locally. Pick by call site: in a request handler, use
awaitForTranslationOrFallbackToOriginal β it never rejects, and returns the key on
failure (the failure is still logged). In a script or a build step, use
awaitForTranslationOrThrow β it rejects, and an ignored rejection crashes the process on
purpose, so a one-off run fails loudly instead of shipping untranslated output.
π¨ CRITICAL NODE.JS USAGE NOTE π¨
You MUST await both functions' calls, always β even awaitForTranslationOrFallbackToOriginal, which never rejects, still needs the await for rate limiting. You would be blocked by 429 Too many requests if you didn't.
For awaitForTranslationOrThrow, failure to await is not optional. If the underlying
translation process encounters an error (network issue, API error, etc.) the promise
rejects, and an unhandled rejection terminates the Node process β deliberately, so a script
or build step that cannot translate fails loudly instead of shipping the wrong text.
Handling it is honoured, though: a try/catch or a .catch() gets the error and your
process keeps running, so you can fall back to your own text. Only an ignored rejection is
fatal. The error names the key and carries the underlying failure as its cause.
Before 3.2.0 this was backwards: ignoring the rejection was silent, and a correct
try/catchcrashed the process anyway.
Deprecated:
awaitForTranslationis an alias ofawaitForTranslationOrThrow, kept for backward compatibility since 3.5.0. It will be removed in 4.0.0 β useawaitForTranslationOrThroworawaitForTranslationOrFallbackToOriginalinstead.
import { awaitForTranslationOrThrow } from "i18n-keyless-node";
// --- CORRECT USAGE (Mandatory) ---
async function getGreetingSafe(name: string, lang: string): Promise<string> {
const greetingTemplate = await awaitForTranslationOrThrow("Hello {user}", lang);
return greetingTemplate.replace("{user}", name);
}
// --- ALSO CORRECT: .catch() is honoured, your fallback runs, nothing crashes ---
awaitForTranslationOrThrow("Processing complete.", "es")
.then(message => {
console.log(message); // Output: "Procesamiento completo."
})
.catch(error => {
console.error("Failed to get processing message:", error);
// Handle error, maybe use fallback text
});
// DO NOT DO THIS:
awaitForTranslationOrThrow("This will crash if it rejects!", "de");
// ALSO DO NOT DO THIS (assigning promise without handling rejection):
const promise = awaitForTranslationOrThrow("This also crashes if it rejects!", "it");Fetch all translations for all supported languages:
import { getAllTranslationsForAllLanguages } from "i18n-keyless-node";
// Fetch and update translation store with latest translations
const response = await getAllTranslationsForAllLanguages(store);
if (response?.ok) {
// Handle successful translation update
console.log("Translations updated successfully");
}By default, all of a project's translations live in one bucket. On large projects that can
overflow browser storage (Setting the value of 'i18n-keyless-translations' exceeded the quota) and means every client downloads everything.
A namespace splits translations into independent slices: each namespace is fetched and persisted on its own, so a client only downloads and stores the parts it actually renders.
// React β per call (component or function)
<I18nKeylessText namespace="checkout">Pay now</I18nKeylessText>
getTranslation("Pay now", { namespace: "checkout" });
// Node
await awaitForTranslationOrThrow("Pay now", "fr", { namespace: "checkout" });Set a default namespace once in init (a per-call namespace always overrides it):
init({
API_KEY: "<YOUR_API_KEY>",
storage: myStorage,
defaultNamespace: "app-ui",
languages: { primary: "en", supported: ["en", "fr", "es"] },
});For high-cardinality, transient namespaces β e.g. one namespace per discussion in a
chat/community β add unpersistedNamespace. Those translations stay in memory only: never
written to storage, never reloaded at boot, never refetched on language change. So opening
hundreds of discussions adds zero storage weight and zero boot cost.
<I18nKeylessText namespace={`discussion-${id}`} unpersistedNamespace>
{message}
</I18nKeylessText>
unpersistedNamespaceis a client-storage concern only; it has no effect ini18n-keyless-node(the node store is in-memory regardless).
Namespaces are backward compatible: the default namespace reuses the existing storage keys,
so apps that don't use namespaces are unchanged. Self-hosted backends only need to handle an
optional namespace on the translate routes β see
Using your own API.
Translate text your users wrote, not just text you wrote β a review, a comment, a chat
message β with the same <T> you already use.
Pass originLanguage to say what language the text arrived in:
<T originLanguage="es">Hola mundo</T>Every reader sees it in their own language. A French reader gets "Bonjour le monde", an English reader "Hello world", and a Spanish reader gets the original text back untouched β never a round-trip through a translation.
It works with the imperative API and on the server too:
getTranslation(review.body, { originLanguage: review.lang });
// node
await awaitForTranslationOrThrow(review.body, "en", { originLanguage: review.lang });How it stays cheap. The row is keyed by the primary-language version, so the same sentence submitted by ten users costs one translation. Text already seen is recognised by its original wording, never re-translated. Pair it with an unpersisted namespace when the content is high-cardinality and short-lived:
<T originLanguage={msg.lang} namespace={`chat-${roomId}`} unpersistedNamespace>
{msg.body}
</T>Render fully translated HTML on the server β real content for crawlers and a first paint with no flash of untranslated text.
Wrap the tree in a provider and hand it the language for this request:
import { I18nKeylessProvider, getServerTranslations } from "i18n-keyless-react";
export async function handler(request) {
const lang = langFromUrl(request); // /en/about -> "en"
const translations = await getServerTranslations(lang);
return renderToString(
<I18nKeylessProvider lang={lang} translations={translations}>
<App />
</I18nKeylessProvider>
);
}<T> reads the provider first and falls back to the store, so SPA mode is untouched β
adding SSR to an existing app changes nothing about how it already works.
- No cross-request leaking. The language lives in per-render context, not in the process-wide store, so concurrent requests in different languages cannot mix.
getTranslation()works too. A plain function cannot read React context, so seed the store once in your client entry withhydrateFromServer({ lang, translations })beforehydrateRoot.- Less traffic than a SPA, not more. Usage analytics are suppressed on the server, and a long-lived process fetches each language once per boot.
- Edge-safe. Request scoping uses
AsyncLocalStoragewhen available and degrades to a no-op when it is not, instead of crashing.
Full reference, including per-request scoping with runWithI18nKeyless, in
docs/SSR.md.
i18n-keyless covers the 50 App Store
localizations,
as 48 language codes. Any of them can be your primary.
ar Arabic |
bn Bangla |
ca Catalan |
zh-Hans Chinese (Simplified) |
zh-Hant Chinese (Traditional) |
hr Croatian |
cs Czech |
da Danish |
nl Dutch |
en English |
en-GB English (U.K.) |
fi Finnish |
fr French |
fr-CA French (Canada) |
de German |
el Greek |
gu Gujarati |
he Hebrew |
hi Hindi |
hu Hungarian |
id Indonesian |
it Italian |
ja Japanese |
kn Kannada |
ko Korean |
ms Malay |
ml Malayalam |
mr Marathi |
no Norwegian |
or Odia |
pl Polish |
pt Portuguese |
pt-BR Portuguese (Brazil) |
pa Punjabi |
ro Romanian |
ru Russian |
sk Slovak |
sl Slovenian |
es Spanish |
es-MX Spanish (Latin America) |
sv Swedish |
ta Tamil |
te Telugu |
th Thai |
tr Turkish |
uk Ukrainian |
ur Urdu |
vi Vietnamese |
A bare language code matches every region of that language: fr covers fr-FR, fr-CA,
fr-BE and fr-CH at once. Adding a region narrows it. So we only regionalize where the
translation is genuinely different text:
zh-Hans/zh-Hantβ a script, not a region. There is no barezh: Simplified and Traditional aren't mutually readable.pt-BRβ Brazilian vocabulary differs from European Portuguese in everyday UI words (usuΓ‘rio/utilizador, arquivo/ficheiro, tela/ecrΓ£).es-MXβ Latin American Spanish (computadora/ordenador, celular/mΓ³vil).fr-CAβ QuΓ©bec French.en-GBβ British spelling.
You're billed per language you opt into, so ['pt'] is one translation and
['pt', 'pt-BR'] is two. Start bare; add a variant when you actually want that second
translation.
resolveLang maps any BCP-47 tag β navigator.language,
Localization.getLocales()[0].languageTag, an Accept-Language entry β onto a language you
ship, most specific first:
import { resolveLang } from 'i18n-keyless-core';
resolveLang('pt-BR'); // 'pt-BR'
resolveLang('pt-AO'); // 'pt' β no Angolan variant, falls back to the bare language
resolveLang('zh-TW'); // 'zh-Hant'
resolveLang('es-419'); // 'es-MX'
// Pass `supported` so you only ever get a language you actually ship
resolveLang(navigator.language, { supported: ['pt', 'en'], fallback: 'en' });
// 'pt-BR' device β 'pt'App Store Connect has no bare slots β it wants fr-FR, not fr. toAppStoreLocale maps a
language onto its listing slot:
import { toAppStoreLocale } from 'i18n-keyless-core';
toAppStoreLocale('fr'); // 'fr-FR'
toAppStoreLocale('en'); // 'en-US'
toAppStoreLocale('pt'); // 'pt-PT'
toAppStoreLocale('pt-BR'); // 'pt-BR'Apple's en-AU and en-CA slots have no dedicated language β fill them from en, or opt
into en-GB for British spelling.
While the Quick Start uses the i18n-keyless service via API_KEY, you have other options:
This is the easiest way to get started. Provide your API_KEY during initialization as shown in the Quick Start guides.
(React Setup Example - Covered in Quick Start)
(Node Setup Example - Covered in Quick Start)
If you prefer to host your own translation backend, you can configure i18n-keyless to point to your API endpoints.
To use your own API, you need to provide the API_URL in the init configuration. Your API must implement the following routes:
-
GET /translate/:lang: This route should return all translations for a given language. If a?namespace=<ns>query param is present (see Namespaces), return only that namespace's translations; when absent, return the default bucket (for non-namespaced projects that's everything β unchanged behaviour). Response format to GET /translate/en:{ "ok": true, "data": { "translations": { "Bonjour le monde": "Hello world", "Bienvenue chez nous": "Welcome to our website", "Au revoir": "Goodbye" } }, "error": null, "message": "" // there would be a message if the key is not valid, or whatever } -
POST /translate: This route should accept a body with the key to translate and return the translated text. Request body:{ "key": "Bonjour le monde", "languages": ["en","nl","it","de","es"], "primaryLanguage": "fr" }The body may also include an optional
"namespace"(see Namespaces) β store the key under it; absent β default bucket. It is omitted from the request when the namespace is the default, so non-namespaced apps send the exact body above.Response format:
{ "ok": true, "message": "", // there would be a message if the key is not valid, or whatever "data": { "translation": { "fr": "Bonjour tout le monde", "en": "Hello world" } } }
Here's how to configure with your API_URL:
// For React
import { init } from "i18n-keyless-react";
import myStorage from "./src/services/storage";
init({
API_URL: "https://your-api.com",
storage: myStorage,
languages: {
primary: "fr",
supported: ["en", "fr"],
},
});
// For Node.js
import { init } from "i18n-keyless-node";
await init({
API_URL: "https://your-api.com",
languages: {
primary: "fr",
supported: ["en", "fr"],
},
});Alternatively, you can provide custom functions to handle the translation and retrieval of all translations:
// For React
import { init } from "i18n-keyless-react";
import myStorage from "./src/services/storage";
async function handleTranslate(key, languages, primaryLanguage) {
// Your custom logic to translate the key
return { ok: true, message: "" };
}
async function getAllTranslations(lang) {
// Your custom logic to fetch all translations for a specific language
return {
ok: true,
data: {
translations: {
"Bonjour le monde": "Hello world",
}
}
};
}
init({
storage: myStorage,
languages: {
primary: "fr",
supported: ["en", "fr"],
},
handleTranslate: handleTranslate,
getAllTranslations: getAllTranslations
});
// For Node.js
import { init } from "i18n-keyless-node";
async function handleTranslate(key, languages, primaryLanguage) {
// Your custom logic to translate the key
return { ok: true, message: "" };
}
async function getAllTranslationsForAllLanguages() {
// Your custom logic to fetch translations for all languages
return {
ok: true,
data: {
translations: {
en: {
"Bonjour le monde": "Hello world"
},
fr: {}
}
}
};
}
await init({
languages: {
primary: "fr",
supported: ["en", "fr"],
},
handleTranslate: handleTranslate,
getAllTranslationsForAllLanguages: getAllTranslationsForAllLanguages
});Every package and port speaks the same wire protocol to the same API, so a project can mix them (a Laravel backend and a Vue front end, a Flutter app and a Node cron) on one API key and one dashboard, and an app migrating from one package to another keeps its cache and its device id.
docs/PROTOCOL.md: the language-neutral specification. Endpoints, headers, timeout and retry, thekey__contextstorage format, the queue, ETag replay, usage analytics, identity (sdkandunique_id), the 48 language codes. Verified against the API source.conformance/: JSON test vectors that every SDK replays. The TypeScript core, the Laravel port and the Flutter port run them in their test suites.docs/PORT_CHECKLIST.md: what a new port (Python, Ruby, ...) must ship before it is called conformant.
Runtime labels sent in the sdk header: react-client / react-server, vue-client /
vue-server, angular-client / angular-server, browser, node, laravel, flutter.
A *-server label, node and laravel are servers (counted by connection, no device id);
everything else is a device.
For better integration and consistency, wrap I18nKeylessText within your own custom text component:
import * as I18nKeyless from 'i18n-keyless-react';
import type { I18nKeylessTextProps } from 'i18n-keyless-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
I18nKeyless.init({
API_KEY: 'API_KEY',
storage: window.localStorage,
languages: {
primary: 'en',
supported: [ 'en', 'fr', /* 'es', 'pt', 'ar', 'de', 'it', 'ja', 'ko', 'nl', 'pl', 'ro', 'hu', 'ru', 'sv', 'tr', 'zh-Hans', 'cs', 'el', β¦ */ ],
},
});
export default function MyText({
children,
i18nProps,
}: {
children: string;
i18nProps?: I18nKeylessTextProps;
}) {
// getTranslation does not subscribe to the store, so subscribe here.
// Without this, the text keeps the previous language after a language switch.
I18nKeyless.useCurrentLanguage();
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
// put your custom components - all the default italic/bold etc. are already setup in the lib
strong: ({ ...props }) => <span className="text-neo-pink" {...props} />,
}}
>
{I18nKeyless.getTranslation(children, i18nProps)}
</ReactMarkdown>
);
}You could also put markdown the same way here
import { StyleProp, Text, TextProps, TextStyle } from "react-native";
import { I18nKeylessText, type I18nKeylessTextProps } from "i18n-keyless-react";
import { colors } from "~/utils/colors";
interface MyTextProps {
className?: string;
style?: StyleProp<TextStyle>;
color?: keyof typeof colors;
textProps?: TextProps;
skipTranslation?: boolean;
children: I18nKeylessTextProps["children"];
debug?: I18nKeylessTextProps["debug"];
context?: I18nKeylessTextProps["context"];
replace?: I18nKeylessTextProps["replace"];
forceTemporary?: I18nKeylessTextProps["forceTemporary"];
}
export default function MyText({
className,
style = {},
children,
color = "app-white",
textProps,
skipTranslation = false,
debug = false,
context,
replace,
forceTemporary,
}: MyTextProps) {
if (skipTranslation) {
if (debug) {
console.log("skipTranslation", children);
}
return (
<Text
className={["text-dark dark:text-white", className].join(" ")}
style={[style, { color: color ? colors[color] : undefined }]}
{...textProps}
>
{children}
</Text>
);
}
if (debug) {
console.log("children translated", children);
}
return (
<Text
className={["text-dark dark:text-white", className].join(" ")}
style={[style, { color: color ? colors[color] : undefined }]}
{...textProps}
>
<I18nKeylessText
context={context}
replace={replace}
forceTemporary={forceTemporary}
debug={debug}
>
{children}
</I18nKeylessText>
</Text>
);
}Multiple pains exist with the current i18n solutions.
| Pain Point | Traditional i18n | i18n-keyless |
|---|---|---|
| Key Management | Manual key creation & maintenance required | No keys needed - use natural language directly |
| Translation Management | Manual tracking of missing translations across languages | Automatic translation handling via AI |
| Code Readability | Read cryptic keys like "user.welcome.message" |
Read actual text like "Welcome to our app!" |
| Setup Time | Hours of dev setup + ongoing maintenance | Minutes to initialize |
| Cost | ~$1600 for 1000 keys (dev time) | $8/month for 1000 keys |
Today most of the systems use keys to translate the text:
{
"en": {
"hello": "Hello"
},
"fr": {
"hello": "Bonjour"
}
}This is painful to generate. This is painful to maintain.
When you see a text in the app, and you want to update it, you need to find the corresponding key, update the text, and make sure to not forget to update the key if needed.
With i18n-keyless, you don't care about the i18n system at all.
With the key system, you also need to manage the translations in the app. You need to not forget any. In all the languages you support. You need to check manually, or create a script to do it.
With i18n-keyless, you don't care about the i18n system at all.
With the key system, when you read the code and the content, you have to read keys, not natural language. So you don't really know what you are reading. Sometimes you should make a fix because a sentence is not grammatically correct. But you don't know that because you read keys, not natural language.
With i18n-keyless, you read natural language. So you know exactly what you are reading. And you can make sure the sentence is grammatically correct, in real time.
With basic i18n system on your own, you need at least to
- setup the keys' system: at least 1 hour of senior dev time
- back and forth for each new key: 1 minutes per key, x1000 keys = 1000 minutes = 16 hours
At 100$ per hour, that's 1600$ for 1000 keys.
With i18n-keyless.com, at 8$ a month for 1000 keys, you can afford 200 months of subscription.
You can setup your own system : it took me at least 1.5 day to make it strong enough, that would cost you at least 1200$ for
- handling translation with AI
- in several languages
- storage in DB
- retrieving translations from DB
- only the latest ones to make the service fast and efficient
- handling multiple languages
- maintaining the service
Need help or have questions? Reach out to:
- Twitter: @ambroselli_io
- Email: arnaud.ambroselli.io@gmail.com
Β© 2025 i18n-keyless