32blogby StudioMitsu
nextjs10 min read

next-intlでNext.jsを多言語対応する完全ガイド

next-intl v4をApp Routerに組み込む手順を解説。32blog.comで実際に使っているja/en切替の実装を完全公開。

nextjsnext-intli18n多言語対応App RouterTypeScript
目次

32blog.comは日本語と英語の2言語に対応している。URLは /ja/nextjs/slug/en/nextjs/slug のプレフィックス方式だ。

この多言語対応を実現するために使ったのが next-intl v4 だ。設定からルーティング、コンポーネントでの使い方、MDX記事の言語切替まで、実際に32blog.comで動かしている構成を全部公開する。

next-intlとはどんなライブラリか?

Next.jsの多言語対応は標準で next.config.jsi18n オプションがある(Pages Routerのみ)。しかしApp Routerではこのオプションが廃止されている。App Routerで多言語対応するには自力でルーティングを組むか、next-intlのようなライブラリを使う必要がある。

next-intlはApp Router専用の多言語ライブラリで、以下の機能を提供する。

  • URLプレフィックスによる言語ルーティング(/ja/.../en/...
  • Server ComponentとClient Component両方で使える翻訳フック
  • TypeScript対応(翻訳キーの型チェック)
  • SEO対応(hreflang自動設定のユーティリティ)

インストールとファイル構成

bash
npm install next-intl

32blog.comのファイル構成はこうなっている。

src/
├── app/
│   ├── [locale]/          # 言語プレフィックス
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── [category]/
│   │       └── [slug]/
│   │           └── page.tsx
│   └── api/
├── i18n/
│   ├── routing.ts         # ルーティング設定
│   └── request.ts         # リクエスト設定
├── messages/
│   ├── ja.json            # 日本語翻訳
│   └── en.json            # 英語翻訳
└── middleware.ts           # ロケール検出・リダイレクト

ルーティング設定

まず src/i18n/routing.ts でサポートする言語とデフォルト言語を定義する。

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

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

  // /en/... はURLにプレフィックスあり、/ja/... はデフォルトなのでリダイレクト
  // localePrefix: "always" にすると /ja/... も明示的にプレフィックスあり
  localePrefix: "always",
});

次に middleware.ts を作る。これがリクエストを見てロケールを検出し、適切なURLにリダイレクトする役割を担う。

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

export default createMiddleware(routing);

export const config = {
  // ミドルウェアを適用するパス(静的ファイルや内部APIは除外)
  matcher: [
    "/",
    "/(ja|en)/:path*",
    "/((?!api|_next|_vercel|.*\\..*).*)",
  ],
};

リクエスト設定

src/i18n/request.ts ではServer Componentでのロケール取得方法を定義する。

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

export default getRequestConfig(async ({ requestLocale }) => {
  // ロケールを取得(URLから)
  let locale = await requestLocale;

  // サポートされていないロケールの場合はデフォルトにフォールバック
  if (!locale || !routing.locales.includes(locale as "ja" | "en")) {
    locale = routing.defaultLocale;
  }

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

翻訳ファイルの作成

messages/ja.json に翻訳テキストを定義する。

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

レイアウトへの組み込み

app/[locale]/layout.tsx でnext-intlのプロバイダーを設定する。

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;

  // サポートされていないロケールは404
  if (!routing.locales.includes(locale as "ja" | "en")) {
    notFound();
  }

  // Server Componentから翻訳メッセージを取得
  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        {/* Client Componentで翻訳を使うためのプロバイダー */}
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Server Componentでの翻訳使用

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

// Server Componentでは 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>
  );
}

Client Componentでの翻訳使用

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

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

export function NavMenu() {
  // Client ComponentではuseTranslationsを使う
  const t = useTranslations("nav");

  return (
    <nav>
      {/* next-intlのLinkは自動でロケールプレフィックスを付ける */}
      <Link href="/">{t("home")}</Link>
      <Link href="/about">{t("about")}</Link>
    </nav>
  );
}

next-intlが提供する Link コンポーネントを使うと、現在のロケールが自動でURLに付与される。/about と書いても、日本語ユーザーには /ja/about に、英語ユーザーには /en/about にリンクが張られる。

言語切替ボタンの実装

ユーザーが言語を切り替えられるスイッチャーを作る。

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

import { useLocale, useTranslations } 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) => {
    // 現在のパスを維持したまま言語を切り替える
    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>
  );
}

router.replace にロケールオプションを渡すだけで、同じパスの別言語版に遷移できる。例えば /ja/nextjs/hydration-error-fix を見ていた状態でEnglishボタンを押すと /en/nextjs/hydration-error-fix に移動する。

TypeScriptでの型チェック設定

next-intl v4ではTypeScriptの型チェックを設定できる。翻訳キーの打ち間違いをビルド時に検出できるようになる。

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

type Messages = typeof en;

declare global {
  interface IntlMessages extends Messages {}
}

これを設定すると、t("nav.hme") のような存在しないキーを書いたときにTypeScriptがエラーを出してくれる。

tsx
// ❌ キーが存在しないのでTypeScriptエラー
const title = t("nav.hme"); // Error: Argument of type '"hme"' is not assignable...

// ✅ 正しいキー
const title = t("nav.home"); // OK

SEO(hreflang)の設定

多言語サイトではSEOのために hreflang タグを各ページに設定する必要がある。next-intlはこのユーティリティも提供している。

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

import { getTranslations } from "next-intl/server";
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設定
        "ja": `${baseUrl}/ja/${category}/${slug}`,
        "en": `${baseUrl}/en/${category}/${slug}`,
        "x-default": `${baseUrl}/ja/${category}/${slug}`,
      },
    },
  };
}

これで各ページに <link rel="alternate" hreflang="ja" href="..."> タグが自動生成される。Googleは複数言語のページが同じコンテンツの翻訳版であることを正しく認識できる。

まとめ

next-intl v4の設定ポイントをまとめる。

ファイル役割
i18n/routing.tsサポートロケールとデフォルトロケールを定義
middleware.tsリクエストのロケール検出とリダイレクト
i18n/request.tsServer Componentでのメッセージロードを設定
messages/ja.json日本語翻訳テキスト
app/[locale]/layout.tsxNextIntlClientProviderの設定

Server Componentでは getTranslations、Client Componentでは useTranslations と使い分ける点が最初は混乱しやすい。URLリンクはnext-intlの Link を使えば自動でロケールが付くので、素のNext.jsの Link と使い分けることも忘れずに。

32blog.comでは22,000以上のページビューをこの構成で捌いている。App Routerのnext-intlは安定して動いている。