0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js 15 + React 19でライブラリなしのi18n(EN/JA切り替え)を実装した話

0
Posted at

はじめに

Next.js 15 + React 19で構築したプロジェクト(HashFlops)で、next-intlなどのi18nライブラリを使わずに、クライアントサイドの英語/日本語切り替えを実装しました。

この記事では、React Contextベースのシンプルなi18n実装のアーキテクチャと、バイリンガルサイトで遭遇した課題・解決策を共有します。

なぜライブラリなしを選んだのか

Next.js向けのi18nライブラリ(next-intl, next-i18next等)は多数存在しますが、今回のプロジェクトでは以下の理由から自前実装を選択しました。

  • 2言語のみ(EN/JA): 大規模な翻訳管理機能は不要
  • 同一URLでの切り替え: サブパス(/en, /ja)やサブドメイン方式が不要
  • Server ComponentのSEOメタデータはEN固定: クローラーは英語ページを見る
  • クライアントサイドで即座に切り替えたい: ページリロードなしの体験

アーキテクチャ概要

実装は4つのファイルで構成されています。

ファイル 役割
context.tsx LanguageProvider + useLanguage hook
translations.ts 全翻訳ペア(型安全なネストキー)
LanguageToggle.tsx 切り替えボタンコンポーネント
各ページI18nコンポーネント ページごとのi18nラッパー

1. LanguageProvider(React Context)

言語状態をReact Contextで管理します。

// context.tsx
'use client';
import { createContext, useContext, useState, useEffect } from 'react';

type Language = 'en' | 'ja';

interface LanguageContextType {
  lang: Language;
  setLang: (lang: Language) => void;
  t: (key: string) => string;
}

const LanguageContext = createContext<LanguageContextType | null>(null);

function detectLanguage(): Language {
  // 1. Cookieを確認
  const cookie = document.cookie
    .split('; ')
    .find(row => row.startsWith('lang='));
  if (cookie) return cookie.split('=')[1] as Language;

  // 2. タイムゾーンで判定
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
  if (tz === 'Asia/Tokyo') return 'ja';

  // 3. ブラウザ言語で判定
  const browserLang = navigator.language;
  if (browserLang.startsWith('ja')) return 'ja';

  // 4. デフォルト: English
  return 'en';
}

ポイント: タイムゾーン優先の言語検出

navigator.language だけだと、日本在住でもブラウザを英語設定にしているユーザーには英語が表示されてしまいます。Asia/Tokyo タイムゾーンを最優先にすることで、日本にいるユーザーにはデフォルトで日本語を表示します。

2. 翻訳ファイル(型安全なネストキー)

翻訳データは1ファイルにまとめ、TypeScriptの型で安全にアクセスします。

// translations.ts
const translations = {
  en: {
    nav: {
      characters: 'Explorers',
      battles: 'Encounters',
      rankings: 'Rankings',
      guide: 'Guide',
    },
    home: {
      heroTitle: 'Witness the clash of 768.',
      heroDescription: 'A deterministic frontier where...',
      ctaPrimary: 'View Encounters',
      ctaSecondary: 'Browse Explorers',
    },
    // ... 以下続く
  },
  ja: {
    nav: {
      characters: '図鑑',
      battles: 'エンカウンター',
      rankings: 'ランキング',
      guide: 'ガイド',
    },
    home: {
      heroTitle: '768体の激突を見届けよ。',
      heroDescription: '決定論的フロンティア...',
      ctaPrimary: 'エンカウンターを見る',
      ctaSecondary: '図鑑を見る',
    },
  },
} as const;

t() 関数の実装:

ドット区切りのネストキー(例: 'home.heroTitle')でアクセスします。

function t(key: string): string {
  const keys = key.split('.');
  let value: any = translations[lang];
  for (const k of keys) {
    value = value?.[k];
  }
  return value ?? key; // 翻訳が見つからなければキーをそのまま返す
}

3. Cookie永続化

ユーザーが言語を切り替えたら、Cookieに保存して次回訪問時に復元します。

function setLanguage(newLang: Language) {
  setLang(newLang);
  document.cookie = `lang=${newLang};path=/;max-age=31536000;SameSite=Lax`;
}

365日有効のCookieで、ブラウザを閉じても設定が保持されます。

4. 言語切り替えトグル

切り替えUIは「文A」ボタンで実装しました。

// LanguageToggle.tsx
'use client';
import { useLanguage } from '@/i18n/context';

export function LanguageToggle() {
  const { lang, setLang } = useLanguage();

  return (
    <button
      onClick={() => setLang(lang === 'en' ? 'ja' : 'en')}
      className="px-3 py-1.5 rounded-full border text-sm"
      aria-label="Toggle language"
    >
      {lang === 'en' ? '文A' : 'A文'}
    </button>
  );
}

5. Server Component + Client Componentの共存パターン

Next.js 15では、SEO用のメタデータはServer Componentで静的に出力し、ページ本文はClient Componentでi18n対応する構成が有効です。

// page.tsx (Server Component)
export const metadata: Metadata = {
  title: 'Encounters | HashFlops',
  description: 'Browse encounter logs...',
};

export default function BattlesPage() {
  return <BattlesI18n />;
}
// BattlesI18n.tsx (Client Component)
'use client';
import { useLanguage } from '@/i18n/context';

export function BattlesI18n() {
  const { t } = useLanguage();
  return (
    <div>
      <h1>{t('battles.title')}</h1>
      <p>{t('battles.description')}</p>
      {/* ... */}
    </div>
  );
}

メタデータは英語固定にしています。理由は:

  • 検索エンジンはメタデータを見てインデックスする
  • 同一URLで言語が変わる場合、メタデータを動的にすると混乱する
  • 英語メタデータ + 日本語本文は実用上問題なし

6. バイリンガルタイポグラフィ(The Plant方式)

英語と日本語では、最適なフォントサイズとletter-spacingが異なります。The Plantのバイリンガル対応を参考に、言語ごとにCSSを切り替えています。

/* globals.css */

/* English: 16px base, tighter tracking */
html[lang="en"] body {
  font-size: 16px;
  letter-spacing: -0.01em;
}

/* Japanese: 15px base, wider tracking */
html[lang="ja"] body {
  font-size: 15px;
  letter-spacing: 0.04em;
}

LanguageProviderで<html>タグのlang属性を動的に変更します。

useEffect(() => {
  document.documentElement.lang = lang;
}, [lang]);

7. 英文コピーの考え方

バイリンガルサイトでよくある失敗は、日本語を直訳した英語です。

NG例:

  • JP: 「768体の激突を見届けよ。」
  • EN: 「Witness the clash of 768 bodies.」(直訳、不自然)

OK例:

  • JP: 「768体の激突を見届けよ。」
  • EN: 「Witness the clash of 768.」(ネイティブ向けの自然な表現)

翻訳は1:1対応にせず、各言語で自然な表現になるよう独立して書くのがポイントです。

まとめ

  • React Context + 1ファイル翻訳でシンプルに実装可能
  • タイムゾーンベースの言語検出で日本ユーザーの体験を改善
  • **Server Componentメタデータ(EN固定)+ Client Component本文(i18n)**の分離パターン
  • Cookie永続化でリロード後も設定を保持
  • The Plant方式タイポグラフィで言語ごとに最適な表示

next-intlのようなライブラリは素晴らしいですが、2言語・同一URLという要件であれば、自前実装のほうがシンプルで依存関係も少なく済みます。同様の要件で悩んでいる方の参考になれば幸いです。


HashFlops: https://www.hashflops.com/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?