32blogby StudioMitsu
nextjs10 min read

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

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

nextjsnext-intli18n多言語対応App Router落とし穴
目次

32blog.comをnext-intl v4で日英対応したとき、ドキュメントに書いてない落とし穴を何個も踏んだ。

「動いているように見えるけど実は壊れている」というパターンが多く、気づくまでに時間がかかった。この記事では実際に踏んだ落とし穴を全部まとめて、解決策とともに公開する。

落とし穴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)/:path*",
    "/((?!api|_next|_vercel|.*\\..*).*)"],
};

.*\\..* の部分がポイントだ。ドットを含むパス(= 拡張子あり = 静的ファイル)を除外している。

落とし穴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 のものを使う必要がある。

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

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

落とし穴3: Server ComponentでuseTranslationsを使おうとする

next-intlはServer ComponentとClient Componentで使う関数が異なる。

Server Componentで間違えるパターン:

tsx
// "use client" がない = Server Component
import { useTranslations } from "next-intl";

export default async function HomePage() {
  // エラー: useTranslations is not supported in Server Components
  const t = useTranslations("home");

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

Server Componentでの正しい書き方:

tsx
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 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"] 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;
}

英語記事を追加したのに /en/nextjs/... が404になる場合、これが原因であることが多い。

落とし穴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}`,
        // 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}`,
        // 言語が判定できないユーザーをデフォルト言語へ
        "x-default": `${baseUrl}/ja/${category}/${slug}`,
      },
    },
  };
}

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

落とし穴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 が使われる。両方用意しておくのが安全だ。

落とし穴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 を含め忘れると、言語切替後も古いロケールでフォーマットされたデータが表示される。

まとめ

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で縛っておくのが一番の予防策だ。