Skip to main content

How to migrate from i18next to i18n-keyless

· 9 min read
Founder of 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

  1. Don't big-bang. Install both libraries, run them in parallel for a sprint or two.
  2. New strings → keyless. All new code uses <I18nKeyless> from day one of the migration.
  3. Sweep old strings file-by-file. Each PR replaces a batch of t("key") calls with <I18nKeyless>. Existing translations from fr.json etc. are imported into the keyless backend so users see no regression.
  4. 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:

  1. Smoke-test in the source language. The strings should render correctly because they're coming straight from JSX.
  2. 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.
  3. 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.ts config 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 sizeMigration time
~100 strings1 week
~500 strings2–3 weeks
~2,000 strings1–2 months (sweep file-by-file)
~10,000+ stringsConsider 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