32blogby StudioMitsu
nextjs8 min read

Complete Guide to Next.js i18n with next-intl

How to set up next-intl v4 with App Router for multilingual sites. Full walkthrough of the ja/en setup powering 32blog.com.

nextjsnext-intli18ninternationalizationApp RouterTypeScript
On this page

32blog.com supports both Japanese and English. URLs follow a prefix pattern: /ja/nextjs/slug for Japanese and /en/nextjs/slug for English.

The library making this work is next-intl v4. This article walks through the full setup — routing config, component-level translation usage, locale switcher, and MDX article language switching — everything I actually use on 32blog.com.

What Is next-intl?

Next.js supports internationalization natively via the i18n option in next.config.js — but only in Pages Router. App Router dropped that option. To add i18n to App Router, you either build your own routing or use a library like next-intl.

next-intl is an App Router-first i18n library with these key features:

  • URL prefix-based locale routing (/ja/..., /en/...)
  • Translation hooks that work in both Server and Client Components
  • TypeScript support (type-checked translation keys)
  • SEO utilities (hreflang helpers)

Installation and File Structure

bash
npm install next-intl

Here's the file structure I use on 32blog.com:

src/
├── app/
│   ├── [locale]/          # Locale prefix
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── [category]/
│   │       └── [slug]/
│   │           └── page.tsx
│   └── api/
├── i18n/
│   ├── routing.ts         # Routing config
│   └── request.ts         # Request config
├── messages/
│   ├── ja.json            # Japanese translations
│   └── en.json            # English translations
└── middleware.ts           # Locale detection and redirects

Routing Configuration

Start with src/i18n/routing.ts to define supported locales and the default.

typescript
// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";

export const routing = defineRouting({
  locales: ["ja", "en"],
  defaultLocale: "ja",

  // "always" means /ja/... and /en/... both have explicit prefixes
  localePrefix: "always",
});

Next, create middleware.ts. This intercepts requests, detects the locale, and redirects to the correct URL.

typescript
// src/middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";

export default createMiddleware(routing);

export const config = {
  // Apply middleware to these paths (exclude static files and internal APIs)
  matcher: [
    "/",
    "/(ja|en)/:path*",
    "/((?!api|_next|_vercel|.*\\..*).*)",
  ],
};

Request Configuration

src/i18n/request.ts defines how Server Components load translations for the current request.

typescript
// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";

export default getRequestConfig(async ({ requestLocale }) => {
  // Get locale from the URL
  let locale = await requestLocale;

  // Fall back to default locale if unsupported locale detected
  if (!locale || !routing.locales.includes(locale as "ja" | "en")) {
    locale = routing.defaultLocale;
  }

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

Creating Translation Files

Define translation text in messages/ja.json:

json
{
  "nav": {
    "home": "ホーム",
    "about": "このブログについて",
    "articles": "記事一覧"
  },
  "home": {
    "hero": {
      "title": "ググって最後にたどり着くページ",
      "subtitle": "エラー解決・技術Tips・実装例を実体験ベースで書いています"
    },
    "latestArticles": "新着記事",
    "allArticles": "すべての記事を見る"
  },
  "article": {
    "publishedAt": "公開日",
    "updatedAt": "更新日",
    "readingTime": "{minutes}分で読めます",
    "tableOfContents": "目次",
    "relatedArticles": "関連記事"
  }
}

And in messages/en.json:

json
{
  "nav": {
    "home": "Home",
    "about": "About",
    "articles": "Articles"
  },
  "home": {
    "hero": {
      "title": "The last page you land on after Googling",
      "subtitle": "Error fixes, tech tips, and real-world implementations based on actual experience"
    },
    "latestArticles": "Latest Articles",
    "allArticles": "View all articles"
  },
  "article": {
    "publishedAt": "Published",
    "updatedAt": "Updated",
    "readingTime": "{minutes} min read",
    "tableOfContents": "Table of Contents",
    "relatedArticles": "Related Articles"
  }
}

Integrating into the Layout

Set up the next-intl provider in app/[locale]/layout.tsx:

tsx
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";

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

  // Return 404 for unsupported locales
  if (!routing.locales.includes(locale as "ja" | "en")) {
    notFound();
  }

  // Fetch translation messages from Server Component
  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        {/* Provider lets Client Components access translations */}
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Using Translations in Server Components

tsx
// app/[locale]/page.tsx
import { getTranslations } from "next-intl/server";

// Server Components use getTranslations
export default async function HomePage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: "home" });

  return (
    <main>
      <section>
        <h1>{t("hero.title")}</h1>
        <p>{t("hero.subtitle")}</p>
      </section>
    </main>
  );
}

