32blogby Studio Mitsu

next-intl多言語対応で踏んだ落とし穴まとめ

32blog.comのnext-intl実装で実際に踏んだ落とし穴と解決策。ミドルウェア設定・動的ルート・SEOまで実例で解説。

by omitsu15 min read
目次

next-intl v4で多言語サイトを作るときに踏みやすい落とし穴は、ミドルウェアのmatcherパターン不備、Next.jsのLinkとnext-intlのLinkの混在、非同期Server ComponentでのuseTranslations誤用、generateStaticParamsのロケール漏れ、hreflangのx-default設定漏れの5つだ。どれも「動いているように見えるけど実は壊れている」パターンで、気づくのに時間がかかる。

32blog.comをnext-intl v4で日英西の3言語対応したとき、公式ドキュメントに書いてない落とし穴を何個も踏んだ。この記事では実際に踏んだ落とし穴を全部まとめて、解決策とともに公開する。

落とし穴1: ミドルウェアのmatcherパターンが間違っている

最初にハマった落とし穴がこれだ。ミドルウェアの matcher パターンが不完全だと、一部のリクエストでロケール検出が走らず / にアクセスすると /ja/ にリダイレクトされない。

よくある間違いパターン:

typescript
// src/middleware.ts
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

このパターンは一見正しそうに見えるが、.png.svg などの静的ファイルまでミドルウェアが処理しようとする。

正しいパターン:

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

export default createMiddleware(routing);

export const config = {
  matcher: [
    "/",
    "/(ja|en|es)/:path*",
    "/((?!api|_next|_vercel|.*\\..*).*)"],
};

.*\\..* の部分がポイントだ。ドットを含むパス(= 拡張子あり = 静的ファイル)を除外している。これはnext-intlミドルウェアドキュメントで推奨されているパターンだ。

落とし穴2: next-intlのLinkとNext.jsのLinkを混在させる

コンポーネント内でNext.jsの Link を使い続けると、言語切替が正しく動かない。

素のNext.js Linkを使う問題:

tsx
// これは言語プレフィックスが付かない
import Link from "next/link";

export function ArticleCard({ slug, category }) {
  return (
    <Link href={`/${category}/${slug}`}>
      記事を読む
    </Link>
  );
}

日本語ユーザーが /ja/ ページにいる状態でこのリンクをクリックすると、/nextjs/hydration-error-fix に飛ぶ。/ja/ プレフィックスが消える。

next-intlのLinkを使う修正:

tsx
// next-intlのLinkは自動でロケールプレフィックスを付ける
import { Link } from "@/i18n/routing";

export function ArticleCard({ slug, category }) {
  return (
    <Link href={`/${category}/${slug}`}>
      記事を読む
    </Link>
  );
}

同様に、useRouterredirect も next-intl のものを使う必要がある。next-intlのナビゲーションAPIを参照してほしい。

typescript
// 素のNext.jsのhooksではロケールが失われる
import { useRouter } from "next/navigation";

// next-intlのhooksを使う
import { useRouter, redirect } from "@/i18n/routing";

落とし穴3: 非同期Server ComponentでuseTranslationsを使う

next-intl v4では useTranslations はServer Componentでも動く。ただし、使うとそのページが動的レンダリングにオプトインされる。静的レンダリングしたいなら getTranslations を使う必要がある。

tsx
// ⚠️ 動くが、動的レンダリングになる
// "use client" がない = Server Component
import { useTranslations } from "next-intl";

export default function HomePage() {
  const t = useTranslations("home");
  // このページは静的レンダリングされなくなる
  return <h1>{t("title")}</h1>;
}

Server Componentでの正しい書き方:

tsx
// ✅ 静的レンダリングをサポートするgetTranslationsを使う
import { getTranslations } from "next-intl/server";

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

  return <h1>{t("title")}</h1>;
}

使い分けの早見表(Server & Client Components ドキュメント参照):

Server ComponentClient Component
翻訳取得getTranslations (await必須)useTranslations
ロケール取得getLocaleuseLocale
メッセージ取得getMessagesuseMessages

落とし穴4: 動的ルートでgenerateStaticParamsにロケールを含め忘れる

