Skip to main content

How to migrate from react-intl to i18n-keyless

· 8 min read
Founder of i18n-keyless

If your app is on react-intl (FormatJS) and you've decided to switch to i18n-keyless, this guide walks through the migration end-to-end: dual-run strategy, message-by-message replacement, handling ICU MessageFormat, and finally removing react-intl.

If you haven't decided yet, read the comparison first — react-intl genuinely wins for ICU-heavy use cases.

TL;DR

  1. Install both libraries. They coexist without conflict.
  2. Import your existing translations (extracted JSON files) into the i18n-keyless backend so users don't see regressions.
  3. Stop adding new <FormattedMessage> components. New code goes keyless.
  4. Sweep file-by-file. Replace <FormattedMessage> with <I18nKeyless>, simplify ICU where possible, branch explicitly where needed.
  5. Remove react-intl. Drop the dependency, the <IntlProvider>, the locale loader, and any @formatjs/cli config.

Total time for a typical 500-message app: 1–3 sprints depending on ICU complexity.

Step 1: Install i18n-keyless alongside react-intl

npm install i18n-keyless-react
// In your app entry (App.tsx, main.tsx, or _app.tsx for Next.js)
import * as I18nKeyless from "i18n-keyless-react";

I18nKeyless.init({
API_KEY: "YOUR_API_KEY",
storage: window.localStorage,
languages: {
primary: "en", // mirror your react-intl default locale
supported: ["en", "fr", "de", "es"], // mirror IntlProvider's supported locales
},
});

Keep your existing <IntlProvider> in place. The two libraries don't conflict — they're separate React component trees that don't share state.

Step 2: Export and import existing translations

react-intl typically extracts messages via @formatjs/cli to JSON files like:

// extracted/en.json
{
"checkout.confirm.cta": {
"defaultMessage": "Confirm payment",
"description": "CTA on checkout page"
}
}

// translated/fr.json
{
"checkout.confirm.cta": "Confirmer le paiement"
}

You need to map: defaultMessage (English) → translated value (per locale).

import enExtracted from "./extracted/en.json";
import frTranslated from "./compiled/fr.json";
import deTranslated from "./compiled/de.json";

const records = Object.entries(enExtracted).map(([id, { defaultMessage }]) => ({
source: defaultMessage,
translations: {
fr: frTranslated[id] ?? null,
de: deTranslated[id] ?? null,
},
}));

// POST to the i18n-keyless API to pre-populate translations.
// Use your private_key for admin endpoints.

After this import, when your code switches from <FormattedMessage id="checkout.confirm.cta" defaultMessage="Confirm payment" /> to <I18nKeyless>Confirm payment</I18nKeyless>, the translation already exists in the keyless backend.

Step 3: Codify the rule

Update your team docs:

Stop adding new <FormattedMessage> components. New strings use <I18nKeyless>.

Disable any eslint-plugin-formatjs rules that enforce extraction on new strings (or add an exception for i18n-keyless-react).

Step 4: Sweep messages, batch by batch

Simple messages

// Before
<FormattedMessage id="hero.title" defaultMessage="Welcome to Acme" />

// After
<I18nKeyless>Welcome to Acme</I18nKeyless>

Messages with values (interpolation)

// Before
<FormattedMessage
id="welcome.user"
defaultMessage="Hello, {name}!"
values={{ name: user.name }}
/>

// After
<I18nKeylessText replace={{ '{name}': user.name }}>{`Hello, {name}!`}</I18nKeylessText>

Same {name} placeholder syntax in the source string — react-intl uses {name} and i18n-keyless does too by default (configurable). The big difference: replace keys in i18n-keyless include the literal placeholder including the braces ('{name}'), not just the bare variable name. See the value replacement guide.

Messages with nested formatters (FormattedNumber, FormattedDate)

// Before
<FormattedMessage
id="total"
defaultMessage="Total: {total}"
values={{
total: <FormattedNumber value={amount} style="currency" currency="USD" />,
}}
/>

// After
const formatted = new Intl.NumberFormat(currentLocale, {
style: "currency",
currency: "USD",
}).format(amount);

<I18nKeylessText replace={{ '{total}': formatted }}>{`Total: {total}`}</I18nKeylessText>

You're swapping react-intl's <FormattedNumber> wrapper for native Intl.NumberFormat (which is what react-intl uses internally anyway). Same engine, fewer abstractions.

The same pattern applies to <FormattedDate> (use Intl.DateTimeFormat) and <FormattedRelativeTime> (use Intl.RelativeTimeFormat).

useIntl hook

// Before
import { useIntl } from "react-intl";
function Component() {
const intl = useIntl();
const placeholder = intl.formatMessage({
id: "search.placeholder",
defaultMessage: "Search...",
});
return <input placeholder={placeholder} />;
}

// After
import { getTranslation } from "i18n-keyless-react";
function Component() {
return <input placeholder={getTranslation("Search...")} />;
}

getTranslation is the function-call equivalent of <I18nKeyless>.

