Intl-T

Migration

Move from next-intl, i18next or react-intl — the API map, file by file, and the sharp edges up front

Your message files come with you unchanged. Almost all of the work is in three files, and this page maps every API you are likely to be using.

It assumes you know what a node is — that page is the one that makes the rest click.

Should you migrate?

Established i18n libraries are good at what they do — be honest about the trade. They are battle-tested at a scale Intl-T is not yet, their ecosystems are larger (more docs, more answered questions, first-class Crowdin / Tolgee / Lokalise workflows), and those built on intl-messageformat are strictly ICU-compliant, quoting rules included. Two things some of them ship that Intl-T does not have:

  • Localized pathnames (/en/about/es/acerca-de). There is no Intl-T equivalent.
  • A dedicated formatter API surface (useFormatter-style hooks with now/timezone state).

If you depend on either, or on a translation-management pipeline built around your current library, staying put is a reasonable call.

What you get by switching:

  • Your JSON is the API. t.HomePage.welcome({ user }) — autocomplete at every depth, and the variables of that message inferred from that message. No key registry, no global.d.ts augmentation, no codegen step.
  • Routing is decoupled from translation. createTranslation and createNavigation know nothing about each other. Adopt the tree without the router, or run the router with no [locale] segment at all.
  • Zero third-party runtime dependencies, and no plugin in next.config.ts.
  • One API everywhere. App Router, Pages Router, a Vite SPA and plain Node all use the same tree.
  • Another locale is just another branch: t.es.HomePage.title, without switching the app's locale.

API map

Everything below assumes i18n/translation.ts and i18n/navigation.ts from Quick Start.

Translating

next-intlIntl-T
useTranslations("HomePage")useTranslation("HomePage")useTranslations also exists as an alias
await getTranslations("HomePage")await getTranslation("HomePage")getTranslations also exists as an alias
t("title")t.titleor t("title") when the key is dynamic
t("welcome", { user })t.welcome({ user })or t("welcome", { user })
t.rich("terms", { link })t("terms", { link })see Rich text — there is no .rich
t.raw("key")t.key.nodethe uninjected message
useLocale()useLocale()from i18n/navigation.ts, not from createTranslation — see Gotchas
useFormatter()ICU in the message{amount, number, currency}, {date, date, long} — or import { format } from "intl-t/format"

Providing

next-intlIntl-T
NextIntlClientProvider<Translation locale={locale}>from i18n/translation.ts
getMessages()not needed: the tree is imported once and shared by server and client
setRequestLocale(locale)await setRequestLocale(locale)from intl-t/next; it is async
getRequestConfig (i18n/request.ts)delete the file; createTranslation({ locales }) replaces it
createNextIntlPlugin()delete it; there is no Next.js plugin

Routing

