HonoX に Inertia の機構を取り入れてSPAっぽくしてみた
1. はじめに
以前の記事 で Hono × Inertia × React の組み合わせを書いた。型貫通と state 管理の楽さは最高だったが、React bundle と SPA client への重さも気になっていた。
その延長として、今度は「HonoX に Inertia の機構を取り入れる、ただし React は抜く」という実験を行ってみた。狙いを一行でまとめると:
サーバーが client の初期状態を作って送る。そこから先のインタラクティブは client が頑張る。
これを Hono ecosystem の純度を保ったまま薄く実現できるか、という試み。
動くものは既にデプロイしてある:
- デモ: https://honox-frame.asahi-gaia1530.workers.dev/login
-
ログイン:
test@example.com/password - リポジトリ: https://github.com/ashunar0/honox-frame
ログインすると Ping CRM 風の mini SaaS が動く。sidebar / Counter は遷移を跨いで生き続け、Dashboard では特定の prop だけが部分更新される —— こういう挙動を実機で確認できる。
※ 仮称
honox-frame。命名は未確定で、PoC が固まってから決める予定。
2. position — HonoX と Hono+Inertia の間
yusukebe さんの軌跡を辿ると、こうなっている:
Hono (server)
↓
HonoX (server + lightweight islands、SPA nav なし)
↓
Hono+Inertia (SPA nav あり、React/Vue を抱える)
ここで気付いた —— この2点の間が空いている。
- HonoX は MPA + islands で軽いが、SPA navigation が無い
- Hono+Inertia は SPA navigation を提供してくれるが、React/Vue を抱える
「HonoX に SPA navigation を載せる、ただし React/Vue は抱えない」という選択肢が Hono ecosystem 上に存在しなかった。これを埋めるのが honox-frame。
技術カテゴリで言うと "SPA っぽい MPA" 系統で、Rails の Hotwire / Astro view transitions に近い位置。
| FW | wire format | client runtime | 系統 |
|---|---|---|---|
| Hotwire (Turbo) | HTML | Stimulus | Rails |
| Inertia | JSON | React/Vue | SPA |
| Hono+Inertia | JSON | React/Vue | SPA on Hono |
| honox-frame | HTML + JSON envelope + frame swap | hono/jsx/dom (~5kb) | HonoX 進化 |
Hotwire / Inertia を知ってる読者には「Hono 版のあれだな」で1秒で位置が伝わるはず。
3. なぜ React を抜いたのか
最初に強調しておくと、Hono+Inertia を否定する話ではない。Hono+Inertia は「React/Vue ecosystem に乗りたい人」に対する正解で、自分も別プロジェクトでは普通に使う。
ただ honox-frame で React を抜いたのには理由がある:
3.1 Hono ecosystem 純度
Hono は backend FW として bundle 軽さを重視している。そこに client runtime として React を抱えると、ecosystem の哲学と少しズレる。hono/jsx/dom は ~5kb で、HonoX が既に採用している runtime。これを SPA navigation 込みでも使い切る のが筋だと思った。
3.2 bundle size
- React + ReactDOM: ~50kb (gzip)
- hono/jsx/dom: ~5kb (gzip)
ざっくり10倍違う。Cloudflare Workers での edge SSR / SPA UX を考えると、無視できない差。
3.3 "Inertia の機構" は React/Vue 依存ではない
Inertia の核は wire format(envelope)と navigation の orchestration であって、render layer は分離可能。これを hono/jsx/dom に置き換えたらどうなるか、という検証がやりたかった。
つまり一文でまとめると:
HonoX × Inertia の機構 ÷ React
4. 動かしてみる — 見える 2 つの意義
show case(app/)は Ping CRM 風の mini SaaS。動かして見える本質は、ざっくり 2 つに集約できる。
実装側で必要な primitive は <Frame> component ひとつだけ。これで swap 対象の領域を囲むと、外側は touch されず、内側だけが nav 時に差し替わる:
import { Frame } from "honox-frame/server";
export default function Layout({ children }) {
return (
<div class="flex">
<Sidebar /> {/* Frame 外 → 永続 */}
<Frame id="main">{children}</Frame> {/* Frame 内 → swap */}
</div>
);
}
中身はたった数行 —— <div data-honox-frame={id}> を出すだけの marker。client runtime はこの data-honox-frame 属性を頼りに swap 対象を見つける。
4.1 共通部分は再レンダリングされない

sidebar の nav を順に押すと、sidebar 自体は flash せず、main 領域だけが切り替わる。さらに sidebar の Counter が 5 のまま遷移しているのに注目 —— frame の外(sideb
ar 全体)は DOM ごと残り続けている。
<div data-honox-frame="main"> で囲まれた要素の中身だけを client runtime が swap するため、外側の sidebar / Counter は touch されない。Hotwire の Turbo Frame + Inertia の persistent layout を組み合わせた挙動。
link 遷移だけでなく form submit 後の遷移 にも適用される(_method=DELETE 等の HTTP method override にも対応)。
裏側では
X-Honox-Mode: jsonheader をつけた fetch が1本飛び、server は{ component, props, url }の envelope を返している(詳細は section 5)。
4.2 main の中も必要なところだけ差分更新できる

