Skip to content
Rails 8 with Inertia and React: Hotwire Alternative Guide

Rails 8 with Inertia and React: Hotwire Alternative Guide

Building a Rails 8 admin panel with Inertia and React instead of Hotwire: the trade-offs, the architecture, and a practical tutorial.

Also available in Português (Brasil)

Share

A few weeks ago I built a small but complete application as part of a technical assessment: a user management system with an admin dashboard, live counters, role management, and a background spreadsheet importer with a real-time progress bar. The brief left one big decision open. The frontend could be Hotwire, which is the Rails default, or React integrated through Inertia.js.

I picked Inertia and React. This post explains why, what it cost me, how the pieces fit together, and ends with a tutorial that gets you from an empty Rails 8 app to a live-updating page in about an hour.

The brief

The requirements were deliberately shaped like a real sprint:

  • Rails 8 on a recent Ruby, PostgreSQL, and the Solid trio for cache, queue and cable. No Redis.
  • The built-in Rails 8 authentication generator, extended with an admin and member role.
  • An admin dashboard showing the total number of users and a breakdown by role, updated in real time.
  • Full CRUD on users for admins, self-service profile editing for members, public registration for visitors.
  • Spreadsheet import (CSV and XLSX) processed asynchronously, with live progress on screen.
  • A multi-stage Dockerfile, a Kamal deploy file, parallel tests with at least 90 percent coverage, and a strict linter setup.

Each item is routine on its own. Together they force you to make architectural choices early, because the real-time pieces and the background job touch everything else.

Why Inertia and React instead of Hotwire

I like Hotwire. For most Rails apps it is the right default, and I want to be clear that it would have worked here. But three parts of this brief pushed me the other way.

Forms with interactive validation. The brief asked for frontend feedback as the user types, in addition to server validation. With Stimulus you end up writing a validation controller per form, or a generic one that reads data attributes, and either way you are reimplementing a small state machine in vanilla JavaScript. With React the form is already state. A hook that runs rules on blur and on change, and merges its errors with the server's, is about 50 lines and works for every form in the app.

A progress bar driven by WebSocket events. Turbo Streams can update a progress bar by replacing a partial on every broadcast. That works, but each broadcast carries HTML and the server renders it. I wanted the broadcast to be a tiny signal and the client to refetch only what changed. Inertia's partial reloads do exactly that: a broadcast says "this import changed", the page calls router.reload({ only: ['import'] }), and one prop is re-evaluated server side.

TypeScript across the boundary. With Inertia the controller hands props to the page component. Typing those props means the compiler catches a renamed field before a system test does. Hotwire does not have an equivalent seam to type.

There was also a personal reason. I had not shipped Inertia before, and an assessment with a clear scope is a good place to learn a tool properly instead of half-learning it under production pressure.

What Inertia actually is

If you have not used it, the mental model is short. Inertia is not an API layer. Your controllers stay Rails controllers, your routes stay Rails routes, and your redirects stay redirects. The only change is that instead of rendering an ERB template, a controller renders a named page component and passes it a hash of props:

def index
  render inertia: "Admin/Users/Index", props: {
    users: UserSerializer.collection(search.records),
    filters: search.to_props
  }
end

The first request returns a full HTML page with the props embedded. Every navigation after that is an XHR that returns only the next page's props as JSON, and Inertia swaps the component. You get a single-page feel with no client router, no API versioning, and no duplicated auth logic.

The honest cons

  • Two build systems. Vite sits beside Bundler. The Dockerfile needs Node in the build stage, and your CI runs npm ci and tsc as well as RSpec. Hotwire with import maps needs none of that.
  • Server-side rendering is a separate process. Without SSR, the first paint is a blank div until React boots. With SSR you run a small Node server beside Puma. It is manageable, and I cover it below, but it is one more thing to supervise.
  • The redirect dance. Inertia expects a failed POST to redirect back with errors in the session, not to render a 422 page. Once you know this it is a one-liner, but it surprised me for an afternoon.
  • You will write a serializer layer. Props are JSON, so every model that reaches a page needs an explicit shape. I consider this a pro because it stops accidental leaks of columns, but it is extra code.
  • Hotwire is the path of least resistance in Rails. Generators, documentation and most blog posts assume Turbo. Choosing Inertia means reading its own docs and occasionally translating.

