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

> Inertia is Turbolinks with one twist: render JSON instead of HTML on the second visit. We rebuild the whole protocol on a real Rails app with a 50-line client and a 16-line server, then audit what the real gem adds on top.

- Date: 2026-07-14T00:00:00.000Z
- Authors: Svyatoslav Kryukov, Travis Turner
- Categories: Rails, Open Source
- URL: https://evilmartians.com/chronicles/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](https://inertiajs.com/docs/v3/getting-started) 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.

*Read also Evil Martians' article*: https://evilmartians.com/chronicles/inertiajs-in-rails-a-new-era-of-effortless-integration

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](https://github.com/skryukov/the-joy-of-inertia-rails). 

---

*Playbook, Stackblitz, Fountain, Monograph–we joined the best-in-class Rails startups to speed up, scale up and win! Curious? Let's talk!* [Contact Evil Martians](https://evilmartians.com/contact-us)

---

## 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](https://github.com/turbolinks/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](https://turbo.hotwired.dev/handbook/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](/blog/simplicity-vanished-solving-the-mystery-with-inertia-js-and-rails) about what that costs.

*Read also Evil Martians' article*: https://evilmartians.com/chronicles/simplicity-vanished-solving-the-mystery-with-inertia-js-and-rails

[Jonathan Reinink](https://reinink.ca/articles/server-side-apps-with-client-side-rendering) 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:

```json
{
  "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](https://inertiajs.com/docs/v3/core-concepts/the-protocol) 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()`:

```js
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. `` 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](https://vite.dev/guide/features#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.

Taken together, (`visit()`, ``, history, boot, and a `usePage()` hook) is [50 lines of readable React](https://github.com/skryukov/the-joy-of-inertia-rails/blob/fd76716/src/inertia.jsx): clicks swap components over XHR with zero document reloads, and history restores the right pages.

<details>
<summary>The whole brush, all 50 lines (comments come free)</summary>

```jsx
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 (

  )
}

// 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()
}
```

</details>

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](https://inertiajs.com/) 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`](https://api.rubyonrails.org/classes/ActionController/Renderers.html), the same registration behind `render json:`, and it's been there since Rails 3.

```ruby
# 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
end
```

Sixteen lines, and this isn't an approximation, it's the same mechanism the real gem registers in [its engine](https://github.com/inertiajs/inertia-rails/blob/master/lib/inertia_rails/engine.rb), 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 `` 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:

```ruby
class ApplicationController < ActionController::Base
  def default_render
    render inertia: "#{controller_path}/#{action_name}", props: view_assigns.symbolize_keys
  end
end
```

Now a controller looks like it did in your first Rails tutorial:

```ruby
class PagesController < ApplicationController
  def about
    @year = 2026
  end
end
```

Set 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 production answer: give props an explicit shape with serializers. [alba-inertia](https://github.com/skryukov/alba-inertia) does exactly that, and keeps the same controller-and-action naming convention.

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](https://github.com/skryukov/the-joy-of-inertia-rails/commit/c0f3693).** 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:

```js
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](https://github.com/skryukov/the-joy-of-inertia-rails/commit/a436fd3), 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:

```ruby
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
end
```

That `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](https://github.com/inertiajs/inertia-rails/blob/master/lib/inertia_rails/middleware.rb) 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:

```ruby
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](https://github.com/inertiajs/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](https://github.com/inertiajs/inertia-rails/blob/master/lib/inertia_rails/controller.rb):

```ruby
def inertia_location(url)
  headers['X-Inertia-Location'] = url
  head :conflict
end
```

Two 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.

TypeScript users: props can be typed end to end with [Typelizer](https://github.com/skryukov/typelizer), generated from your serializers. A separate post's worth of material.

## 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](https://github.com/inertiajs/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](https://world.hey.com/dhh/the-one-person-framework-711e6318) 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](https://x.com/reinink/status/1886519391367598359) in 2025, and new releases now come [straight from the Laravel team](https://laravel.com/blog/inertiajs-v3-is-now-in-beta). 

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](https://www.youtube.com/watch?v=mInAfjwGznE) 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.

---

**We ❤️ startups on Rails** Playbook.com, Stackblitz.com, Fountain.com, Monograph.com–we joined the best-in-class startup teams running on Rails to speed up, scale up and win! Solving performance problems, shipping fast while ensuring maintainability of the application and helping with team upskill. We can confidently call ourselves the most skilled team in the world in working with startups on Rails. Curious? Let's talk! [Contact Evil Martians](https://evilmartians.com/contact-us)
