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 — the two libraries cover the same CLDR plural rules from opposite directions.

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 <I18nKeylessText>, map {count, plural, ...} to count and {gender, select, ...} to select. Zero-specific display text ("Your cart is empty") becomes a conditional in the component.
  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 <I18nKeylessText>Confirm payment</I18nKeylessText>, 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 <I18nKeylessText>.

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
<I18nKeylessText>Welcome to Acme</I18nKeylessText>

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 <I18nKeylessText>.

ICU MessageFormat — plurals

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 maps {count, plural, ...} to one source form plus a count prop. Write the sentence once, in the "other" shape, and pass the number:

<I18nKeylessText count={items.length}>{"{count} items"}</I18nKeylessText>

The model writes every CLDR form each language needs (French 2, Russian/Polish 4, Arabic 6), validated against Intl.PluralRules before they're saved — see the plurals and genders guide. Zero-specific display text ("Your cart is empty" instead of "0 items") is display logic, not a plural rule — handle it with a conditional:

{items.length === 0 ? (
<I18nKeylessText>Your cart is empty</I18nKeylessText>
) : (
<I18nKeylessText count={items.length}>{"{count} items"}</I18nKeylessText>
)}

This is the standard pattern — zero-specific text is a display concern, not a localization one.

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
<I18nKeylessText select={{ gender: user.gender }}>They liked your post</I18nKeylessText>

select maps {gender, select, ...} directly: pass the current value, and the model writes one variant per value in every language, with the agreement that language needs. Values are identifiers (male, female, admin), at most 10 per variable and 2 variables per sentence — see the plurals and genders guide.

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 awaitForTranslationOrFallbackToOriginal(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 <I18nKeylessText>) 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, deeply integrated with FormatJS tooling1–2 months
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