多言語サイトで generateStaticParams を使う場合、ロケールのパラメータも含める必要がある。

ロケールなしで生成する(デフォルト以外の記事が404になる):

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

// ロケールが"ja"のみ生成される
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({
    // locale がない!
    category: post.category,
    slug: post.slug,
  }));
}

ロケールを含めて全言語版を生成する:

tsx
export async function generateStaticParams() {
  const locales = ["ja", "en", "es"] as const;
  const paths = [];

  for (const locale of locales) {
    const posts = await getPostsByLocale(locale);
    for (const post of posts) {
      paths.push({
        locale,
        category: post.category,
        slug: post.slug,
      });
    }
  }

  return paths;
}

英語やスペイン語の記事を追加したのに404になる場合、これが原因であることが多い。Next.jsのgenerateStaticParamsドキュメントで動的セグメントとの連携について詳しく説明されている。

落とし穴5: hreflangのx-defaultが設定されていない

SEOでよくやらかすのが x-default の設定漏れだ。

x-defaultがない(Googleが言語判定できない):

tsx
export async function generateMetadata({ params }) {
  const { locale, category, slug } = await params;

  return {
    alternates: {
      languages: {
        "ja": `https://32blog.com/ja/${category}/${slug}`,
        "en": `https://32blog.com/en/${category}/${slug}`,
        "es": `https://32blog.com/es/${category}/${slug}`,
        // x-defaultがない!
      },
    },
  };
}

x-defaultを追加した正しい設定:

tsx
export async function generateMetadata({ params }) {
  const { locale, category, slug } = await params;
  const baseUrl = "https://32blog.com";

  return {
    alternates: {
      canonical: `${baseUrl}/${locale}/${category}/${slug}`,
      languages: {
        "ja": `${baseUrl}/ja/${category}/${slug}`,
        "en": `${baseUrl}/en/${category}/${slug}`,
        "es": `${baseUrl}/es/${category}/${slug}`,
        // 言語が判定できないユーザーをデフォルト言語へ
        "x-default": `${baseUrl}/ja/${category}/${slug}`,
      },
    },
  };
}

x-default はどの言語にも当てはまらないユーザーが来たときにリダイレクトされるURLだ。設定しないとGoogleが「このサイトは多言語対応だが、デフォルトがわからない」と判断してしまう。Googleのhreflang仕様に詳しい説明がある。

落とし穴6: notFoundのロケール対応

notFound() を呼び出すと、Next.jsの標準 not-found.tsx が表示される。しかし app/not-found.tsx[locale] セグメントの外なので、翻訳が効かない。

app/
├── not-found.tsx          ← [locale]の外 → 翻訳できない
└── [locale]/
    └── not-found.tsx      ← ここに置くと翻訳が効く

app/not-found.tsx の問題:

tsx
// next-intlが提供するtransを使えない
export default function NotFound() {
  return (
    <div>
      <h1>404 - ページが見つかりません</h1>
      {/* 英語ユーザーにも日本語が表示される */}
    </div>
  );
}

app/[locale]/not-found.tsx に置く修正:

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

export default async function NotFound() {
  const t = await getTranslations("notFound");

  return (
    <div>
      <h1>{t("title")}</h1>
      <p>{t("description")}</p>
    </div>
  );
}

not-found.tsx はServer Componentなので useTranslations ではなく getTranslations を使う必要がある。notFound()[locale] セグメント内から呼び出した場合は app/[locale]/not-found.tsx が表示されるが、ミドルウェアが処理する前の404は app/not-found.tsx が使われる。両方用意しておくのが安全だ。next-intlのnot-foundガイドに推奨される設定がある。

落とし穴7: クライアントコンポーネントのlocaleが古い

言語切替直後に、Client Componentが古いロケールを参照し続けることがある。

depsにlocaleがない(切替後も古い表示が残る):

tsx
"use client";

import { useLocale } from "next-intl";
import { useMemo } from "react";

export function LocalizedDate({ date }: { date: Date }) {
  const locale = useLocale();

  const formatted = useMemo(
    () => date.toLocaleDateString(locale),
    [] // localeがdepsにない
  );

  return <time>{formatted}</time>;
}

