Internationalization

intermediate

Type-safe internationalization with next-intl. Includes locale detection, server components support, and message formatting.

i18ninternationalizationlocalizationnext-intl
Tested on201619TS5.9
$ bunx sinew add developer-experience/i18n
Interactive 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-intl

5Configuration

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    # Spanish
TypeScript

Add more locales by extending locales in lib/i18n/config.ts and shipping a matching messages/<locale>.json file.

Related patterns