Dashboard で refresh stats を押すと、stats の更新時刻だけが更新され、それ以外は touch されない。注目は Dashboard 上に置いた Counter —— stats を refresh しても Counter は数字を保ったままで、リセットされない。
router.reload({ only: ["stats"] })
または HTML 上に <a href="..." data-honox-only="stats"> と書けば、X-Honox-Partial-Data: stats header が飛び、server は stats prop だけ resolve して返す。client は受け取った partial を既存の props にマージするので、それ以外の component state は触られない。Inertia v2 の partial reload 相当。
5. 設計の核 — envelope 切替と c.render overload
honox-frame の核は c.render の overload と envelope 切替。
5.1 c.render overload
通常の HonoX では c.render(<Page />) は JSX を取って HTML を返す。honox-frame はこれに コンポーネント関数 + props の signature を加える:
app.get("/dashboard", (c) => {
return c.render(DashboardPage, {
user: c.var.user,
stats: () => fetchStats(), // 関数なら resolve 時に await
});
});
関数値の props は lazy に解決される(Inertia v2 の deferred / lazy props 相当)。
5.2 envelope 切替
server は X-Honox-Mode header を見て HTML or JSON を返す:
| Mode | response | 用途 |
|---|---|---|
html(default) |
full ページ HTML(<Page /> を render + page meta を <script> 埋め込み) |
初回ロード、SEO、JS 無効時 |
json |
{ component, props, url, partial, flash } JSON envelope |
SPA nav |
client(navigate.ts)は SPA nav 時に常に X-Honox-Mode: json を送る。HTML response は fallback / 初回ロード用。
補足: 元々 "HTML + JSON + Frame の 3-mode" として設計したが、実装上は server は 2-mode(HTML / JSON)に整理し、Frame 体験は client 側で HTML response から
[data-honox-frame]を抽出する経路に統合した。
5.3 その他の API(要点だけ)
-
partial reload protocol:
X-Honox-Partial-Data: stats,activityheader で対象 prop を指定。server は該当 prop だけ resolve、client は既存 props にマージ -
c.forward / c.back+ flash messaging: redirect with flash を Hono context API として提供、payload は cookie 経由
いずれも Inertia 系の API を Hono の c.html / c.json / c.text の自由度と地続きに、薄く乗せた形。実装の詳細は第3弾の記事で深掘りする予定。
6. 内部構造(軽く)
src/lib/ 以下、合計 500行強 で完結する。
| ファイル | 役割 | 行数 |
|---|---|---|
client/navigate.ts |
link/form intercept、fetch、frame swap、json handling、history 同期 | ~290 |
client/app.tsx |
persistent App layer(hono/jsx/dom)、partial merge、flash dispatch | ~87 |
server/render.ts |
withHonoxFrame middleware、c.render overload、c.forward / c.back
|
~140 |
server/Frame.tsx |
<Frame id="main"> marker |
~5 |
server/flash.ts |
flash payload を cookie で運ぶ | ~28 |
HonoX を fork してない
これが設計的に重要なポイント。HonoX を dependency として package.json に置き、app/client.ts で extension layer を起動するだけ:
// app/client.ts(最小パターン)
import { createClient } from "honox/client";
import { initNavigation, setPage } from "honox-frame/client";
createClient(); // HonoX の islands hydration
initNavigation({ onPageData: (d) => setPage(d) }); // honox-frame の SPA nav runtime
※ 実際の
app/client.tsではこの上に page meta 復元 / hydration coordination が乗っている。詳細は repo 参照。
HonoX 本体の進化が無料で乗ってくる。HonoX 側に hook が必要なら本体に PR する、という方向で考えられる。HonoX が fork されずに進化できることを示したい、という意図がある。
7. 達成と制約
show case で稼働している機能:
- Frame swap(sidebar 維持の SPA navigation)
- Islands re-hydrate(state 維持)
- Form intercept(method override 含む)
- HTML/JSON envelope 切替 + 受信側 frame swap
- Partial reload(Inertia v2 風)
- Lazy props(function 値を server で resolve)
- Flash messaging(c.forward / c.back / Toast)
roadmap にあるが現状未実装または保留:
- Streaming SSR — PoC route として一度実装したが、現バージョンの show case からは外して再検討中
- 命名(仮称 honox-frame、未確定)
- HonoX 本体への侵襲度の決め事(必要 hook を本体に逆 PR するか、extension layer 内で完結させ続けるか)
- signal-as-cache 的な拡張の検討
- ページ全体の client hydration — フロントFW の文脈で言うと このカテゴリの域、それ以上の領域には踏み込んでない
core vision との距離
正直に書いておくと、honox-frame の core vision —— サーバーが client の初期状態を作って送り、その先のインタラクティブは client が頑張る —— は、現状の show case で まだ半分くらいしか visible になっていない。
frame swap / form intercept / partial reload は navigation 周りの可視機能で、これらは core vision の "サーバー作の初期状態を効率的に届ける" 部分。一方で、その先にあるべき "client が頑張る" 領域 —— client 側のページデータ cache、optimistic update、router.reload({ only: [...] }) を使った状態境界の意識的な設計 —— は、まだ show case に練り込めてない。
この vision の真価は、これらを使い切ったときに visible になる、という見立て。
8. まとめ
HonoX に Inertia の機構を取り入れる実験を続けてきた結果、2 つの意義(共通部分の preservation と 局所差分更新)が実機レベルで動く show case が用意できた段階。Hotwire/Inertia 系として実用できる粒度まで揃った形。
一行でまとめると:
HonoX × Inertia の機構 ÷ React
技術的に "世界初" の機能は無い。frame swap は Hotwire、partial reload + lazy props は Inertia v2、islands は Astro/Fresh/Marko に既存。新規性は組み合わせと文脈 にある —— Hono ecosystem における第一号の "SPA-like MPA" 選択肢で、HTML/JSON envelope と client frame swap を一つの薄い runtime にまとめてる点。
研究プロトタイプとして公開している:
- リポジトリ: https://github.com/ashunar0/honox-frame
-
デモ: https://honox-frame.asahi-gaia1530.workers.dev/login (
test@example.com/password)
フィードバックや意見は大歓迎です。GitHub Issues か X まで。
Discussion