32blogby Studio Mitsu

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

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

by omitsu14 min read
目次

next-intl v4は、Next.js App Routerに多言語対応を追加するためのライブラリだ。npm install next-intl でインストールし、i18n/routing.ts でロケール設定、ミドルウェアで言語検出、Server Componentでは getTranslations、Client Componentでは useTranslations で翻訳テキストを表示する。

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

この記事では、ルーティング設定からコンポーネントでの使い方、言語切替ボタン、SEOのhreflang設定まで、実際に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            # 英語翻訳
│   └── es.json            # スペイン語翻訳
└── middleware.ts           # ロケール検出・リダイレクト

ルーティング設定

まず src/i18n/routing.ts でサポートする言語とデフォルト言語を定義する。設定オプションの詳細はnext-intlのルーティングドキュメントを参照。

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

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

  // "always" にすると /ja/...、/en/...、/es/... すべてにプレフィックスが付く
  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|es)/: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" | "es")) {
    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" | "es")) {
    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 } 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ではAppConfig インターフェースへのモジュール拡張でTypeScriptの型チェックを設定できる。翻訳キーの打ち間違いをビルド時に検出できる。

typescript
// global.d.ts
import { routing } from "@/i18n/routing";
import en from "../messages/en.json";

declare module "next-intl" {
  interface AppConfig {
    Locale: (typeof routing.locales)[number];
    Messages: typeof en;
  }
}

Locale を登録すると、ロケール文字列が厳密に型チェックされる(routing.ts にない "fr" などを書くとコンパイルエラー)。Messages を登録すると翻訳キーの補完と型チェックが有効になる。

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

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

Locale 型を登録しておくと、next-intlの hasLocale() ヘルパーで未知の文字列を安全にナローイングできる。

typescript
import { hasLocale } from "next-intl";

// narrowing後、localeは "ja" | "en" | "es" 型になる
if (hasLocale(routing.locales, locale)) {
  // TypeScriptはlocaleが有効だと認識する
}

SEO(hreflang)の設定

多言語サイトではSEOのために hreflang タグを各ページに設定する必要がある。next-intlはNext.jsのメタデータAPIと組み合わせてこれを簡単に実現できる。

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設定
        "ja": `${baseUrl}/ja/${category}/${slug}`,
        "en": `${baseUrl}/en/${category}/${slug}`,
        "es": `${baseUrl}/es/${category}/${slug}`,
        "x-default": `${baseUrl}/ja/${category}/${slug}`,
      },
    },
  };
}

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

FAQ

next-intlはNext.js 16で使える?

使える。next-intl v4はNext.js 13〜16をサポートしている。ただしNext.js 16の use cache ディレクティブは getTranslations() が内部で headers() を読むため、まだシームレスに動かない。既存の setRequestLocale パターンは引き続き動作する。

getTranslationsuseTranslations の違いは?

getTranslationsServer Component用の非同期関数。useTranslations はClient Component用のReact hook。間違ったコンテキストで使うとランタイムエラーになる。

URLプレフィックスなしで使える?

使える。routing.tslocalePrefix: "as-needed" を設定すると、デフォルトロケール以外のみプレフィックスが付く。localePrefix: "never" にすればドメインやCookieベースの検出に切り替えられる。

後から言語を追加するにはどうすればいい?

routing.ts にロケールを追加し、messages/{locale}.json ファイルを作成し、ミドルウェアのmatcher正規表現を更新するだけ。コンポーネントの変更は不要で、next-intlが自動的に認識する。

next-intlはクライアントバンドルサイズに影響する?

next-intl自体は軽量(クライアントランタイムはgzip後 約2KB)。バンドルへの主な影響は NextIntlClientProvider に渡す翻訳JSONのサイズだ。namespaceごとにメッセージを分割すれば軽減できる。

多言語サイトのSEOはどう対応する?

next-intlはNext.jsのメタデータAPIと組み合わせてhreflangユーティリティを提供する。ロケールプレフィックス付きURLと適切なcanonicalタグを設定すれば、Googleは各ページの全言語バージョンを正しく関連付ける。

next-intlとnext-i18nextはどっちがいい?

next-i18nextはPages Router向けでi18nextのラッパー。App Routerプロジェクトでは、Server Componentsをネイティブサポートするnext-intlの方が適している。

まとめ

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

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

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

基本が動いたら、next-intlとi18nの落とし穴もチェックしてほしい。ロケール依存のフォーマットで起きるhydrationミスマッチや、スケールで破綻する翻訳キー設計のパターンをまとめている。Server Componentsのレンダリングモデルをより深く理解したいなら、Next.js SSR完全ガイドも参考になる。

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