React Router v7(Remix)&Cloudflare Workersで多国語(i18n)サイトを始める


> **■ Notice**
> 本記事の内容には、筆者の主観や解釈が含まれている場合があります。また、情報に誤りがある可能性もありますので、もし不適切な内容や不備、より良い意見などにお気づきの際は、ご指摘いただけると幸いです。

# はじめに

普段、個人開発に適していそうな技術スタックを考えている中、今までは「Next.js&Vercel」を中心に使ってました。普段から「複雑」という意見はあるものの、そこまで気にはなっていませんでしたが、そういう自分も、最近は少し複雑になったなと感じ始めたことに加え、プラットフォーム従属の心配や、Hobbyプランの制限などが厳しいなどの課題もまた、少しではありますが感じるようになりました。

その中、対案として「React Router v7&Cloudflare Workers」という選択肢が目に入りましたので、試そうと思いました。

その中、自分がやりたかったことで、「Next.js」では比較的に簡単にできた「Storybook」「i18n」「Cache仕組み」などの検討や導入が、「React Router v7 & Cloudflare Workers」でやろうと思ったら、色々多くの試行錯誤に直面したので、その過程を残すうちに、本記事を作成するという今に至ります。

では、今の背景を踏まえて、本論に入ろうと思います。

# 概要
「作るものの紹介」「やること」「多国語対応においての前提」「技術スタック概要」「ソースコードについて」のセクションで概要を簡略に説明します。

## 作るものの紹介

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436887/ef32d53e-543f-47a9-906d-9f77c753f6ef.png)

上記の簡単なページをReact Router v7とCloudflare Workers環境を前提に作ります。
このページは以下の特徴を持ちます。

- 「日本語」「英語」「韓国語」の言語切替が可能で、言語ごとのコンテンツを表示します
- 「URL-Path-Base 多国語対応」により言語別SEO管理が可能です
- Cloudflare Workersへ簡単にDeployができ、Cache APIを利用した「TTL付きレスポンスキャッシュ」の仕組みを仕入れてます。
- Chakra UIを導入しテーマ切り替えが可能です（Light or Dark）
- Storybookによるコンポーネント単位動作確認の環境を整えており、「起動時の環境変数注入」が必要ですが「多国語環境での確認」を可能としています。またVitestと統合していて、作成したStoryをTestCodeとしてテストできる環境を整えています。

## やること
本記事でやることとゴールを簡略に紹介します。

**Step1. Cloudflare Workers CLIを利用したReact Router v7プロジェクト生成**
Cloudflare Workers CLIを使って、React Router v7ベースのプロジェクトをテンプレートから簡単に生成します。

> **■ Note**
> Cloudflareの登録過程、Deploy時の認証過程などは、本記事では省略します。

**Step2. Chakra UI 導入 (ダークモード適用)**
Chakra UIを導入し、ColorMode機能を利用してダークモードを簡単に適用した、簡単なサンプルページを作成します。

**Step3. Storybook 導入**
プロジェクトにStorybookを導入します。Storybook導入時にVitest統合によりStoryのテストが可能な環境に整います。その過程で発生する互換性問題によるエラーを解決します。

**Step4. 多国語機能の導入 (remix-i18next)**
プロジェクトに`remix-i18next`を導入し、多国語機能を実装します。導入は以下の段取りで行います。

- 共通ファイル作成 (ロケール別の言語ファイル、共通設定)
- ミドルウェア作成 (URL-Path-Base 多国語対応＆Fallback処理)
- ミドルウェアをアプリケーションに適用
- 多国語切り替えをページに実装
- Storybookの多国語対応 (ロケールを環境変数として注入)

**Step5. ページキャッシュ実装 (Cloudflare Workers Cache API)**
Cloudflare WorkersのCache APIを使用して、ページ単位のキャッシュを実装し、レスポンスのオーバーヘッドの防止を図ります。

**Step6. Cloudflare WorkersへDeploy**
ローカルで動作確認したアプリケーションを、Cloudflare Workersにデプロイして公開します。

## 多国語対応においての前提
この記事で紹介する例では「URL-Path-Base 多国語対応」「Server Side Rendering中心のロケール別言語データ処理」を前提とします

### URL-Path-Base 多国語対応
多国語対応においての言語検知の方式はいくつかありますが、この記事では「URL-Path-Base 多国語対応」を中心に紹介します。

参考として、多国語対応を実装する際には、URL構成や言語の検出・切り替え方法にいくつかの選択肢があります。以下は代表的な3つの方式とその特徴を比較した表です。

| 対応方式                  | 例                                  | メリット                                                     | デメリット                                 |
| --------------------- | ---------------------------------- | -------------------------------------------------------- | ------------------------------------- |
| URL-Path-Base（推奨） | `/en/about`, `/ja/about`           | - SEOに強い（Googleなどが言語ごとにクロール可能）<br>- ブックマークや共有時に明示的な言語URL | - 各ルートに言語prefixの考慮が必要<br>- URLがやや長くなる |
| サブドメインベース         | `en.example.com`, `ja.example.com` | - 企業/国別サイトのように分離可能<br>- CDNやホスティングで別設定可能                 | - サブドメイン設定が必要<br>- ドメイン管理のコストが増える     |
| クエリパラメータベース       | `/about?lang=en`                   | - 実装が簡単<br>- 既存ルーティングに影響が少ない                             | - SEO評価が下がる可能性あり<br>- 言語がURLに明示されず不自然 |

本記事では、SEO対応と共有性を重視し、URL-Path-Base方式を採用しています。
本記事で導入する`remix-i18next`は「クエリパラメータベース」が基本ですが、`findLocale`関数オプションで機能を拡張してURL-Path-Base方式を実装します。

また、初回アクセス時にURLに言語情報が含まれていない場合は、`Accept-Languageヘッダー`をもとに最適な言語を判定し、リダイレクトを行う fallback 処理を実装しています。

> **■ Notice**
> 上記に加えて、ユーザーが選択したロケールをCookieなどに保持し、次の訪問もユーザーが過去に選択したロケールが選択されることも追加できますが、この記事では省略します。

### Server Side Rendering中心のロケール別言語データ処理
この記事ではServer Side Renderingにおいての処理を中心に紹介します。
Client Side Rendering環境においてのi18n実装は以下の公式ドキュメントを参考できます。

- https://github.com/sergiodxa/remix-i18next/tree/main?tab=readme-ov-file#client-side-configuration

## 技術スタック概要

使う技術スタックを簡略に紹介します。

#### Cloudflare Workers
V8ベースの超高速なサーバレス実行環境で、グローバルに分散されたエッジでJavaScriptアプリを実行できます。
- https://developers.cloudflare.com/workers/

#### React Router v7 (Remix)
Reactに特化したルーティングライブラリで、直感的なルート定義とデータ取得を可能にします。
v7ではRemixと統合され、frameworkモードとして提供されており、React Router単体でもサーバサイドレンダリング（SSR）やデータローダーなどのRemixの機能を活用できます。
- https://reactrouter.com/

#### Chakra UI
アクセシビリティに配慮されたReact用のUIライブラリで、ダークモードやレスポンシブデザインにも簡単に対応できます。
- https://chakra-ui.com/

#### Storybook
UIコンポーネントの開発・テスト・ドキュメント化を支援するツールで、コンポーネント単位での確認や動作テストが可能です。
- https://storybook.js.org/

#### remix-i18next
i18nextをベースにした国際化ライブラリで、React RouterやRemixとの統合に特化しています。
- https://github.com/sergiodxa/remix-i18next

## ソースコードについて

