32blog.comは日本語と英語の2言語に対応している。URLは /ja/nextjs/slug と /en/nextjs/slug のプレフィックス方式だ。
この多言語対応を実現するために使ったのが next-intl v4 だ。設定からルーティング、コンポーネントでの使い方、MDX記事の言語切替まで、実際に32blog.comで動かしている構成を全部公開する。
next-intlとはどんなライブラリか?
Next.jsの多言語対応は標準で next.config.js の i18n オプションがある(Pages Routerのみ)。しかしApp Routerではこのオプションが廃止されている。App Routerで多言語対応するには自力でルーティングを組むか、next-intlのようなライブラリを使う必要がある。
next-intlはApp Router専用の多言語ライブラリで、以下の機能を提供する。
- URLプレフィックスによる言語ルーティング(
/ja/...、/en/...) - Server ComponentとClient Component両方で使える翻訳フック
- TypeScript対応(翻訳キーの型チェック)
- SEO対応(hreflang自動設定のユーティリティ)
インストールとファイル構成
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 でサポートする言語とデフォルト言語を定義する。
// 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にリダイレクトする役割を担う。
// 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でのロケール取得方法を定義する。
// 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 に翻訳テキストを定義する。
{
"nav": {
"home": "ホーム",
"about": "このブログについて",
"articles": "記事一覧"
},
"home": {
"hero": {
"title": "ググって最後にたどり着くページ",
"subtitle": "エラー解決・技術Tips・実装例を実体験ベースで書いています"
},
"latestArticles": "新着記事",
"allArticles": "すべての記事を見る"
},
"article": {
"publishedAt": "公開日",
"updatedAt": "更新日",
"readingTime": "{minutes}分で読めます",
"tableOfContents": "目次",
"relatedArticles": "関連記事"
}
}
{
"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のプロバイダーを設定する。
// 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での翻訳使用
// 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での翻訳使用
// 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 にリンクが張られる。
言語切替ボタンの実装
ユーザーが言語を切り替えられるスイッチャーを作る。
// 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の型チェックを設定できる。翻訳キーの打ち間違いをビルド時に検出できるようになる。
// types/global.d.ts
import en from "../messages/en.json";
type Messages = typeof en;
declare global {
interface IntlMessages extends Messages {}
}
これを設定すると、t("nav.hme") のような存在しないキーを書いたときにTypeScriptがエラーを出してくれる。
// ❌ キーが存在しないので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はこのユーティリティも提供している。
// 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.ts | Server Componentでのメッセージロードを設定 |
messages/ja.json | 日本語翻訳テキスト |
app/[locale]/layout.tsx | NextIntlClientProviderの設定 |
Server Componentでは getTranslations、Client Componentでは useTranslations と使い分ける点が最初は混乱しやすい。URLリンクはnext-intlの Link を使えば自動でロケールが付くので、素のNext.jsの Link と使い分けることも忘れずに。
32blog.comでは22,000以上のページビューをこの構成で捌いている。App Routerのnext-intlは安定して動いている。