How to migrate from i18next to i18n-keyless
If you've decided to move from i18next to i18n-keyless, this is the practical guide. We'll cover the dual-run strategy (so you can ship the migration incrementally), code transformations, gotchas around namespaces / interpolation / pluralization, and how to handle existing translation files.
Read the comparison article first if you haven't decided yet.
TL;DR
- Don't big-bang. Install both libraries, run them in parallel for a sprint or two.
- New strings → keyless. All new code uses
<I18nKeyless>from day one of the migration. - Sweep old strings file-by-file. Each PR replaces a batch of
t("key")calls with<I18nKeyless>. Existing translations fromfr.jsonetc. are imported into the keyless backend so users see no regression. - Remove i18next once the codebase is clean. Single PR removes the dependency and the locale JSON files.
Total time for a typical 500-string app: 1–3 sprints, depending on team size.
Step 1: Install i18n-keyless alongside i18next
Don't remove anything yet. Just add:
npm install i18n-keyless-react
Initialize it in your app entry (probably main.tsx or App.tsx):
import * as I18nKeyless from "i18n-keyless-react";
I18nKeyless.init({
API_KEY: "YOUR_API_KEY",
storage: window.localStorage,
languages: {
primary: "en", // or whatever your i18next fallbackLng was
supported: ["en", "fr", "de"], // mirror your i18next supportedLngs
},
});
See the quick-setup guide for storage options (IndexedDB, MMKV, etc.).
The two libraries don't fight — i18next continues to handle existing t() calls; i18n-keyless handles new <I18nKeyless> components. Both ship in your bundle during the migration window.
Step 2: Import existing translations into the keyless backend
This is the bit most teams worry about: "I don't want to lose my existing translations." You won't.
i18n-keyless has a manual translation feature. You can pre-populate the backend with your existing translations so that when the SDK encounters a string, it serves your pre-translated value instead of generating a new one via AI.
The mapping you need: for each string in your i18next locale files, you know:
- The English source (
en.json) - The French translation (
fr.json) - The German translation (
de.json) - Etc.
Write a one-time import script:
import en from "./locales/en/common.json";
import fr from "./locales/fr/common.json";
import de from "./locales/de/common.json";
function flatten(obj: any, prefix = ""): Record<string, string> {
const out: Record<string, string> = {};
for (const k of Object.keys(obj)) {
const v = obj[k];
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string") out[key] = v;
else Object.assign(out, flatten(v, key));
}
return out;
}
const enFlat = flatten(en);
const frFlat = flatten(fr);
const deFlat = flatten(de);
const records = Object.entries(enFlat).map(([key, sourceText]) => ({
source: sourceText,
translations: {
fr: frFlat[key] ?? null,
de: deFlat[key] ?? null,
},
}));
// POST records to the i18n-keyless API to pre-populate translations
// (use your private_key for admin endpoints)
Once imported, when your code switches from t("hero.title") to <I18nKeyless>Welcome to Acme</I18nKeyless>, the keyless backend already knows that "Welcome to Acme" → "Bienvenue chez Acme" in French. No regression for users.
Step 3: Codify the migration rule
Add to your CONTRIBUTING.md or team docs:
All new strings use
<I18nKeyless>. Don't add new keys to i18next locale files.
This stops the bleed. The set of i18next strings becomes finite and only shrinks.
Step 4: Sweep old strings, batch by batch
Pick a directory or component tree. Replace its t() calls with <I18nKeyless>. Ship. Repeat.
Simple cases
// Before
import { useTranslation } from "react-i18next";
function Hero() {
const { t } = useTranslation();
return <h1>{t("hero.title")}</h1>;
}
// After
import { I18nKeyless } from "i18n-keyless-react";
function Hero() {
return <h1><I18nKeyless>Welcome to Acme</I18nKeyless></h1>;
}
Drop the useTranslation hook. Drop the t import. Inline the source string.
Interpolation
// Before
t("greeting", { name: user.name }) // "Hello {{name}}"
// After
<I18nKeylessText replace={{ '{name}': user.name }}>{`Hello {name}`}</I18nKeylessText>
i18n-keyless uses {name} placeholders by default (the placeholder syntax is configurable — {name}, {{name}}, [name], %name%, etc. — see the value replacement guide). The keys in replace are the literal placeholder string including the braces — not the bare variable name.
Functions / non-JSX contexts
If you need a translated string outside JSX (e.g., for aria-label, title, or third-party components):
// Before
<TabBar>
<Tab label={t("tabs.home")} />
</TabBar>
// After
import { getTranslation } from "i18n-keyless-react";
<TabBar>
<Tab label={getTranslation("Home")} />
</TabBar>
getTranslation is the function-call equivalent of <I18nKeyless>.
Pluralization
This is the trickiest case. i18next has built-in plural rules:
// Before
t("itemCount", { count: items.length });
// en.json: { "itemCount_one": "1 item", "itemCount_other": "{{count}} items" }
i18n-keyless doesn't have CLDR-style plural rule resolution natively. You have two patterns:
Pattern A — explicit branching (recommended for simple plurals):
// After
{items.length === 0 && <I18nKeyless>No items</I18nKeyless>}
{items.length === 1 && <I18nKeyless>1 item</I18nKeyless>}
{items.length > 1 && (
<I18nKeylessText replace={{ '{count}': items.length }}>
{`{count} items`}
</I18nKeylessText>
)}
Pattern B — use Intl.PluralRules for complex plurals:
const pluralRules = new Intl.PluralRules(currentLocale);
const form = pluralRules.select(items.length); // 'zero' | 'one' | 'few' | 'many' | 'other'
const messages = {
one: "1 item",
other: `{count} items`,
};
return (
<I18nKeylessText replace={{ '{count}': items.length }}>
{messages[form] ?? messages.other}
</I18nKeylessText>
);
If your app has heavy plural needs in non-English locales, weigh whether keyless is the right tool or whether i18next's ICU-style plurals are worth keeping. See the comparison article for the trade-off.
Namespaces
i18next namespaces don't have a direct equivalent in keyless — there are no keys to namespace. The migration drops them entirely. Instead, if you want to track origin, use the context feature to disambiguate strings that have the same source but different meanings (e.g., "Open" as a button vs "Open" as a status).
Step 5: Verify the cutover
After each sweep PR:
- Smoke-test in the source language. The strings should render correctly because they're coming straight from JSX.
- Switch to a non-source locale. Verify the imported translations show up. If a translation is missing, the source string falls back gracefully and a new translation is queued via AI.
- Check the dashboard. New strings appearing means your sweep is exposing new content — fine, but worth a glance to make sure nothing is wildly off.
Step 6: Remove i18next
When grep returns nothing:
grep -r "from 'react-i18next'" src/
grep -r "from 'i18next'" src/
grep -r "useTranslation" src/
You can delete:
npm uninstall i18next react-i18next i18next-browser-languagedetector i18next-http-backend- Your
i18n.tsconfig file - The
<I18nextProvider>wrapper in your app entry public/locales/**directory- Any i18next-related Babel/SWC plugins
Ship one final PR titled "Remove i18next." Celebrate.
Common gotchas
Locale detection
i18next has its own locale-detection plugin (i18next-browser-languagedetector) that reads from navigator.language, cookies, localStorage, etc. i18n-keyless reads the user's locale from your config (typically you control it via your own logic — Zustand store, URL param, user preference, etc.). Make sure your locale-switching logic is wired up to set the locale on the i18n-keyless SDK during the migration, not just on i18next.
Suspense / loading states
i18next has react.useSuspense and various loading patterns. i18n-keyless renders the source string while waiting for a translation, so there's no loading state to handle — it just shows English (or your primary language) until the translated value arrives, then re-renders.
Linting and key-reference checks
If you use eslint-plugin-i18next or @formatjs/eslint-plugin to validate keys, those rules become irrelevant after migration. Remove them in the cleanup PR.
Server-side rendering
If you use Next.js / Remix with i18next on the server, you'll want to install i18n-keyless-node for the SSR path. See the Node.js setup for the equivalent server-side initialization.
How long does this take?
Rough estimates for a single engineer doing it part-time:
| App size | Migration time |
|---|---|
| ~100 strings | 1 week |
| ~500 strings | 2–3 weeks |
| ~2,000 strings | 1–2 months (sweep file-by-file) |
| ~10,000+ strings | Consider whether keyless is the right move at all — at this scale, TMS workflows might be more appropriate |
FAQ
Will users notice the migration?
Not if you import existing translations first (Step 2). Cached translations serve the same values; the only behavior change is that new strings will be AI-translated rather than waiting on a TMS pass.
Can I undo it?
Yes — until you delete the i18next config and locale files. The dual-run window is fully reversible.
What if I have some strings I don't want auto-translated?
Mark them as "manual only" in the dashboard or pre-populate them via the import script. The AI will not overwrite manually-set translations.
What about my CI / TMS integrations?
Continuous-localization Crowdin/Lokalise integrations become unnecessary post-migration — you'd disable them in the cleanup PR.
Do I need to update tests?
If your tests assert on specific translated strings, yes — update them to assert on the source-language string (which is what <I18nKeyless> renders by default in test environments without a configured backend). Or mock the SDK.
Next steps
- Compare with your current setup: i18n-keyless vs i18next.
- Read the philosophy: The complete guide to keyless i18n.
- Set up the SDK: Quick setup.
- Add context for ambiguous strings: Add context guide.