本記事で作成したソースコードは、以下のGithubレポジトリから参照できます。
・https://github.com/genie-oh/rrv7-cf-i18n-starter

また、記事のStepごとにCommit履歴を参考できるように連携します。
では、本格的に導入過程を紹介します。

# Step1. Cloudflare Workers CLIを利用したReact Router v7プロジェクト生成
Cloudflareが提供する公式のテンプレートを使うことで、Reactベースのエッジアプリケーションを素早く立ち上げることができます。
- https://developers.cloudflare.com/workers/get-started/guide/

React Router v7もテンプレートに含まれており、以下のガイドから確認することができます。
- https://developers.cloudflare.com/workers/framework-guides/web-apps/react-router/

## プロジェクト生成
まずは、Cloudflare WorkersとReact Router v7をベースにしたプロジェクトをCLIから生成します。以下のコマンドを実行して、React RouterベースのCloudflare Workersプロジェクトを作成します。

```sh
pnpm create cloudflare@latest rrv7-cf-i18n-starter --framework=react-router
```

実行後、`rrv7-cf-i18n-starter`というディレクトリに初期構成済みのプロジェクトが生成されます。中には react-router、vite、typescript などの設定が含まれ、すぐに開発を始められる状態でプロジェクトが生成されます。

生成されたプロジェクト構造は以下を参考できます。

<details><summary>👉 生成されたプロジェクト構造の補足</summary>

```sh
├── app                                  # React アプリのソースコード本体
│   ├── app.css                          # グローバルCSSスタイル
│   ├── entry.server.tsx                 # サーバー側のエントリーポイント（SSR用）
│   ├── root.tsx                         # アプリ全体のレイアウトやルート定義
│   ├── routes                           # ページルートを構成するReactコンポーネントのディレクトリ
│   ├── routes.ts                        # React Router v7 ルートの設定（ルート構造の定
├── package.json                         # 依存パッケージとスクリプトの定義
├── pnpm-lock.yaml                       # パッケージバージョンのロックファイル
├── react-router.config.ts               # React Router設定ファイル（Viteと連携）
├── tsconfig.cloudflare.json             # Cloudflare用TypeScriptコンパイル設定
├── tsconfig.json                        # 通常のTypeScriptコンパイル設定
├── tsconfig.node.json                   # Node.js用の補助的なtsconfig（テストやユーティリティで使用）
├── vite.config.ts                       # Viteビルドの設定ファイル
├── worker-configuration.d.ts            # wrangler.jsoncから生成された型定義ファイル
├── workers                              # Cloudflare Workers本体コード（エッジ関数）
│   └── app.ts                           # fetchハンドラーと中間処理ロジックの実装
└── wrangler.jsonc                       # Cloudflare Workersの設定ファイル（デプロイ、環境変数など）
```

</details>

## ✅️ソースコード参照

> **■ Note**
> commit : [Initialize web application via create-cloudflare CLI](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/9b563cec9aea23f5d6aa6a7581d94d00d21e5f65)

# Step2. Chakra UI 導入 (ダークモード適用)
React向けのモダンなUIライブラリ「Chakra UI」を導入し、簡単なスタイリングとダークモード切り替えを実装します。公式ガイドについては以下のURLから参考できます。

- https://chakra-ui.com/docs/get-started/frameworks/vite

## インストール
`@chakra-ui/react` および `@emotion/react`をインストールします。
また、`chakra-cli`のスニペット機能を使って、`ColorMode`や`Provider`の初期設定コードを自動的に追加します。

```sh
pnpm i @chakra-ui/react @emotion/react
npx @chakra-ui/cli snippet add color-mode
npx @chakra-ui/cli snippet add provider
```

## Chakra UIのプロバイダーを設定 (app/root.tsx)
Chakra UIのテーマ機能をアプリ全体で利用できるようにするには、`app/root.tsx`に`Provider`をラップして適用します。
スニペットで自動生成された`components/ui/provider.tsx`をAppコンポーネントに組み込みます。

以下のDiffを参考し`app/root.tsx`を修正します。

<details><summary>👉 (mod) app/root.tsx</summary>

```diff_tsx
+import { Provider } from "./components/ui/provider";
...
-    <html lang="en">
+    <html lang="en" suppressHydrationWarning>
...
 export default function App() {
-  return <Outlet />;
+  return (
+    <Provider>
+      <Outlet />
+    </Provider>
+  );
```

</details>

> **■ Note**
> `suppressHydrationWarning`の場合、サーバのレンダリング結果とクライアントの結果が一致しない可能性の警告を無視するという宣言と把握してますが、Chakra UIのテーマ切り替えなどによってこの警告が出る可能性がありますが、異常動作などではないため、予め無視宣言をいれるようにします。

## サンプルページの作成 (app/routes/sample.tsx)
Chakra UIのコンポーネントやダークモード切替ボタンを使った動作確認用ページを作成します。
このページでは、`Chakra UI`のレイアウト系コンポーネントをいくつか利用するのと、`ColorMode`スニペットが提供する`ColorModeButton`によるライト/ダークモードの切替を実装します。

以下を参考し`app/routes/sample.tsx`を作成します。

<details><summary>👉 (new) app/routes/sample.tsx</summary>

```tsx
import {
  Box,
  Button,
  Container,
  HStack,
  Spacer,
  Text,
  VStack,
} from "@chakra-ui/react";
import { ColorModeButton } from "~/components/ui/color-mode";
import type { Route } from "./+types/sample";

// このページのメタ情報（タイトルや説明文）を設定します。
// React Router の `meta` 関数を使って、SEO やブラウザタブの表示に利用されます。
export function meta({}: Route.MetaArgs) {
  return [
    { title: "Sample Page" },
    { name: "description", content: "Welcome to Sample Page!" },
  ];
}

// この `loader` 関数は、ルートに入る前に実行されるサーバー側のデータ取得関数です。
// React Router v7 の `data router` API によって、リクエストごとに呼び出されます。
// ここでは、Cloudflare Workers の `context` 経由で環境変数の値を取得し、
// クライアント側のコンポーネントへ `loaderData` として渡しています。
export function loader({ context }: Route.LoaderArgs) {
  const message: string = context.cloudflare.env.VALUE_FROM_CLOUDFLARE;
  return { message };
}

// Sample ページのコンポーネントです。
export default function Sample({ loaderData }: Route.ComponentProps) {
  return (
    <Container
      minH="100vh"
      h="100%"
      bg={{ base: "green.100", _dark: "green.900" }}
    >
      <HStack p={4}>
        {/* Chakra UIのcolor-modeスニペットが提供するのライトモード／ダークモードを切り替えるボタンです。*/}
        <ColorModeButton />
        <Spacer />
        <Text>Sample Page : {loaderData.message}</Text>
      </HStack>
      <Box>
        <VStack>
          <Button>button</Button>
        </VStack>
      </Box>
    </Container>
  );
}

```

</details>

## サンプルページのルーティング指定 (app/routes.ts)
作成したサンプルページをルーティングに登録することで、/sample パスでアクセスできるようにします。

以下のDiffを参考し`app/routes.ts`を修正します。

<details><summary>👉 (mod) app/routes.ts</summary>

```diff_typescript
-import { type RouteConfig, index } from "@react-router/dev/routes";
+import { type RouteConfig, index, route } from "@react-router/dev/routes";
 
-export default [index("routes/home.tsx")] satisfies RouteConfig;
+export default [
+  index("routes/home.tsx"),
+  route("sample", "routes/sample.tsx"),
+] satisfies RouteConfig;
```

</details>

## ✅️ソースコード参照

