Quick Start
A working, typed i18n setup in five minutes
Installation
npm install intl-tNo 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.
{
"homepage": {
"title": "Homepage",
"welcome": "Welcome, {user}!",
"items": "{count, plural, =0 {No items} one {# item} other {# items}}",
},
}{
"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 app | Import from |
|---|---|
| Next.js App Router | intl-t/next |
| Next.js Pages Router | intl-t/react |
| React SPA (Vite, CRA…) | intl-t/react |
| Node server, CLI, tests | intl-t |
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 },
});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 },
});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.
import { createNavigation } from "intl-t/next";
export const { proxy, Link, useRouter, redirect, generateStaticParams } = createNavigation({
allowedLocales: ["en", "es"] as const,
defaultLocale: "en",
});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 middleware —
createNavigation returns both names, so only the file name changes.
With the default routing your pages live under app/[locale]/.
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.
import { createNavigation } from "intl-t/next";
export const { proxy } = createNavigation({
allowedLocales: ["en", "es"] as const,
defaultLocale: "en",
strategy: "request",
});export { proxy } from "@/i18n/navigation";
export const config = {
matcher: "/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)",
};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:
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>;
}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:
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.
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>
</>
);
}"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" });Typos are compile errors
..titel;