Skip to content
Codeloom
Next.js

Next.js i18n: Complete Internationalization Guide

Build multilingual Next.js apps with the App Router using locale routing, server-side translations, dynamic language switching, and SEO best practices.

·10 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • Setting up locale-based routing in the App Router
  • Loading and rendering translations on the server
  • Building a language switcher component
  • SEO optimization for multilingual sites

Prerequisites

  • Next.js App Router fundamentals
  • Basic understanding of React Server Components

The i18n Strategy in App Router

The App Router does not include a built-in i18n configuration like the Pages Router did. Instead, you build internationalization using the filesystem routing you already know: a [locale] dynamic segment wraps your entire app, and middleware detects the user’s preferred language.

This approach gives you more control and works well with Server Components because translations load on the server without shipping translation bundles to the client.

Here is the file structure you will build:

app/
  [locale]/
    layout.tsx
    page.tsx
    about/
      page.tsx
dictionaries/
  en.json
  es.json
  fr.json
  de.json
middleware.ts
lib/
  i18n.ts

Setting Up the Locale Configuration

Start with a central configuration file that defines your supported locales and default language:

// lib/i18n.ts
export const locales = ["en", "es", "fr", "de"] 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",
};

export function isValidLocale(locale: string): locale is Locale {
  return locales.includes(locale as Locale);
}

Creating Translation Dictionaries

Store your translations as JSON files. Each file maps the same keys to the translated strings:

// dictionaries/en.json
{
  "nav": {
    "home": "Home",
    "about": "About",
    "blog": "Blog",
    "contact": "Contact"
  },
  "home": {
    "title": "Welcome to Our Platform",
    "subtitle": "Build something great today",
    "cta": "Get Started",
    "features": {
      "fast": "Lightning Fast",
      "fastDescription": "Optimized for speed at every level",
      "secure": "Secure by Default",
      "secureDescription": "Enterprise-grade security built in",
      "scalable": "Infinitely Scalable",
      "scalableDescription": "Grows with your business needs"
    }
  },
  "about": {
    "title": "About Us",
    "description": "We are a team of passionate developers building tools for the modern web."
  },
  "common": {
    "learnMore": "Learn More",
    "backToHome": "Back to Home",
    "language": "Language"
  }
}
// dictionaries/es.json
{
  "nav": {
    "home": "Inicio",
    "about": "Acerca de",
    "blog": "Blog",
    "contact": "Contacto"
  },
  "home": {
    "title": "Bienvenido a Nuestra Plataforma",
    "subtitle": "Construye algo grandioso hoy",
    "cta": "Comenzar",
    "features": {
      "fast": "Ultra Rapido",
      "fastDescription": "Optimizado para velocidad en todos los niveles",
      "secure": "Seguro por Defecto",
      "secureDescription": "Seguridad de nivel empresarial incorporada",
      "scalable": "Infinitamente Escalable",
      "scalableDescription": "Crece con las necesidades de tu negocio"
    }
  },
  "about": {
    "title": "Acerca de Nosotros",
    "description": "Somos un equipo de desarrolladores apasionados construyendo herramientas para la web moderna."
  },
  "common": {
    "learnMore": "Saber Mas",
    "backToHome": "Volver al Inicio",
    "language": "Idioma"
  }
}

Loading Dictionaries on the Server

Create a function that loads the right dictionary based on the locale. Using dynamic imports keeps each translation bundle lazy-loaded:

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

const dictionaries = {
  en: () => import("@/dictionaries/en.json").then((m) => m.default),
  es: () => import("@/dictionaries/es.json").then((m) => m.default),
  fr: () => import("@/dictionaries/fr.json").then((m) => m.default),
  de: () => import("@/dictionaries/de.json").then((m) => m.default),
};

export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>>;

export async function getDictionary(locale: Locale): Promise<Dictionary> {
  return dictionaries[locale]();
}

Because this runs in a Server Component, the JSON is loaded on the server and the translation data never becomes part of your client JavaScript bundle.

Middleware for Locale Detection

The middleware detects the user’s preferred locale and redirects them to the right path:

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