> **■ Note**
> commit : [chore: setup chakra-ui with darkmode](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/c61c3b6f69d701da5dc6ca056e53519f020404cc)

# Step3. Storybook導入

UIコンポーネントを独立して開発・テスト・ドキュメント化するために、Storybookを導入します。ガイドについては以下のURLを参考できます。
- https://storybook.js.org/docs/get-started/install

## インストール
以下を参考しインストールコマンドを実行します。初期設定では Recommended: Component dev, docs, test を選択し、開発・テスト・ドキュメント機能を一括で有効にします。

```sh
pnpm create storybook@latest

? What configuration should we install? › - Use arrow-keys. Return to submit.
❯   Recommended: Component dev, docs, test
```

インストール完了後、Storybookを起動しようとすると、ViteとReact Routerとの互換性の問題により複数のエラーが発生します。
以下では、それらのエラーを解消する手順を紹介します。

## インストール後のエラー解消

### Storybook初期起動とStoryのvitestエラーの確認と解消

#### エラー確認

Storybookを起動すると以下のようなエラーが発生します。

```sh
pnpm run storybook
...
=> Failed to build the preview
Error: The React Router Vite plugin requires the use of a Vite config file
```

また、Storybook用のvitestプロジェクトを実行すると以下のようなモジュール解決エラーが発生します。

```sh
pnpm vitest --project=storybook --reporter=verbose
...
✘ [ERROR] Could not resolve "../pkg"
node_modules/.pnpm/lightningcss@1.30.1/node_modules/lightningcss/node/index.js:17:27:
      17 │   module.exports = require(`../pkg`);
```

#### 「vite.config.ts」を修正し解消

Storybookやvitest実行時にReact RouterやCloudflare pluginよりエラーが発生していることが推測できますが、Storybookやvitest実行時は両方不要なため、vite.config.tsのplugin処理を環境に応じて切り替えます。

以下のDiffを参考し`vite.config.ts`を修正します。

<details><summary>👉 (mod) vite.config.ts</summary>

```diff_typescript
+const isStorybook = process.argv[1]?.includes("storybook");
+const isVitest = process.env.VITEST;
+const isServerRunning = !isStorybook && !isVitest;
...
 export default defineConfig({
   plugins: [
-    cloudflare({ viteEnvironment: { name: "ssr" } }),
+    isServerRunning && cloudflare({ viteEnvironment: { name: "ssr" } }),
     tailwindcss(),
-    reactRouter(),
+    isServerRunning && reactRouter(),
     tsconfigPaths(),
   ],
 });
```

</details>

### `vitest.workspace.ts`のdeprecatedエラー解消

#### エラー確認

Storybook用のVitest設定をvitest.workspace.tsに定義している場合、deprecated警告が発生します。

```sh
# vitest.workspace.ts
The signature '(config: TestProjectConfiguration[]): TestProjectConfiguration[]' of 'defineWorkspace' is deprecated.ts(6387)
config.d.ts(95, 3): The declaration was marked as deprecated here.
```

#### vite.config.ts にテスト設定を統合

既存の vitest.workspace.ts を削除し、vite.config.ts にStorybook用のVitest設定を統合します。

```sh
rm vitest.workspace.ts
```

以下のDiffを参考し、`vite.config.ts`に設定を追加します。

<details><summary>👉 (mod) vite.config.ts</summary>

```diff_typescript
...
+import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
...
+const dirname =
+  typeof __dirname !== "undefined"
+    ? __dirname
+    : path.dirname(fileURLToPath(import.meta.url));

+
 export default defineConfig({
...
+  test: {
+    projects: [
+      "vite.config.ts",
+      {
+        extends: "vite.config.ts",
+        plugins: [
+          // The plugin will run tests for the stories defined in your Storybook config
+          // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
+          storybookTest({ configDir: path.join(dirname, ".storybook") }),
+        ],
+        test: {
+          name: "storybook",
+          browser: {
+            enabled: true,
+            headless: true,
+            provider: "playwright",
+            instances: [{ browser: "chromium" }],
+          },
+          setupFiles: [".storybook/vitest.setup.ts"],
+        },
+      },
+    ],
+  },
 });
```

</details>

### 起動確認

修正後、以下のコマンドでStorybookとStorybookテストが正常に動作することを確認します。

```sh
# storybook 起動成功
pnpm run storybook
...
Storybook 9.0.9 for react-vite started

# storybook vitest 成功
pnpm vitest --project=storybook --reporter=verbose
...
 Test Files  3 passed (3)
      Tests  8 passed (8)
```

### test-storybookコマンドの追加

Storybookに対するテストを簡単に実行できるように、以下のDiffを参考し、`package.json`にStoryテストの実行コマンドを追加を追加しておきます。

<details><summary>👉 (mod) package.json</summary>

```diff_json
...
+    "build-storybook": "storybook build",
+    "test-storybook": "vitest --project=storybook --reporter=verbose"
```

</details>

## Storyファイル作成

### 不要なサンプルStoryを削除

Storybookインストール時に生成された不要なサンプルStoryは削除します。

```sh
rm -rf stories/
```

### React RouterとChakra UIに対応するDecoratorを設定

Storybook上でReact RouterとChakra UIを適切に動作させるために、共通のDecoratorを作成します。

以下を参考し`.storybook/common-decorator.tsx`を作成します。

<details><summary>👉 (new) .storybook/common-decorator.tsx</summary>

```ts
import React from "react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Provider } from "../app/components/ui/provider";
import type { StoryContext } from "@storybook/react-vite";

export default (Story: React.ComponentType, context: StoryContext) => {
  //React Routerのパス設定を、今後作成するStoryから注入して使えるようにします
  const initialPath = context.parameters?.initialPath;
  if (!initialPath) {
    throw new Error("initialPath is required");
  }

  //Storybook環境ではBrowserRouterの機能までは不要と思い、テストなどで好まれるMemoryRouterを利用するようにします
  // path: "*"で、すべての経路について、elementに定義したレンダリングが行われるようにします
  // initialEntriesの１つ目の要素が、現在のパスになります
  const router = createMemoryRouter(
    [
      {
        path: "*",
        element: (
          <Provider>
            <Story />
          </Provider>
        ),
      },
    ],
    {
      initialEntries: [initialPath],
    }
  );

  return <RouterProvider router={router} />;
};
```

</details>

次に、以下のDiffを参考し、作成したDecoratorを`.storybook/preview.ts`に登録します。

<details><summary>👉 (mod) .storybook/preview.ts</summary>

```diff_typescript
+import commonDecorator from "./common-decorator";
...
 const preview: Preview = {
+  decorators: [commonDecorator],
```

</details>

### サンプルページのStory作成

Storybookで動作確認できるように、Sample ページに対応したStoryファイルを作成します。

まず、サンプルページのコンポーネントにpropsの構造を、実際利用するタイプのみに絞り、Storyやテスト作成がしやすいように調整します。

以下のDiffを参考し`app/routes/sample.tsx`を修正します。

<details><summary>👉 (mod) app/routes/sample.tsx</summary>

```diff_tsx
+export default function Sample({
+  loaderData,
+}: Pick<Route.ComponentProps, "loaderData">) {
```

</details>


次に、`stories/routes/sample.stories.tsx`に実際のStoryファイルを作成します。

<details><summary>👉 (new) stories/routes/sample.stories.tsx</summary>

