Internationalization in Next.js: 11-Locale Implementation

Build a production-ready 11-locale Next.js app with next-intl v4. Covers routing, RTL support, translation workflows, hreflang SEO, and server/client component patterns.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分で読める1.4k 語数|

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>
  );
}

翻訳ワークフロー

新しいキーの追加

  1. 最初に en.json に追加します (信頼できる情報源)
  2. 翻訳スクリプトを実行します。
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 を使用します。パターンは大きく異なります。Pag​​es Router は getStaticProps + NextIntlProvider を使用しますが、App Router はレイアウトで getRequestConfig + NextIntlClientProvider を使用します。 Pages Router を使用している場合は、i18n を大規模に実装する前に App Router への移行を検討してください。

アラビア語とウルドゥー語の RTL サポートを設定するにはどうすればよいですか?

ロケールが ar または ur の場合は、<html> 要素に dir="rtl" を設定します。方向固有のスタイルには、Tailwind の rtl: バリアント プレフィックスを使用します (RTL では rtl:mr-4ml-4 になります)。互換性のあるフォントをロードする — Noto Sans Arabic はアラビア語とウルドゥー語の両方の文字をカバーします。ハードコーディングされた left/right CSS 値は避けてください。 start/end 論理プロパティ (Tailwind v4 では ms-4me-4) を使用します。

大量のコンテンツを迅速に翻訳するための最良のアプローチは何ですか?

すべてのキーとコンテンツの最初の機械翻訳には Google Translate API を使用し、その後、ネイティブ スピーカーに視認性の高いコンテンツ (ホームページ、製品説明、CTA コピー) をレビューしてもらいます。ブログ コンテンツの場合、機械翻訳は出発点として許容されますが、技術的な正確性を確認する必要があります。少なくともトラフィックの上位 3 ~ 5 のロケールをネイティブ スピーカーがレビューするための予算を設定します。


次のステップ

国際化を正しく行うと、競争の堀が生まれます。コンテンツは 11 倍多くの潜在顧客にリーチし、SEO の hreflang シグナルは分割されたリンクの資産ではなく統合され、母国語のユーザーは大幅に高い変換率で変換されます。

ECOSIRE は、5,577 個のコンテンツ ファイル、12,543 個の変換キー、および RTL レイアウト サポートをカバーする完全な 11 ロケール実装を構築および維持しています。 Next.js アプリケーションの国際化が必要な場合は、フロントエンド エンジニアリング サービスを探索して、コードベースに国際化を実装する方法を確認してください。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット