Next.js + Vercel から HonoX + Cloudflare Workers に移行しました
この投稿はEnglishでも表示されます。
はじめに
個人サイトを Next.js + Vercel から HonoX + Cloudflare Workers へ移行しました。
きっかけは Vercel の Fluid Active CPU が無料枠を大幅に超過し、毎日 Pro へのアップグレード警告が届くようになったことです。Fluid Active CPU とは Vercel が CPU 使用時間をベースに課金する指標で、サーバーが動くたびに消費されます。
原因は i18n 対応で SSG から SSR に切り替えたことでした。SSG(Static Site Generation)はビルド時に HTML を生成する方式で、一度生成した HTML をそのまま配信するため実行コストがかかりません。一方 SSR(Server Side Rendering)はリクエストのたびにサーバーで HTML を生成するため、アクセスのたびに CPU を消費します。
言語切り替えに Cookie を参照する必要があり、ミドルウェアで Cookie を見てルートを振り分けるだけで SSR 固定になってしまいます。リクエストのたびにサーバーが動くのでコストが跳ね上がりました。
「ミドルウェアを挟むだけで SSR 固定というのはどうなんだろう」という疑問もあり、移行を決めました。
なぜ Cloudflare Workers + HonoX を選んだか
移行先として Cloudflare Workers を選んだ理由はシンプルで、エッジ実行・高速・無料枠が大きいの3点です。個人サイト規模であれば実質無料で運用できます。
エッジ実行とは、ユーザーの近くにある世界中のサーバー(エッジ)でコードを動かす仕組みです。日本からアクセスすれば日本のサーバーが応答するため、遅延が小さくなります。
Cloudflare 上で Next.js を動かす選択肢も調べました。公式ドキュメントを見ると OpenNext アダプター経由で App Router や SSR もサポートされています。ただ Next.js はバージョンごとにアーキテクチャが大きく変わるため、Cloudflare 側がそれに追従し続けるのは大変そうという印象がありました。実際、15.2 で導入された Node.js Middleware は執筆時点でまだ未サポートです。このサイトでは Cookie を見て言語を振り分けるミドルウェアを Node.js で動かしていたため、そのまま Cloudflare に持ち込むことができません。単純移行ではなく、フレームワークごと書き直す判断をした理由のひとつがここにあります。
Cloudflare に移行するなら Hono を試してみようという流れで、ちょうど HonoX が出てきていました。Hono の作者である yusukebe さんが HonoX について という記事を書いており、やってみることにしました。
HonoX は Hono をサーバーフレームワーク、Vite をビルドシステムとして組み合わせたメタフレームワークです。Hono 単体に「ファイルベースルーティング」と「Islands アーキテクチャ」を追加したものと捉えると分かりやすいです。HonoX で書いたコードは実質的に Hono のアプリケーションであり、app/server.ts に Hono インスタンスが入ります。
HonoX プロジェクトの基本構造
ディレクトリ構成はこうなっています。
app/
├── server.ts # サーバーエントリポイント(Hono インスタンス)
├── client.ts # クライアントエントリポイント
├── style.css # グローバルスタイル
├── routes/ # ファイルベースルーティング
│ ├── _renderer.tsx # HTML テンプレート
│ ├── _middleware.ts # ミドルウェア(言語検出・リダイレクト)
│ ├── _404.tsx # 404 ページ
│ ├── _error.tsx # エラーページ
│ ├── index.tsx # /
│ ├── [lang]/ # /{lang}/* のルート群
│ │ ├── index.tsx
│ │ ├── blogs/
│ │ ├── contact/
│ │ └── ...
│ ├── api/ # API ルート
│ ├── sitemap.xml.ts
│ └── robots.txt.ts
├── components/ # UI コンポーネント(SSR)
├── islands/ # インタラクティブコンポーネント(クライアント)
├── i18n/ # i18n カスタム実装
└── renderer/ # レンダラー共通処理
content/
├── ja/blogs/ # 日本語ブログ記事(Markdown)
└── en/blogs/ # 英語ブログ記事(Markdown)
app/server.ts が Hono のエントリポイントで、createApp() を呼ぶだけでファイルベースルーティングが有効になります。routes/ 以下のファイルが自動的にルートとして登録されます。
_ プレフィックスのファイルはルートとして登録されず、特別な役割を持ちます。_renderer.tsx が HTML の骨格、_middleware.ts がそのディレクトリ以下に適用されるミドルウェアです。
islands/ ディレクトリがポイントです。Islands アーキテクチャとは、ページの大部分をサーバーで HTML として生成しつつ、インタラクションが必要な部分だけをクライアントサイドの JavaScript として動かす設計です。ここに置いたコンポーネントだけがブラウザで hydrate(サーバーが生成した HTML に JavaScript のイベントを結びつける処理)されます。それ以外のコンポーネントは SSR のみで、ブラウザに JS が配信されません。問い合わせフォームや検索ボックスなど、インタラクションが必要な部分だけを islands/ に置く設計です。
Vite 設定のポイント
vite.config.ts はこうなっています。
import build from '@hono/vite-build/cloudflare-workers'
import adapter from '@hono/vite-dev-server/cloudflare'
import tailwindcss from '@tailwindcss/vite'
import honox from 'honox/vite'
export default defineConfig({
plugins: [
honox({
devServer: { adapter },
client: { input: ['/app/client.ts', '/app/style.css'] },
}),
tailwindcss(),
build(),
],
})
adapter と build() は両方必要です。 adapter は開発サーバーを Cloudflare Workers 互換環境で動かすためのもの、build() は本番ビルドを Workers 向けに出力するためのものです。片方だけだと「ローカルでは動くが本番で壊れる」という問題が起きます。
client.input の明示も必要です。 これがないと app/client.ts(islands の hydration スクリプト)と app/style.css(Tailwind CSS)がクライアント向けにバンドル(複数のファイルをブラウザが読み込める形にまとめる処理)されません。
Tailwind CSS v4 は @tailwindcss/vite プラグインとして統合されており、PostCSS の設定は不要です。
i18n:外部ライブラリなしの型安全カスタム実装
next-intl を使っていましたが、Next.js から離れた時点でそのまま持ち込む理由がなくなりました。翻訳ファイルの構造とロケール検出のロジックはシンプルなので、外部ライブラリへの依存を増やすよりカスタム実装のほうが軽量で制御しやすいと判断しました。
結果として TypeScript の型推論を活かしたドット記法キーの補完が実現でき、翻訳キーのタイポをビルド時に検出できるようになりました。ドット記法キーとは 'contactForm.divisions.informationSystems' のように、ネストしたオブジェクトをドットでつないで表現する方法です。
const t = createT('ja')
t('contactForm.divisions.informationSystems') // 存在しないキーはコンパイルエラー
Markdown 処理:ランタイムパースなしの SSR
ブログ記事は content/ja/blogs/ 以下に Markdown ファイルとして管理しています。カスタム Vite プラグインでビルド時に変換しているため、ランタイムでのパース処理はありません。
*.md?blog というクエリ付きでインポートすると、プラグインがビルド時に Markdown を処理して JS オブジェクトとして返します。
// ルートコンポーネントでのインポート例
import blogData from '../../content/ja/blogs/my-post.md?blog'
// blogData の中身
{
title: '記事タイトル',
excerpt: '記事の概要',
html: '<p>レンダリング済み HTML</p>',
tags: ['honox', 'cloudflare'],
createdAt: '2026-03-20',
// ...
}
プラグインの処理内容はシンプルで、gray-matter でフロントマター(Markdown ファイルの先頭に --- で囲まれた YAML 形式のメタデータ)を解析し、zenn-markdown-html で本文を HTML に変換します。
// vite-plugin-blog-md.ts(抜粋)
const { data, content } = matter(raw)
const html = await markdownHtml(content, { embedOrigin: 'https://embed.zenn.studio' })
Hono JSX のバグ:canonical リンクが消える問題
canonical とは、同じ内容が複数の URL で表示される場合に「正規の URL はこちらです」と検索エンジンに伝えるための <link rel="canonical"> タグです。hreflang は「この URL は日本語版、こちらは英語版」と言語ごとの URL を検索エンジンに伝えるタグです。多言語サイトでは両方を <head> に入れる必要があります。
Hono JSX は <link> 要素の重複排除キーとして href を使います。rel="canonical" と hreflang="en" が同じ href を持つと canonical が「重複」と判定されて除去されてしまいます。
// NG: hreflang と href が同じ URL になると canonical が消える
<link rel="canonical" href={pageUrl} />
<link rel="alternate" hreflang="en" href={pageUrl} />
// OK: raw() で Hono JSX の処理を迂回して直接出力する
{raw(`<link rel="canonical" href="${pageUrl}">`)}
<link rel="alternate" hreflang="en" href={pageUrl} />
テスト基盤(Playwright)
E2E(End-to-End)テストとは、実際のブラウザを使って「ページを開く → フォームに入力する → 送信する」といったユーザー操作を自動化して検証するテストです。i18n・リダイレクト・OGP・フォーム送信など、移行で壊れやすい箇所が多かったため、Playwright による E2E テストを導入しました。実装していく中でいくつか工夫が必要な点がありました。GA/GTM のブロック、Islands の hydration 待ち、フォーム送信のインターセプトの3つを紹介します。
GA/GTM のブロック
テスト中に Google Analytics や GTM へのリクエストが飛ぶと PV が水増しされてしまいます。Playwright の fixture(テストの前後処理をまとめる仕組み)でリクエストをブロックし、各テストファイルで @playwright/test の代わりにこの fixture から test をインポートするだけで適用されるようにしています。
// e2e/fixtures.ts
export const test = base.extend({
page: async ({ page }, use) => {
await page.route(
/https?:\/\/(www\.google-analytics\.com|www\.googletagmanager\.com)\/.*/,
(route) => route.abort(),
)
await use(page)
},
})
Islands の hydration 完了を待つ
Islands は非同期で hydrate されるため、クリックや入力の前に完了を待つ必要があります。waitForLoadState('networkidle') は遅いので、フォームの autofocus が発火したことを hydration 完了のシグナルとして使っています。
await page.goto('/ja/contact')
// autofocus が発火 = island の hydration 完了
await expect(page.locator('input[type="text"]').first()).toBeFocused()
フォーム送信のテスト
SendGrid へのリクエストを page.route() でインターセプトして、実際にメールを送らずにリクエストボディを検証しています。
let requestBody: Record<string, string> | null = null
await page.route('/api/contact', async (route) => {
requestBody = route.request().postDataJSON()
await route.fulfill({ status: 200, body: JSON.stringify({ ok: true }) })
})
// フォーム送信後にリクエストボディを検証
expect(requestBody?.division).toBe('情報システム') // 英語キーではなく日本語ラベル
Docker ローカル開発環境の整備
Node.js や pnpm のバージョンをローカルに揃えるのが面倒なので、Docker コンテナに統一しました。よく使うコマンドは Makefile にまとめています。
make dev # 開発サーバー起動
make check # フォーマット・リント・型チェック・依存関係チェックをまとめて実行
make test # ユニットテスト + E2E テスト
make test-prod # 本番環境に対して E2E テスト
make fmt # フォーマット自動修正
CI 環境では ifdef CI で分岐し Docker を使わず pnpm を直接実行するため、CI で Docker in Docker(Docker コンテナの中でさらに Docker を動かすことになってしまう問題)になる問題も回避できています。
ツール選定
フォーマッターとリンターについて、移行前後で構成が変わりました。
Biome
フォーマッターは移行前から引き続き Biome を使っています。Prettier と比べて高速で、設定がシンプルなのが気に入っています。
oxlint
リンターとして oxlint を追加しました。ESLint は動作が遅く CI のボトルネックになりやすい一方、oxlint は設定がほぼ不要で高速です。Biome がフォーマット担当、oxlint がリント担当と役割を分けています。
dependency-cruiser
依存関係の循環を検出するために dependency-cruiser を導入しました。循環依存とは「A が B を使い、B が A を使う」という状態で、コードが複雑になるにつれて気づかず発生しやすくなります。make check に組み込んでいるため、循環依存が生まれた時点で CI が落ちます。
おわりに
移行してよかったと感じています。Vercel の警告が届かなくなったのはもちろん、コードがシンプルになり、何がどこで動いているかが把握しやすくなりました。Next.js は機能が豊富な分、動作の裏側に隠れた部分が多くありました。HonoX は素の Hono アプリケーションに近いため、挙動を追いやすいです。
課題もあります。移行直後に PageSpeed Insights でモバイルの LCP が 4.5 秒(目標 2.5 秒以下)と出ました。CSS のレンダリングブロック、GTM の読み込み、画像サイズの未指定が原因で、それぞれ preload・遅延読み込み・サイズ明示で対処しました。Vercel 時代は CDN キャッシュに助けられていた部分があり、SSR に切り替えたことで改めてパフォーマンスと向き合う必要が出てきました。今後も継続して計測・改善していく予定です。
関連する技術ブログ
Next.js App Router 時代のテスト戦略:Jest・RTL・Playwrightで支える開発の安心感
2025/04/22Next.js App Routerに最適なORMを徹底比較:Prisma / Drizzle / Kysely / TypeORMの選び方ガイド【前編】
2025/03/13Next.js × ORM 実践比較:Prisma / Drizzle / Kysely をDocker上で動かして違いを体感する【後編】
2025/03/13React Router v7(フレームワーク利用) 実践ガイド:ブログサイトを作りながら学ぶサーバーサイド、クライアントサイドのレンダリング
2025/01/23