ICU MessageFormat — plurals

This is where the migration takes thought. react-intl handles ICU plurals natively:

// Before
<FormattedMessage
id="cart.items"
defaultMessage="{count, plural, =0 {Your cart is empty} one {# item} other {# items}}"
values={{ count: items.length }}
/>

i18n-keyless doesn't have CLDR plural-rule resolution natively. You have two options:

Option A — explicit branching (recommended for binary plurals):

{items.length === 0 && <I18nKeyless>Your cart is empty</I18nKeyless>}
{items.length === 1 && <I18nKeyless>1 item</I18nKeyless>}
{items.length > 1 && (
<I18nKeylessText replace={{ '{count}': items.length }}>
{`{count} items`}
</I18nKeylessText>
)}

Option B — Intl.PluralRules for locales that need more than two forms:

const rules = new Intl.PluralRules(currentLocale);
const form = rules.select(items.length); // 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'

const messages: Record<string, string> = {
zero: "Your cart is empty",
one: "1 item",
other: `{count} items`,
};

return (
<I18nKeylessText replace={{ '{count}': items.length }}>
{messages[form] ?? messages.other}
</I18nKeylessText>
);

If your app has heavy ICU plural usage in non-English locales (Polish, Arabic, Russian), this is the part of the migration where you should pause and reconsider whether keyless is right for you. See the comparison for more on the trade-off.

ICU MessageFormat — select

// Before
<FormattedMessage
id="user.greeting"
defaultMessage="{gender, select, female {She} male {He} other {They}} liked your post"
values={{ gender: user.gender }}
/>

// After
const subject =
user.gender === "female" ? "She" :
user.gender === "male" ? "He" : "They";

<I18nKeylessText replace={{ '{subject}': subject }}>{`{subject} liked your post`}</I18nKeylessText>

Branch explicitly. This is more verbose but clearer in code review.

Step 5: Verify

After each sweep PR:

  1. Source-locale smoke test. All replaced strings render correctly.
  2. Target-locale smoke test. Switch to French / German / etc. — pre-imported translations show up; new strings fall back to source until AI translates them.
  3. Pluralization edge cases. Specifically test count = 0, 1, and >1 for any plural strings you simplified.

Step 6: Remove react-intl

When grep returns nothing:

grep -r "from 'react-intl'" src/
grep -r "from '@formatjs" src/
grep -r "FormattedMessage" src/
grep -r "useIntl" src/

Cleanup:

  • npm uninstall react-intl @formatjs/intl @formatjs/cli
  • Remove <IntlProvider> from your app entry
  • Delete extracted/ and compiled/ directories
  • Remove @formatjs/cli config from package.json
  • Drop FormatJS Babel/SWC plugins
  • Remove eslint-plugin-formatjs from your ESLint config

Common gotchas

Locale loading

react-intl typically loads compiled message catalogs at runtime (one bundle per locale). i18n-keyless doesn't ship locale files — translations come from the backend at first encounter and are cached. Remove your locale-loader code; it's no longer needed.

Locale-aware date/number/list formatting

react-intl wraps Intl.* APIs in components. After migration, just use Intl.* directly. There's no functionality loss.

Polyfills

If you used @formatjs/intl-numberformat, @formatjs/intl-pluralrules, etc. for older browser support — keep those if you still target old browsers. They polyfill native Intl.* APIs and are independent of react-intl.

Server-side rendering

If you SSR with react-intl (Next.js / Remix), use i18n-keyless-node for the server-side path. Use awaitForTranslation(text, lang) instead of <FormattedMessage> server-side.

Type-safe message IDs

react-intl + the formatjs/enforce-id lint rule gives you compile-time-checked message IDs. Keyless doesn't have this — there's no ID surface. Some teams replace it with a lightweight convention (e.g., a Strings constants file referenced by <I18nKeyless>) but this defeats some of the keyless benefit. Most teams just trust the source string.

How long does it take?

App size & ICU complexityMigration time
100 messages, mostly simple3–5 days
500 messages, some plurals2 weeks
2,000 messages, heavy ICU1–2 months — and reconsider whether keyless is right
10,000+ messagesProbably stay on react-intl

FAQ

Will my message IDs be lost?

The IDs themselves aren't preserved (there are no IDs in keyless). But every translation associated with an ID is preserved — they're keyed by the source string after import.

Do I need to migrate all messages at once?

No. Dual-run is the entire point. Migrate file-by-file or directory-by-directory. Each PR is independently shippable.

What about the description field?

react-intl's description field (the translator note) doesn't have a direct equivalent. If a string is ambiguous and needs context, use the context feature on a per-string basis.

Can I keep using FormatJS polyfills with i18n-keyless?

Yes — FormatJS's Intl.* polyfills are independent of react-intl. Use them freely with keyless if you support old browsers.

What if I rely heavily on Intl.RelativeTimeFormat?

Use it directly: new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-1, 'day'). That's the same API react-intl wraps in <FormattedRelativeTime>.

Next steps