The joy of Inertia Rails: painting your own with 50 happy little lines

Hi there, welcome back, so glad you could join us again. You know, we’ve painted with Inertia.js on this blog many times, but today is gonna be a little different: today we make the brush ourselves. Then, we’ll end up with a 50-line React client, a 16-line Rails renderer, wet-on-wet, running on a real Rails app. There won’t be any mistakes here (only happy little headers) so just relax, and we’ll let this post unfold.
This one’s for anyone deciding whether to bet on Inertia for frontend (that includes the future you who’ll have to defend that bet). People will say a frontend needs a big bunch of things: a client-side router, a state library, an API layer, a year of your life. We don’t need that here. All we need is one little header, one nice little page object, and about fifty lines. And you can do it too.
Inertia’s own docs insist it isn’t a framework, and by the end of this post you’ll see why: the whole thing fits on one small canvas. Don’t evaluate Inertia by its feature list. Evaluate this claim instead: its wire format is small enough to read in one sitting, and every feature is just that same format, applied again.
Everything below runs, and nothing is pseudocode. The demo app is a real Rails app with one Git commit per feature: skryukov/the-joy-of-inertia-rails.

Irina Nazarova CEO at Evil Martians
Wet-on-wet: Turbolinks, 2012
Everything on this blog happens wet-on-wet. We don’t wait for a layer to dry; we paint the next stroke right into the last one, and the painting stays alive the whole time.
The web learned its own version of that trick in 2012. The Rails answer to “why does every click re-download the whole world?” was Turbolinks, and its mechanics fit in one sentence: intercept the click, fetch the next page’s HTML over XHR (an in-page request; today you’d use fetch), swap the <body>, keep the current JavaScript runtime alive, and update the URL with pushState.
The browser never does a full navigation—the canvas never dries—so the app feels like an SPA. But the server keeps doing what servers do best: routing, authentication, rendering, redirects. This comes with no API layer, client-side router, or state synchronization problems. The idea lives on today as Turbo Drive, the heart of Hotwire.
That technique carries a little but radical premise: the server owns the page.
”Beat the devil out of it”: the mutation
Now, suppose your views aren’t ERB templates. Your team builds views in React, or Vue, or Svelte—because you want their component model, their ecosystem, their typed props. Turbolinks has nothing to swap: there’s no server-rendered <body> to fetch, because the “view” is a component living in the browser.
This is the fork in the road where most teams throw out their technique with the paint. They build a JSON API, adopt a client-side router, add a data-fetching library, and sign up to keep two applications in agreement about what a user is. We’ve written before about what that costs.
Jonathan Reinink asked the better question: what’s the smallest change to the Turbolinks idea that survives component views? And a component is just a function of two things—a name and its props.
So: on the first visit, render HTML, same as always. Then, on every visit after that, render JSON that describes the component to draw.
From there, everything else falls out of two questions.
What goes in the JSON? The component’s name, and its props, the URL, (so the address bar can tell the truth), and an asset version, so the server can notice your tab is running last week’s JavaScript. This ends up as four fields:
{
"component": "pages/about",
"props": { "year": 2026 },
"url": "/about",
"version": "1"
}How does the server know which kind of visit this is? One request header: X-Inertia: true. Present, you get the JSON. Absent, you get a full HTML page with that same JSON embedded in a <script type="application/json"> tag for the client to boot from. That’s your first visit: some HTML whose only real job is to hold the base layer every later stroke blends into.
That’s it. That’s the protocol. One page object, one header, two representations of the same URL.
Now, the official protocol doc does run longer these days: Inertia v3 lists a dozen more page-object fields and a small stack of headers.
Look at how it grew, though: each extra field is optional, shows up only when its feature does, and carries exactly one feature: deferredProps rides with lazy loading, mergeProps with pagination, clearHistory with logout. The minimal page object in that same doc is still these four fields. Hold onto that, because every feature for the rest of this post (partial reloads, deferred props, form errors, deploy refreshes) is this object again, with slightly different contents.
A client in 50 lines
Here’s our brush, visit():
async visit(url, { replace = false } = {}) {
const response = await fetch(url, {
headers: { 'X-Inertia': 'true', 'X-Inertia-Version': this.page.version },
})
const page = await response.json()
const method = replace ? 'replaceState' : 'pushState'
history[method](page, '', page.url)
this.setPage(page)
}Fetch the page object, push the URL, render page.component with page.props. A “navigation” is five plain lines. Notice there’s no client-side router and no route table—the server still owns the URLs, exactly as it did in the Turbolinks days.
The rest of the client is bookkeeping of the same caliber. <Link> is an anchor that steps aside for modified and middle clicks (new tabs and downloads keep working), then preventDefault()s and calls visit(). The string-to-component mapping is the app’s job, not the framework’s: a small registry (or a Vite glob import) resolves 'pages/about' to the actual component. Back and forward buttons work because each page object was pushed into history.state, so restoring one is a single listener: (event) => event.state && setPage(event.state).
And booting is reading the page object the server left in a <script> tag and seeding it into history.replaceState, so coming Back to the very first page has something to restore, then rendering. Like the real client, usePage() returns the whole page object, so components can reach url and version too.
One honest shortcut remains: we hardcode version: '1' where the gem digests your asset bundle.
Three clicks, three little fetches; two Backs, zero requests. The document loads once.
Taken together, (visit(), <Link>, history, boot, and a usePage() hook) is 50 lines of readable React: clicks swap components over XHR with zero document reloads, and history restores the right pages.
The whole brush, all 50 lines (comments come free)
import React, { useState, useEffect, createContext, useContext } from 'react'
import { createRoot } from 'react-dom/client'
// Any component can read the current page object via usePage() (props, url, ...).
const PageContext = createContext(null)
export const usePage = () => useContext(PageContext)
// A singleton that owns navigation and tells React when the page changes.
export const router = {
page: null,
notify: () => {},
// The core "visit": ask the server for a page object over XHR,
// update history, and swap the page WITHOUT reloading the document.
async visit(url, { replace = false } = {}) {
const response = await fetch(url, {
headers: { 'X-Inertia': 'true', 'X-Inertia-Version': this.page.version },
})
const page = await response.json()
const method = replace ? 'replaceState' : 'pushState'
history[method](page, '', page.url)
this.setPage(page)
},
setPage(page) {
this.page = page
this.notify(page)
},
}
// Intercept anchor clicks so navigation is an XHR, not a full page load.
// Modified clicks (cmd, ctrl, shift, alt, middle) keep native browser behavior.
export function Link({ href, children }) {
function handleClick(event) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return
event.preventDefault()
router.visit(href)
}
return <a href={href} onClick={handleClick}>{children}</a>
}
// Renders the current component with its props; re-renders on navigation.
function App({ resolve }) {
const [page, setPage] = useState(router.page)
useEffect(() => {
// Let the router push new pages into React state.
router.notify = setPage
// Browser back/forward: re-render the page saved in history state.
// (Entries the client didn't create, like #hash jumps, have null state.)
const onPopState = (event) => event.state && router.setPage(event.state)
window.addEventListener('popstate', onPopState)
return () => window.removeEventListener('popstate', onPopState)
}, [])
const Component = resolve(page.component)
return (
<PageContext.Provider value={page}>
<Component {...page.props} />
</PageContext.Provider>
)
}
// Boot: read the server-rendered page object, then hand control to React.
export function createInertiaApp({ resolve }) {
const el = document.getElementById('app')
router.setPage(JSON.parse(document.getElementById('page').textContent))
history.replaceState(router.page, '', router.page.url)
createRoot(el).render(<App resolve={resolve} />)
}The router and the page object know nothing about React—the hooks and JSX are just the rendering shell around them. A Vue adapter maps the same page object onto a reactive component; Svelte, onto mount. That’s what an Inertia “adapter” is: the same technique on a different canvas. The official client ships all three, sharing one core.
The Rails half in 16 lines
The server’s whole job is answering that one header, and there’s nothing to it. Rails has a public API for teaching render new tricks: ActionController::Renderers.add, the same registration behind render json:, and it’s been there since Rails 3.
# config/initializers/inertia.rb
ActiveSupport.on_load(:action_controller) do
ActionController::Renderers.add :inertia do |component, options|
page = {
component: component,
props: options.fetch(:props, {}),
url: request.original_fullpath,
version: '1',
}
if request.headers['X-Inertia']
response.set_header('X-Inertia', 'true')
render json: page
else
render html: helpers.tag.script(page.to_json.html_safe, type: 'application/json', id: 'page') + helpers.tag.div(id: 'app'), layout: true
end
end
endSixteen lines, and this isn’t an approximation, it’s the same mechanism the real gem registers in its engine, down to calling render from inside the renderer block.
render inertia: becomes a first-class sibling of render json:. The HTML branch renders the page object into a <script type="application/json"> tag beside an empty <div id="app"> mount, dropped into your regular app layout (layout: true). This is exactly what the client’s boot function reads.
You could stop here and write render inertia: 'pages/about', props: { year: 2026 } in every action. But that notation repeats what Rails already knows: you’re in PagesController#about, so the component is pages/about; your instance variables are the props. Rails has a hook for “the action finished without rendering”, named default_render, and overriding it buys full convention-over-configuration in three lines:
class ApplicationController < ActionController::Base
def default_render
render inertia: "#{controller_path}/#{action_name}", props: view_assigns.symbolize_keys
end
endNow a controller looks like it did in your first Rails tutorial:
class PagesController < ApplicationController
def about
@year = 2026
end
endSet an instance variable, done. The component name is derived, the props are your ivars, and the explicit render inertia: form still works whenever you want it. One thing to mind before you ship it: every instance variable becomes a prop, @current_user included—and a model prop serializes through as_json, meaning every column, password digest and all. That’s what the sidenote’s serializers are for.
The full server framework in the demo: 16 lines of renderer, 3 of convention. Every controller action is either bare or a one-liner, and because the protocol is plain HTTP, the entire debugging surface fits in curl:
$ curl -H 'X-Inertia: true' -H 'X-Inertia-Version: 1' localhost:5190/about
{"component":"pages/about","props":{"year":2026},"url":"/about","version":"1"}Keep making happy little features
The features below push us past the number in the title. That’s all right. The 50 lines were the base coat; from here we’ll add a few strokes at a time, three lines here, six there, right on the paint you’ve already seen. (Count along, because the counting is the argument: if each feature is a visible five-line diff on a core you’ve already read, there’s nowhere for any magic to hide. The demo repo makes this literal with one Git commit per feature.)
Deploy refresh: +3 client lines. You deployed; a user’s tab still runs last week’s bundle. The page object already carries version and the client already echoes it in a header, so the server answers a stale version with 409 Conflict plus an X-Inertia-Location header. The client’s entire reaction:
if (response.status === 409) {
return (window.location = response.headers.get("X-Inertia-Location"));
}One real page load gets fresh JavaScript. The server half is a three-line branch in the renderer: if an Inertia request’s version doesn’t match the current one, answer 409 with the request’s own URL in X-Inertia-Location. (Plain first visits carry no version and skip the branch.) This is the only moment where we’ll let the canvas dry. Because for one deliberate, full reload we get a beautiful status code.
Forms: +6 client lines, and a guestbook that’s plain Rails with a model, a migration, a controller. Teach visit() to take a method and a JSON body, send Rails’ CSRF token in an X-CSRF-Token header (it lives in the csrf-token meta tag that csrf_meta_tags renders; forgery protection stays on, and a tokenless POST still gets a 422), and the controller does what controllers have done for twenty years—validate, stash errors, redirect:
def create
message = Message.new(name: params[:name].presence || "Anonymous", message: params[:message])
unless message.save
session[:inertia_errors] = { message: message.errors[:message].first }
end
redirect_to "/guestbook", status: :see_other
endThat status: :see_other is the detail that keeps PRG safe beyond POST: a 303 tells the browser to follow with a GET no matter what it just sent. fetch replays PUT, PATCH, and DELETE against a plain 302’s target, so the gem’s middleware quietly rewrites those to 303 for you.
And here the brush does the work for you: fetch follows the 303 redirect on its own and re-sends the headers, so the response that arrives is already the next page’s JSON. The existing swap path consumed it without a single change. Post/Redirect/Get works verbatim, and the errors’ entire delivery mechanism is one line in the renderer:
props: options.fetch(:props, {}).merge(errors: session.delete(:inertia_errors) || {}),Errors ride the session across the redirect and arrive as one more prop. The real gem does exactly this, wrapped in a much nicer API.
After both features and the convention override: client core 59 lines, server half 22 (the guestbook’s own model, migration, and controller are app code, not framework).
Everybody needs a friend: features reusing features
Walk the inertia_rails feature list with one question in hand: new technique, or the same stroke with different paint? Nearly every feature is the same stroke: a header, a field, a filtered hash. Most of the rest never touch the wire at all, pure comfort for the painter. What’s left is real machinery, and that gets its own section of this post.
Partial reloads look like a data-fetching layer with dependency tracking: “re-fetch only the props this widget needs.” On the wire it’s a header pair: the client sends X-Inertia-Partial-Data: messages plus the component it’s already showing (X-Inertia-Partial-Component, so a mismatch falls back to a full response), and the server filters the props hash before evaluating it. Write props as lambdas (props: { messages: -> { Message.all.as_json }, users: -> { User.all.as_json } }) and the unrequested ones never run—the block behind users, query and serialization both, simply never fires. Perceived: a subsystem. Actual: props.slice.
External redirects are the escape hatch for OAuth and Stripe, where the redirect leaves your app for a site that speaks neither CORS nor Inertia. The gem’s entire implementation:
def inertia_location(url)
headers['X-Inertia-Location'] = url
head :conflict
endTwo lines you’ve already met—it’s the deploy-refresh mechanism, pointed at a different URL. The 409 got a friend: one stroke, two features.
Deferred props look like a suspense-and-streaming system: paint the page now, load the expensive chart after. The server’s actual move is to leave the prop out and list its name in a deferredProps field; the client renders, then immediately fetches the missing props—using a partial reload. Partial reloads got a friend too. You already read the second half of this feature two paragraphs ago.
And a whole family of headline features (polling, prefetching, instant visits on hover, view transitions, progress bars) requires zero server-side code at all. The same brush, tapped at cleverer moments.
Where the “real” complexity lives
Everything you’ve watched runs, but the 50 lines are just the easy ninety percent. The hard part is making all the features compose (partial reloads meeting deferred props, merge, and once-props, with precedence rules to referee) and stay native to Rails (errors and flash riding the session across a redirect, CSRF, the rest). That reconciliation is the densest code in the gem, and it never reads as a clean stroke.
The one true exception is SSR because it runs your components in a sidecar Node process, the single place Inertia costs an operational dependency instead of a header, though even that fails gently: a dead sidecar degrades to client-side rendering, not a 500.
Unglamorous, edge-case-ridden, never finished; exactly the kind of work you want a full-time team maintaining, not a fork.
Talent is a pursued interest
Look back at what the Rails side of this post actually did: we registered a renderer, overrode default_render, redirected with a status code, stashed errors in the session, read two headers, set two. Not a single new noun was involved.
The real gem keeps that discipline across its whole surface. We co-maintain inertia_rails at Evil Martians, and its whole integration is a 65-line engine plugging into extension points Rails built for exactly this: the renderer registry, the middleware stack, the routing DSL, the flash. Inertia doesn’t sit between you and Rails; it hides inside render, redirect_to, and the session, and lets Rails keep being Rails: routing, flash, CSRF, caching, the whole one-person framework promise, with React or Vue or Svelte as the view layer.
One more thing that future you can use when defending the bet: Inertia’s protocol, all three official clients, and the Laravel adapter (the place where the feature set is born) are developed by Laravel’s own team. Inertia’s creator officially handed the project to Laravel in 2025, and new releases now come straight from the Laravel team.
The Rails world never fielded full-time, framework-funded staffing for its React story; with Inertia, Rails gets it for free. That’s exactly why the Rails adapter can spend everything it has on the job you’ve watched all through this post: making every feature land the most Rails-native way it can, and keeping pace as the protocol grows.
Talent is a pursued interest. Anything that you’re willing to practice, you can do. The man who said that was into painting on TV, but it holds for frameworks. You’ve read the whole technique now; the demo is one repo, one commit per feature—clone it and read the diffs, or don’t, and rebuild the whole thing yourself before lunch.
And bottom line: when evaluating a frontend architecture, ask to see its wire format before its feature list. If nobody can show you the core in fifty lines, …the magic is load-bearing. Load-bearing magic is what pings you at 2 a.m.
Of course, a small wire format doesn’t erase complexity, it just tells you where to look for it. Inertia earns trust by making this easy: the protocol is small enough to rebuild in an afternoon, the gem spends its complexity budget on the edges you’d actually hit, and the whole technique borrows from the most battle-tested navigation trick Rails ever shipped.

