DEV Community

Cover image for A Complete Guide to i18n in Next.js 15 App Router with next-intl (Supporting 8 Languages)
mukitaro
mukitaro

Posted on

A Complete Guide to i18n in Next.js 15 App Router with next-intl (Supporting 8 Languages)

TL;DR

This article walks through implementing multi-language support in Next.js 15 using next-intl, with real production code from DevType - a typing practice game for programmers.

Live Demo in 8 Languages:


Why next-intl?

When building a multi-language Next.js application, you have several options. I chose next-intl for these reasons:

Feature next-intl react-i18next next-translate
App Router Support First-class Adapter needed Limited
Server Components Native Manual setup Not supported
Type Safety Excellent Good Moderate
Bundle Size Small Larger Small

next-intl is built specifically for Next.js and provides seamless integration with the App Router and Server Components.


Project Structure

Here's the i18n file structure in DevType:

src/
├── app/
│   ├── [locale]/           # Dynamic locale segment
│   │   ├── layout.tsx      # Locale-specific layout
│   │   ├── page.tsx        # Home page
│   │   ├── play/
│   │   ├── ranking/
│   │   └── ...
│   ├── layout.tsx          # Root layout
│   └── sitemap.ts          # SEO sitemap
├── i18n/
│   ├── config.ts           # Locale configuration
│   ├── routing.ts          # Navigation helpers
│   └── request.ts          # Server-side message loading
├── middleware.ts           # Locale detection & routing
└── messages/
    ├── en.json
    ├── ja.json
    ├── zh.json
    └── ...
Enter fullscreen mode Exit fullscreen mode

Step 1: Install next-intl

npm install next-intl
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure Locales

Create a central configuration file for your supported languages:

src/i18n/config.ts

export const locales = ["en", "ja", "zh", "es", "pt", "de", "fr", "it"] as const;
export type Locale = (typeof locales)[number];

export const defaultLocale: Locale = "en";

export const localeNames: Record<Locale, string> = {
  en: "English",
  ja: "日本語",
  zh: "中文",
  es: "Español",
  pt: "Português",
  de: "Deutsch",
  fr: "Français",
  it: "Italiano",
};
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Use as const for type inference
  • Export a Locale type for type safety
  • Store display names for UI language selectors

Step 3: Set Up Routing

Create navigation helpers that work with your locale structure:

src/i18n/routing.ts

import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";
import { locales, defaultLocale } from "./config";

export const routing = defineRouting({
  locales,
  defaultLocale,
  localePrefix: "always", // URLs always include locale: /en, /ja, etc.
});

export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing);
Enter fullscreen mode Exit fullscreen mode

The localePrefix: "always" option ensures clean, consistent URLs:

  • https://devtype.honualohak.com/en (English)
  • https://devtype.honualohak.com/ja (Japanese)
  • https://devtype.honualohak.com/zh (Chinese)

Important: Use the exported Link component instead of Next.js's default Link to ensure correct locale handling.


Step 4: Configure Message Loading

Set up server-side message loading:

src/i18n/request.ts

import { getRequestConfig } from "next-intl/server";
import { locales, type Locale } from "./config";

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;

  // Fallback if locale is invalid
  if (!locale || !locales.includes(locale as Locale)) {
    locale = "en";
  }

  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  };
});
Enter fullscreen mode Exit fullscreen mode

Step 5: Configure Next.js

Wrap your Next.js config with the next-intl plugin:

next.config.ts

import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");

const nextConfig: NextConfig = {
  // Your other config options...
};

export default withNextIntl(nextConfig);
Enter fullscreen mode Exit fullscreen mode

Step 6: Set Up Middleware

The middleware handles locale detection, redirects, and can be combined with other middleware (like authentication).

Combining middleware (like Supabase Auth) with next-intl can be tricky because both try to manage the response. The code below demonstrates how to merge cookies correctly so authentication and localization work together:

src/middleware.ts