If your app is mostly server-rendered pages with light interactivity, Hotwire wins on simplicity. If you have real client state, forms that need to feel instant, or a team that already thinks in React, Inertia lets you have that without giving up the monolith.

Characteristics of the implementation

Here is how the app is shaped. I am describing patterns rather than pasting the full source, because the patterns are what transfer to your project.

Thin controllers, explicit policies

Authorization is a small policy object per model, modelled on Pundit but hand-written in about 30 lines. A concern on the base controller exposes authorize!(record) and policy_for(record). The policy also owns two things that often get scattered:

  • The permitted attributes. Strong params call params.expect(user: policy.permitted_attributes), so the policy decides whether role can be submitted. A member cannot promote themselves by editing the request.
  • The scope. Admins get User.all. Members get User.where(id: current_user.id). Every controller finds records through that scope, so a member requesting someone else's edit page gets a 404, not a permission error that confirms the record exists.

A serializer per model

Each model that reaches the frontend has a plain Ruby serializer with an as_json method and a collection class method. The user serializer is also where the avatar variant URL is built, so the React component receives a ready-to-use image URL and knows nothing about Active Storage.

Real-time without a broadcast storm

The dashboard counters are the interesting part. The naive approach broadcasts after every user commit. During an import of ten thousand rows that is ten thousand broadcasts and ten thousand client refetches.

The implementation has three layers of protection:

  1. A debounced broadcaster. It claims a cache key with unless_exist: true, which is atomic across Puma workers and the job process because the cache is Solid Cache in Postgres. The first caller in a one-second window broadcasts immediately. Later callers schedule a single trailing job so the final state is never missed.
  2. A suppression switch for bulk operations. The import job wraps its loop in a block that sets a flag in ActiveSupport::IsolatedExecutionState. The model callbacks check that flag and stay silent. The job broadcasts once when it finishes.
  3. Client-side coalescing. The React hook that subscribes to the channel waits 250 milliseconds before refetching, so a burst of signals becomes one request.

The broadcast payload itself carries no data, only a type. The client refetches through the normal controller, which means the stats always flow through authorization and the serializer. A client can never receive numbers it is not allowed to see.

A resumable import job

The import runs as a Solid Queue job using ActiveJob::Continuable, which is the Rails 8.1 way to write jobs with checkpoints. The job has three steps: count the rows, import them, finalize. The import step keeps a cursor and advances it every hundred rows. If the worker is killed, the job restarts from the cursor and skips rows it already committed, so nothing is created twice.

A few details that matter in practice:

  • Row parsing is a small ActiveModel object. It normalizes header names, strips characters that would turn a cell into a spreadsheet formula on re-export, and validates before anything touches the database.
  • CSV and XLSX are two classes behind one factory, both Enumerable. The job does not know which format it is reading.
  • Per-row errors go into a jsonb column, capped at a few hundred entries, so a badly broken file cannot bloat the record.
  • Existing emails are skipped rather than failed, and a unique-index race with a concurrent signup is also treated as a skip.

SSR that ships without node_modules

Vite builds the SSR bundle with noExternal: true, so React and Inertia are inlined into one JavaScript file. The production image copies only the Node binary, not node_modules, and a Puma plugin starts the SSR server beside the web process. The whole thing is controlled by one environment variable and falls back to client rendering if the bundle is missing.

Tests that enforce the bar

RSpec with parallel_tests, one database per worker, and SimpleCov failing the run under 90 percent line coverage. System specs use Capybara with the Playwright driver and cover the parts that matter end to end: the live dashboard, the import flow, client-side validation, responsive layout and SSR hydration.

Tutorial: from zero to a live-updating page

This gets you a working Rails 8 app with Inertia, React, TypeScript, Tailwind, and a page whose counter updates over Solid Cable. I assume Ruby 3.4 or newer, Node 22 or newer, and PostgreSQL running locally.

1. Create the app

rails new live_admin --database=postgresql --skip-hotwire --skip-jbuilder
cd live_admin
bin/rails db:create

Skipping Hotwire keeps Turbo and Stimulus out of the way. The Solid gems are already in the Gemfile on Rails 8.

2. Add Vite, Inertia and React

bundle add vite_rails inertia_rails
bundle exec vite install
npm install react react-dom @inertiajs/react @inertiajs/vite @vitejs/plugin-react tailwindcss @tailwindcss/vite
npm install -D typescript @types/react @types/react-dom

Replace the generated vite.config.ts:

import { defineConfig } from 'vite'
import RubyPlugin from 'vite-plugin-ruby'
import react from '@vitejs/plugin-react'
import inertia from '@inertiajs/vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [tailwindcss(), RubyPlugin(), inertia(), react()],
})

Create the entrypoint at app/javascript/entrypoints/inertia.tsx:

import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'

createInertiaApp({
  resolve: (name) => {
    const pages = import.meta.glob('../pages/**/*.tsx', { eager: true })
    return pages[`../pages/${name}.tsx`]
  },
  setup({ el, App, props }) {
    createRoot(el).render(<App {...props} />)
  },
})

And a stylesheet at app/javascript/entrypoints/application.css:

@import "tailwindcss";

Update the layout so the Inertia page is rendered into a root element:

<!DOCTYPE html>
<html>
  <head>
    <%= csrf_meta_tags %>
    <%= vite_client_tag %>
    <%= vite_stylesheet_tag "application" %>
    <%= vite_typescript_tag "inertia" %>
    <%= inertia_headers %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

3. Render your first page

# config/routes.rb
root "dashboard#show"

# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
  def show
    render inertia: "Dashboard", props: { stats: -> { DashboardStats.new.to_h } }
  end
end

The lambda makes stats a lazy prop. Inertia evaluates it on the first visit and on any partial reload that names it, and skips it otherwise.

# app/queries/dashboard_stats.rb
class DashboardStats
  def to_h
    counts = User.group(:role).count
    { total: counts.values.sum, by_role: counts }
  end
end
// app/javascript/pages/Dashboard.tsx
type Props = { stats: { total: number; by_role: Record<string, number> } }

export default function Dashboard({ stats }: Props) {
  return (
    <main className="mx-auto max-w-3xl p-6">
      <h1 className="text-2xl font-semibold">Dashboard</h1>
      <p className="mt-4 text-5xl">{stats.total}</p>
      <ul className="mt-2 text-sm text-gray-600">
        {Object.entries(stats.by_role).map(([role, n]) => (
          <li key={role}>{role}: {n}</li>
        ))}
      </ul>
    </main>
  )
}

Start everything with bin/dev and open the root URL. You have a React page rendered by a Rails controller with no API in between.

4. Wire up Solid Cable

Solid Cable is the default Action Cable adapter in Rails 8, so there is nothing to install. Create a channel that only streams to signed-in users:

# app/channels/dashboard_channel.rb
class DashboardChannel < ApplicationCable::Channel
  def subscribed
    stream_from "dashboard:stats"
  end
end

Add a broadcaster and call it from the model:

# app/broadcasters/dashboard_broadcaster.rb
class DashboardBroadcaster
  STREAM = "dashboard:stats"
  WINDOW = 1.second

  def self.call
    return unless Rails.cache.write("dashboard:lead", true, expires_in: WINDOW, unless_exist: true)

    ActionCable.server.broadcast(STREAM, { type: "stats.changed" })
  end
end

# app/models/user.rb
class User < ApplicationRecord
  after_commit -> { DashboardBroadcaster.call }, on: %i[create destroy]
end

This is the leading-edge half of the debounce. It is enough for a tutorial. In production you also want a trailing broadcast so the last change in a burst is never dropped, which is a one-line job scheduled with set(wait: WINDOW).

5. Subscribe from React

Install the Action Cable client:

npm install @rails/actioncable

Create one shared consumer so components do not open a socket each:

// app/javascript/lib/cable.ts
import { createConsumer, type Consumer } from '@rails/actioncable'

let consumer: Consumer | null = null
export const getConsumer = () => (consumer ??= createConsumer())

Then a hook that listens and asks Inertia to refetch only the stats prop:

// app/javascript/hooks/useDashboardStream.ts
import { useEffect } from 'react'
import { router } from '@inertiajs/react'
import { getConsumer } from '@/lib/cable'

export function useDashboardStream() {
  useEffect(() => {
    const refresh = () => router.reload({ only: ['stats'] })
    const sub = getConsumer().subscriptions.create(
      { channel: 'DashboardChannel' },
      { connected: refresh, received: refresh },
    )
    return () => sub.unsubscribe()
  }, [])
}

Call it at the top of the Dashboard component. Open the page in one tab, create a user in a Rails console in another terminal, and watch the number change.

6. A form with live validation

Inertia ships a useForm hook. Combine it with a tiny rules object and you have validation that runs on blur and on submit, with server errors landing in the same place:

import { useForm } from '@inertiajs/react'

const rules = {
  full_name: (v: string) => (v.trim().length < 2 ? 'Name is too short' : null),
  email_address: (v: string) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Enter a valid email'),
}

export default function Register() {
  const form = useForm({ full_name: '', email_address: '', password: '' })

  const check = (field: keyof typeof rules) => {
    const message = rules[field](form.data[field])
    message ? form.setError(field, message) : form.clearErrors(field)
  }

  return (
    <form onSubmit={(e) => { e.preventDefault(); form.post('/registration') }}>
      <input
        value={form.data.full_name}
        onChange={(e) => form.setData('full_name', e.target.value)}
        onBlur={() => check('full_name')}
      />
      {form.errors.full_name && <p role="alert">{form.errors.full_name}</p>}
      {/* repeat for the other fields */}
      <button disabled={form.processing}>Create account</button>
    </form>
  )
}

On the Rails side, a failed create redirects back with the errors hash and Inertia merges it into form.errors:

def create
  user = User.new(params.expect(user: %i[full_name email_address password]))
  if user.save
    redirect_to root_path, notice: "Welcome"
  else
    redirect_to new_registration_path, inertia: { errors: user.errors }
  end
end

7. A background job with progress

Finally, the skeleton of a resumable job. Replace the body with your own row processing:

class ProcessImportJob < ApplicationJob
  include ActiveJob::Continuable

  def perform(import)
    step :import_rows, start: 0 do |step|
      import.rows.each_with_index do |row, index|
        next if index < step.cursor

        process(row)
        if index % 100 == 0
          import.update_columns(processed_rows: index)
          ImportBroadcaster.call(import)
          step.set! index
        end
      end
    end

    step :finalize do
      import.update!(status: :completed)
      ImportBroadcaster.call(import)
    end
  end
end

The page that shows the import subscribes to a per-import stream and calls router.reload({ only: ['import'] }) on each signal, exactly as the dashboard did. Solid Queue runs the job with bin/jobs, and bin/dev starts that process for you if you add it to the Procfile.

Closing thoughts

Inertia is not a replacement for Hotwire. It is a different answer to the same question: how do you keep the monolith while giving the frontend real state. For this app, with its forms, its live counters and its progress bars, React behind Inertia produced less code and clearer code than I expect Stimulus would have, and it kept every controller, route and redirect where a Rails developer expects to find them.

The costs are real. You maintain a Node toolchain, you think about SSR, and you write serializers. If those sound like things you already do, Inertia will feel natural. If they sound like overhead, Hotwire is still the right call, and Rails 8 makes it an excellent one.

Comments

Sign in with Google or GitHub to comment.