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:

  1. Sinatra慣習に乗る. Rendering a page is inertia 'Component', props: {...} — the same shape as Sinatra's erb :index. A render inertia: 'Component' alias is also provided for Rails refugees.
  2. Class-level config uses set and inertia_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 visitX-Inertia: true request gets Content-Type: application/json, X-Inertia: true, Vary: X-Inertia, body = page object.
  • Version mismatch — Inertia GET with mismatched X-Inertia-Version409 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 reloadsX-Inertia-Partial-Component + X-Inertia-Partial-Data / X-Inertia-Partial-Except headers 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 sessioninertia_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, false in Workers builds because Rack::CommonLogger uses String#gsub! (Opal does not implement mutable string methods).

License

MIT.