はじめに
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/