import createMiddleware from "next-intl/middleware";
import type { NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
import { routing } from "@/i18n/routing";

const intlMiddleware = createMiddleware(routing);

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

  // Skip i18n middleware for auth callbacks
  if (pathname.startsWith("/auth/")) {
    return await updateSession(request);
  }

  // Handle Supabase session
  const supabaseResponse = await updateSession(request);

  // Handle i18n routing
  const intlResponse = intlMiddleware(request);

  // Merge cookies from Supabase response
  supabaseResponse.cookies.getAll().forEach((cookie) => {
    intlResponse.cookies.set(cookie.name, cookie.value);
  });

  return intlResponse;
}

export const config = {
  matcher: [
    "/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)",
  ],
};
Enter fullscreen mode Exit fullscreen mode

The middleware automatically:

  • Detects the user's preferred language from browser settings
  • Redirects to the appropriate locale URL
  • Handles locale switching

Step 7: Create the Locale Layout

The [locale] dynamic segment layout provides translations to all child components:

src/app/[locale]/layout.tsx

import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { locales, type Locale } from "@/i18n/config";

type Props = {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
};

// Generate static params for all locales (for static generation)
export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params;

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

  // Enable static rendering
  setRequestLocale(locale);

  // Load messages for this locale
  const messages = await getMessages();

  return (
    <NextIntlClientProvider messages={messages}>
      <div className="flex min-h-screen flex-col">
        <Header />
        <main className="flex-1">{children}</main>
        <Footer />
      </div>
    </NextIntlClientProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • generateStaticParams() enables static generation for all locales
  • setRequestLocale() is required for static rendering
  • NextIntlClientProvider makes translations available to client components

Step 8: Create Translation Files

Create JSON files for each language in the messages/ directory:

messages/en.json

{
  "common": {
    "appName": "DevType",
    "tagline": "Code like a Pro.",
    "loading": "Loading...",
    "error": "An error occurred"
  },
  "nav": {
    "home": "Home",
    "ranking": "Ranking",
    "about": "About",
    "login": "Login",
    "logout": "Logout"
  },
  "home": {
    "hero": {
      "title": "Type Code,\nSharpen Skills",
      "subtitle": "Practice real-world code in a professional code editor.",
      "cta": "Start Now"
    }
  },
  "play": {
    "wpm": "WPM",
    "accuracy": "Accuracy",
    "mistakes": "Mistakes"
  }
}
Enter fullscreen mode Exit fullscreen mode

messages/ja.json

{
  "common": {
    "appName": "DevType",
    "tagline": "コードを極めろ。",
    "loading": "読み込み中...",
    "error": "エラーが発生しました"
  },
  "nav": {
    "home": "ホーム",
    "ranking": "ランキング",
    "about": "DevTypeについて",
    "login": "ログイン",
    "logout": "ログアウト"
  },
  "home": {
    "hero": {
      "title": "コードを打ち込め、\nスキルを磨け",
      "subtitle": "本物のコードエディタで実践的なコードを練習しよう。",
      "cta": "今すぐ始める"
    }
  },
  "play": {
    "wpm": "WPM",
    "accuracy": "正確率",
    "mistakes": "ミス"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 9: Use Translations in Components

In Server Components

import { getTranslations } from "next-intl/server";

export default async function HomePage() {
  const t = await getTranslations("home");

  return (
    <section>
      <h1>{t("hero.title")}</h1>
      <p>{t("hero.subtitle")}</p>
      <button>{t("hero.cta")}</button>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

In Client Components

"use client";

import { useTranslations } from "next-intl";

export function GameStats() {
  const t = useTranslations("play");

  return (
    <div>
      <span>{t("wpm")}: 85</span>
      <span>{t("accuracy")}: 98%</span>
      <span>{t("mistakes")}: 3</span>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

With Variables

// In JSON:
// "shareText": "I scored {score} points! #DevType"

const t = useTranslations("result");
const message = t("shareText", { score: 8500 });
// Output: "I scored 8500 points! #DevType"
Enter fullscreen mode Exit fullscreen mode

Step 10: SEO - Sitemap with hreflang

For SEO, generate a sitemap with hreflang alternates for all languages:

src/app/sitemap.ts

import type { MetadataRoute } from "next";
import { locales } from "@/i18n/config";

const APP_URL = "https://devtype.honualohak.com";

const staticPages = ["", "/ranking", "/about", "/faq"];

export default function sitemap(): MetadataRoute.Sitemap {
  const sitemap: MetadataRoute.Sitemap = [];

  for (const locale of locales) {
    for (const page of staticPages) {
      const url = `${APP_URL}/${locale}${page}`;

      // Generate alternates for all locales
      const languages: Record<string, string> = {};
      for (const altLocale of locales) {
        languages[altLocale] = `${APP_URL}/${altLocale}${page}`;
      }
      languages["x-default"] = `${APP_URL}/en${page}`;

      sitemap.push({
        url,
        lastModified: new Date(),
        changeFrequency: page === "" ? "daily" : "weekly",
        priority: page === "" ? 1 : 0.8,
        alternates: {
          languages,
        },
      });
    }
  }

  return sitemap;
}
Enter fullscreen mode Exit fullscreen mode

This generates sitemap entries like:

<url>
  <loc>https://devtype.honualohak.com/en</loc>
  <xhtml:link rel="alternate" hreflang="en" href="https://devtype.honualohak.com/en"/>
  <xhtml:link rel="alternate" hreflang="ja" href="https://devtype.honualohak.com/ja"/>
  <xhtml:link rel="alternate" hreflang="zh" href="https://devtype.honualohak.com/zh"/>
  <xhtml:link rel="alternate" hreflang="x-default" href="https://devtype.honualohak.com/en"/>
  <!-- ... other locales -->
</url>
Enter fullscreen mode Exit fullscreen mode

Common Patterns

Type-Safe Translation Keys

Create a type definition file (e.g., src/types/i18n.d.ts or src/global.d.ts) to catch typos at compile time:

// src/types/i18n.d.ts
// Infer translation keys from your JSON structure
type Messages = typeof import("../../messages/en.json");

declare global {
  interface IntlMessages extends Messages {}
}
Enter fullscreen mode Exit fullscreen mode

Locale-Aware Links

Always use the exported Link from routing:

import { Link } from "@/i18n/routing";

// Automatically adds current locale to href
<Link href="/ranking">Rankings</Link>
// Renders: /en/ranking or /ja/ranking based on current locale
Enter fullscreen mode Exit fullscreen mode

Dynamic Locale Switching

"use client";

import { useRouter, usePathname } from "@/i18n/routing";
import { locales, localeNames } from "@/i18n/config";

export function LocaleSwitcher() {
  const router = useRouter();
  const pathname = usePathname();

  const switchLocale = (newLocale: string) => {
    router.replace(pathname, { locale: newLocale });
  };

  return (
    <select onChange={(e) => switchLocale(e.target.value)}>
      {locales.map((locale) => (
        <option key={locale} value={locale}>
          {localeNames[locale]}
        </option>
      ))}
    </select>
  );
}
Enter fullscreen mode Exit fullscreen mode

Performance Tips

  1. Static Generation: Use generateStaticParams() to pre-render all locale variants at build time.

  2. Message Splitting: For large apps, consider splitting messages by page/feature:

   messages: {
     ...(await import(`../../messages/${locale}/common.json`)).default,
     ...(await import(`../../messages/${locale}/home.json`)).default,
   }
Enter fullscreen mode Exit fullscreen mode
  1. Lazy Loading: Load translations for specific features on demand:
   const messages = await import(`@/messages/${locale}/dashboard.json`);
Enter fullscreen mode Exit fullscreen mode

Conclusion

Implementing multi-language support in Next.js 15 with next-intl is straightforward when you follow this structure. The key components are:

  1. Centralized config (src/i18n/config.ts)
  2. Routing helpers (src/i18n/routing.ts)
  3. Middleware for automatic locale detection
  4. [locale] layout to provide translations
  5. JSON message files for each language
  6. SEO sitemap with hreflang alternates

Try it yourself at DevType - available in 8 languages!


Resources


What languages does your app support? Share your i18n setup in the comments!

Top comments (0)