Using Translations in Client Components

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

import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";

export function NavMenu() {
  // Client Components use useTranslations
  const t = useTranslations("nav");

  return (
    <nav>
      {/* next-intl's Link automatically prepends the current locale */}
      <Link href="/">{t("home")}</Link>
      <Link href="/about">{t("about")}</Link>
    </nav>
  );
}

Using next-intl's Link component means locale prefixes get added automatically. Writing /about will link to /ja/about for Japanese users and /en/about for English users — without any extra code.

Building the Locale Switcher

Here's the locale switcher that lets users toggle between languages:

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

import { useLocale } from "next-intl";
import { useRouter, usePathname } from "@/i18n/routing";

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

  const switchLocale = (newLocale: string) => {
    // Stay on the same path but switch locale
    router.replace(pathname, { locale: newLocale });
  };

  return (
    <div>
      <button
        onClick={() => switchLocale("ja")}
        aria-current={locale === "ja" ? "true" : undefined}
      >
        日本語
      </button>
      <button
        onClick={() => switchLocale("en")}
        aria-current={locale === "en" ? "true" : undefined}
      >
        English
      </button>
    </div>
  );
}

Pass a locale option to router.replace and next-intl handles the path transformation. If a user is reading /ja/nextjs/hydration-error-fix and clicks "English," they'll land on /en/nextjs/hydration-error-fix.

TypeScript Integration for Type-Safe Keys

next-intl v4 supports TypeScript-checked translation keys. Typos in key names become build errors.

typescript
// types/global.d.ts
import en from "../messages/en.json";

type Messages = typeof en;

declare global {
  interface IntlMessages extends Messages {}
}

With this in place:

tsx
// ❌ Key doesn't exist — TypeScript error
const title = t("nav.hme"); // Error: Argument of type '"hme"' is not assignable...

// ✅ Correct key — no error
const title = t("nav.home"); // OK

SEO: Setting Up hreflang

Multilingual sites need hreflang tags so Google can correctly associate language versions of the same page. next-intl makes this straightforward via Next.js metadata.

tsx
// app/[locale]/[category]/[slug]/page.tsx

import type { Metadata } from "next";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string; category: string; slug: string }>;
}): Promise<Metadata> {
  const { locale, category, slug } = await params;
  const post = await getPost(category, slug, locale);

  const baseUrl = "https://32blog.com";

  return {
    title: post.title,
    description: post.description,
    alternates: {
      canonical: `${baseUrl}/${locale}/${category}/${slug}`,
      languages: {
        // hreflang tags
        "ja": `${baseUrl}/ja/${category}/${slug}`,
        "en": `${baseUrl}/en/${category}/${slug}`,
        "x-default": `${baseUrl}/ja/${category}/${slug}`,
      },
    },
  };
}

This automatically generates <link rel="alternate" hreflang="ja" href="..."> tags in each page's <head>. Google can then correctly understand that the two versions are translations of the same content, not duplicate pages.

Wrapping Up

Here's a summary of the key files in the next-intl v4 setup:

FileRole
i18n/routing.tsDefine supported locales and default locale
middleware.tsDetect locale from requests and redirect
i18n/request.tsConfigure how Server Components load messages
messages/ja.jsonJapanese translation strings
app/[locale]/layout.tsxSet up NextIntlClientProvider

The main thing that trips people up: use getTranslations in Server Components and useTranslations in Client Components. Similarly, use next-intl's Link instead of Next.js's built-in Link — otherwise locale prefixes won't be added automatically.

32blog.com runs this setup across all pages serving thousands of pageviews. The App Router version of next-intl has been solid and stable.