sinatra-inertia
A Sinatra extension that implements the full Inertia.js v2 wire protocol — page-object responses, version-mismatch detection, partial reloads, deferred / lazy / always / optional / merge / once props, encrypted history, 303 redirect promotion, and error/flash session sweeps.
Pure Sinatra-compatible: depends only on sinatra and rack. Runs on
MRI Ruby and on the homura Cloudflare
Workers + Opal stack.
Why "驚き最小"?
Two principles drove the API:
- Sinatra慣習に乗る. Rendering a page is
inertia 'Component', props: {...}— the same shape as Sinatra'serb :index. Arender inertia: 'Component'alias is also provided for Rails refugees. - Class-level config uses
setandinertia_share do ... end, matching Sinatra's existing DSL surface (set :views,helpers do … end).
Installation
# Gemfile
gem 'sinatra'
gem 'sinatra-inertia'
Hello, Inertia
require 'sinatra'
require 'sinatra/inertia'
set :inertia_version, -> { ENV.fetch('ASSETS_VERSION', '1') }
get '/' do
inertia 'Pages/Hello', props: { name: 'world' }
end
<!-- views/layout.erb -->
<!doctype html>
<html>
<head>
<title>App</title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<div id="app" data-page="<%= @page_json %>"></div>
<script type="module" src="/assets/app.js"></script>
</body>
</html>
Public API
Helpers (per-request)
| helper | purpose |
|---|---|
inertia(component, props:, layout:) |
Render an Inertia response (HTML on first hit, JSON on Inertia visit). |
render(inertia: 'Comp', props: {...}) |
Rails-style alias for the above. |
inertia_request? |
True when X-Inertia: true header is present. |
inertia_errors(payload = nil) |
Read or write validation errors that survive one redirect. |
inertia_clear_history! |
Mark the next response's history as cleared. |
inertia_encrypt_history! |
Mark the next response's history as encrypted. |
Class-level DSL
| DSL | purpose |
|---|---|
set :inertia_version, -> { ... } |
Asset version. Mismatch on Inertia GET → 409 + X-Inertia-Location. |
set :inertia_layout, :layout |
ERB layout used for full-page rendering (default :layout). |
set :inertia_encrypt_history, true |
Default encryptHistory: true on every page. |
inertia_share do … end |
Block whose return Hash is merged into every page's props. |
Prop wrappers (Inertia v2 transport modes)
inertia 'Page', props: {
todos: -> { Todo.all }, # plain lazy
csrf: Inertia.always { csrf_token }, # always sent
stats: Inertia.defer(group: 'meta') { stats }, # excluded from initial response
filter: Inertia.optional { params[:f] }, # only on partial-reload request
feed: Inertia.merge(page_items) # client-side array merge
}
| wrapper | semantics |
|---|---|
bare Proc/-> |
resolved every request when included; partial-reload aware. |
Inertia.always { … } |
always included, even on partials that omit it. |
Inertia.defer(group:) { … } |
excluded on initial visit; client refetches in second roundtrip. |
Inertia.optional { … } |
only resolved when explicitly requested via X-Inertia-Partial-Data. |
Inertia.lazy { … } |
alias of optional (Inertia v1 name). |
Inertia.merge(value) |
sent as a merge prop (mergeProps array on page object). Honours X-Inertia-Reset: prop1,prop2 (Inertia 2.0) — reset props are emitted as plain values and dropped from mergeProps. |
Protocol features
- Initial GET — full HTML response. The layout sees
@page_json(HTML- escaped JSON) and@page(raw Hash). - Inertia visit —
X-Inertia: truerequest getsContent-Type: application/json,X-Inertia: true,Vary: X-Inertia, body = page object. - Version mismatch — Inertia GET with mismatched
X-Inertia-Version→409 Conflict+X-Inertia-Location: <url>. Client hard-reloads. - 303 redirect promotion — non-GET 302 responses are auto-promoted to 303 so the browser follows with GET.
- Partial reloads —
X-Inertia-Partial-Component+X-Inertia-Partial-Data/X-Inertia-Partial-Exceptheaders narrow which props are resolved. - Encrypted history / clear history — set per-app via setting or
per-route via
inertia_encrypt_history!/inertia_clear_history!. - Errors session —
inertia_errors(field: 'msg')survives one redirect and is automatically swept on render.
Validation pattern (no 422, no client state)
post '/todos' do
if params[:title].to_s.strip.empty?
inertia_errors title: "can't be blank"
redirect back # 303 by middleware; Inertia client follows
else
Todo.create(params)
redirect '/', 303
end
end
get '/' do
inertia 'Todos/Index', props: {
todos: Todo.all,
values: { title: params[:title] }
}
end
The error payload appears on the next render's props.errors and is
swept from the session — exactly the experience described in the
"modern monolith" articles, no 422 dance, no client state machine.
Compatibility
- MRI Ruby ≥ 3.1
- Sinatra ≥ 3.0 (incl. 4.x)
- Rack ≥ 2.0 (incl. 3.x)
- homura (Opal on Cloudflare Workers) — vendored Sinatra/Rack work as-is,
but set
:logging, falsein Workers builds becauseRack::CommonLoggerusesString#gsub!(Opal does not implement mutable string methods).
License
MIT.