Keep every string in one JSON message file per language, give each language its own URL such as /de/about, and format dates, prices and plurals with the browser's built-in Intl APIs. In the Next.js App Router, next-intl wires these together: a [locale] route segment, a proxy that picks the language, and useTranslations in components.
| Part | What to use |
|---|---|
| Strings | messages/en.json, messages/de.json and so on |
| URLs | One path prefix per language: /en/..., /de/... |
| Language detection | next-intl's createMiddleware in proxy.ts (middleware.ts before Next.js 16) |
| Dates, numbers, money | Intl.DateTimeFormat and Intl.NumberFormat |
| Plurals | ICU message syntax or Intl.PluralRules |
| Search engines | hreflang links for every language version, plus x-default |
Updated
Translation is more than swapping words. Word order changes, plural rules differ, Arabic reads right to left and every country writes dates its own way. Try the switcher below to see one small product card change language, then build the same thing in your app with the setup files and the hreflang generator.
Switch language and change the item count. Strings come from a message object, and the price, date and plural form come from the browser's Intl APIs.
Enter your domain, a page path and your language codes to get the link tags for the head, or the Next.js alternates object.
Install with npm install next-intl. These files follow the structure in next-intl's own getting started guide for locale-based routing.
One file per language, same keys in each. ICU syntax handles plurals inside the message.
// messages/en.json
{
"Cart": {
"title": "Your cart",
"items": "{count, plural, =0 {No items} one {# item} other {# items}}"
}
}
// messages/de.json
{
"Cart": {
"title": "Ihr Warenkorb",
"items": "{count, plural, =0 {Keine Artikel} one {# Artikel} other {# Artikel}}"
}
}Routing lists your locales once. The proxy (called middleware.ts before Next.js 16) redirects visitors to a language prefix.
// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "de", "ar"],
defaultLocale: "en",
});
// src/proxy.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
};Reads the locale from the [locale] segment, rejects unknown ones and loads that language's messages.
import * as rootParams from "next/root-params";
import { notFound } from "next/navigation";
import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";
export default getRequestConfig(async ({ locale }) => {
if (!locale) {
const value = await rootParams.locale();
if (hasLocale(routing.locales, value)) locale = value;
else notFound();
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});The plugin links request.ts. Move your pages under app/[locale]/ and read messages with useTranslations. next/root-params is on by default from Next.js 16.3; earlier versions need experimental.rootParams.
// next.config.ts
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = {};
export default createNextIntlPlugin()(nextConfig);
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { routing } from "@/i18n/routing";
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
return (
<html lang={locale} dir={locale === "ar" ? "rtl" : "ltr"}>
<body><NextIntlClientProvider>{children}</NextIntlClientProvider></body>
</html>
);
}
// src/app/[locale]/cart/page.tsx
import { useTranslations } from "next-intl";
export default function CartPage() {
const t = useTranslations("Cart");
return <h1>{t("title")} ({t("items", { count: 3 })})</h1>;
}Works in any React app, translated or not.
const price = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5);
// "1.234,50 €"
const date = new Intl.DateTimeFormat("ar-EG", { dateStyle: "long" }).format(new Date(2026, 9, 14));
const rule = new Intl.PluralRules("ar-EG").select(3);
// "few": Arabic has six plural categories, English has two for cardinals| Rule | Example |
|---|---|
| Use full absolute URLs | https://example.com/de/about, not /de/about |
| Each version lists itself and all others | The German page links to en, de, ar and x-default |
| Language is ISO 639-1, optional region ISO 3166-1 alpha-2 | de, en-GB, pt-BR |
| Never a region alone | be is Belarusian, not Belgium; use nl-BE or fr-BE |
| x-default for everyone else | Points at a language picker or your main version |
Every piece of text a person sees should come from a message file keyed by meaning, such as Cart.checkoutButton. Developers edit English, translators edit the other files, and nobody hunts through components for hard-coded copy.
Namespacing by component keeps files readable. next-intl's useTranslations('Cart') then gives you t('checkoutButton') inside that component only.
Intl.NumberFormat writes 1234.5 euros as 1.234,50 € for a German reader, with the symbol and separators placed the way that locale expects. Intl.DateTimeFormat does the same for dates, and Intl.PluralRules tells you whether a number takes the one, few, many or other form in a given language.
These APIs ship in every modern browser and in Node, so formatting adds no weight to your bundle and stays correct for languages you have never studied.
Arabic, Hebrew, Persian and Urdu read right to left. Setting dir="rtl" on the html element mirrors text alignment and the order of inline content, but fixed left and right values in CSS stay put.
Use logical properties such as margin-inline-start and padding-inline-end, or Tailwind's ms- and pe- utilities, so spacing flips automatically with the direction.
For the Next.js App Router, next-intl is purpose built and supports Server Components. For a plain React app without Next.js, pick a library that stores messages as JSON, so moving to another one later stays manageable.
Subfolders such as example.com/de/ are the simplest to run, because one site and one domain carry everything. next-intl also supports domain-based routing if you need de.example.com or a country domain.
Return alternates.languages from metadata or generateMetadata, with one entry per language URL. Next.js prints a link rel="alternate" hreflang tag for each. Every language version must list all the others and itself, or search engines may ignore the set.
You can use machine translation as a first draft, but have a fluent speaker review it, especially headlines, prices and legal text. Thin or unreviewed translations can confuse readers and weaken trust.
Related: Multilingual website guide, Next.js middleware examples, JavaScript date formatting, Sitemap and robots.txt examples, Infinite scroll in React.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.