このレシピは @event/i18n(実装済み・検証済み)をイベント app に配線する完全手順。 対象 app を apps/<app> とする(例では apps/starter — starter には既に全配線が入っているので照合先にできる)。 上から順に実行すれば完了する。
この機能は feature flag 不要(全ページ共通の基盤層。event.config.ts の features には何も足さない。 nav も route も増えず、layout と表示コンポーネントだけが変わる)。
仕組みの要点(詳細は第16章):
pickLang(localized, lang, fallback?)… React 非依存の純関数。2 形を 1 引数で受ける:
{ value, valueEn } 対(4S の title/titleEn 等)と { ja, en, ... } record 形。 EN が空なら base(ja) にフォールバック(4S languages.en 規則)。
LangProvider / useLang… client context。initial は server が cookie から解決して渡す(SSR 一致)。useT() / <T>… provider の現在言語で pickLang するヘルパ。
1. 依存を追加
apps/<app>/package.json の dependencies に追加:
"@event/i18n": "*"
apps/<app>/next.config.ts の transpilePackages 配列に "@event/i18n" を足す。
monorepo root で:
yarn install
2. 言語 cookie 名を決める
apps/<app>/src/lib/lang.ts(新規。cookie 名はイベント固有にする — 同一ドメインで複数イベントを 運用しても混線しないよう <app>_lang 形式にする):
/** <app> の言語 cookie 名(イベント固有化)。layout と LangSwitcher が共有する。 */
export const LANG_COOKIE = "<app>_lang";
(starter の実物は export const LANG_COOKIE = "starter_lang"; の 1 行だけ。)
3. layout.tsx — server で cookie を読み LangProvider で包む
apps/<app>/src/app/layout.tsx を次の形にする(既存の metadata / globals.css import は保持。 add-design 適用済みの app なら <head> の style 注入ブロックもそのまま残す):
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { cookies } from "next/headers";
import { LangProvider, langFromCookie } from "@event/i18n";
import { LANG_COOKIE } from "@/lib/lang";
import "./globals.css";
export const metadata: Metadata = {
title: "<app> — event site",
description: "<イベントの説明>",
};
export default async function RootLayout({ children }: { children: ReactNode }) {
// 初期言語は cookie から server で解決(SSR と初期描画を一致させる)。既定 ja。
const cookieValue = (await cookies()).get(LANG_COOKIE)?.value;
const initialLang = langFromCookie(cookieValue, LANG_COOKIE) ?? "ja";
return (
<html lang={initialLang}>
<body>
<LangProvider initial={initialLang} fallback="ja" cookieName={LANG_COOKIE}>
{children}
</LangProvider>
</body>
</html>
);
}
ポイント: initial を server で解決して渡すので hydration mismatch が起きない。 cookieName を渡してあるので setLang が選択を cookie に永続化し(path=/ ・1 年 ・SameSite=Lax)、 次回訪問時に server が同じ初期言語を復元する。middleware は不要。
4. スイッチャと useT 使用例
apps/<app>/src/app/LangSwitcher.tsx(client・ja/en 最小スイッチャ):
"use client";
import { useLang, type Lang } from "@event/i18n";
/**
* ja/en 言語スイッチャ(最小)。@event/i18n の useLang で現在言語を切り替え、
* LangProvider に cookieName を渡してあるので選択は cookie に永続化される。
*/
const OPTIONS: { code: Lang; label: string }[] = [
{ code: "ja", label: "日本語" },
{ code: "en", label: "EN" },
];
export function LangSwitcher() {
const { lang, setLang } = useLang();
return (
<div style={{ display: "inline-flex", gap: 4, fontFamily: "var(--mono)", fontSize: 11 }}>
{OPTIONS.map(({ code, label }) => {
const active = lang === code;
return (
<button
key={code}
type="button"
onClick={() => setLang(code)}
aria-pressed={active}
style={{
padding: "3px 10px",
cursor: "pointer",
letterSpacing: "0.08em",
textTransform: "uppercase",
border: "1px solid var(--line)",
background: active ? "var(--accent)" : "transparent",
color: active ? "var(--surface)" : "var(--ink-3)",
}}
>
{label}
</button>
);
})}
</div>
);
}
apps/<app>/src/app/NewsDemo.tsx(client・useT で { title, titleEn } を出し分け):
"use client";
import { useT } from "@event/i18n";
export interface NewsItem {
id: string;
title: string;
titleEn?: string;
}
/**
* pickLang 出し分け — news の `{ title, titleEn }` を value/valueEn 対として
* useT() に渡し、LangSwitcher の選択言語で見出しを切り替える。EN 空なら JA へ
* フォールバック(4S languages.en 規則)。
*/
export function NewsDemo({ items }: { items: NewsItem[] }) {
const t = useT();
return (
<ul style={{ margin: 0, paddingLeft: 20, fontSize: 14 }}>
{items.map((n) => (
<li key={n.id} style={{ marginBottom: 4 }}>
{t({ value: n.title, valueEn: n.titleEn })}
</li>
))}
</ul>
);
}
(t() に渡すのは { value, valueEn } の 2 キーのみでよい。record 形なら t({ ja: "日本語", en: "English" })。1 文字列だけ描くなら <T of={{ value, valueEn }} /> も使える。)
apps/<app>/src/app/page.tsx への組み込み(抜粋 — import と JSX の追加分):
import { LangSwitcher } from "./LangSwitcher";
import { NewsDemo, type NewsItem } from "./NewsDemo";
import news from "../../content/news.json";
// pickLang 出し分け用の news({ title, titleEn })。
const newsItems: NewsItem[] = news
.filter((n) => n.visible)
.map((n) => ({ id: n.id, title: n.title, titleEn: n.titleEn }));
<LangSwitcher />
{/* ... */}
<NewsDemo items={newsItems} />
5. CMS に titleEn フィールドを追加
apps/<app>/src/lib/cms.ts の NEWS_SCHEMA.fields で、title の直後に追加:
{ type: "text", name: "titleEn", label: "タイトル (EN)" },
(starter の NEWS_SCHEMA には既に入っている。required は付けない — titleEn 空 = 「未翻訳。EN 表示でも ja にフォールバック」が正しい挙動。)
apps/<app>/content/news.json の各記事に titleEn を持たせる(例):
[
{
"id": "news-sample-1",
"publishedAt": "2026-07-01T09:00:00.000Z",
"title": "テンプレートからサイトを立ち上げました",
"titleEn": "We launched the site from the template",
"slug": "hello-event",
"body": "サンプル記事です。",
"pinned": true,
"visible": true
}
]
以後は /admin/cms/news の「タイトル (EN)」欄から編集できる。
6. 検証
yarn workspace <app> typecheck # 型が通ること
yarn workspace <app> dev
- トップページの EN ボタンを押す → news 見出しが英語に切り替わる
- titleEn が空の記事は日本語のまま(フォールバック規則の確認)
- ページを再読み込み → EN のまま(cookie 永続 + server 初期解決の確認)
- 日本語 に戻す → 見出しが日本語へ
完了条件: typecheck が通り、ja/en 切替が news に効き、再読み込みで選択言語が保持される。