Internationalization
intermediateType-safe internationalization with next-intl. Includes locale detection, server components support, and message formatting.
i18ninternationalizationlocalizationnext-intl
Tested on⬢20▲16⚛19TS5.9
$ bunx sinew add developer-experience/i18nInteractive demo coming soon
1The Problem
Adding multi-language support requires:
- Locale detection and routing
- Server component support
- Type-safe message formatting
- Date, number, and currency formatting
2The Solution
Use next-intl for type-safe internationalization with server component support. Includes locale configuration, message files, middleware for routing, and formatting utilities.
3Files
lib/i18n/config.ts
lib/i18n/config.tsTypeScript
// Supported locales. Add a locale here and ship a matching messages/<locale>.json.
export const locales = ["en", "es"] as const;
export type Locale = (typeof locales)[number];
// Default locale
export const defaultLocale: Locale = "en";
// Locale metadata
export const localeNames: Record<Locale, string> = {
en: "English",
es: "Español",
};
// Currency formats
export const currencyFormats: Record<Locale, { currency: string; locale: string }> = {
en: { currency: "USD", locale: "en-US" },
es: { currency: "EUR", locale: "es-ES" },
};lib/i18n/request.ts
lib/i18n/request.tsTypeScript
import { hasLocale } from "next-intl";
import { getRequestConfig } from "next-intl/server";
import { locales, defaultLocale } from "./config";
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = hasLocale(locales, requested) ? requested : defaultLocale;
return {
locale,
messages: (await import(`@/messages/${locale}.json`)).default,
};
});middleware.ts
middleware.tsTypeScript
import createMiddleware from "next-intl/middleware";
import { locales, defaultLocale } from "@/lib/i18n/config";
export default createMiddleware({
locales,
defaultLocale,
localePrefix: "as-needed",
});
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};app/[locale]/layout.tsx
app/[locale]/layout.tsxTSX
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { locales } from "@/lib/i18n/config";
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
// In Next.js 15+ params is a Promise and must be awaited.
const { locale } = await params;
// Validate the locale without an unsafe cast.
if (!hasLocale(locales, locale)) {
notFound();
}
// Enable static rendering for locale-aware server components.
setRequestLocale(locale);
const messages = await getMessages();
return <NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>;
}messages/en.json
messages/en.jsonJSON
{
"common": {
"loading": "Loading...",
"error": "Something went wrong",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete"
},
"auth": {
"signIn": "Sign in",
"signOut": "Sign out",
"signUp": "Sign up"
},
"greeting": {
"hello": "Hello, {name}!",
"welcome": "Welcome back, {name}"
},
"errors": {
"required": "{field} is required",
"notFound": "Page not found"
}
}components/locale-switcher.tsx
components/locale-switcher.tsxTSX
"use client";
import { useLocale } from "next-intl";
import { useRouter, usePathname } from "next/navigation";
import { locales, localeNames, type Locale } from "@/lib/i18n/config";
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const handleChange = (newLocale: Locale) => {
const segments = pathname.split("/");
if (locales.includes(segments[1] as Locale)) {
segments[1] = newLocale;
} else {
segments.splice(1, 0, newLocale);
}
router.push(segments.join("/"));
};
return (
<select
value={locale}
onChange={(e) => handleChange(e.target.value as Locale)}
className="rounded-lg border px-4 py-2"
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeNames[loc]}
</option>
))}
</select>
);
}hooks/use-formatted.ts
hooks/use-formatted.tsTypeScript
"use client";
import { useFormatter, useLocale } from "next-intl";
import { currencyFormats, type Locale } from "@/lib/i18n/config";
export function useFormatted() {
const format = useFormatter();
const locale = useLocale() as Locale;
return {
date: (date: Date | number, options?: Intl.DateTimeFormatOptions) =>
format.dateTime(date, options),
relative: (date: Date | number) => format.relativeTime(date),
number: (value: number, options?: Intl.NumberFormatOptions) => format.number(value, options),
currency: (value: number) => {
const { currency, locale: currencyLocale } = currencyFormats[locale];
return new Intl.NumberFormat(currencyLocale, { style: "currency", currency }).format(value);
},
percent: (value: number) =>
format.number(value, { style: "percent", maximumFractionDigits: 1 }),
};
}4Dependencies
$ bun add next-intl5Configuration
Next.js Config
6Usage
Use Translations in Server Components
import { useTranslations } from "next-intl";
export default function Page() {
const t = useTranslations("common");
return <h1>{t("loading")}</h1>;
}TSX
Use Translations with Parameters
import { useTranslations } from "next-intl";
export function Greeting({ name }: { name: string }) {
const t = useTranslations("greeting");
return <p>{t("hello", { name })}</p>;
}TSX
Format Dates and Currency
"use client";
import { useFormatted } from "@/hooks/use-formatted";
export function ProductPrice({ price, date }) {
const format = useFormatted();
return (
<div>
<span>{format.currency(price)}</span>
<span>{format.date(date)}</span>
</div>
);
}TSX
7File Structure
messages/
├── en.json # English
└── es.json # SpanishTypeScript
Add more locales by extending locales in lib/i18n/config.ts and shipping a matching
messages/<locale>.json file.