目次
App Router は、Next.js におけるデータフェッチの考え方を変えました。同時に、i18n についての考え方も変える必要があります。世に出回っているガイドの多くは、今でも翻訳をクライアントサイドの問題として扱っています——実行時に読み込む JSON ファイルの束として。それは Pages Router の時代には理にかなっていました。しかし 2026 年、React Server Components がデフォルトとなった今、より良いアプローチが可能です。
この記事では、RSC ネイティブな i18n のために私たちが採用したパターンを解説します。ミドルウェアでのロケール検出方法、クライアントオーバーヘッドなしにサーバーコンポーネントで翻訳をフェッチする方法、クライアントアイランドをクリーンにハイドレートする方法、そして CDN ファーストのデリバリーが状況をどう変えるかについてです。
なぜ RSC が i18n の根本を変えるのか
Pages Router では、getServerSideProps または getStaticProps がサーバーサイドで翻訳をフェッチし、それをページバンドルにシリアライズしていました。クライアントはすべてを再ハイドレートしていました——ユーザーごとに変化することのない翻訳辞書も含めて。
Server Components はそのパターンをなくします。RSC の出力にはハイドレーションステップがありません——HTML は最終形です。つまり、サーバーコンポーネントで翻訳をフェッチし、翻訳済み文字列を直接 HTML にレンダリングし、クライアントに翻訳関連の JavaScript をゼロで配信できます。辞書バンドルなし、実行時ルックアップなし、ハイドレーションの不一致なし。
落とし穴があるとすれば、翻訳が必要なクライアントコンポーネント(インタラクティブなフォームや動的 UI)は依然として何らかのクライアントサイドアクセスを必要とする点です。2つの関心事を明示的に分離すれば、パターンは明確になります。
ミドルウェア:リクエストがアプリに到達する前のロケール検出
ロケールルーティングはミドルウェアで行います。エッジで、コンポーネントがレンダリングされる前に実行する必要があるためです。
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es'] as const
type Locale = typeof SUPPORTED_LOCALES[number]
const DEFAULT_LOCALE: Locale = 'en'
function detectLocale(request: NextRequest): Locale {
// 1. Check URL path prefix
const pathname = request.nextUrl.pathname
const pathLocale = pathname.split('/')[1] as Locale
if (SUPPORTED_LOCALES.includes(pathLocale)) {
return pathLocale
}
// 2. Check Accept-Language header
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
const preferred = acceptLanguage
.split(',')
.map(lang => lang.split(';')[0].trim().slice(0, 2) as Locale)
.find(lang => SUPPORTED_LOCALES.includes(lang))
if (preferred) return preferred
}
return DEFAULT_LOCALE
}
export function middleware(request: NextRequest) {
const locale = detectLocale(request)
const pathname = request.nextUrl.pathname
// Redirect root to locale-prefixed path
if (!SUPPORTED_LOCALES.some(l => pathname.startsWith(`/${l}`))) {
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
)
}
// Inject locale into headers for server components
const response = NextResponse.next()
response.headers.set('x-locale', locale)
return response
}
export const config = {
matcher: ['/((?!api|_next|_static|favicon.ico).*)'],
}
x-locale ヘッダーは、ミドルウェアとサーバーコンポーネントをつなぐ橋です。すべてのリクエストにセットすることで、どのサーバーコンポーネントも props のバケツリレーなしに読み取ることができます。
App Router の構造:レイアウトセグメントとしてのロケール
App Router の慣例的なアプローチでは、ロケール固有のルートを [locale] セグメント以下にまとめます:
app/
[locale]/
layout.tsx ← sets locale context, fetches base translations
page.tsx ← home page
blog/
[slug]/
page.tsx
dashboard/
page.tsx
api/
...
[locale]/layout.tsx は、サブツリー全体の翻訳をブートストラップする場所です:
// app/[locale]/layout.tsx
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { TranslationProvider } from '@/components/translation-provider'
import { fetchTranslations } from '@/lib/translations'
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es']
interface LayoutProps {
children: React.ReactNode
params: { locale: string }
}
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { locale } = await params
if (!SUPPORTED_LOCALES.includes(locale)) {
notFound()
}
// Fetch base translations for layout-level strings
const baseTranslations = await fetchTranslations(locale, 'common')
return (
<html lang={locale}>
<body>
<TranslationProvider locale={locale} messages={baseTranslations}>
{children}
</TranslationProvider>
</body>
</html>
)
}
サーバーコンポーネント:エッジで翻訳をフェッチする
重要な変化はここです。アプリとともにロケールファイルをバンドルする代わりに、リクエスト時に CDN から翻訳をフェッチします。この関数は他の非同期データフェッチと同じように見えます:
// lib/translations.ts
import { cache } from 'react'
type Messages = Record<string, string>
// React cache() deduplicates fetches within a single render pass
export const fetchTranslations = cache(async (
locale: string,
namespace: string
): Promise<Messages> => {
const url = `https://cdn.better-i18n.com/v1/${locale}/${namespace}.json`
const res = await fetch(url, {
next: {
revalidate: 3600, // CDN-delivered, revalidate hourly
tags: [`translations-${locale}-${namespace}`],
},
})
if (!res.ok) {
// Fall back to English rather than breaking the page
if (locale !== 'en') {
return fetchTranslations('en', namespace)
}
throw new Error(`Failed to fetch translations: ${res.status}`)
}
return res.json()
})
React の cache() ラッパーにより、同じページ上の複数のサーバーコンポーネントが同じ名前空間をリクエストした場合、1回のレンダリングパスにつきネットワークリクエストは1度だけ発生します。Next.js のフェッチキャッシングと組み合わせることで、CDN レスポンスは通常エッジキャッシュから配信されます——翻訳ルックアップのレイテンシはサブミリ秒です。
このパターンを使ったサーバーコンポーネントの例:
// app/[locale]/blog/[slug]/page.tsx
import { fetchTranslations } from '@/lib/translations'
import { getPost } from '@/lib/posts'
interface PageProps {
params: { locale: string; slug: string }
}
export default async function BlogPost({ params }: PageProps) {
const { locale, slug } = await params
// Both fetches happen in parallel
const [t, post] = await Promise.all([
fetchTranslations(locale, 'blog'),
getPost(slug),
])
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<p className="meta">
{t['blog.readTime'].replace('{n}', String(post.readTimeMinutes))}
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<footer>
<a href={`/${locale}/blog`}>{t['blog.backToList']}</a>
</footer>
</article>
)
}
クライアント JavaScript はゼロ。ハイドレーションもなし。翻訳キーのルックアップはサーバー上で行われ、結果はシリアライズされた HTML になります。
型安全な翻訳キー
文字列ベースのキールックアップ(t['blog.readTime'])は、キーが変わったときに静かに壊れます。ベースロケールのメッセージファイルから型を生成することで対処します:
// types/translations.ts (generated, do not edit manually)
// Generated from: en/blog.json
export type BlogNamespaceKey =
| 'blog.readTime'
| 'blog.backToList'
| 'blog.publishedOn'
| 'blog.updatedOn'
| 'blog.authorBy'
| 'blog.sharePost'
| 'blog.relatedPosts'
// Typed accessor
export function t(messages: Record<string, string>, key: BlogNamespaceKey): string {
const value = messages[key]
if (value === undefined) {
// In development, surface the missing key
if (process.env.NODE_ENV === 'development') {
console.warn(`Missing translation key: ${key}`)
}
return key
}
return value
}
生成ステップは、翻訳ファイルが変更されるたびに CI パイプラインの一部として実行されます。TypeScript はその後、欠落したキーや名前変更されたキーを本番環境の実行時ではなくコンパイル時に検出します。
これは、Better i18n のようなプラットフォームが真に価値を発揮する領域の一つです——型生成が自動化され、ダッシュボードから新しいキーをプッシュすると同時にエディタ上で型の出力が更新されます。
クライアントコンポーネントのハイドレーション:翻訳アイランド
すべてをサーバーコンポーネントにできるわけではありません。フォーム、モーダル、インタラクティブな UI は、動的な文字列——エラーメッセージ、バリデーションフィードバック、非同期状態ラベル——のためにクライアントサイドの翻訳アクセスが必要です。
パターンは、関連するメッセージのサブセットをシリアライズするコンテキストプロバイダーです:
// components/translation-provider.tsx
'use client'
import { createContext, useContext } from 'react'
type Messages = Record<string, string>
const TranslationContext = createContext<{
messages: Messages
locale: string
} | null>(null)
export function TranslationProvider({
children,
messages,
locale,
}: {
children: React.ReactNode
messages: Messages
locale: string
}) {
return (
<TranslationContext.Provider value={{ messages, locale }}>
{children}
</TranslationContext.Provider>
)
}
export function useTranslations(namespace?: string) {
const ctx = useContext(TranslationContext)
if (!ctx) throw new Error('useTranslations must be used inside TranslationProvider')
return {
t: (key: string) => {
const fullKey = namespace ? `${namespace}.${key}` : key
return ctx.messages[fullKey] ?? fullKey
},
locale: ctx.locale,
}
}
レイアウトのサーバーコンポーネントがメッセージをフェッチし、プロバイダーに渡します。クライアントコンポーネントは追加のフェッチをトリガーすることなく useTranslations() を呼び出せます:
// components/contact-form.tsx
'use client'
import { useTranslations } from '@/components/translation-provider'
import { useState } from 'react'
export function ContactForm() {
const { t } = useTranslations('forms')
const [error, setError] = useState<string | null>(null)
async function handleSubmit(formData: FormData) {
const result = await submitContact(formData)
if (!result.ok) {
setError(t('contact.submitError'))
}
}
return (
<form action={handleSubmit}>
<label>{t('contact.nameLabel')}</label>
<input name="name" required />
<label>{t('contact.emailLabel')}</label>
<input name="email" type="email" required />
<button type="submit">{t('contact.submitButton')}</button>
{error && <p role="alert">{error}</p>}
</form>
)
}
重要な制約:クライアントコンポーネントが実際に必要とする名前空間だけを渡してください。翻訳辞書全体をクライアントバンドルにシリアライズしては、本末転倒です。
静的ルート:generateStaticParams
静的生成ページ(マーケティングページ、ブログ投稿、ドキュメント)では、generateStaticParams を使ってビルド時にすべてのロケールを事前レンダリングします:
// app/[locale]/page.tsx
export async function generateStaticParams() {
const locales = ['en', 'de', 'fr', 'es']
return locales.map(locale => ({ locale }))
}
export async function generateMetadata({ params }: { params: { locale: string } }) {
const { locale } = await params
const t = await fetchTranslations(locale, 'home')
return {
title: t['home.meta.title'],
description: t['home.meta.description'],
alternates: {
languages: {
'en': '/en',
'de': '/de',
'fr': '/fr',
'es': '/es',
},
},
}
}
CDN デリバリーの翻訳では、ビルド時に CDN からメッセージファイルをフェッチし、静的 HTML に焼き込みます。実行時には CDN が静的ファイルを配信します——翻訳ルックアップ時間は事実上ゼロです。
next-intl および next-i18next との比較
next-intl は App Router 互換ライブラリとして最も成熟しています。上述したミドルウェアやコンテキストのボイラープレートの多くを処理してくれます。トレードオフは、ロケールファイルがリポジトリ内に存在すること(/messages/en.json、/messages/de.json)を前提としている点です。新しい翻訳を追加するたびにコードのデプロイが必要です。
next-i18next は Pages Router 向けに構築されました。App Router プロジェクトでは、せいぜい互換シムに過ぎません。新規プロジェクトでの使用は避けたほうがよいでしょう。
CDN ファーストアプローチ(本記事で解説したもの)は、翻訳コンテンツをコードのデプロイから切り離します。フランス語の翻訳を更新すると、数秒以内に CDN 上で反映されます——再デプロイなし、PR なし、CI を待つ必要もありません。翻訳チームはエンジニアリングチームとは独立して動けます。
ファイルベースのアプローチに対する「シンプルだ」という運用面の主張は、20のロケールと専任のローカリゼーションチームが存在するとすぐに崩れます。翻訳 PR のマージはノイズであって、価値ではありません。
2026年ならこう変える
- ストリーミング翻訳:CDN フェッチが解決する間、翻訳量の多いコンポーネントを
<Suspense>で囲む——CDN の cold キャッシュがレイテンシを引き起こすエッジケースで有効です。 - コンポーネントごとの名前空間粒度:レイアウトレベルのフェッチではなく、各コンポーネントが必要とする名前空間だけをフェッチする。翻訳要件が限定的なページではペイロードサイズを削減できます。
- 複数形とフォーマットルール:ライブラリの抽象化に頼るのではなく、
Intl.PluralRulesとIntl.NumberFormatを直接使用する。ブラウザ API は今やサポートされているすべての環境で信頼できます。
はじめに
上記のパターンは、CDN デリバリーの翻訳ソースであれば何でも動作します。インフラ構築をスキップして、型安全なキー、自動 CDN デリバリー、翻訳者に優しいエディタをすぐに使いたい場合は、Better i18n の Next.js インテグレーションがこのスタック——ミドルウェアヘルパー、型付きキー生成、コードに触れずに翻訳チームが使えるダッシュボード——をカバーしています。
API リファレンスと SDK インテグレーションガイドの全文は開発者向けドキュメントをご覧ください。