Skip to main content
Back to Blog
Tutorials
3 min read
January 23, 2025

How to Build a Multi-Language Site With i18n Routing in Next.js

Set up full internationalization with locale-based routing, translation files, language detection, RTL support, and SEO hreflang tags.

Ryel Banfield

Founder & Lead Developer

Full internationalization requires routing, translations, and SEO. Here is how to set it up.

Configuration

// lib/i18n/config.ts
export const locales = ["en", "es", "fr", "de", "ja", "ar"] as const;
export type Locale = (typeof locales)[number];

export const defaultLocale: Locale = "en";

export const localeNames: Record<Locale, string> = {
  en: "English",
  es: "Espanol",
  fr: "Francais",
  de: "Deutsch",
  ja: "Japanese",
  ar: "Arabic",
};

export const rtlLocales: Locale[] = ["ar"];

export function isRtl(locale: Locale): boolean {
  return rtlLocales.includes(locale);
}

Translation Files

// messages/en.json
{
  "common": {
    "home": "Home",
    "about": "About",
    "contact": "Contact",
    "learnMore": "Learn More"
  },
  "home": {
    "title": "Welcome to Our Site",
    "subtitle": "We build digital experiences",
    "cta": "Get Started"
  },
  "contact": {
    "title": "Get in Touch",
    "name": "Your Name",
    "email": "Email Address",
    "message": "Message",
    "send": "Send Message"
  }
}
// messages/es.json
{
  "common": {
    "home": "Inicio",
    "about": "Acerca de",
    "contact": "Contacto",
    "learnMore": "Saber Mas"
  },
  "home": {
    "title": "Bienvenido a Nuestro Sitio",
    "subtitle": "Construimos experiencias digitales",
    "cta": "Comenzar"
  },
  "contact": {
    "title": "Contactenos",
    "name": "Su Nombre",
    "email": "Correo Electronico",
    "message": "Mensaje",
    "send": "Enviar Mensaje"
  }
}

Translation Loader

// lib/i18n/translations.ts
import type { Locale } from "./config";

type Messages = Record<string, Record<string, string>>;

const messageCache = new Map<Locale, Messages>();

export async function getMessages(locale: Locale): Promise<Messages> {
  if (messageCache.has(locale)) {
    return messageCache.get(locale)!;
  }

  const messages = (await import(`@/messages/${locale}.json`)).default;
  messageCache.set(locale, messages);
  return messages;
}

export function createTranslator(messages: Messages) {
  return function t(key: string, params?: Record<string, string | number>): string {
    const [namespace, ...rest] = key.split(".");
    const messageKey = rest.join(".");

    let message = messages[namespace]?.[messageKey] ?? key;

    // Replace parameters: "Hello {name}" -> "Hello World"
    if (params) {
      for (const [param, value] of Object.entries(params)) {
        message = message.replace(`{${param}}`, String(value));
      }
    }

    return message;
  };
}

Middleware for Locale Detection

// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";

function getLocaleFromHeaders(request: NextRequest): Locale {
  const acceptLanguage = request.headers.get("accept-language") ?? "";
  const preferred = acceptLanguage
    .split(",")
    .map((lang) => lang.split(";")[0].trim().split("-")[0])
    .find((lang) => locales.includes(lang as Locale));
  return (preferred as Locale) ?? defaultLocale;
}

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

  // Check if the path already has a locale
  const pathnameLocale = locales.find(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
  );

  if (pathnameLocale) return NextResponse.next();

  // Skip static files and API routes
  if (
    pathname.startsWith("/_next") ||
    pathname.startsWith("/api") ||
    pathname.includes(".")
  ) {
    return NextResponse.next();
  }

  // Check cookie for saved preference
  const cookieLocale = request.cookies.get("locale")?.value as Locale | undefined;
  const locale = cookieLocale && locales.includes(cookieLocale)
    ? cookieLocale
    : getLocaleFromHeaders(request);

  // Redirect to localized path
  const url = request.nextUrl.clone();
  url.pathname = `/${locale}${pathname}`;
  return NextResponse.redirect(url);
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)"],
};

Route Structure

app/
  [locale]/
    layout.tsx
    page.tsx
    about/
      page.tsx
    contact/
      page.tsx

Localized Layout

// app/[locale]/layout.tsx
import { notFound } from "next/navigation";
import { locales, isRtl, type Locale } from "@/lib/i18n/config";
import { getMessages, createTranslator } from "@/lib/i18n/translations";

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;

  if (!locales.includes(locale as Locale)) {
    notFound();
  }

  const messages = await getMessages(locale as Locale);
  const t = createTranslator(messages);

  return (
    <html lang={locale} dir={isRtl(locale as Locale) ? "rtl" : "ltr"}>
      <body>
        <TranslationProvider messages={messages} locale={locale as Locale}>
          <nav className="flex items-center gap-4 p-4 border-b">
            <a href={`/${locale}`}>{t("common.home")}</a>
            <a href={`/${locale}/about`}>{t("common.about")}</a>
            <a href={`/${locale}/contact`}>{t("common.contact")}</a>
            <div className="ml-auto">
              <LanguageSwitcher currentLocale={locale as Locale} />
            </div>
          </nav>
          {children}
        </TranslationProvider>
      </body>
    </html>
  );
}

Translation Context

"use client";

import { createContext, useContext } from "react";
import { createTranslator } from "@/lib/i18n/translations";
import type { Locale } from "@/lib/i18n/config";

type Messages = Record<string, Record<string, string>>;
type TranslateFunction = (key: string, params?: Record<string, string | number>) => string;

const TranslationContext = createContext<{
  t: TranslateFunction;
  locale: Locale;
}>({
  t: (key) => key,
  locale: "en",
});

export function TranslationProvider({
  messages,
  locale,
  children,
}: {
  messages: Messages;
  locale: Locale;
  children: React.ReactNode;
}) {
  const t = createTranslator(messages);
  return (
    <TranslationContext.Provider value={{ t, locale }}>
      {children}
    </TranslationContext.Provider>
  );
}

export function useTranslation() {
  return useContext(TranslationContext);
}

Language Switcher

"use client";

import { usePathname } from "next/navigation";
import { locales, localeNames, type Locale } from "@/lib/i18n/config";

export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
  const pathname = usePathname();

  function switchLocale(newLocale: Locale) {
    // Replace current locale in path
    const segments = pathname.split("/");
    segments[1] = newLocale;
    const newPath = segments.join("/");

    // Save preference
    document.cookie = `locale=${newLocale};path=/;max-age=${60 * 60 * 24 * 365}`;
    window.location.href = newPath;
  }

  return (
    <select
      value={currentLocale}
      onChange={(e) => switchLocale(e.target.value as Locale)}
      className="text-sm border rounded-md px-2 py-1"
    >
      {locales.map((locale) => (
        <option key={locale} value={locale}>
          {localeNames[locale]}
        </option>
      ))}
    </select>
  );
}

SEO: Hreflang Tags

// app/[locale]/layout.tsx β€” add to <head>
import { locales } from "@/lib/i18n/config";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const baseUrl = process.env.NEXT_PUBLIC_APP_URL!;

  return {
    alternates: {
      canonical: `${baseUrl}/${locale}`,
      languages: Object.fromEntries(
        locales.map((l) => [l, `${baseUrl}/${l}`]),
      ),
    },
  };
}

Need Multilingual Websites?

We build internationalized websites that reach global audiences. Contact us to expand your reach.

i18ninternationalizationroutingtranslationsNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles