Intl-T

Quick Start

A working, typed i18n setup in five minutes

Installation

npm install intl-t

No other packages. react and next are optional peers — install only what you use.

Setup

Write your messages

A message file is a plain JSON tree. Nest it however your app is organized: the shape you write is the shape you get, fully typed.

i18n/messages/en.json
{
  "homepage": {
    "title": "Homepage",
    "welcome": "Welcome, {user}!",
    "items": "{count, plural, =0 {No items} one {# item} other {# items}}",
  },
}
i18n/messages/es.json
{
  "homepage": {
    "title": "Inicio",
    "welcome": "¡Bienvenido, {user}!",
    "items": "{count, plural, =0 {Sin artículos} one {# artículo} other {# artículos}}",
  },
}

Coming from next-intl or i18next?

Your existing message files work as they are — ICU syntax is supported and the nesting you already have becomes the tree you access. See the migration guide.

Create the translation

Pick the entry point that matches your framework. This is the one choice everything else follows from.

Your appImport from
Next.js App Routerintl-t/next
Next.js Pages Routerintl-t/react
React SPA (Vite, CRA…)intl-t/react
Node server, CLI, testsintl-t
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 },
});
i18n/translation.ts
import { createTranslation } from "intl-t/react";
import en from "./messages/en.json";
import es from "./messages/es.json";

export const { Translation, useTranslation, t } = createTranslation({
  locales: { en, es },
});
i18n/translation.ts
import { createTranslation } from "intl-t";
import en from "./messages/en.json";
import es from "./messages/es.json";

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

Static imports ship every language to the client

With static locales, the whole dictionary — all languages — travels in the client bundle (instant offline switching, zero config; ideal for 2–3 locales). With more languages, switch to loaders: one line per locale, one lazy chunk per locale, and the provider hydrates the active one via props.

`locales` is required

createTranslation({}) does not throw — it returns an instance that renders every key as its own name (t("homepage.title")"homepage.title"). If your app is rendering key paths instead of text, you forgot locales.

Wire it into your app

createNavigation builds the routing proxy and the locale-aware navigation helpers from one config.

i18n/navigation.ts
import { createNavigation } from "intl-t/next";

export const { proxy, Link, useRouter, redirect, generateStaticParams } = createNavigation({
  allowedLocales: ["en", "es"] as const,
  defaultLocale: "en",
});
proxy.ts
export { proxy } from "@/i18n/navigation";

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

proxy.ts, not middleware.ts

Next.js 16 renamed middleware.ts to proxy.ts. On Next 15 and below the file is middleware.ts and you export middlewarecreateNavigation returns both names, so only the file name changes.

With the default routing your pages live under app/[locale]/.

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>
  );
}

Want /es/about URLs without an app/[locale]/ folder, or the locale hidden entirely? See Routing — the URL shape and the locale source are independent choices.

Use the React surface for translations, and the proxy for locale detection. Do not import intl-t/next from page code — it pulls in App Router server modules.

i18n/navigation.ts
import { createNavigation } from "intl-t/next";

export const { proxy } = createNavigation({
  allowedLocales: ["en", "es"] as const,
  defaultLocale: "en",
  strategy: "request",
});
proxy.ts
export { proxy } from "@/i18n/navigation";

export const config = {
  matcher: "/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)",
};
pages/_app.tsx
import { Translation } from "@/i18n/translation";
import type { AppProps } from "next/app";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <Translation locale={pageProps.locale}>
      <Component {...pageProps} />
    </Translation>
  );
}

The proxy resolves the locale and hands it to the request as the x-locale header:

pages/index.tsx
import { useTranslation } from "@/i18n/translation";
import type { GetServerSideProps } from "next";

export const getServerSideProps: GetServerSideProps = async ctx => ({
  props: { locale: (ctx.req.headers["x-locale"] as string) ?? "en" },
});

export default function Home() {
  const t = useTranslation("homepage");
  return <h1>{t.title}</h1>;
}
main.tsx
import { createRoot } from "react-dom/client";
import App from "./App";
import { Translation } from "./i18n/translation";

createRoot(document.getElementById("root")!).render(
  <Translation>
    <App />
  </Translation>,
);

With Vite, point the JSX runtime at Intl-T so translation nodes render directly as JSX children:

vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react({ jsxImportSource: "intl-t" })],
  optimizeDeps: { exclude: ["intl-t"] },
});

optimizeDeps.exclude matters: without it Vite's dependency optimizer can duplicate the library and break node identity checks.

Render

The translation is a tree of nodes. Walk it exactly as you wrote your JSON.

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>
      <p>{t.items({ count: 3 })}</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>
      <p>{t.items({ count: 3 })}</p>
    </>
  );
}

What you just got

These are live TypeScript blocks — hover the highlighted lines.

Autocomplete over your whole tree

..
  • addChildren
  • allowedLocales
  • apply
  • arguments
  • base
  • bind
  • call
  • caller
  • child
  • children
  • current
  • currentLocale
  • dir
  • en
  • fork
  • g
  • get
  • getChildren
  • getNode
  • getTranslation
  • getTranslations
  • global
  • hero
  • id
  • items
  • key
  • keys
  • length
  • load
  • locale
  • locales
  • mainLocale
  • name
  • node
  • parent
  • path
  • promise
  • prototype
  • set
  • setChildren
  • setLocale
  • setSource
  • settings
  • t
  • then
  • title
  • toJSON
  • toLocaleString
  • toObject
  • toString
  • Tr
  • Trans
  • translation
  • Translation
  • translationNode
  • TranslationProvider
  • use
  • useLocale
  • useTranslation
  • useTranslations
  • valueOf
  • values
  • variables
  • welcome
  • with

Variables inferred from the message itself

welcome declares {user}, so user is what it asks for — no schema, no codegen.

const greeting = ..({ : "Ada" });
const greeting: Translation<TranslationSettings<"en", "en", {
    readonly en: {
        readonly homepage: {
            readonly title: "Homepage";
            readonly welcome: "Welcome, {user}!";
        };
    };
}, Values, ".", Node>, "Welcome, {user}!", Values & {
    readonly user: "Ada";
}, "en", ["homepage", "welcome"]>

Typos are compile errors

..titel;
Property 'titel' does not exist on type 'Translation<TranslationSettings<"en", "en", { readonly en: { readonly homepage: { readonly title: "Homepage"; }; }; }, Values, ".", Node>, { readonly title: "Homepage"; }, Values, "en", [...]>'. Did you mean 'title'? Property 'titel' does not exist on type '{ <const VV extends Values>(variables?: Override<Partial<Values>, VV> | undefined): Translation<TranslationSettings<"en", "en", { readonly en: { readonly homepage: { readonly title: "Homepage"; }; }; }, Values, ".", Node>, { ...; }, Override<...>, "en", [...]>; <LL extends "en", const VV extends Values = Values>(loc...'.

Next steps

On this page