```ts
import type { StoryObj } from "@storybook/react-vite";
import Sample from "../../app/routes/sample";

// Storybook Meta
const meta = {
  title: "Routes/Sample",
  component: Sample,
  parameters: {
    initialPath: "/sample", // CommonDecoratorに、initialPathを引数として渡します
    layout: "fullscreen",
    docs: {
      description: {
        component: "Home page component with navigation and language switcher.",
      },
    },
  },
  tags: ["autodocs"],
};

export default meta;
type Story = StoryObj<typeof meta>;

export const SampleStory: StoryObj<typeof meta> = {
  args: { //Sampleコンポーネント関数の引数をここで渡します
    loaderData: {
      message: "Message from Storybook",
    },
  },
};
```

</details>

### Storyのテスト時の初回起動時のエラー回避

#### エラー確認

初回の`pnpm run test-storybook`や、Storybook 起動時の`Run Tests`実行時、バンドル時の問題と思われる、以下のエラーが発生することがありました。

```sh
pnpm run test-storybook
...
 FAIL   storybook (chromium)  stories/routes/sample.stories.tsx [ stories/routes/sample.stories.tsx ]
Error: Failed to import test file .node_modules/.pnpm/@storybook+addon-vitest@9.0.5_@vitest+browser@3.2.2_@vitest+runner@3.2.2_react-dom@19.1_7fd573b6bb5c70d0d369de7af2669d44/node_modules/@storybook/addon-vitest/dist/vitest-plugin/setup-file.mjs
Caused by: Error: Vitest failed to find the runner. This is a bug in Vitest. Please, open an issue with reproduction.
```

そのため、以下の対応をしております。

#### vite.config.tsにBundleオプション調整

以下のDiffを参考し、`vite.config.ts`を修正します。

<details><summary>👉 (mod) vite.config.ts</summary>

```diff_typescript
   test: {
     projects: [
       "vite.config.ts",
       {
...
+        optimizeDeps: {
+          // 初回実行時に以下のエラーが発生するのを防ぐためにViteバンドル対象に含める
+          // > Error: Failed to import test file .node_modules/.pnpm/@storybook+addon-vitest@9.0.5_@vitest+browser@3.2.2_@vitest+runner@3.2.2_react-dom@19.1_7fd573b6bb5c70d0d369de7af2669d44/node_modules/@storybook/addon-vitest/dist/vitest-plugin/setup-file.mjs
+          include: [
+            "react",
+            "react-dom",
+            "react/jsx-dev-runtime",
+            "@storybook/react-vite",
+            "@storybook/addon-vitest",
+            "@vitest/browser",
+          ],
+          // 上記のinclude設定によりlightningcssの解決エラーが発生するため、Viteバンドル対象から除外
+          // > Error : node_modules/.pnpm/lightningcss@1.30.1/node_modules/lightningcss/node/index.js:17:27: ERROR: Could not resolve "../pkg"
+          exclude: ["playwright", "playwright-core", "lightningcss"],
+        },
+        build: {
+          rollupOptions: {
+            // vitest実行前のプリビルドで巨大なパッケージをViteバンドル対象から除外
+            external: [
+              "playwright",
+              "playwright-core",
+              "lightningcss",
+              "chromium-bidi",
+            ],
+          },
+        },
       },
```

</details>

## ✅️ソースコード参照

> **■ Note**
> - commit : [chore: setup storybook - install at first](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/f0b9791a002330e3f26f0a6d0bf7fc01ae996b3d)
- commit : [chore: setup storybook - fix related configure to run storybook with vitest](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/b298d2106de45a94c00e521cb98f776ec2c35228)
- commit : [chore: setup storybook - remove all sample stories](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/346d3d51f94fe742f41ad054feade1e23c5e1fb5)
- commit : [chore: setup storybook - make sample.stories and decorator with fix vite.config](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/d9acb34843c9a46ea98d8a835f6c54e052a444f6)



# Step4. 多国語機能の導入 (remix-i18next)

React Router v7で多国語対応を行うために、remix-i18nextを導入します。
ガイドは以下のURLから参照できます。

- https://github.com/sergiodxa/remix-i18next

## インストール
remix-i18nextとその依存ライブラリ（i18next、react-i18nextなど）をインストールします。

```sh
pnpm install remix-i18next i18next react-i18next i18next-browser-languagedetector i18next-fetch-backend
```

## 共通ファイル作成 (ロケール別の言語ファイル、共通設定)

### ロケール別の言語ファイルの作成

まず、ベースとなる日本語の言語ファイルを作成します。

<details><summary>👉 (new) app/infra/i18n/locales/ja.ts</summary>

```ts
export default {
  sample: {
    title: "サンプルタイトル",
    description: "サンプル説明",
  },
};
```

</details>

あと、英語と韓国語の言語ファイルも作成してみます。その際にベートして作成した日本語の言語ファイルのタイプを継承します。

<details><summary>👉 (new) app/infra/i18n/locales/en.ts</summary>

```ts
import type ja from "./ja";

export default {
  sample: {
    title: "sample title",
    description: "sample description",
  },
} satisfies typeof ja;
```

</details>

<details><summary>👉 (new) app/infra/i18n/locales/ko.ts</summary>

```ts
import type ja from "./ja";

export default {
  sample: {
    title: "샘플 타이틀",
    description: "샘플 설명",
  },
} satisfies typeof ja;
```

</details>

ここで作成した各言語ファイル（ja.ts, en.ts, ko.ts）は、アプリ内で言語を切り替えた際に自動的に切り替えられるテキストのソースとして使用されます。
今後、ブラウザのリクエストやURLパスなどから言語が検知された際に、対応する翻訳データが読み込まれ、ページ上のテキストがその言語に応じて表示されるようになります。

### 共通設定ファイル作成 (i18n-config.ts)

言語データリソース参照、利用可能ロケールコードリストとラベル名、fallback言語の定義・管理などを目的とした共通設定を作成します。

アプリ全体やStorybookなどで共通利用するための設定です。

<details><summary>👉 (new) app/infra/i18n/i18n-config.ts</summary>

```ts
import ja from "./locales/ja";
import en from "./locales/en";
import ko from "./locales/ko";

// 言語切替のSelect要素など、利用できる言語の表示などで参照します
export const supportedLanguagesLabelAndValue = [
  { label: "日本語", value: "ja" },
  { label: "English", value: "en" },
  { label: "한국어", value: "ko" },
] as const;

//利用可能な言語コードのバリデーションなどで参照します
export const supportedLanguages: string[] = supportedLanguagesLabelAndValue.map(
  (lang) => lang.value
);

//言語検知ができなかった際のFallback言語を設定します
export const fallbackLanguage = "ja";

//Middlewareへの言語データの渡しや、Storybookからのデータ参照などに使います
export const i18nextResources = {
  ja: { translation: ja },
  en: { translation: en },
  ko: { translation: ko },
};

//ja.tsなど、言語ファイルで作成したタイプ構造を、他のところでも流用できるようにします
export type i18nextResourceType = typeof ja;
```

</details>

## ミドルウェア作成(URL-Path-Base 多国語対応＆Fallback処理)

URLパスでの言語指定（例：/ja/sample）に対応し、パスに言語が無い場合は`Accept-Languageヘッダー`をもとに適切なロケールにリダイレクトするようなミドルウェアを作成します。

<details><summary>👉 (new) app/middleware/i18next.ts</summary>