function getPreferredLocale(request: NextRequest): string {
  // Check cookie first
  const cookieLocale = request.cookies.get("preferred-locale")?.value;
  if (cookieLocale && isValidLocale(cookieLocale)) {
    return cookieLocale;
  }

  // Parse Accept-Language header
  const acceptLanguage = request.headers.get("accept-language");
  if (acceptLanguage) {
    const preferred = acceptLanguage
      .split(",")
      .map((lang) => {
        const [code, priority] = lang.trim().split(";q=");
        return {
          code: code.split("-")[0].toLowerCase(),
          priority: priority ? parseFloat(priority) : 1,
        };
      })
      .sort((a, b) => b.priority - a.priority)
      .find((lang) => isValidLocale(lang.code));

    if (preferred) {
      return preferred.code;
    }
  }

  return defaultLocale;
}

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

  // Check if the pathname already has a locale
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) {
    return NextResponse.next();
  }

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

  // Redirect to the preferred locale
  const locale = getPreferredLocale(request);
  const newUrl = new URL(`/${locale}${pathname}`, request.url);
  return NextResponse.redirect(newUrl);
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)"],
};

When someone visits /about, middleware redirects them to /en/about or /es/about based on their browser language or saved preference.

Building the Locale Layout

The [locale] layout wraps your entire app and provides the locale context:

// app/[locale]/layout.tsx
import { notFound } from "next/navigation";
import { locales, type Locale } from "@/lib/i18n";
import { getDictionary } from "@/lib/dictionaries";
import { Navigation } from "@/components/Navigation";

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

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const dict = await getDictionary(locale as Locale);

  return {
    title: {
      template: `%s | ${dict.home.title}`,
      default: dict.home.title,
    },
  };
}

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 dict = await getDictionary(locale as Locale);

  return (
    <html lang={locale}>
      <body>
        <Navigation locale={locale as Locale} dict={dict} />
        <main>{children}</main>
      </body>
    </html>
  );
}

The generateStaticParams function tells Next.js to pre-render pages for all supported locales at build time.

Page Components with Translations

Each page loads its translations on the server:

// app/[locale]/page.tsx
import { getDictionary } from "@/lib/dictionaries";
import type { Locale } from "@/lib/i18n";
import Link from "next/link";

export default async function HomePage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const dict = await getDictionary(locale as Locale);

  return (
    <div className="max-w-4xl mx-auto px-4 py-16">
      <section className="text-center mb-16">
        <h1 className="text-5xl font-bold mb-4">{dict.home.title}</h1>
        <p className="text-xl text-gray-600 mb-8">{dict.home.subtitle}</p>
        <Link
          href={`/${locale}/signup`}
          className="bg-blue-600 text-white px-8 py-3 rounded-lg text-lg hover:bg-blue-700"
        >
          {dict.home.cta}
        </Link>
      </section>

      <section className="grid md:grid-cols-3 gap-8">
        <FeatureCard
          title={dict.home.features.fast}
          description={dict.home.features.fastDescription}
        />
        <FeatureCard
          title={dict.home.features.secure}
          description={dict.home.features.secureDescription}
        />
        <FeatureCard
          title={dict.home.features.scalable}
          description={dict.home.features.scalableDescription}
        />
      </section>
    </div>
  );
}

function FeatureCard({
  title,
  description,
}: {
  title: string;
  description: string;
}) {
  return (
    <div className="border rounded-lg p-6">
      <h3 className="text-xl font-semibold mb-2">{title}</h3>
      <p className="text-gray-600">{description}</p>
    </div>
  );
}

Language Switcher Component

The language switcher lets users change their preferred language. It updates a cookie so middleware remembers the choice:

// components/LanguageSwitcher.tsx
"use client";

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

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

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

    // Set cookie for middleware to remember
    document.cookie = `preferred-locale=${newLocale};path=/;max-age=${60 * 60 * 24 * 365}`;

    router.push(newPath);
  }

  return (
    <div className="relative">
      <select
        value={currentLocale}
        onChange={(e) => switchLocale(e.target.value as Locale)}
        className="appearance-none bg-white border rounded px-3 py-1.5 pr-8 text-sm"
      >
        {locales.map((locale) => (
          <option key={locale} value={locale}>
            {localeNames[locale]}
          </option>
        ))}
      </select>
    </div>
  );
}

