2026-11-04
Full AI
Kagero: Ruby WayでInertiaの体験を作る ... users
はじめに
sinatra-kagero(Kagero)は、React・Vue・Svelteを書かずに、SinatraのrouteとRubyのPageクラスだけでSPA的な画面遷移を作るgemです。
route、props、入力の検証、HTMLはRubyで書きます。
ブラウザ側のfetch、履歴、DOMの差し替えは、gemに入っている小さなJS(以下、runtime)が受け持ちます。
Cloudflare Workersで動かしています。
サンプルは、Sinatraをhomura(Opal)でCloudflare Workersに載せ、D1をデータベースに使っています。

- RubyGems: https://rubygems.org/gems/sinatra-kagero
- GitHub: https://github.com/kazuph/homura/tree/main/gems/sinatra-kagero
- サンプル: https://kagero-d1-todo.kazu-san.workers.dev/
Inertiaのpage objectを、Rubyで描く
Inertiaは、サーバーがJSONのAPIではなく「次のページ」を返す仕組みです。 ページは、どのcomponentをどのpropsで描くかを書いたJSONで、page objectと呼ばれます。 普通のInertiaでは、ブラウザ側のReactやVueのcomponentがこのJSONを受けて描きます。
Kageroはpage objectの形をそのまま使い、componentの描画だけをサーバーのRubyに戻します。
RubyのPageクラスがHTMLまで作り、そのHTMLをprops.kagero.htmlに入れて返します。
サンプルの/にInertiaの要求を送ると、次のJSONが返ります(HTMLとtodosの一部を省略)。
{
"component": "Todos/Index",
"props": {
"flash": {},
"todos": [
{
"id": 10,
"title": "これが日本語対応!",
"done": true,
"created_at": 1782437916
},
"…"
],
"stats": {
"total": 3,
"open": 1,
"done": 2
},
"errors": {},
"kagero": {
"component": "Todos/Index",
"html": "<style>…</style><section class=\"shell\"><header class=\"hero\">…",
"title": "Kagero D1 Todo"
}
},
"url": "/",
"version": "kagero-1"
}
runtimeは、このJSONを受けて<main>の中身を差し替え、URLとタブのタイトルを書き換えるだけです。
1回のフォーム送信で起きること
TODOを1件追加するときの流れです。
最初の表示だけは普通のHTMLです。
サーバーはPageを描いたHTMLに、page objectとruntimeを埋め込んで返します。
同時に、偽のフォーム送信を防ぐトークン(XSRF-TOKEN)をcookieに入れます。
2回目からは、runtimeがクリックと送信を横取りしてJSONでやり取りします。
送信が通ったら、サーバーは一覧ページを描き直して、URLを/にしたpage objectを返します。
POSTのあとに/へリダイレクトするのと同じ結果を、1往復で得られます。
アプリ側のコード
route
register Sinatra::KageroしたSinatraアプリです。
pageでPageを返し、送信後はredirect_pageで「次のページ」を返します。
class App < Sinatra::Base
register Sinatra::Kagero
# 設定とヘルパー(db・todos・todo_stats・index_page など)は省略
get "/" do
page(
Pages::Todos::Index,
todos: todos,
stats: todo_stats,
errors: page_errors || {},
flash: flash_payload
)
end
post "/todos" do
command = Commands::CreateTodo.new(params)
if command.valid?
db[:todos].insert(title: command.title, done: 0, created_at: Time.now.to_i)
if page_request?
index_page(flash: {notice: "Todo added"})
else
set_flash(notice: "Todo added")
redirect to("/"), 303
end
elsif page_request?
index_page(errors: command.errors)
else
page_errors(command.errors)
redirect to("/"), 303
end
end
# GET /done、完了の切り替え、削除の route は省略
end
page_request?は、要求にX-Inertia: trueヘッダーが付いているか(runtimeからの要求か)を見ます。
index_pageはredirect_page("/", Pages::Todos::Index, ...)を呼ぶヘルパーです。
runtimeからの要求なら一覧のpage objectをURL/で返し、それ以外でCSRFの検査を通った要求なら303で/へリダイレクトします。
Page
画面はKagero::Pageで書きます。
Kagero::PageはPhlexのPhlex::HTMLを継承していて、h1 { "..." }のようにRubyのメソッド呼び出しでHTMLを組み立てます。
module Pages
module Todos
class Index < Kagero::Page
title "Kagero D1 Todo"
props do
prop :todos, Array
prop :stats, Hash, required: false, default: -> { {} }
prop :errors, Hash, required: false, default: -> { {} }
prop :flash, Hash, required: false, default: -> { {} }
end
def view_template
style { raw(safe(css)) }
section(class: "shell") do
header(class: "hero") do
# 見出しと説明文(省略)
end
kagero_link(href: "/done") { "完了済み" }
render_flash
render_stats
render_form
render_list
end
end
private
# render_flash・render_stats は省略
def render_form
section(class: "panel", "aria-label": "Create TODO") do
kagero_form(action: "/todos", method: "post", class: "new-form", "data-kagero-preserve-scroll": "true") do
div(class: "field") do
label(for: "title") { "Title" }
input(
id: "title",
type: "text",
name: "title",
placeholder: "次にやること",
"aria-invalid": field_error?(:title).to_s,
"aria-describedby": field_error?(:title) ? "title-error" : nil
)
if field_error?(:title)
p(id: "title-error", class: "error", role: "alert") { error_for(:title) }
end
end
button(type: "submit", class: "button-primary") { "追加" }
end
end
end
# render_list・error_for・CSS などは省略
end
end
end
propsは、Pageが受け取る値の宣言です。
宣言にない値は捨て、必須の値が無いときや型が違うときは例外にします。
kagero_formとkagero_linkは、普通の<form>と<a>にdata-kagero属性を付けるだけのヘルパーで、runtimeはこの属性を目印に送信とクリックを横取りします。
Command
入力の検証もRubyです。
module Commands
class CreateTodo < Kagero::Command
attribute :title, String, default: ""
validates_presence_of :title, message: "title_required"
validates_length_of :title, maximum: 80, message: "title_too_long"
def title
@title.to_s.strip
end
end
end
検証に落ちると、routeはerrorsを入れた一覧のpage objectを返し、Pageが入力欄の下にエラーを描きます。

