Next.js多言語化の実践:next-intl・hreflang・RTL対応の教訓#
私が運用している3つの本番サイトは、テーマ的にはまったく関係がありません。開発者ポートフォリオ、ランナー向け計算ツール、天候が体調に与える影響を扱うヘルスケアサービス——それぞれ別物です。ですが、i18nアーキテクチャはすべて同じです。dodecaidr.proは5言語(ru、en、es、ja、zh-CN)に対応し、runcalculator.proも同じく5言語、meteohealth.proはアラビア語のRTLレイアウトを含む6言語に対応しています。3つともNext.js App RouterとNext-intlの上に構築されており、3つとも同じ落とし穴——壊れたhreflang、整合性テストで気づかれずに抜け落ちる翻訳、アラビア語で崩れるレイアウト——にぶつかってきました。
この記事はnext-intlの入門チュートリアルではありません。runcalculator.proとmeteohealth.proから実際のコードを引用しながら、3つのプロジェクトすべてで再利用しているアーキテクチャ上の判断を整理し、自分が実際に犯した、あるいは意図的に避けたミスをまとめたものです。
3つの本番サイト、共通する悩み#
サイトが単一言語で動いている間、i18nは翻訳文字列のためのライブラリにすぎません。しかし言語数が5つを超えると、i18nはルーティング、SEO、コンテンツのテスト、レイアウトにまで関わるアーキテクチャ上の意思決定になります。3つのプロジェクトすべてで、私は同じパターンに行き着きました。
localePrefix: 'always'— デフォルト言語であっても「プレフィックスなし」のパスは存在せず、すべてのURLがロケールプレフィックス(/ru/...、/en/...)を持ちます。キャッシュ、ログ、hreflangの扱いがシンプルになる一方、明示的なx-defaultが必要になります。- レンダリング前にロケールを解決するミドルウェア/プロキシ — next-intlはページの組み立てが始まる前に、URL・Cookie・
Accept-Languageからロケールを決定します。 - 単一のソースから生成されるhreflangと
x-default— ページごとに手書きするのではなく、ロケール一覧を把握した関数が生成します。 - 並列フォルダに配置されるローカライズ済みコンテンツ — 数万行に及ぶJSON辞書ではなく、
content/<type>/<locale>/...という構造を使います。 - ロケール整合性テスト — 新しい記事に5〜6のロケールバージョンのいずれかが欠けているとCIが失敗するスクリプトです。
- RTLレイアウトの検証 — 3プロジェクト全部に関係するわけではありませんが、アラビア語が存在する場所(meteohealth.pro)では避けて通れません。
以降では、それぞれのポイントが実際のコードでどう表現されているかを見ていきます。
唯一の信頼できる情報源:ロケール設定#
他人のプロジェクトでよく見かける(そして自分も一度やってしまった)最初のミスは、ミドルウェア、next-intlの設定、言語切り替えコンポーネントのそれぞれにロケール一覧が重複して存在していることです。ロケールを追加すると切り替えUIには表示されるのに、ミドルウェア側では動かない——そこには別の配列があるからです。
解決策は、他のすべてがインポートする単一のファイルを用意することです。
// lib/i18n/config.ts
export const locales = ['ru', 'en', 'es', 'ja', 'zh-CN'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'ru'
export const i18nConfig = {
locales,
defaultLocale,
localePrefix: 'always' as const, // every URL keeps its locale prefix
}
export function isValidLocale(locale: string): locale is Locale {
return locales.includes(locale as Locale)
}
export function getValidLocale(locale: string | undefined): Locale {
if (!locale) return defaultLocale
return isValidLocale(locale) ? locale : defaultLocale
}ミドルウェア、サイトマップ生成関数、hreflang生成関数、そしてMDXコンテンツローダーは、すべてここからlocalesとdefaultLocaleをインポートします。新しいロケールの追加は配列を1つ編集し、翻訳ファイルとコンテンツファイルを追加するだけで済み、ルーティングロジックを手で書き換える必要は一切ありません。
next-intl v4:requestLocaleとメッセージの読み込み#
next-intl v4では、getRequestConfigに渡されるロケールが同期的ではなくPromiseとして届きます。この変更により、型チェックをしていなかったコードはv3からのアップグレード時に壊れました。現在の標準的なパターンは次のとおりです。
// i18n.ts
import { notFound } from 'next/navigation'
import { getRequestConfig } from 'next-intl/server'
import { locales, defaultLocale, isValidLocale, getValidLocale, type Locale } from './lib/i18n/config'
export default getRequestConfig(async ({ requestLocale }) => {
// next-intl v4: locale arrives as a Promise
const requested = await requestLocale
if (requested && !isValidLocale(requested)) {
notFound()
}
const locale = getValidLocale(requested)
const [common, marketing] = await Promise.all([
import(`./locales/${locale}/common.json`),
import(`./locales/${locale}/marketing.json`),
])
return {
locale,
messages: {
common: common.default,
marketing: marketing.default,
},
timeZone: getTimeZone(locale),
}
})
function getTimeZone(locale: Locale): string {
const timeZones: Record<Locale, string> = {
ru: 'Europe/Moscow',
en: 'America/New_York',
es: 'Europe/Madrid',
ja: 'Asia/Tokyo',
'zh-CN': 'Asia/Shanghai',
}
return timeZones[locale] ?? 'UTC'
}チュートリアルではあまり触れられない2点があります。まずnotFound()は、ロケールが渡された上で無効な場合にのみ呼ぶべきで、渡されていない場合に呼んではいけません——そうしないと、ロケールがまだ解決されていない経路がすべて壊れてしまいます。次にタイムゾーンは装飾的な要素ではありません。設定しなければ、Intl.DateTimeFormatとnext-intlの日付フォーマッターはサーバーのタイムゾーンにフォールバックし、日本語版サイトの記事公開日が前日の日付として表示されることがあります。
middleware.tsからproxy.tsへ:Next.js 16での変更点#
Next.js 16はmiddleware.tsをproxy.tsにリネームしました。ファイル名とエクスポートされる関数名は変わりましたが、next-intlとの契約は変わっていません。next-intl/middlewareのcreateMiddlewareは、これまでどおり単一のハンドラーにラップされます。
// proxy.ts — renamed from middleware.ts in Next.js 16
import createMiddleware from 'next-intl/middleware'
import { NextRequest } from 'next/server'
import { locales, defaultLocale } from './lib/i18n/config'
const intlMiddleware = createMiddleware({
locales,
defaultLocale,
localePrefix: 'always',
})
export default function proxy(request: NextRequest) {
return intlMiddleware(request)
}
export const config = {
// skip /api, /_next and static files with a dot in the path
matcher: ['/((?!api|_next|.*\\..*).*)'],
}実運用では、このファイルにリクエストのエンドツーエンドロギング(メソッド、パス、ステータス、所要時間)を追加すると便利です。intlMiddleware(request)を呼び出した「後」に実行すれば、i18nのリダイレクトを妨げることはありません。
hreflangとx-default:サイトの75%が失敗する場所#
Ahrefsの統計によれば、国際版を持つサイトの約75%がhreflangを誤って実装しています。相互リンクの欠如(AページがBページにリンクしているのに逆方向がない)、タグ内の壊れたURL、そして最も多いのが——すべての言語バージョンでcanonicalが英語ページを指しているケースです。この最後のミスは実質的に検索エンジンに「他のロケールは無視してよい」と伝えてしまい、サイト運営者自身が自分のi18nの取り組みを無効化してしまう典型例です。
すべてのページでhreflangを手打ちしないよう、私は1つの関数で生成し、generateMetadataとサイトマップの両方で再利用しています。
// Generates alternates for both metadata and sitemap entries
function generateAlternates(route: string) {
const alternates: Record<string, string> = {}
for (const locale of locales) {
alternates[locale] = route
? `${baseUrl}/${locale}/${route}`
: `${baseUrl}/${locale}`
}
// With localePrefix: 'always' there is no locale-neutral URL,
// so x-default has to point somewhere — the default locale is the pragmatic choice
alternates['x-default'] = route
? `${baseUrl}/${defaultLocale}/${route}`
: `${baseUrl}/${defaultLocale}`
return alternates
}
// app/[locale]/articles/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params
return {
alternates: {
canonical: `${baseUrl}/${locale}/articles/${slug}`,
languages: generateAlternates(`articles/${slug}`),
},
}
}見落としがちなポイントとして、x-defaultはちょうど1回だけ登場し、どのロケールにも一致しないユーザー向けのページ——通常はホームページか言語選択ページ——を指すべきです。localePrefix: 'always'の場合、そもそもロケール中立なURLは存在しないため、x-defaultはデフォルトロケールを指すことになります。これは「正解」ではなく現実的な妥協であり、半年後に誰かが存在しないルートパスへ「修正」してしまわないよう、コードベース内に明示的にドキュメント化しておく価値があります。
各ページは自分自身も参照する必要があります(自己参照hreflang)。これがないと、検索エンジンはそのページのalternateタグ一式をまるごと無視してよいことになります。
並列フォルダに配置するMDXコンテンツ:Zodで検証し、整合性テストで確認する#
UI翻訳のJSON辞書は、ボタンやラベルには適した解決策ですが、記事やプロジェクト説明、プライバシーポリシーのようなコンテンツには向いていません。長文をJSONに保持すると、書式やコードブロックが失われ、エディタでファイルを開いてそのまま書くという単純な体験も失われます。そのため、コンテンツはロケールごとの並列フォルダに配置しています。
content/articles/
├── ru/general/nextjs-i18n-next-intl.mdx
├── en/general/nextjs-i18n-next-intl.mdx
├── es/general/nextjs-i18n-next-intl.mdx
├── ja/general/nextjs-i18n-next-intl.mdx
└── zh-CN/general/nextjs-i18n-next-intl.mdx各ファイルのフロントマターは、読み込み時にZodスキーマで検証されます。必須フィールド、許可されたカテゴリの値、YYYY-MM-DD形式の日付——翻訳者(あるいは夜23時の自分自身)がフィールドを書き忘れたり、カテゴリ名をタイプミスしたりすると、ビルドは本番で空の記事カードを静かに表示するのではなく、わかりやすいZodエラーで失敗します。
別途用意した整合性テストスクリプトは、すべてのslugがすべてのロケールにファイルとして存在すること、そしてJSON翻訳辞書の構造(値ではなくキー)が言語間で一致していることを確認します。このテストがなければ、典型的な失敗パターンはこうなります。記事がロシア語で公開され、PRがマージされ、1ヶ月後になって日本語版サイトがいまだに404を表示している——あるいはもっと悪いことに、共有のデフォルト値から取られた古いタイトルを表示している、というケースです。
RTLはdir="rtl"だけの話ではない:meteohealth.proからの教訓#
3つのプロジェクトのうち、アラビア語に対応しているのはmeteohealth.proだけであり、まさにそこでru/en/es/ja/zh-CNでは一切発生しないRTL特有のバグに遭遇しました——これら5言語はいずれもレイアウトを反転させません。<html>へのdir="rtl"指定で作業の約60%はカバーできます。テキストは正しい方向に配置され、正しい方向に流れます。残り40%は、鏡合わせに反転すべきアイコン(「前進」を示す矢印が逆方向を指すことはできません)、文字単位で方向が切り替わらずにアラビア数字とテキストを受け付けるべき入力欄、そしてRTLでは単純に逆側になってしまうmargin-left/padding-rightベースのレイアウトです。
// app/[locale]/layout.tsx
import { locales, type Locale } from '@/lib/i18n/config'
const RTL_LOCALES: Locale[] = ['ar'] // meteohealth.pro only
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: Locale }>
}) {
const { locale } = await params
const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr'
return (
<html lang={locale} dir={dir}>
<body className="ms-0 pe-4 rtl:pe-0 rtl:ps-4">
{children}
</body>
</html>
)
}RTLロケールを持つあらゆるプロジェクトに応用できる実践的な結論は次のとおりです。
- 物理プロパティ(
margin-left/padding-right)ではなく、論理CSSプロパティ(margin-inline-start、padding-inline-end。Tailwindではms-/ps-/me-/pe-)を使う——そうすればrtl:でクラスを二重に書かなくても、反転は自動的に起こります。 - 論理プロパティではカバーできないケース(アイコン、特定の変形)には、Tailwindの
rtl:修飾子、たとえば矢印にはrtl:rotate-180を使います。 - ブランド名、メールアドレス、コードのようにラテン文字が混在するアラビア語テキストは
<bdi>で囲むべきです。そうしないと、方向の境界で文字の並び順が視覚的に崩れることがあります。 - RTLはリリース前の一度の目視チェックでは検証できません。カード、フォーム、ナビゲーションといった主要画面を
ltr版とrtl版でスクリーンショット比較することで、ユーザーからの報告でしか発覚しないような回帰を検出できます。 - テスト環境には初日から本物のアラビア語テキストを用意すべきで、「翻訳は後で追加する」ではいけません。疑似的に鏡合わせにしたラテン文字上のRTLバグと、本物のアラビア文字上のRTLバグは、まったく別の種類のバグです。
3プロジェクトの比較とチェックリスト#
| プロジェクト | ロケール数 | RTL | コンテンツ | 特徴 |
|---|---|---|---|---|
| dodecaidr.pro | 5(ru, en, es, ja, zh-CN) | なし | MDX + Zodスキーマ、CI上の整合性テスト | ポートフォリオと記事はUI文字列ではなくコンテンツとして扱う |
| runcalculator.pro | 5 | なし | MDXによる説明文とローカライズされた計算式 | ペースや距離などの数値フォーマットもテキストと同様にロケールに依存する |
| meteohealth.pro | 6(+アラビア語) | あり | MDXに加え、ローカライズされたスクリーンショットと5言語以上のポリシー | RTLが理論ではなく日々のレイアウト確認になる唯一のプロジェクト |
プロジェクトが変わっても引き継がれる要点を簡潔にまとめると、次のようになります。
- ロケールとデフォルトロケールは3箇所ではなく1つのファイルにまとめる。
- ミドルウェア/
proxy.tsはレンダリング前にロケールを解決し、next-intlの上で独自の判断を行わない。 - hreflangと
x-defaultは関数で生成し、ページごとに手作業でコピーしない。 - コンテンツはロケールごとのMDXとし、フロントマターはZodで検証し、CIで整合性テストを行う。
- RTLは
dirの一度きりの修正ではなく、論理CSSプロパティと、アラビア語やヘブライ語がロケール一覧に含まれる場合の専用テストパスをセットで扱う。
紙の上に書けば、この5点はすべて常識のように聞こえます。しかし実際には、ルールとして定着するまでに、3つのプロジェクトのどれかでこの5点それぞれが少なくとも一度は破られてきました。