```ts
import { redirect } from "react-router";
import { unstable_createI18nextMiddleware } from "remix-i18next/middleware";
import {
  supportedLanguages,
  fallbackLanguage,
  i18nextResources,
} from "~/infra/i18n/i18n-config";

export const [i18nextMiddleware, getLocale, getInstance] =
  unstable_createI18nextMiddleware({
    detection: {
      supportedLanguages,
      fallbackLanguage,
      findLocale: async (request) => {
        const url = new URL(request.url);
        const pathname = url.pathname;

        //まずURLパスから言語コードの取得を試みます
        const localeOnPath = pathname.split("/").at(1);
        if (localeOnPath && supportedLanguages.includes(localeOnPath)) {
          return localeOnPath;
        }

        //URLパスから有効な言語コードが検知できなかった場合,Accept-Languageヘッダから言語検知を試みます
        let localeToRedirect = fallbackLanguage;
        const acceptLanguages =
          request.headers.get("accept-language")?.split(",") || [];
        for (const code of acceptLanguages) {
          const lang = code.split("-")[0];
          if (supportedLanguages.includes(lang)) {
            localeToRedirect = lang;
            break;
          }
        }

        //Accept-Languageで検知した言語、またはFallback言語をURLパスにつけた新しいURLでリダイレクトさせます
        const newPathname =
          pathname === "/"
            ? `/${localeToRedirect}/`
            : `/${localeToRedirect}${pathname}`;
        throw redirect(url.origin + newPathname + url.search);
      },
    },
    i18next: {
      resources: i18nextResources,
    },
  });
```

</details>

## ミドルウェアをアプリケーションに適用

### unstable_middlewareの活性化

React Router v7に対応する最新の`remix-i18next`は、React Router v7の実験起動である`unstable_middleware`を設定して、先程に作成したミドルウェアを適用する必要があるので、まずその機能を活性化します。

以下のDiffを参考し`react-router.config.ts`を修正します。

<details><summary>👉 (mod) react-router.config.ts</summary>

```diff_typescript
+    unstable_middleware: true,
```

</details>

### AppRootにミドルウェアを設定

ミドルウェアをルートコンポーネントに反映します。追加で、ロケール情報をHTMLタグの`lang`属性に反映します。

以下のDiffを参考し`app/root.tsx`を修正します。

<details><summary>👉 (mod) app/root.tsx</summary>

```diff_tsx
+  useLoaderData,
 } from "react-router";
...
+import { i18nextMiddleware } from "~/middleware/i18next";
+import { fallbackLanguage } from "./infra/i18n/i18n-config";
+
+export const unstable_middleware = [i18nextMiddleware];
+
+//htmlタグのlang属性に言語コードを渡すためにLoaderから言語の取得を試みます
+export async function loader({ params }: Route.LoaderArgs) {
+  return { locale: params.locale };
+}
+
...
 export function Layout({ children }: { children: React.ReactNode }) {
+  const data = useLoaderData<typeof loader>();
+  const locale = data?.locale || fallbackLanguage;
+
   return (
-    <html lang="en" suppressHydrationWarning>
+    <html lang={locale} suppressHydrationWarning>
```

</details>

### ルーティングにロケールのパスパラメータを追加

ミドルウェアがURLパスの先頭でロケールを検出できるよう、ルーティング構成を変更します。

以下のDiffを参考し`app/routes.ts`を修正します。

<details><summary>👉 (mod) app/routes.ts</summary>

```diff_typescript
 export default [
-  index("routes/home.tsx"),
-  route("sample", "routes/sample.tsx"),
+  route("/:locale", "routes/home.tsx"),
+  route("/:locale/sample", "routes/sample.tsx"),
 ] satisfies RouteConfig;
```

</details>

### React RouterのContextのタイプを`unstable_RouterContext`に変更

`unstable_middleware`を有効にすることで、`AppLoadContext`ではなく、`unstable_createContext`によるMapベースのContextを利用が強いられます。

また、Contextは、Cloudflare Workersのエントリーポイントから生成し渡されるようになっているため、`unstable_createContext`に対応するContextで渡すように修正が必要です。

以下のDiffを参考し`workers/app.ts`を修正します。

<details><summary>👉 (mod) workers/app.ts</summary>

```diff_typescript
-import { createRequestHandler } from "react-router";
-
-declare module "react-router" {
-  export interface AppLoadContext {
-    cloudflare: {
-      env: Env;
-      ctx: ExecutionContext;
-    };
-  }
-}
+import { createRequestHandler, unstable_createContext } from "react-router";
 
 const requestHandler = createRequestHandler(
   () => import("virtual:react-router/server-build"),
   import.meta.env.MODE
 );
 
+export const ServerGlobalContext = unstable_createContext<{
+  cloudflare: {
+    env: Env;
+    ctx: ExecutionContext;
+  };
+}>();
+
 export default {
   async fetch(request, env, ctx) {
-    return requestHandler(request, {
-      cloudflare: { env, ctx },
-    });
+    return requestHandler(
+      request,
+      //pass context as Map with unstable_createContext to use unstable_middleware
+      new Map([
+        [
+          ServerGlobalContext,
+          {
+            cloudflare: { env, ctx },
+          },
+        ],
+      ])
+    );
   },
 } satisfies ExportedHandler<Env>;
```

</details>

### Home修正 (Context参照を`unstable_RouterContext`方式に対応)

`unstable_RouterContext`方式の変更により、Contextを参照する側でも修正が必要になってきます。

まず、観点外のページの修正ではありますが、プロジェクト生成時に作られた`app/routes/home.tsx`でもContextを参照しているので修正します。

以下のDiffを参考し`app/routes/home.tsx`を修正します。

<details><summary>👉 (mod) app/routes/home.tsx</summary>

```diff_tsx
 export function loader({ context }: Route.LoaderArgs) {
-  return { message: context.cloudflare.env.VALUE_FROM_CLOUDFLARE };
+  return {
+    message:
+      //unstable_RouterContextを強いられることで、ContextのタイプがObjectからMapに変わったので、それに合う取得方法に修正します
+      context.get(ServerGlobalContext).cloudflare.env.VALUE_FROM_CLOUDFLARE,
+  };
 }
```

</details>

## 多国語切り替えをページに実装

「パス切り替えによる言語切り替え選択UI」と「言語別データ取得・表示」を、Sampleページに反映します。

以下のDiffを参考し`app/routes/sample.tsx`を修正します。

<details><summary>👉 (mod) app/routes/sample.tsx</summary>

