🌏

Next.js App Router + 静的エクスポートで実現する多言語対応アーキテクチャ

に公開

背景と要件

以下の制約条件下で多言語対応を実現したい。

  • Next.js 15 App Router with TypeScript
  • Static Export (output: 'export') - S3 + CloudFront での配信
  • サーバーサイドレンダリング不可 - 動的APIやミドルウェアが使用できない
  • SEO対応必須 - 検索エンジンに適切な言語コンテンツを提供

アーキテクチャ概要

1. ルーティング設計

/                    # ルートページ (デフォルト: en)
/en/                 # 英語ページ
/ja/                 # 日本語ページ

Dynamic Segment [locale] を使用し、静的生成時に各言語のページを事前レンダリングします。

2. 翻訳データ管理

翻訳データはJSON形式で管理:

src/locales/en.json
{
  "title": "title"
}
src/locales/ja.json
{
  "title": "タイトル"
}

3. React Context API による状態管理

src/i18n/context.tsx
interface I18nContextType {
  messages: Record<string, any>;
  locale: string;
}

const I18nContext = createContext<I18nContextType | undefined>(undefined);

export function I18nProvider({ 
  children, 
  messages, 
  locale 
}: I18nProviderProps) {
  return (
    <I18nContext.Provider value={{ messages, locale }}>
      {children}
    </I18nContext.Provider>
  );
}

export function useTranslations() {
  const context = useContext(I18nContext);
  if (!context) {
    throw new Error('useTranslations must be used within I18nProvider');
  }
  
  const t = (key: string): string => {
    // ネストしたキーをサポート
    const keys = key.split('.');
    let value: any = context.messages;
    for (const k of keys) {
      value = value?.[k];
    }
    return typeof value === 'string' ? value : key;
  };
  
  return { 
    t, 
    locale: context.locale,
    messages: context.messages,
    isLoaded: true 
  };
}

4. レイアウト階層での Context 提供

src/app/layout.tsx
export default async function RootLayout({ children }: Props) {
  const locale = defaultLocale;
  const messages = await getMessages(locale);
  
  return (
    <html lang={locale}>
      <body>
        <I18nProvider messages={messages} locale={locale}>
          <LanguageSwitcherClient />
          {children}
        </I18nProvider>
      </body>
    </html>
  );
}
src/app/[locale]/layout.tsx
export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params;
  const messages = await getMessages(locale);
  
  return (
    <div lang={locale}>
      <I18nProvider messages={messages} locale={locale}>
        {children}
      </I18nProvider>
    </div>
  );
}

デフォルト言語とクライアントサイドリダイレクト

サーバーサイド (静的生成時)

ルートページ (/) は常に 英語 (en) で生成されます:

src/app/page.tsx
export default async function RootPage() {
  const locale = defaultLocale; // 'en'
  const messages = await getMessages(locale);
  
  return (
    <I18nProvider messages={messages} locale={locale}>
      <ClientLocaleRedirect />
      <PageContent />
    </I18nProvider>
  );
}

理由:

  • SEO対策: 検索エンジンクローラーに安定したコンテンツを提供
  • 静的エクスポート制約: サーバー側でユーザー言語を判定できない

クライアントサイド (ブラウザ)

初回アクセス時、ブラウザの言語設定を検出して自動リダイレクト:

src/components/ClientLocaleRedirect.tsx
'use client';

export default function ClientLocaleRedirect() {
  useEffect(() => {
    // リダイレクト済みかチェック
    if (typeof window === 'undefined' || 
        sessionStorage.getItem('localeRedirectDone')) {
      return;
    }

    const supportedLocales = ['en', 'ja'];
    const pathname = window.location.pathname;
    
    // ルートパスの場合のみリダイレクト
    if (pathname === '/') {
      // ブラウザの言語設定を取得
      const browserLocales = navigator.languages || [navigator.language];
      
      // サポートされている言語を探す
      const preferredLocale = browserLocales
        .map(lang => lang.split('-')[0]) // 'ja-JP' -> 'ja'
        .find(lang => supportedLocales.includes(lang));
      
      // デフォルトと異なる場合はリダイレクト
      if (preferredLocale && preferredLocale !== defaultLocale) {
        sessionStorage.setItem('localeRedirectDone', 'true');
        window.location.replace(`/${preferredLocale}`);
      }
    }
  }, []);

  return null;
}

動作シナリオ

状況 SSG時 クライアント動作
検索エンジンクローラー / を英語で生成 リダイレクトなし
日本語ブラウザユーザー / を英語で生成 /ja へリダイレクト
英語ブラウザユーザー / を英語で生成 リダイレクトなし (そのまま表示)
未サポート言語ユーザー / を英語で生成 リダイレクトなし (英語表示)

コンポーネントでの使用方法

'use client';

function NetworkInfoClient() {
  const { t } = useTranslations();
  
  return (
    <div>
      <h2>{t('title')}</h2>
    </div>
  );
}

技術的な利点

  1. 型安全性: TypeScript + Context API により、翻訳キーの存在チェックが可能
  2. 依存関係の明確化: useTranslations を使うコンポーネントは必ず Provider 内に配置する必要があり、設計が明確
  3. パフォーマンス: 静的生成により高速なページ配信
  4. SEO: 各言語ページが独立したURLを持ち、検索エンジンに最適化
  5. UX: ブラウザ言語設定による自動リダイレクトで、ユーザーの手間を削減

まとめ

Next.js App Router の静的エクスポート環境下で、React Context API を活用した多言語対応を実現しました。サーバーサイドの制約をクライアントサイドのロジックで補完し、SEOとUXの両立を達成しています。

デフォルト言語の変更は config.ts の defaultLocale を編集するだけで対応可能で、拡張性も確保されています。

[PR]つくったアプリはこちら📱

https://own-info-app.sloth255.com

Discussion