Hono × JSXではじめる！Cloudflare Pagesを使ったSPAウェブアプリ開発

HonoでSPA（Single Page Application）のウェブアプリを作ろうと思い、公式ガイド [Client Components](https://hono.dev/docs/guides/jsx-dom) にたどり着きました。試行錯誤で何とかデモを動かせるようになったので、その結果を共有します！

# デモとソースコード

作ったデモは次の2つの記事のものと同じです。useStateを使ったカウンターと、API経由での日付の取得が動きます。

https://zenn.dev/laiso/articles/c7eba95ce43feb

https://zenn.dev/yusukebe/articles/06d9cc1714bfb7

上記はReactで作ってあるのですが、それをわざわざJSXのClient Componentsを使う形に戻しました。

デモアプリはCloudflare Pagesで公開しています（利用停止するかもしれません。あらかじめご容赦ください）。

https://cf-pages-jsx-client-component.pages.dev/

作成したコードは次のリポジトリーで公開しています。

https://github.com/takatama/cf-pages-jsx-client-component

# ファイル構成

フロントエンドは`src/client.tsx`に、バックエンドは`src/index.tsx`に実装します。

<details><summary>プロジェクトのファイル構成を展開</summary>

```console
.
├── README.md
├── package-lock.json
├── package.json
├── public
│   └── static
│       └── style.css
├── src
│   ├── client.tsx
│   └── index.tsx
├── tsconfig.json
├── vite.config.ts
└── wrangler.toml
```

</details>


# 手順

## 1. `create-hono`でCloudflare Pagesのプロジェクトを作る

https://hono.dev/docs/getting-started/cloudflare-pages

に従って、Cloudflare Pagesのプロジェクトを作ります。なお、記事執筆時は次のバージョンで動かしています。

- create-hono 0.14.3
- vite 5.4.11

```console
npm create hono@latest <プロジェクト名>
```

<details><summary>実行結果を展開</summary>

```console
> npx
> create-hono cf-pages-jsx-client-component

create-hono version 0.14.3
✔ Using target directory … cf-pages-jsx-client-component
? Which template do you want to use? cloudflare-pages
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd <プロジェクト名>
```
</details>

作ったディレクトリーに移動して、開発用サーバーを起動します。

```console
cd <プロジェクト名>
npm run dev
```

<details><summary>実行結果を展開</summary>

```console
> dev
> vite


  VITE v5.4.11  ready in 685 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help
(!) Could not auto-determine entry point from rollupOptions or html files and there are no explicit optimizeDeps.include patterns. Skipping dependency pre-bundling.
```

</details>

はじめて開発用サーバーを起動したときだけ、警告（(!) Could not auto-determine entry point...）が表示されます。

## 2. 設定を変更する

設定ファイルを書き換えていきます。

### tsconfig.json

`compileOptions.lib`に`"DOM"`と`"DOM.Iterable"`を追加します。

```diff:tsconfig.json
    "lib": [
-      "ESNext"
+      "DOM", "DOM.Iterable", "ESNext"
    ],
```

<details><summary>tsconfig.json を展開</summary>

```json:tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "skipLibCheck": true,
    "lib": [
      "DOM", "DOM.Iterable", "ESNext"
    ],
    "types": [
      "@cloudflare/workers-types/2023-07-01",
      "vite/client"
    ],
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx"
  },
}
```

</details>

変更し忘れていると、次のエラーが表示されます。

- Cannot find name 'document'. Do you need to change your target library? Try changing the 'lib' compiler option to include 'dom'.ts(2584)
- Property 'entries' does not exist on type 'Headers'.ts(2339)

### vite.config.ts と package.json

Cloudflare Pagesへのデプロイ前に、client modeのビルドを追加します。

```diff:vite.config.ts
- export default defineConfig({
+ export default defineConfig(({ mode }) => {
+   if (mode === "client")
+     return {
+       esbuild: {
+         jsxImportSource: "hono/jsx/dom",
+       },
+       build: {
+         rollupOptions: {
+           input: "./src/client.tsx",
+           output: {
+             entryFileNames: "static/client.js",
+           },
+         },
+       },
+     };
+   return {
    plugins: [
      build(),
      devServer({
        adapter,
        entry: 'src/index.tsx'
      })
    ]
+  }    
})
```

<details><summary>vite.config.tsを展開</summary>

```ts:vite.config.ts
import build from '@hono/vite-build/cloudflare-pages'
import devServer from '@hono/vite-dev-server'
import adapter from '@hono/vite-dev-server/cloudflare'
import { defineConfig } from 'vite'

export default defineConfig(({ mode }) => {
  if (mode === "client")
    return {
      esbuild: {
        jsxImportSource: "hono/jsx/dom", // Optimized for hono/jsx/dom
      },
      build: {
        rollupOptions: {
          input: "./src/client.tsx",
          output: {
            entryFileNames: "static/client.js",
          },
        },
      },
    };
  return {
    plugins: [
      build(),
      devServer({
        adapter,
        entry: 'src/index.tsx'
      })
    ]
  }    
})
```

</details>

```diff:package.json
  "scripts": {
    "dev": "vite",
-    "build": "vite build",
+    "build": "vite build --mode client && vite build",
    "preview": "wrangler pages dev",
    "deploy": "npm run build && wrangler pages deploy"
  },
```

<details><summary>package.jsonを展開</summary>

```json:package.json
{
  "name": "cf-pages-jsx-client-component",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build --mode client && vite build",
    "preview": "wrangler pages dev",
    "deploy": "npm run build && wrangler pages deploy"
  },
  "dependencies": {
    "hono": "^4.6.15"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20241218.0",
    "@hono/vite-build": "^1.2.0",
    "@hono/vite-dev-server": "^0.17.0",
    "vite": "^5.2.12",
    "wrangler": "^3.96.0"
  }
}
```

</details>

変更し忘れていると、`dist/client.js`が生成されず、デプロイに成功しても空白のページが表示されます。

参考

https://github.com/honojs/examples/tree/main/hono-vite-jsx

## 3. 実装する

フロントエンドとバックエンドを実装していきます。

### フロントエンド`src/client.tsx`

Reactを使う部分を、JSXに書き換えます。

```diff:src/client.tsx
- import { useState } from 'react'
- import { createRoot } from 'react-dom/client'
+ import { useState } from 'hono/jsx/dom'
+ import { createRoot } from 'hono/jsx/dom/client'
```

<details><summary>src/client.tsxを展開</summary>

```tsx:src/client.tsx
import { useState } from 'hono/jsx/dom'
import { createRoot } from 'hono/jsx/dom/client'

function App() {
  return (
    <>
      <h1>Hello, Hono with JSX!</h1>
      <h2>Example of useState()</h2>
      <Counter />
      <h2>Example of API fetch()</h2>
      <ClockButton />
    </>
  )
}

function Counter() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

const ClockButton = () => {
  const [response, setResponse] = useState<string | null>(null)

  const handleClick = async () => {
    const response = await fetch('/api/clock')
    const data = await response.json()
    const headers = Array.from(response.headers.entries()).reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
    const fullResponse = {
      url: response.url,
      status: response.status,
      headers,
      body: data
    }
    setResponse(JSON.stringify(fullResponse, null, 2))
  }

  return (
    <div>
      <button onClick={handleClick}>Get Server Time</button>
      {response && <pre>{response}</pre>}
    </div>
  )
}

const domNode = document.getElementById('root')!
const root = createRoot(domNode)
root.render(<App />)
```

</details>

### バックエンド`src/index.tsx`

フロントエンドから呼び出すAPI`/api/clock`と、フロントエンド本体のHTMLを返却する処理をそれぞれ実装します。

```tsx:src/index.tsx
import { Hono } from 'hono'

const app = new Hono()

app.get('/api/clock', (c) => {
  return c.json({
    time: new Date().toLocaleTimeString()
  })
})

app.get('/', (c) => {
  return c.html(
    <html>
      <head>
        <link href="/static/style.css" rel="stylesheet" />
      </head>
      <body>
        <div id="root"></div>
        {import.meta.env.PROD ? (
          <script type="module" src="/static/client.js" />
        ) : (
          <script type="module" src="/src/client.tsx" />
        )}
      </body>
    </html>
  )
})

export default app
```

Cloudflare Pagesへのデプロイ前にclient modeでbuildすることで、`/src/client.tsx`を`/static/client.js`に変換します。デプロイしたコードは`import.meta.env.PROD`が`true`になり、`/static/client.js`を参照します。

# まとめ

この記事では、HonoとJSXを使って簡単なSPA（Single Page Application）を作る手順を解説しました。ポイントは次の通りです：

- **Honoを使ったプロジェクトのセットアップ**  
  `create-hono`を使ってCloudflare Pages用のプロジェクトを簡単に構築しました。

- **JSXでのフロントエンド開発**  
  React風の記法を活用して、カウンターやAPIを使った日付取得のコンポーネントを作成しました。

- **バックエンドとの連携**  
  フロントエンドからバックエンドAPIを呼び出し、データを動的に表示する仕組みを構築しました。

- **開発とデプロイのプロセス**  
  Viteを活用したローカル開発から、Wranglerを使ったCloudflare Pagesへのデプロイまでの流れを体験しました。

この記事が、HonoとJSXを使ったウェブアプリ開発の第一歩となれば幸いです！