```diff_tsx
 import {
   Button,
   Container,
   HStack,
+  Portal,
+  Select,
   Spacer,
   Text,
   VStack,
+  createListCollection,
 } from "@chakra-ui/react";
+import { useLocation, useNavigate } from "react-router";
+import { ServerGlobalContext } from "workers/app";
 import { ColorModeButton } from "~/components/ui/color-mode";
+import {
+  supportedLanguagesLabelAndValue,
+  type i18nextResourceType,
+} from "~/infra/i18n/i18n-config";
+import { getInstance } from "~/middleware/i18next";
 import type { Route } from "./+types/sample";
 
-export function meta({}: Route.MetaArgs) {
+export function meta({ data }: Route.MetaArgs) {
+  const translations = data?.translations;
+
   return [
-    { title: "Sample Page" },
-    { name: "description", content: "Welcome to Sample Page!" },
+    //Loaderのデータはmeta関数でも参照することができます
+    //meta情報においても、i18nによる言語別データを利用できます
+    { title: translations?.sample.title },
+    { name: "description", content: translations?.sample.description },
   ];
 }
 
 export function loader({ context }: Route.LoaderArgs) {
-  const message: string = context.cloudflare.env.VALUE_FROM_CLOUDFLARE;
-  return { message };
+  const message: string =
+    context.get(ServerGlobalContext).cloudflare.env.VALUE_FROM_CLOUDFLARE;
+  //説明
+  const i18next = getInstance(context);
+  const translations: Pick<i18nextResourceType, "sample"> = {
+    sample: {
+      title: i18next.t("sample.title"),
+      description: i18next.t("sample.description"),
+    },
+  };
+
+  return { message, translations };
 }
 
 export default function Sample({
   loaderData,
-}: Pick<Route.ComponentProps, "loaderData">) {
+  params,
+}: Pick<Route.ComponentProps, "loaderData" | "params">) {
+  const { message, translations } = loaderData;
+  const { locale } = params;
+  const location = useLocation();
+  const navigate = useNavigate();
+
+  //言語切替のSelect要素を生成するためのリストを生成します
+  const languages = createListCollection({
+    items: supportedLanguagesLabelAndValue,
+  });
+
+  //言語切替のSelect要素のイベント処理を作成します
+  const handleLanguageChange = (details: { value: string[] }) => {
+    const selectedLanguage = details.value[0];
+    const currentPath = location.pathname;
+    const newPath = currentPath.replace(
+      new RegExp(`^/${locale}`),
+      `/${selectedLanguage}`
+    );
+    navigate(newPath);
+  };
+
   return (
     <Container
       minH="100vh"
@@ -34,9 +75,43 @@ export default function Sample({
       <HStack p={4}>
         <ColorModeButton />
         <Spacer />
-        <Text>Sample Page : {loaderData.message}</Text>
+        <Text>Sample Page : {message}</Text>
+        <Spacer />
+        {/* 言語切替のSelect要素UI */}
+        <Select.Root
+          collection={languages}
+          value={[locale]}
+          onValueChange={handleLanguageChange}
+          width="120px"
+        >
+          <Select.HiddenSelect />
+          <Select.Control>
+            <Select.Trigger>
+              <Select.ValueText placeholder="Language" />
+            </Select.Trigger>
+            <Select.IndicatorGroup>
+              <Select.Indicator />
+            </Select.IndicatorGroup>
+          </Select.Control>
+          <Portal>
+            <Select.Positioner>
+              <Select.Content>
+                {languages.items.map((language) => (
+                  <Select.Item item={language} key={language.value}>
+                    {language.label}
+                    <Select.ItemIndicator />
+                  </Select.Item>
+                ))}
+              </Select.Content>
+            </Select.Positioner>
+          </Portal>
+        </Select.Root>
       </HStack>
       <Box>
+        <HStack>
+          <Text>
+            {translations.sample.title} / {translations.sample.description}
+          </Text>
+        </HStack>
         <VStack>
           <Button>button</Button>
         </VStack>
```

</details>

## Storybookの多国語対応 (ロケールを環境変数として注入)

Storybook上でもi18n動作を確認するため、起動時にロケールを環境変数として指定し、それに応じて翻訳とパスを切り替えます。

> ※Storybook起動中に動的にロケールを変更するような設定を試しましたが、不安定でうまくいかない部分があったため、起動時にロケールを固定する方式を採用しています。

### Storybook起動コマンド追加

環境変数`STORYBOOK_LOCALE`にロケールを指定してStorybookを起動できるように、`package.json` に起動用コマンドを追加します。

以下のDiffを参考し`package.json`を修正します。

<details><summary>👉 (mod) package.json</summary>

```diff_json
     "storybook": "storybook dev -p 6006",
+    "storybook-ja": "STORYBOOK_LOCALE=ja storybook dev -p 6006",
+    "storybook-en": "STORYBOOK_LOCALE=en storybook dev -p 6006",
+    "storybook-ko": "STORYBOOK_LOCALE=ko storybook dev -p 6006",
```

</details>

### 環境変数の参照

Storybook内で`STORYBOOK_LOCALE`の環境変数を参照するために、以下のファイルを追加します。

#### `vite-env.d.ts`の追加

Vite環境下で`import.meta.env`をTypeScriptで認識させるために、型定義ファイルを追加します。

<details><summary>👉 (new) vite-env.d.ts</summary>

```ts
/// <reference types="vite/client" />
```

</details>

#### `.storybook/storybook-env.ts`の追加

起動時のロケールを共通的に読み取れるユーティリティモジュールを作成します。

<details><summary>👉 (new) .storybook/storybook-env.ts</summary>

```
import {
  fallbackLanguage,
  i18nextResources,
} from "../app/infra/i18n/i18n-config";

export default {
  locale:
    (import.meta.env.STORYBOOK_LOCALE as keyof typeof i18nextResources) ||
    fallbackLanguage,
};
```

</details>

### ロケールをStorybookのUI上に表示 (Toolbarに表示)

Storybook上で、現在どういうロケールで実行されているかを表示する方法として、Toolbarにロケールを表示するように設定します。

関連レファレンスは以下を参照できます。

- https://storybook.js.org/docs/essentials/toolbars-and-globals

以下のDiffを参考し`.storybook/preview.ts`を修正します。

<details><summary>👉 (mod) .storybook/preview.ts</summary>