depsにlocaleを含める修正:

tsx
"use client";

import { useLocale } from "next-intl";
import { useMemo } from "react";

export function LocalizedDate({ date }: { date: Date }) {
  const locale = useLocale();

  const formatted = useMemo(
    () => date.toLocaleDateString(locale),
    [date, locale] // localeを含める
  );

  return <time>{formatted}</time>;
}

useMemouseCallback の依存配列に locale を含め忘れると、言語切替後も古いロケールでフォーマットされたデータが表示される。ReactのuseMemoドキュメントで依存配列の仕組みが説明されている。

FAQ

next-intlはPages Routerでも使える?

next-intl v4はPages RouterとApp Routerの両方をサポートしている。ただしAPIが大きく異なる。この記事はApp Router向けの内容だ。Pages Routerを使っている場合はnext-intl Pages Routerドキュメントを確認してほしい。

ミドルウェアがリダイレクトしないときのデバッグ方法は?

ミドルウェア関数内に console.log を入れて、呼び出されているか確認する。matcher パターンがリダイレクト対象のパスを除外していないかチェック。レスポンスヘッダーの x-next-intl-locale が欠落していれば、ミドルウェアがリクエストを処理していない。

next-intlはTurbopackで使える?

使える。next-intl v4はTurbopack(Next.js 16のデフォルトバンドラー)で問題なく動作する。特別な設定は不要。問題が出た場合はnext-intlを最新バージョンに更新してみてほしい。

ESLintでnext/linkの直接インポートを禁止するには?

ESLintのno-restricted-importsルールを使う:

json
{
  "rules": {
    "no-restricted-imports": ["error", {
      "paths": [{
        "name": "next/link",
        "message": "Link は '@/i18n/routing' からインポートしてください"
      }, {
        "name": "next/navigation",
        "importNames": ["useRouter", "redirect"],
        "message": "'@/i18n/routing' からインポートしてください"
      }]
    }]
  }
}

useTranslationsはServer Componentでエラーになる?

next-intl v4ではエラーにならない。Server Componentで useTranslations を使うと、そのページが動的レンダリングにオプトインされるだけだ。静的レンダリングしたい場合は next-intl/servergetTranslations を使う。Server & Client Componentsのドキュメントを参照。

初回訪問者のロケール検出はどう動く?

next-intlのミドルウェアが Accept-Language ヘッダー、Cookie、設定されたデフォルト言語の順で自動検出する。検出戦略はルーティング設定でカスタマイズできる。32blogでは日本語をデフォルトに設定し、ブラウザの言語設定に応じてリダイレクトしている。

next-intlとnext-i18nextの違いは?

next-i18nextはPages Router時代に人気だったが、App Routerへの対応が十分ではない。next-intlはApp Routerを前提に設計されており、React Server Componentsをネイティブにサポートし、活発にメンテナンスされている(2026年3月時点でv4.8.3)。新規のApp Routerプロジェクトにはnext-intlがおすすめだ。

まとめ

next-intlで実際に踏んだ落とし穴をまとめる。

落とし穴症状修正
matcherパターンが不完全ルートへのリダイレクトが動かない正しいmatcherパターンに修正
Next.jsのLinkを使う言語切替でプレフィックスが消えるnext-intlのLinkを使う
非同期Server ComponentでuseTranslations動的レンダリングにオプトインgetTranslations (await)に変更
generateStaticParamsにlocaleなしデフォルト以外の記事が404ロケール×記事の全組み合わせを生成
x-defaultがないSEOで言語判定が不完全hreflangにx-defaultを追加
not-foundのロケール対応漏れ404ページが翻訳されないapp/[locale]/not-found.tsx に配置
useMemoのdepsにlocaleなし言語切替後も古い表示depsに locale を追加

next-intlは設定さえ正しければ非常に快適に使えるライブラリだ。ただ、Next.jsの標準APIとの混在(特にLinkとuseRouter)が最も見落としやすいので、プロジェクト初期にESLintで縛っておくのが一番の予防策だ。

関連記事: