レシピ · 6手 · 1プロンプト実装

多言語 i18n を追加する

既存イベント app に @event/i18n(pickLang / LangProvider / useT)を配線し、cookie 永続の ja/en 切替が CMS の titleEn 出し分けまで通る状態にする

前提: new-event解説 ch.16module: 多言語 i18n

このレシピは @event/i18n(実装済み・検証済み)をイベント app に配線する完全手順。 対象 app を apps/<app> とする(例では apps/starter — starter には既に全配線が入っているので照合先にできる)。 上から順に実行すれば完了する。

この機能は feature flag 不要(全ページ共通の基盤層。event.config.ts の features には何も足さない。 nav も route も増えず、layout と表示コンポーネントだけが変わる)。

仕組みの要点(詳細は第16章):

{ value, valueEn } 対(4S の title/titleEn 等)と { ja, en, ... } record 形。 EN が空なら base(ja) にフォールバック(4S languages.en 規則)。

1. 依存を追加

apps/<app>/package.json の dependencies に追加:

"@event/i18n": "*"

apps/<app>/next.config.tstranspilePackages 配列に "@event/i18n" を足す。

monorepo root で:

yarn install

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 行だけ。)

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.tsNEWS_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
  1. トップページの EN ボタンを押す → news 見出しが英語に切り替わる
  2. titleEn が空の記事は日本語のまま(フォールバック規則の確認)
  3. ページを再読み込み → EN のまま(cookie 永続 + server 初期解決の確認)
  4. 日本語 に戻す → 見出しが日本語へ

完了条件: typecheck が通り、ja/en 切替が news に効き、再読み込みで選択言語が保持される。