next-intlIntl-T
defineRouting({ … }) (i18n/routing.ts)createNavigation({ … })one config, one file — no separate routing module
createNavigation(routing)createNavigation({ … })the same call also returns the proxy and generateStaticParams
locales, defaultLocaleallowedLocales, defaultLocaleuse as const on the array
localePrefix: "always"pathPrefix: "always"
localePrefix: "as-needed"pathPrefix: "default"the default
localePrefix: "never"pathPrefix: "hidden"add strategy: "request" to drop the [locale] folder entirely
pathnames: { … }not supported
<Link href="/about" locale="es"><Link href="/about" locale="es">from i18n/navigation.ts
redirect("/about")redirect("/about")from i18n/navigation.ts
usePathname()usePathname()locale prefix stripped, same as next-intl
useRouter()useRouter()router.push("/about", { locale: "es" })
createMiddleware(routing)proxy / middlewarecreateNavigation returns both; see below
middleware.ts + config.matcherproxy.ts + config.matcherNext 16 renamed the file
messages/*.jsonmessages/*.jsonunchanged — see Message files

File by file

messages/en.json
i18n/routing.ts
i18n/navigation.ts
i18n/request.ts
middleware.ts
next.config.ts
i18n/messages/en.json
i18n/navigation.ts
i18n/translation.ts
proxy.ts
next.config.ts
next-intl filebecomes
messages/*.jsonunchanged
i18n/routing.ts + i18n/navigation.tsone i18n/navigation.ts
i18n/request.tsi18n/translation.ts
middleware.tsproxy.ts (Next 16) or middleware.ts (Next ≤ 15)
next.config.ts plugindeleted

1. i18n/request.tsi18n/translation.ts

getRequestConfig disappears. There is no per-request message loading to configure: you import the tree once, and it is the same tree on the server and on the client.

i18n/translation.ts
import { createTranslation } from "intl-t/next";
import en from "./messages/en.json";
import es from "./messages/es.json";

export const { Translation, useTranslation, getTranslation, t } = createTranslation({
  locales: { en, es },
});

To load locales on demand instead, pass loaders — see Dynamic Import.

2. i18n/routing.ts + i18n/navigation.tsi18n/navigation.ts

One config produces the proxy, the navigation helpers and generateStaticParams.

i18n/routing.ts
import { defineRouting } from "next-intl/routing";

export const routing = defineRouting({
  locales: ["en", "es"],
  defaultLocale: "en",
  localePrefix: "as-needed",
});
i18n/navigation.ts
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";

export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);
i18n/navigation.ts
import { createNavigation } from "intl-t/next";

export const { proxy, Link, redirect, usePathname, useRouter, useLocale, generateStaticParams } = createNavigation({
  allowedLocales: ["en", "es"] as const,
  defaultLocale: "en",
  pathPrefix: "default", // = localePrefix: "as-needed"
});

3. middleware.tsproxy.ts

createNavigation returns the same function under both names, so on Next ≤ 15 only the file name and the export differ.

proxy.ts
export { proxy } from "@/i18n/navigation";

export const config = {
  matcher: "/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)",
};
middleware.ts
export { middleware as default } from "@/i18n/navigation";

export const config = {
  matcher: "/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)",
};

Chaining your own logic works the same way — withProxy (or withMiddleware) hands you the response Intl-T already built:

proxy.ts
import { withProxy } from "@/i18n/navigation";

export const proxy = withProxy(async (req, ev, response) => {
  // `response` already carries the resolved locale (headers + cookie)
  return response;
});

export const config = {
  matcher: "/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)",
};

4. The layout

NextIntlClientProvider becomes <Translation>, and there is nothing to pass it but the locale.

app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";

export default async function RootLayout({ children, params }) {
  const { locale } = await params;
  setRequestLocale(locale);
  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
      </body>
    </html>
  );
}
app/[locale]/layout.tsx
import { Translation } from "@/i18n/translation";
import { setRequestLocale } from "intl-t/next";

export { generateStaticParams } from "@/i18n/navigation";

export default async function RootLayout({ children, params }: { children: React.ReactNode; params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  await setRequestLocale(locale);

  return (
    <html lang={locale}>
      <body>
        <Translation locale={locale}>{children}</Translation>
      </body>
    </html>
  );
}

5. The components

app/[locale]/page.tsx
import { getTranslation } from "@/i18n/translation";

export default async function Page() {
  const t = await getTranslation("HomePage");

  return (
    <>
      <h1>{t.title}</h1>
      <p>{t.welcome({ user: "Ada" })}</p>
    </>
  );
}
components/hero.tsx
"use client";

import { useTranslation } from "@/i18n/translation";

export function Hero() {
  const t = useTranslation("HomePage");

  return (
    <>
      <h1>{t.title}</h1>
      <p>{t.welcome({ user: "Ada" })}</p>
    </>
  );
}

The namespace argument is the same string you passed to useTranslations. What comes back is the node at that path, so t("title") still works — but t.title is the form that is typed and autocompleted.

Message files

They carry over unchanged. Nesting becomes the tree, and ICU is supported: plural, select, selectordinal, number, date all render as they did.

i18n/messages/en.json
{
  "HomePage": {
    "title": "Hello world!",
    "welcome": "Welcome, {user}!",
    "followers": "{count, plural, =0 {No followers yet} =1 {One follower} other {# followers}}",
    "invited": "{gender, select, female {She} male {He} other {They}} invited you",
    "price": "{amount, number, currency}",
  },
}

One thing must be checked before you ship: reserved keys.

Reserved keys

A node uses a few names for itself, so they cannot be message keys — at any depth. The full list is in The Tree: base, values, children, parent, node, path, settings, key, default, catch, then, global, t, variables, current, and any locale code (en, es, …).

A collision never renders your text — you get undefined, an internal value, or a TypeError. Rename the key on both sides of the migration: "settings""preferences", "current""active", "key""label".

This script lists every collision in a message file before you start:

scripts/check-keys.ts
import { invalidKeys } from "intl-t";
import en from "../i18n/messages/en.json";

const reserved = new Set<string>([...invalidKeys, "en", "es"]); // + your locale codes

(function walk(node: unknown, path: string[] = []) {
  if (typeof node !== "object" || node === null) return;
  for (const [key, value] of Object.entries(node)) {
    if (reserved.has(key)) console.log("reserved:", [...path, key].join("."));
    walk(value, [...path, key]);
  }
})(en);

Two smaller differences, both rare in practice:

  • ICU quoting is not implemented. "Escaped '{name}' braces" injects name and keeps the quotes, where strict ICU would print a literal {name}. If you use '…' to escape braces, rewrite those messages.
  • A missing key renders its own path. t("does.not.exist") renders "does.not.exist" rather than throwing — handy for spotting a key you renamed and forgot.

Rich text

t.rich() does not exist. It does not need to: tags written in a message are rendered as React elements by the normal call, and a variable whose name matches a tag replaces that tag with your component.

// "terms": "Please accept the <link>terms</link>."
t.rich("terms", {
  link: chunks => <Link href="/terms">{chunks}</Link>,
});
// "terms": "Please accept the <link>terms</link>."   ← same message
t("terms", {
  link: ({ children }) => <Link href="/terms">{children}</Link>,
});

The handler is a component, not a chunks callback: it receives props, so the children arrive as children. In exchange, tags you do not map render as themselves:

MessageVariables you passRenders
"Read <b>this</b>"noneRead <b>this</b>
"Hello <b>{name}</b>"{ name: "Ada" }Hello <b>Ada</b>
"Hello <b>{name}</b>"{ name: "Ada", b: … }your component, with children = Ada
"Accept the <link>terms</link>"{ link: … }Accept the <a href="/terms">terms</a>

So every b, i, strong and em mapping in your next-intl call sites can simply be deleted. Only tags that must become a component (a Link, a tooltip) still need an entry.

Two edges when moving rich messages

  • Keep hrefs in the component, not in the message. An attribute value containing / is not parsed, and the tag renders as literal text — a message like Go to <a href="/home">home</a> does not become a link. Write <link>home</link> in the message and put the href in the component you pass.
  • A void tag with no variable throws. Wait<br/>here reaches React as a br element with children, which React rejects. Either drop the <br/> from the message, or map it: t("wait", { br: () => <br /> }).

See React § component injection for nesting and attributes.

i18next

The tree model is close to i18next's, so most of a migration is mechanical.

  • {{double braces}} work. "Hi {{name}}" and "Hi {name}" both inject name. Interpolation needs no rewrite.
  • useTranslation("ns") keeps its name — and const { t } = useTranslation("ns") still destructures, because t is the node itself. Namespaces become top-level keys of one JSON file per locale.
  • Flat dotted keys need one line of config. { "nav.home": "Home" } is a key literally containing a dot, and t("nav.home") splits on ., so it resolves nothing. Either nest the keys (recommended — that is what gives you the typed tree), or keep them flat and change the separator:
i18n/translation.ts
export const { t, useTranslation } = createTranslation({
  locales: { en, es },
  pathSeparator: "/", // now t("nav.home") is one key, not a path
});
  • Suffix plurals must become ICU. i18next selects item_one / item_other from count; Intl-T does not. Rewrite them:
i18n/messages/en.json
- "item_one": "{{count}} item",
- "item_other": "{{count}} items"
+ "item": "{count, plural, one {# item} other {# items}}"

react-intl

  • Messages move from defineMessages into the same JSON files, keyed by nesting instead of by flat id. If your ids are dotted (home.title), the note above applies: nest them, or set pathSeparator.
  • intl.formatMessage({ id: "home.title" }, values)t.home.title(values).
  • <FormattedMessage id="home.title" />{t.home.title}, or the <Translation> component.
  • ICU carries over as-is. <FormattedNumber> / <FormattedDate> become {amount, number, currency} / {date, date, long} inside the message.

Gotchas

Variables stick to the node

Passing variables writes them into the node, so that children inherit them and chained calls work. On the client that is what you want. On a long-lived server, two concurrent requests share one tree and can overwrite each other:

const node = t("greet", { user: "Alice" }); // request A
await db.query(); //  request B runs t("greet", { user: "Bob" })
String(node); //  "Hello Bob!"  ← A renders B's data

Give each request its own instance — it costs a few microseconds and nothing else changes:

app/[locale]/page.tsx
import { t } from "@/i18n/translation";
import { TranslationNode } from "intl-t";

export default async function Page() {
  const rt = new TranslationNode({ settings: t.settings }); // isolated per request
  const user = await getUser();

  return <p>{rt.HomePage.welcome({ user: user.name })}</p>;
}

Rendering to strings and sending those to the client is unaffected — by then the text is already a string. The same applies to any message where a missing variable would be filled in by whatever a previous request left there.

useLocale comes from navigation, not from createTranslation

createTranslation does expose a useLocale name, but it is an alias of the translation hook — not locale state. Take the real one from createNavigation (Next.js) or from intl-t/react (SPA, Pages Router). It returns both shapes:

"use client";
import { useLocale } from "@/i18n/navigation";

export function LocaleSwitcher() {
  const [locale, setLocale] = useLocale(); // or: const { locale, setLocale } = useLocale()
  return <button onClick={() => setLocale("es")}>{locale}</button>;
}

proxy.ts, not middleware.ts

Next.js 16 renamed the file. createNavigation returns proxy and middleware as the same function, so on Next ≤ 15 you keep middleware.ts and export middleware as default. Do not forget the matcher — it is not inherited from createNavigation.

Pick the entry point by router, not by framework

intl-t/next pulls in App Router server modules (RSC, server actions). Importing it from Pages Router page code fails to compile.

Your appImport createTranslation from
Next.js App Routerintl-t/next
Next.js Pages Routerintl-t/react
React SPAintl-t/react
Node, CLI, testsintl-t

createNavigation is the exception: it always comes from intl-t/next, and on the Pages Router you may only import it from proxy.ts — never from a page. The Pages Router example shows the full split.

Next steps

On this page