gemの中身
page helper
pageと、runtimeからの要求を受けたredirect_pageは、どちらもrender_kagero_pageに流れます。
def render_kagero_page(page_class, props, url: request.fullpath)
component = page_class.kagero_component_name
page_instance = page_class.new(**props)
page_props = page_instance.kagero_props
body_html = page_instance.call
response_props = page_props.merge(
kagero: Sinatra::Inertia.always(
{
component: component,
html: body_html,
title: page_instance.kagero_title
}
)
)
if inertia_request?
render_kagero_json(component, response_props, url)
else
render_kagero_shell(component, response_props, url)
end
end
page_instance.callがPhlexのHTMLを作ります。
Inertiaの通信の部分(page objectの組み立て、asset versionの照合、部分更新)は、下の層のsinatra-inertiaが受け持ちます。
kageroをalwaysで包む理由は、次の部分更新の節で説明します。
runtime
runtimeの中心はvisitです。
同じoriginの要求だけを送り、POSTなどにはXSRF-TOKENcookieの値をX-XSRF-TOKENヘッダーに入れます。
返ってきたJSONがKageroのpage objectの形をしているかを確かめてから、画面と履歴を書き換えます。
async function visit(url, options = {}) {
const method = (options.method || "GET").toUpperCase();
const targetUrl = new URL(url, location.href);
if (targetUrl.origin !== location.origin) {
if (method === "GET") location.href = targetUrl.href;
else showFailure();
return;
}
startLoading();
const headers = new Headers(options.headers || {});
headers.set("X-Inertia", "true");
headers.set("X-Inertia-Version", currentPage ? currentPage.version : "");
// X-Requested-With と Accept の設定(省略)
if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
const cookie = document.cookie.split("; ").find((entry) => entry.startsWith("XSRF-TOKEN="));
if (cookie) headers.set("X-XSRF-TOKEN", decodeURIComponent(cookie.slice("XSRF-TOKEN=".length)));
}
try {
const response = await fetch(url, {
method,
body: options.body,
headers,
credentials: "same-origin",
mode: "same-origin",
redirect: "follow"
});
// 応答のorigin、409(asset versionの不一致)、失敗応答、page objectの形の検査、
// 部分応答のpropsの重ね方(省略)
rememberScroll();
document.getElementById("kagero-visit-error")?.remove();
applyPage(page, {
replace: options.replace === true,
preserveScroll: options.preserveScroll === true
});
} catch (error) {
showFailure();
} finally {
finishLoading();
}
}
mode: "same-origin"にしているので、途中で別のoriginへリダイレクトされるとfetch自体が失敗し、トークンは外へ出ません。
失敗したときは画面の上に短いメッセージを出し、入力中の文字はそのまま残します。
runtimeの仕事はほかに、クリックと送信の横取り、戻る・進むでの画面の復元、通信中に画面上部へ出す細い線があります。 アプリを書く側が触るJSはありません。
部分更新
一覧の上の「Partial reload」ボタンは、統計(stats)を指定して部分更新を頼みます。
def render_stats
section(class: "stats", "data-testid": "stats") do
stat("Total", stat_value(:total))
stat("Open", stat_value(:open))
stat("Done", stat_value(:done))
kagero_partial_button(only: :stats, class: "stat refresh", "aria-label": "Refresh stats") do
span { "Partial reload" }
strong { "stats" }
end
end
end
runtimeは、Inertiaの部分更新のヘッダー(X-Inertia-Partial-Component: Todos/IndexとX-Inertia-Partial-Data: stats)を付けて同じURLを取り直します。
サーバーのrouteはいつもどおり一覧と統計の両方を取り、ページ全体のHTMLを描きます。
そのあとsinatra-inertiaが、応答に入れるpropsをstatsに絞ります。
ここに落とし穴がありました。
1.0.2までは、画面のHTMLを入れたkageroも絞られて消え、runtimeは画面を書き換えませんでした。
サーバーは新しい件数を返しているのに、表示は変わらないままでした。
1.1.0ではkageroをInertiaのalways(部分更新でも必ず返すprop)にしたので、応答はstatsとkageroになります。
つまりKageroの部分更新で減るのは、返すpropsのJSONだけです。
データベースへの問い合わせと描画はページ全体の分だけ走り、画面もページ全体が新しくなります。
返ってこなかったprops(この例ではtodosなど)は、runtimeが前の値を残します。
CSRFの守り
1.1.0から、サンプルはsinatra-inertiaのCSRFの守りを有効にしています。
サーバーはXSRF-TOKENcookieを配り、POSTなどでは同じ値のX-XSRF-TOKENヘッダーを求めます。
一致しない要求は、routeに届く前に403で拒否し、D1には書き込みません。
ヘッダーを付けるのはruntimeなので、このサンプルのフォーム送信にはruntimeが要ります。 JavaScriptが動かない状態の普通のフォーム送信は403になり、routeの303の分岐には届きません。
Turbo・Inertiaとの違い
| 名前 | 画面を描く場所 | 画面の更新 |
|---|---|---|
| Turbo | サーバーのHTML | ドキュメントやframeのHTMLを差し替える |
| Inertia | ブラウザのcomponent | page object(component+props)を受けてcomponentで描く |
| Kagero | サーバーのRuby(Phlex) | page objectを受け、入っているHTMLで差し替える |
Hotwireに寄せるならHTML断片の差し替えになります。 Kageroはpage objectという形を残したまま、componentをRubyに置いています。 asset versionの照合や部分更新のように、Inertiaの通信の決まりをそのまま使えます。
おわりに
Kageroは、Inertiaのpage objectの中身をRubyで描いたHTMLにしたgemです。 アプリはroute、Page、CommandをRubyで書き、ブラウザ側は送信・履歴・差し替え・CSRFヘッダーを受け持つruntimeだけで動きます。 1.1.0で部分更新とCSRFの守りが、公開サンプルでも動くようになりました。
Enjoy, Ruby-way Inertia!