```diff_typescript
+    globalTypes: {
+      locale: {
+        description: "Global locale for components",
+        toolbar: {
+          title: "Locale",
+          icon: "circlehollow",
+          items: [`locale: ${storybookEnv.locale}`],
+          dynamicTitle: true,
+          disabled: true,
+        },
+      },
+    },
+
+    initialGlobals: {
+      locale: `locale: ${storybookEnv.locale}`,
+    },
````

</details>

### SampleページのStoryにロケールを注入

Storyから`storybook-env.ts`で読み取ったロケール情報を利用し、ロケール情報と、ロケールに基づいた言語データを、Sampleページコンポーネントの引数として注入して、Storybookでもi18nが適用されたページが表示できるようにします。

以下のDiffを参考し`stories/routes/sample.stories.tsx`を修正します。

<details><summary>👉 (mod) stories/routes/sample.stories.tsx</summary>

```diff_tsx
+import storybookEnv from "../../.storybook/storybook-env";
+import { i18nextResources } from "../../app/infra/i18n/i18n-config";
...
 export const SampleStory: StoryObj<typeof meta> = {
   args: {
     loaderData: {
       message: "Message from Storybook",
+      translations: {
+        sample: i18nextResources[storybookEnv.locale]?.translation.sample,
+      },
+    },
+    params: {
+      locale: storybookEnv.locale,
     },
```

</details>

### `virtual:react-router/server-build`エラーの解消

Storybookのビルド中に以下のようなエラーが発生することがあります。

```sh
Error: The following dependencies are imported but could not be resolved:
  virtual:react-router/server-build (imported by /workers/app.ts)
```

これはReact Routerの`createRequestHandler`が`virtual:react-router/server-build`を必要としているために発生するエラーと推測されますが、Storybookでは不要なので、対象モジュールをMockしてエラーを解消します。


まず、以下のDiffを参考し`.storybook/mock-server-build.ts`を作成します。

<details><summary>👉 (new) .storybook/mock-server-build.ts</summary>

```ts
// Mock for virtual:react-router/server-build in Storybook tests
export default {
  routes: [],
  entry: {
    module: {},
  },
  assets: {
    url: "",
    version: "",
  },
};

export const routes = [];
```

</details>

あと、Storybook起動時はMockした`virtual:react-router/server-build`に切り替わるように、`vite.config.ts`を修正します。

以下のDiffを参考し、`vite.config.ts`を修正します。

<details><summary>👉 (mod) vite.config.ts</summary>

```diff_typescript
     tsconfigPaths(),
   ],
+  resolve: {
+    // storybookやtest-storybookで、以下のエラーを避けるために、server-buildをMockする
+    // > Internal server error: Failed to resolve import "virtual:react-router/server-build" from "workers/app.ts". Does the file exist?
+    alias: !isServerRunning
+      ? {
+          "virtual:react-router/server-build": path.resolve(
+            dirname,
+            ".storybook/mock-server-build.ts"
+          ),
+        }
+      : {},
+  },
   test: {
```

</details>

これで、以下のコマンドでロケールを指定してStorybookを起動できるようになります。

```sh
pnpm run storybook-ja
pnpm run storybook-en
pnpm run storybook-ko
```

## ✅️ソースコード参照

> **■ Note**
> - [chore: setup remix-i18next - install at first](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/69722fff4ea551486ee91364db65f6576d5a5f7c)
- [chore: setup remix-i18next - make new files of locales and config](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/28f6f35a6ef9b0d9e11784a096bab565a5d0e5c6)
- [chore: setup remix-i18next - apply middleware](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/c76f6bdde6440d229b0f1e2e7461b891fa24112f)
- [chore: setup remix-i18next - prototyping sample.tsx for i18n features](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/9e6407964f6fd408fdd4601758bb0cebf026c172)
- [chore: setup remix-i18next - for storybook](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/8e729a11272c87da73e106ecc68fce066735fdb3)

# Step5. ページキャッシュ実装 (Cloudflare Workers Cache API)

Cloudflare WorkersのCache API機能を活用し、ページ単位でのレスポンスキャッシュを実装します。ガイドについては、以下のURLを参考できます。
- https://developers.cloudflare.com/workers/runtime-apis/cache/

Cloudflare WorkersのCache APIは、Webブラウザ標準のCache APIと類似するインターフェースを提供しており、HTTPリクエストとレスポンスをキーとしたキャッシュ操作を行うことができます。これにより、以下のようなことが可能になります。

**キャッシュの読み込み**
指定されたリクエストに一致するレスポンスがキャッシュに存在する場合、それを返すことができます（cache.match(request)）。

**キャッシュの保存**
リクエストとレスポンスのペアをキャッシュに保存することができます（cache.put(request, response)）。

**TTL制御**
キャッシュされたレスポンスにはHTTPヘッダー（例：Cache-Control: s-maxage）を用いて、保持期間の指定が可能です。

**動的な条件によるキャッシュ制御**
リクエストURLやヘッダー、パスごとにTTLを変更したり、特定の条件でキャッシュをスキップすることもできます。



## Cloudflare Workersの環境変数設定

Cloudflare Workersでは、`wrangler.jsonc`の`vars`セクションに任意の環境変数を定義し、コード内で参照できます。
ここでは、キャッシュ関連の設定（TTLなど）やログレベルを環境変数として追加します。

以下のDiffを参考し`wrangler.jsonc`に以下の環境変数を設定します。

<details><summary>👉 (mod) wrangler.jsonc</summary>

```diff_json
        "vars": {
+               "LOG": {
+                       "LEVEL": "debug"
+               },
+               "CACHE": {
+                       "DEFAULT_TTL": 120,
+                       "CUSTOM_TTL_PATH_DICT": {
+                               "/": 0
+                       }
+               },
                "VALUE_FROM_CLOUDFLARE": "Hello from Cloudflare"
```

</details>

> **■ Note**
> `CUSTOM_TTL_PATH_DICT`の`"/": 0`設定は、「ルートパスの場合はキャッシュしない」を意味します。

`wrangler.jsonc`を変更したあとは、型定義ファイルを最新に保つために以下のコマンドを実行します。

```sh
pnpm run cf-typegen
...
> wrangler types
...
✨ Types written to worker-configuration.d.ts
...
📣 Remember to rerun 'wrangler types' after you change your wrangler.json file.
```

## キャッシュ動作確認ログ出力のLogger作成

キャッシュの機能ではないですが、キャッシュの状態（ヒット・ミス・格納など）をログとして出力できるよう、事前準備としてシンプルなLoggerを準備します。

ここではライブラリは使わず、JavaScriptの`console`メソッドをラップしただけのカスタムLoggerを実装します。

以下を参考し`app/infra/logger/types.ts`と`app/infra/logger/console-logger.ts`の２つのファイルを作成します。

<details><summary>👉 (new) app/infra/logger/types.ts</summary>

```ts
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface I_Logger extends Pick<Console, LogLevel> {}
```

</details>

<details><summary>👉 (new) app/infra/logger/console-logger.ts</summary>

```ts
import type { I_Logger, LogLevel } from "./types";

const LOG_LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];

export default class ConsoleLogger implements I_Logger {
  private currentLevel: LogLevel;

  constructor(config: { level: LogLevel }) {
    this.currentLevel = config.level;
  }

  private shouldLog(method: LogLevel): boolean {
    return LOG_LEVELS.indexOf(method) >= LOG_LEVELS.indexOf(this.currentLevel);
  }

  debug(message?: any, ...optionalParams: any[]): void {
    if (this.shouldLog("debug")) {
      console.debug(`[DEBUG] ${message}`, ...optionalParams);
    }
  }

  info(message?: any, ...optionalParams: any[]): void {
    if (this.shouldLog("info")) {
      console.info(`[INFO] ${message}`, ...optionalParams);
    }
  }

  warn(message?: any, ...optionalParams: any[]): void {
    if (this.shouldLog("warn")) {
      console.warn(`[WARN] ${message}`, ...optionalParams);
    }
  }

  error(message?: any, ...optionalParams: any[]): void {
    if (this.shouldLog("error")) {
      console.error(`[ERROR] ${message}`, ...optionalParams);
    }
  }
}
```

</details>

## レスポンスキャッシュ機能実装 (Cloudflare Workers Cache API)

Cloudflare Workersでは、`caches.default`の他にも、`caches.open(キー)`を使ってキャッシュストレージにアクセスでき、本記事では後者の方法を利用します。
ルーティング処理の前後でキャッシュを確認・保存するようにし、処理の高速化と無駄な再レンダリングの防止を実現します。

以下のDiffを参考し`workers/app.ts`を修正します。

<details><summary>👉 (mod) workers/app.ts</summary>

```diff_typescript
+import ConsoleLogger from "~/infra/logger/console-logger";
+import type { I_Logger } from "~/infra/logger/types";
...
export const ServerGlobalContext = unstable_createContext<{
     env: Env;
     ctx: ExecutionContext;
   };
 }>();
...
+// TTL（キャッシュの保持時間）をリクエストパスに基づいて決定する関数。
+// 特定のパスにカスタムTTLが設定されている場合はそれを使用します。
+// 設定がなければデフォルトのTTLを使用します。
+function determineTTL(pathname: string, env: Env, logger: I_Logger): number {
+  const customTTLEntries = Object.entries(env.CACHE.CUSTOM_TTL_PATH_DICT);
+
+  for (const [path, ttl] of customTTLEntries) {
+    if (pathname === path) {
+      logger.debug(`use custom TTL for ${path} is ${ttl}`);
+      return ttl;
+    }
+  }
+
+  return env.CACHE.DEFAULT_TTL;
+}
+
+// キャッシュストレージからレスポンスを取得する関数。
+// TTLが0以下であればキャッシュを無視します。
+// マッチするキャッシュがあればそれを返します。
+async function getCachedResponse(
+  cacheKey: Request,
+  cacheStorage: Cache,
+  ttl: number,
+  logger: I_Logger
+): Promise<Response | null> {
+  if (ttl <= 0) {
+    logger.debug(`ttl is 0, ignore getting cache : ${cacheKey.url}`);
+    return null;
+  }
+
+  const cachedResponse = await cacheStorage.match(cacheKey);
+  if (cachedResponse) {
+    logger.debug(`Cache hit - return cached response : ${cacheKey.url}`);
+    return cachedResponse;
+  }
+
+  logger.debug(`Cache miss : ${cacheKey.url}`);
+  return null;
+}
+
+// レスポンスをキャッシュストレージに保存する関数。
+// レスポンスのStatusCodeが「200 OK」以内だったらキャッシュをスキップします。
+// 言語検知Fallbackなどで「302 Redirect」を含めて、「200 OK以外のレスポンス」はキャッシュ対象外とします。
+// TTLが0以下であればキャッシュ保存をスキップします。
+// 有効な場合は、Cache-ControlヘッダーにTTLを追加します。
+async function putResponseToCache(
+  response: Response,
+  ctx: ExecutionContext,
+  cacheKey: Request,
+  cacheStorage: Cache,
+  ttl: number,
+  logger: I_Logger
+): Promise<Response> {
+  if (response.status !== 200) {
+    logger.debug(
+      `response is not 200, given ${response.status}, ignore putting cache: ${cacheKey.url}`
+    );
+    return response;
+  }
+
+  if (ttl <= 0) {
+    logger.debug(`ttl is 0, ignore putting cache : ${cacheKey.url}`);
+    return response;
+  }
+
+  response.headers.append("Cache-Control", `s-maxage=${ttl}`);
+  ctx.waitUntil(cacheStorage.put(cacheKey, response.clone()));
+  logger.debug(`put response to cache: ${cacheKey.url}`);
+
+  return response;
+}
+
 export default {
   async fetch(request, env, ctx) {
-    return requestHandler(
+    const logger = new ConsoleLogger({ level: env.LOG.LEVEL });
+    //ここではQueryParamは使わないことを前提とした実装にしています
+    //URLからQueryParamを除外しない場合は、違うQueryParamごとにCache保持されるようになる特徴があります
+    const urlWithoutQueryParams = new URL(request.url.split("?")[0]);
+    const cacheStorage = await caches.open("cache");
+    const cacheKey = new Request(urlWithoutQueryParams.toString(), request);
+    const cacheTTL = determineTTL(urlWithoutQueryParams.pathname, env, logger);
+
+    const cachedResponse = await getCachedResponse(
+      cacheKey,
+      cacheStorage,
+      cacheTTL,
+      logger
+    );
+    if (cachedResponse) {
+      return cachedResponse;
+    }
+
+    const response = await requestHandler(
       request,
       //pass context as Map with unstable_createContext to use unstable_middleware
       new Map([
         [
           ServerGlobalContext,
           {
             cloudflare: { env, ctx },
+            logger,
           },
         ],
       ])
     );
+
+    return await putResponseToCache(
+      response,
+      ctx,
+      cacheKey,
+      cacheStorage,
+      cacheTTL,
+      logger
+    );
   },
 } satisfies ExportedHandler<Env>;
```

</details>

## 動作確認

### Sampleページにレンダリング時刻出力を追加

ページが実際にキャッシュされているかの確認を用意にするために、レンダリング時刻を出力するようにします。ログと画面に両方出力します。

以下のDiffを参考し`app/routes/sample.tsx`を修正します。

<details><summary>👉 (mod) app/routes/sample.tsx</summary>

```diff_tsx
 export function loader({ context }: Route.LoaderArgs) {
...
-  return { message, translations };
+  const renderedAt = new Date().toLocaleString();
+  const logger = context.get(ServerGlobalContext).logger;
+  logger.debug(`renderedAt: ${renderedAt}`);
+
+  return { message, translations, renderedAt };
 }
...
 export default function Sample({
   loaderData,
   params,
 }: Pick<Route.ComponentProps, "loaderData" | "params">) {
-  const { message, translations } = loaderData;
+  const { message, translations, renderedAt } = loaderData;
...
           <Text>
             {translations.sample.title} / {translations.sample.description}
           </Text>
+          <Text>renderedAt : {renderedAt}</Text>
         </HStack>
```

</details>

### ログ確認

以下のようなログが出力されていれば、キャッシュが正常に動作していることを確認できます。

```sh
# キャッシュがない場合、ページレンダリングが行われ、レンダリング時刻がログに出力されたあと、レスポンスをキャッシュする。
[DEBUG] Cache miss : http://localhost:5173/ja/sample
[DEBUG] renderedAt: 6/15/2025, 9:10:45 PM
[DEBUG] put response to cache: http://localhost:5173/ja/sample

# キャッシュされたあとは、有効時間(TTL)内であれば、キャッシュが利用される
[DEBUG] Cache hit - return cached response : http://localhost:5173/ja/sample
 (x2)
```

## ✅️ソースコード参照

> **■ Note**
> - [chore: setup logger - define interface and implements to console-logger](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/f381b6c106bd4f861f71f0ef0bc2d9b33e536cc7)
- [chore: setup wrangler env vars - loglevel and cache ttl](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/bb0dbd7c50d883f9c62942f58fcddfed9254ac7e)
- [chore: setup workers/app.ts - response cache using Cache API](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/0df8a2d75268e40de37cc954519dc93d0e1c9998)
- [chore: prototyping sample.tsx - response cache](https://github.com/genie-oh/rrv7-cf-i18n-starter/commit/46289d2d2242bbb0fa8f47872ed1509f655c44eb)

# Step6. Cloudflare WorkersへDeploy

開発が完了したら、Cloudflare Workersにアプリケーションをデプロイします。
ローカルでの動作確認が済んでいれば、以下のコマンド一つで簡単に公開できます。

以下のコマンドを実行することで、CloudflareのエッジにReact Routerアプリケーションをデプロイできます。

```
pnpm run deploy
...
Uploaded rrv7-cf-i18n-starter (8.32 sec)
Deployed rrv7-cf-i18n-starter triggers (0.77 sec)
```

デプロイが成功すると、Cloudflare Workersに設定されたURL（例: https://<your-subdomain>.workers.dev）からアプリケーションにアクセス可能になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436887/6666eb15-5944-4fd6-9969-8654b1b45f18.png)

`pnpm run deploy`の内部では、`wrangler deploy`が実行され、`wrangler.jsonc`に定義された名前空間、ルート、環境変数をもとにプロジェクトがビルド・公開されます。

# 終わりに

今回は「React Router v7&Cloudflare Workers」を使うという試みは、思っていた以上に壁も多くありましたが、個人的には、その知見が広まり、「Next.js&Vercel」の対案として使えそうだなと感じる体験でした。

もちろん、今も「Next.js&Vercel」の組み合わせが優れたところは色々あると思います。

2つの比較や深掘りは、今後改めてしたいと思いますが、

「SSG,ISRや、SSR内でのfetchキャッシュなどの高度なキャッシュ機能」「ImageやLinkなどの高度な性能最適化」「豊富うなレファレンス」など、すでに信頼性があり、商用レベルで用意されている機能ですぐ開発したい場合は、学習コストは高いものの、すでに学習済みであれば、まだ「Next.js&Vercel」が、いい選択だとは思います。

反面、「React Router v7&Cloudflare Workers」を採用することで「プラットフォーム移植性」「シンプルなルーティング&データフェッチ&フォームデータ処理」「Cloudflare Workersの幅広い無料枠の活用」「高度ではないが、シンプルで使いやすいキャッシュ戦略」のメリットを取れると思います。

特に、個人開発においては「React Router v7&Cloudflare Workers」が持つメリットは大きいと感じるので、当分の個人開発においては「React Router v7&Cloudflare Workers」の組み合わせを使って行こうかなと思います。