Next.js の国際化: 11 ロケールの実装
世界中の視聴者向けに構築するには、Google 翻訳を介して文字列を実行するだけでは不十分です。実稼働国際化の実装には、ルーティング戦略、RTL (右から左) レイアウトのサポート、サーバー側のロケール解決、翻訳キーの構成、SEO hreflang シグナル、および持続可能な翻訳ワークフローが含まれます。これらのいずれかが間違っていると、アラビア語/ウルドゥー語ユーザー向けのレイアウトが壊れたり、検索ランキングを分割する hreflang タグが欠落したり、コンテンツの増加の重みで翻訳パイプラインが崩壊したりすることになります。
このガイドは、ECOSIRE.COM で使用される完全な 11 ロケール設定 (英語、中国語、スペイン語、アラビア語、ポルトガル語、フランス語、ドイツ語、日本語、トルコ語、ヒンディー語、ウルドゥー語) を next-intl v4.8 上に構築したものについて文書化しています。ここにあるすべてのパターンは、5,577 個の MDX コンテンツ ファイルと 12,543 個の変換キーにわたって実稼働環境でテストされています。
重要なポイント
localePrefix: 'as-needed'を使用する — 英語には接頭辞がありません。他のロケールでは/zh/、/ar/などが取得されます。- next-intl v4 は
routing.ts+navigation.tsパターンを使用します。ロケール対応コンポーネントではnext/navigationから直接インポートしないでください- アラビア語 (
ar) とウルドゥー語 (ur) には、<html>上のdir="rtl"と RTL 互換フォント (Noto Sans Arabic) が必要ですen.jsonキーはネストされたオブジェクトでなければなりません — フラットなドットで区切られたキーは名前空間の解決を妨げます- すべてのページの hreflang に
alternates.languagesを使用したgenerateMetadata()(決して静的なexport const metadata)- サーバー コンポーネントは
getTranslations('namespace')を使用し、クライアント コンポーネントはuseTranslations('namespace')を使用します- 管理ページは 2 つの変換フックを使用します: モジュール固有のキーには
t、共有admin.commonキーにはtc- Google Translate API を使用して新しいキーの翻訳を自動化します。手動翻訳の開発を妨げないでください
プロジェクトのセットアップ
pnpm add next-intl@4
各アプリのディレクトリ構造:
src/
i18n/
routing.ts # defineRouting() — locale list, prefix strategy
navigation.ts # createNavigation() — locale-aware Link, useRouter
request.ts # getRequestConfig() — server-side message loading
messages/
en.json # Source of truth (~12,543 keys)
zh.json
es.json
ar.json
pt.json
fr.json
de.json
ja.json
tr.json
hi.json
ur.json
ルーティング構成
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const locales = [
'en', 'zh', 'es', 'ar', 'pt', 'fr', 'de', 'ja', 'tr', 'hi', 'ur',
] as const;
export type Locale = (typeof locales)[number];
export const rtlLocales: Locale[] = ['ar', 'ur'];
export const localeNames: Record<Locale, string> = {
en: 'English',
zh: '中文',
es: 'Español',
ar: 'العربية',
pt: 'Português',
fr: 'Français',
de: 'Deutsch',
ja: '日本語',
tr: 'Türkçe',
hi: 'हिन्दी',
ur: 'اردو',
};
export const routing = defineRouting({
locales,
defaultLocale: 'en',
localePrefix: 'as-needed', // /en/blog → /blog, /zh/blog → /zh/blog
});
// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, useRouter, usePathname, getPathname } =
createNavigation(routing);
ミドルウェア/プロキシ
next-intl v4 は、ロケールの検出とルーティングにミドルウェア関数を使用します。
// src/proxy.ts (not middleware.ts — naming matters for Next.js 16)
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import { NextRequest, NextResponse } from 'next/server';
const intlMiddleware = createMiddleware(routing);
export function middleware(request: NextRequest) {
// Auth protection for dashboard routes
const isDashboard = request.nextUrl.pathname.includes('/dashboard');
const isPortal = request.nextUrl.pathname.includes('/portal');
const authCookie = request.cookies.get('ecosire_auth');
if ((isDashboard || isPortal) && !authCookie) {
const loginUrl = new URL('/auth/login', request.url);
// Prevent open redirect: only allow relative redirect paths
const from = request.nextUrl.pathname;
if (from.startsWith('/') && !from.startsWith('//')) {
loginUrl.searchParams.set('from', from);
}
return NextResponse.redirect(loginUrl);
}
return intlMiddleware(request);
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|.*\\..*|_vercel|favicon.ico).*)',
],
};
サーバー側のメッセージの読み込み
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Validate locale is supported; fall back to default
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
RTL とフォントをサポートするロケール レイアウト
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing, rtlLocales, type Locale } from '@/i18n/routing';
import { Noto_Sans, Noto_Sans_Arabic } from 'next/font/google';
const notoSans = Noto_Sans({ subsets: ['latin'], variable: '--font-sans' });
const notoArabic = Noto_Sans_Arabic({
subsets: ['arabic'],
variable: '--font-arabic',
});
export async function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'metadata' });
return {
title: { template: `%s | ${t('siteName')}`, default: t('defaultTitle') },
other: { 'content-language': locale }, // For Bing/AI engine language detection
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) {
notFound();
}
const messages = await getMessages();
const isRtl = rtlLocales.includes(locale as Locale);
return (
<html
lang={locale}
dir={isRtl ? 'rtl' : 'ltr'}
className={`${notoSans.variable} ${notoArabic.variable}`}
>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
翻訳キーの構成
en.json 構造体では、フラットなドット区切りのキーではなく、ネストされたオブジェクトを使用する必要があります。
// WRONG — flat keys break next-intl namespace resolution
{
"nav.home": "Home",
"nav.blog": "Blog",
"admin.common.save": "Save"
}
// CORRECT — nested objects
{
"nav": {
"home": "Home",
"blog": "Blog"
},
"admin": {
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"status": "Status"
},
"contacts": {
"title": "Contacts",
"addContact": "Add Contact",
"searchPlaceholder": "Search contacts..."
}
}
}
サーバーコンポーネント
// Server component: use getTranslations (async)
import { getTranslations } from 'next-intl/server';
export default async function ServicesPage({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'services.odoo' });
return (
<main>
<h1>{t('hero.title')}</h1>
<p>{t('hero.description')}</p>
</main>
);
}
// generateMetadata must also fetch translations for locale-aware metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'services.odoo' });
return {
title: t('meta.title'),
description: t('meta.description'),
alternates: {
canonical: `https://ecosire.com${locale === 'en' ? '' : `/${locale}`}/services/odoo`,
languages: {
'x-default': 'https://ecosire.com/services/odoo',
en: 'https://ecosire.com/services/odoo',
zh: 'https://ecosire.com/zh/services/odoo',
ar: 'https://ecosire.com/ar/services/odoo',
// ... all 11 locales
},
},
};
}
クライアントコンポーネント
// Client component: use useTranslations (synchronous)
'use client';
import { useTranslations } from 'next-intl';
// Admin pages use two hooks: module-specific + shared admin.common
export default function ContactsPage() {
const t = useTranslations('admin.contacts'); // Module-specific
const tc = useTranslations('admin.common'); // Shared terms
// IMPORTANT: stateConfig must be inside component (after hooks init)
const stateConfig = {
active: { label: tc('statusActive'), className: 'bg-green-100 text-green-800' },
inactive: { label: tc('statusInactive'), className: 'bg-gray-100 text-gray-700' },
};
return (
<div>
<h1>{t('title')}</h1>
<button>{tc('addNew')}</button>
{/* ... */}
</div>
);
}
言語スイッチャー コンポーネント
// components/language-switcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation'; // NOT next/navigation
import { locales, localeNames } from '@/i18n/routing';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Globe } from 'lucide-react';
export function LanguageSwitcher() {
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const handleLocaleChange = (newLocale: string) => {
router.replace(pathname, { locale: newLocale });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label="Change language">
<Globe className="h-4 w-4" />
<span>{localeNames[locale as keyof typeof localeNames]}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{locales.map((loc) => (
<DropdownMenuItem
key={loc}
onClick={() => handleLocaleChange(loc)}
className={loc === locale ? 'font-bold' : ''}
>
{localeNames[loc]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
翻訳ワークフロー
新しいキーの追加
- 最初に
en.jsonに追加します (信頼できる情報源) - 翻訳スクリプトを実行します。
node apps/web/scripts/translate-missing.mjs
スクリプトは、en.json には存在するが、他のロケール ファイルには存在しないキーを見つけて、Google Translate API を呼び出します。
// scripts/translate-missing.mjs (simplified)
import { Translate } from '@google-cloud/translate/build/src/v2/index.js';
import { readFileSync, writeFileSync } from 'fs';
const translate = new Translate({ key: process.env.GOOGLE_TRANSLATE_API_KEY });
async function translateMissingKeys(sourceLocale, targetLocale, sourceMessages, targetMessages) {
const missing = findMissingKeys(sourceMessages, targetMessages);
for (const [keyPath, value] of missing) {
const [translated] = await translate.translate(value, targetLocale);
setNestedKey(targetMessages, keyPath, translated);
}
return targetMessages;
}
翻訳範囲の検証
# Check for missing keys across all locales
node apps/web/scripts/validate-blog-i18n.mjs
# Fix flat keys accidentally added
node apps/web/scripts/fix-en-flat-keys.mjs
ブログコンテンツの翻訳
MDX コンテンツ ファイルの場合、フロントマターのタイトルは英語のままですが (タイトルは i18n JSON です)、本文のコンテンツはロケールごとに翻訳されます。
src/content/blog/
en/ # English originals (or root directory)
odoo-erp-guide.mdx
zh/
odoo-erp-guide.mdx # Translated body
ar/
odoo-erp-guide.mdx
# ... 10 locale directories
// src/lib/blog.ts — locale-aware loader
export async function getPost(slug: string, locale: string) {
// Try locale-specific file first
const localePath = path.join(contentDir, locale, `${slug}.mdx`);
const englishPath = path.join(contentDir, `${slug}.mdx`);
const filePath = fs.existsSync(localePath) ? localePath : englishPath;
const raw = fs.readFileSync(filePath, 'utf8');
const { data: frontmatter, content } = matter(raw);
return { frontmatter, content, locale };
}
よくある質問
なぜ always ではなく localePrefix: 'as-needed' を使用するのですか?
as-needed では、英語の URL には接頭辞 (/blog/post) がありませんが、他のロケールには接頭辞 (/zh/blog/post) が付きます。これは標準的な規則です。英語がデフォルトであり、URL はクリーンです。 always を使用すると、英語でも /en/blog/post が取得されます。これにより、不要なリダイレクトが作成され、移行する場合に既存の英語のリンク資産が分割されます。
なぜ en.json キーはフラットなドット区切りの文字列ではなく、ネストされたオブジェクトでなければならないのですか?
next-intl の名前空間解決は、名前空間文字列をドットで分割して、JSON ツリーを走査します。キーが "admin.contacts.title" のようなフラットなドット区切りの文字列である場合、 next-intl はネストされたパスとして messages.admin.contacts.title を検索しますが、キーがルート レベルで文字列 "admin.contacts.title" であることが判明し、検索が失敗します。常に実際のネストされたオブジェクトを使用してください。
言語間で複数形化を処理するにはどうすればよいですか?
next-intl は複数形化のために ICU メッセージ形式を使用します。英語には 2 つの複数形 (one/other) があり、アラビア語には 6 つ (0、1、2、少数、多数、その他) があります。翻訳値に {count, plural, one {# item} other {# items}} ICU 構文を使用すると、next-intl が各ロケールに正しい複数形ルールを自動的に適用します。
Pages Router で next-intl を使用できますか?
next-intl v4 は主に App Router 用に設計されています。 Pages Router の場合は、next-intl v2 または v3 を使用します。パターンは大きく異なります。Pages Router は getStaticProps + NextIntlProvider を使用しますが、App Router はレイアウトで getRequestConfig + NextIntlClientProvider を使用します。 Pages Router を使用している場合は、i18n を大規模に実装する前に App Router への移行を検討してください。
アラビア語とウルドゥー語の RTL サポートを設定するにはどうすればよいですか?
ロケールが ar または ur の場合は、<html> 要素に dir="rtl" を設定します。方向固有のスタイルには、Tailwind の rtl: バリアント プレフィックスを使用します (RTL では rtl:mr-4 が ml-4 になります)。互換性のあるフォントをロードする — Noto Sans Arabic はアラビア語とウルドゥー語の両方の文字をカバーします。ハードコーディングされた left/right CSS 値は避けてください。 start/end 論理プロパティ (Tailwind v4 では ms-4、me-4) を使用します。
大量のコンテンツを迅速に翻訳するための最良のアプローチは何ですか?
すべてのキーとコンテンツの最初の機械翻訳には Google Translate API を使用し、その後、ネイティブ スピーカーに視認性の高いコンテンツ (ホームページ、製品説明、CTA コピー) をレビューしてもらいます。ブログ コンテンツの場合、機械翻訳は出発点として許容されますが、技術的な正確性を確認する必要があります。少なくともトラフィックの上位 3 ~ 5 のロケールをネイティブ スピーカーがレビューするための予算を設定します。
次のステップ
国際化を正しく行うと、競争の堀が生まれます。コンテンツは 11 倍多くの潜在顧客にリーチし、SEO の hreflang シグナルは分割されたリンクの資産ではなく統合され、母国語のユーザーは大幅に高い変換率で変換されます。
ECOSIRE は、5,577 個のコンテンツ ファイル、12,543 個の変換キー、および RTL レイアウト サポートをカバーする完全な 11 ロケール実装を構築および維持しています。 Next.js アプリケーションの国際化が必要な場合は、フロントエンド エンジニアリング サービスを探索して、コードベースに国際化を実装する方法を確認してください。
執筆者
ECOSIRE Research and Development Team
ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。
関連記事
Next.js 16 App Router: Production Patterns and Pitfalls
Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.
eコマースを国際市場に拡大する: 完全な戦略ガイド
市場調査、ローカリゼーション、物流、支払い、法令順守、マーケティング戦略を網羅した、国際的な e コマース拡大のための完全ガイド。
国際展開のための ERP: 2026 年の多国運営の管理
ERP システムを使用して、多通貨会計、税務コンプライアンス、ローカリゼーション、サプライ チェーン管理、Odoo 複数企業構成などの国際展開を管理する方法。