i18n in Next.js 14

Add packages.

bun add @formatjs/intl-localematcher # A spec-compliant ponyfill for Intl.LocaleMatcher.
bun add negotiator # An HTTP content negotiator for Node.js
bun add -D @types/negotiator # negotiator types

Create i18n-config.ts

$ touch src/i18n-config.ts
// i18n-config.ts
export const i18n = {
  defaultLocale: "ja",
  locales: ["ja", "en"],
} as const;

export type Locale = (typeof i18n)["locales"][number];

Set up directories

mkdir src/dictionaries
touch src/dictionaries/en.json
touch src/dictionaries/ja.json
{
  "i18n": {
    "title": "Multilingual page"
  }
}
{
  "i18n": {
    "title": "多言語ページ"
  }
}
touch src/get-dictionary.ts
import "server-only";
import type { Locale } from "./i18n-config";

const dictionaries = {
  ja: () => import("./src/dictionaries/ja.json").then((module) => module.default),
  en: () => import("./src/dictionaries/en.json").then((module) => module.default),
};

export const getDictionary = async (locale: Locale) =>
  dictionaries[locale]?.() ?? dictionaries.ja();

Create middleware.ts

touch src/middleware.ts
// touch src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

import { i18n } from "./i18n-config";

import { match as matchLocale } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";

function getLocale(request: NextRequest): string | undefined {
  // Negotiator expects plain object so we need to transform headers
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // @ts-ignore locales are readonly
  const locales: string[] = i18n.locales;

  // Use negotiator and intl-localematcher to get best locale
  let languages = new Negotiator({ headers: negotiatorHeaders }).languages(
    locales,
  );

  const locale = matchLocale(languages, locales, i18n.defaultLocale);

  return locale;
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // Check if there is any supported locale in the pathname
  const pathnameIsMissingLocale = i18n.locales.every(
    (locale) =>
      !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
  );

  // Redirect if there is no locale
  if (pathnameIsMissingLocale) {
    const locale = getLocale(request);

    // e.g. incoming request is /products
    // The new URL is now /en-US/products
    return NextResponse.redirect(
      new URL(
        `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
        request.url,
      ),
    );
  }
}

export const config = {
  // Matcher ignoring `/_next/` and `/api/`
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

The description is detailed below.

function getLocale(request: NextRequest): string | undefined {
  // Negotiator expects plain object so we need to transform headers
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // @ts-ignore locales are readonly
  const locales: string[] = i18n.locales;

  // Use negotiator and intl-localematcher to get best locale
  let languages = new Negotiator({ headers: negotiatorHeaders }).languages(
    locales,
  );

  const locale = matchLocale(languages, locales, i18n.defaultLocale);

  return locale;
}
  • Receives HTTP request objects and determines the best locale (language and regional settings) based on the request.
  • Creates an instance of the Negotiator class and determines the most appropriate language for the request based on the request header and available locales. This takes into account the language preference in the Accept-Language header of the request.
  • The matchLocale function is used to determine the best locale based on the list of negotiated languages, the list of available locales, and the default locale.
export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // Check if there is any supported locale in the pathname
  const pathnameIsMissingLocale = i18n.locales.every(
    (locale) =>
      !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
  );

  // Redirect if there is no locale
  if (pathnameIsMissingLocale) {
    const locale = getLocale(request);

    // e.g. incoming request is /products
    // The new URL is now /en-US/products
    return NextResponse.redirect(
      new URL(
        `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
        request.url,
      ),
    );
  }
}
  • Redirects to the appropriate locale (language setting) based on the URL path of incoming requests.
  • Check if there is any supported locale in the pathname
  • Redirect if there is no locale
export const config = {
  // Matcher ignoring `/_next/` and `/api/`
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
  • Specifies the URL path to which the middleware is applied.

How to use

// src/app/layout.tsx
import { i18n, type Locale } from "@/i18n-config"

export async function generateStaticParams() {
  return i18n.locales.map((locale) => ({ lang: locale }));
}

export default function RootLayout({
  children, params,
}: {
  children: React.ReactNode,
  params: { lang: Locale };
}) {
  return (
    <html lang={params.lang}>
      <body>
        {children}
      </body>
    </html>
  )
}
  • generateStaticParams is an asynchronous function that generates and returns, for each locale (language setting) retrieved from i18n locales, an array of objects with that locale as lang property. It provides the parameters used to generate static pages for each locale during static site generation (SSG).
//src/app/[lang]/i18n/page.tsx
import {getDictionary} from "@/get-dictionary";
import {Locale} from "@/i18n-config";

export default async function I18nPage({ params: { lang } }: {
  params: { lang: Locale}
}){
  const dict = await getDictionary(lang) // en

  return <h1>{dict["i18n"].title}</h1>
}