Handling Pluralization and Interpolation

For more complex translations with variables and plural forms, build a simple formatter:

// lib/translate.ts
type TranslateOptions = {
  count?: number;
  [key: string]: string | number | undefined;
};

export function t(
  template: string | { one: string; other: string },
  options?: TranslateOptions
): string {
  let text: string;

  if (typeof template === "object" && options?.count !== undefined) {
    text = options.count === 1 ? template.one : template.other;
  } else if (typeof template === "string") {
    text = template;
  } else {
    text = template.other;
  }

  if (options) {
    for (const [key, value] of Object.entries(options)) {
      if (value !== undefined) {
        text = text.replace(new RegExp(`{{${key}}}`, "g"), String(value));
      }
    }
  }

  return text;
}

Use it with dictionary entries that include placeholders:

{
  "blog": {
    "postCount": {
      "one": "{{count}} post found",
      "other": "{{count}} posts found"
    },
    "greeting": "Hello, {{name}}!"
  }
}
import { t } from "@/lib/translate";

// "5 posts found"
t(dict.blog.postCount, { count: 5 });

// "1 post found"
t(dict.blog.postCount, { count: 1 });

// "Hello, Alice!"
t(dict.blog.greeting, { name: "Alice" });

SEO for Multilingual Sites

Search engines need to understand the relationship between your translated pages. Add hreflang tags and localized metadata:

// app/[locale]/layout.tsx
import { locales, type Locale } from "@/lib/i18n";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const dict = await getDictionary(locale as Locale);

  return {
    title: dict.home.title,
    description: dict.home.subtitle,
    alternates: {
      canonical: `https://mysite.com/${locale}`,
      languages: Object.fromEntries(
        locales.map((l) => [l, `https://mysite.com/${l}`])
      ),
    },
    openGraph: {
      locale: locale,
      alternateLocales: locales.filter((l) => l !== locale),
    },
  };
}

This generates the proper <link rel="alternate" hreflang="..."> tags that tell Google and other search engines about your translated pages.

Generating a Multilingual Sitemap

Create a sitemap that includes all locale variants of every page:

// app/sitemap.ts
import { locales } from "@/lib/i18n";
import type { MetadataRoute } from "next";

export default function sitemap(): MetadataRoute.Sitemap {
  const baseUrl = "https://mysite.com";

  const pages = ["", "/about", "/blog", "/contact"];

  const entries: MetadataRoute.Sitemap = [];

  for (const page of pages) {
    for (const locale of locales) {
      entries.push({
        url: `${baseUrl}/${locale}${page}`,
        lastModified: new Date(),
        alternates: {
          languages: Object.fromEntries(
            locales.map((l) => [l, `${baseUrl}/${l}${page}`])
          ),
        },
      });
    }
  }

  return entries;
}

Type-Safe Translations

Add type safety so you catch missing translation keys at build time:

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

// Import the English dictionary as the canonical type
import en from "@/dictionaries/en.json";

export type Dictionary = typeof en;

const dictionaries: Record<Locale, () => Promise<Dictionary>> = {
  en: () => import("@/dictionaries/en.json").then((m) => m.default),
  es: () => import("@/dictionaries/es.json").then((m) => m.default),
  fr: () => import("@/dictionaries/fr.json").then((m) => m.default),
  de: () => import("@/dictionaries/de.json").then((m) => m.default),
};

export async function getDictionary(locale: Locale): Promise<Dictionary> {
  return dictionaries[locale]();
}

Now TypeScript will autocomplete dict.home.title and catch typos like dict.home.titl at compile time. If you add a key to en.json, TypeScript will remind you to add it to the other language files too.

Wrapping Up

Internationalization in the Next.js App Router is built on patterns you already know: dynamic route segments, Server Components, and middleware. The [locale] segment organizes your routes, middleware handles detection and redirects, and Server Components load translations without bloating the client bundle.

The type-safe dictionary approach catches missing translations early, the language switcher remembers preferences via cookies, and proper hreflang metadata ensures search engines index every language variant correctly. Start with two languages and expand from there. The architecture scales cleanly because each new language is just another JSON file and a single entry in your locale configuration.