Building Modern Rails UI Without a JavaScript Framework
How Hotwire — Turbo Frames, Streams, full-page morph, and Stimulus — covers most of what a JavaScript framework was required for, and which tool to reach for.
For more than a decade, the default approach to building a UI on top of a Rails application has been to reach for a JavaScript framework. The Rails app becomes an API. A separate frontend consumes it. The team splits along that seam. React arrived, it solved real problems, and people started using it for everything — including things Rails was already equipped to handle. But Rails never stopped being a full-stack framework, and the stack available in 2026 is meaningfully different from what existed when the Rails-as-API pattern became the default.
This post is about that stack. Not as a replacement for a JavaScript framework in every situation, but as a genuine first choice that covers most of what a framework used to be required for. I wrote earlier about why I built Maquina Components, the ERB-based component library that handles the UI primitives in the apps I ship. This post looks at what surrounds it: what the Hotwire stack gives you, how its three mechanisms differ, and where the approach earns its keep.
What the stack gives you
What Hotwire changed was the cost of making server-rendered HTML feel interactive. Pages no longer reload on navigation. Forms update without a flash. Specific parts of the DOM can be replaced surgically. The whole page can reconcile in place after a write. And with importmap, Node.js is optional — Stimulus, Turbo, and your own controllers load through the asset pipeline with no build step.
The architectural benefits follow from a simple premise: the server remains the single source of truth for state. When a form submits, the server validates, persists, and returns the authoritative representation. There is no client-side state to synchronize, no hydration step, no divergence between what the server knows and what the browser shows. Timezone handling, authorization rules, business logic — all of it lives in one place and applies consistently whether it’s an initial page load, a Frame refresh, a Stream update, or a morph reconciliation.
Optimistic UI is available through Turbo’s form submission lifecycle without building a client-side state machine. Progressive enhancement holds — a form still submits if JavaScript hasn’t loaded. The model is web-standard throughout.
Two areas that lagged behind React’s developer experience were ERB tooling and runtime inspection. Both are being addressed now. HERB is a modern HTML+ERB toolchain built on Prism — Ruby’s official default parser since 3.4 — that understands the interleaving of HTML structure and Ruby code. It powers an LSP with real-time autocomplete, precise error reporting, and position-aware diagnostics in editors like VS Code. On the browser side, Hotwire DevTools gives you a DevTools panel that highlights Frames, monitors incoming Streams, shows morph state, logs Turbo events, and surfaces Stimulus warnings. The kind of visibility React DevTools normalized is now available for Hotwire without leaving the browser. The developer experience argument for a JavaScript framework has narrowed considerably.
Three mechanisms, different jobs
The Hotwire stack gives you three distinct ways to update the UI. Understanding what each one does is the foundation for using them well together.
Turbo Frames scope a section of the page into an independent unit. A Frame has an ID, and any navigation or form submission targeting that ID updates only the content inside it — the server renders a full response, Turbo extracts the matching Frame and swaps it in. Frames carry their own loading state, optional navigation history, and persist across page navigations, which makes them natural shells for modals and drawers that need to stay present while their contents change.
Turbo Streams are surgical. A Stream response contains one or more actions — append, prepend, replace, update, remove — each targeting a specific DOM element by ID. Where a Frame swaps a whole section, a Stream can add one row to a table, remove one notification, replace one card. Streams arrive synchronously as a form response, or pushed asynchronously over ActionCable to every connected client. The same format, both delivery modes.
Full-page morph is different in kind. When morph is enabled, a form submission that redirects back to the same URL doesn’t cause a visible page swap — Turbo fetches the new HTML and idiomorph diffs the live DOM against it, reconciling differences in place. Scroll position is preserved. Focus stays. Unlike Frames, morph operates on the whole page. Unlike Streams, it requires no knowledge of specific elements to target; the server renders normally and morph handles the rest.
Each mechanism answers a different question. Frames: “I want this section to navigate independently.” Streams: “I want to change these specific elements, possibly in real time across clients.” Morph: “I want the whole page to stay current without a visible reload.”
How they combine
These three are not alternatives to each other. A single app will typically use all three — sometimes on the same page.
The simplest case for morph is a standard CRUD interface. A user submits a form, the server saves and redirects back. With morph on the layout, the page reconciles in place — the updated list appears, scroll stays put, no flash. No Stream template, no Frame to set up. The redirect is all the coordination there is.
Frames come into their own when you need a persistent named target — a modal shell declared once in the layout that any link in the app can load into, a sidebar that updates independently of the main area, a lazy-loaded section that fetches only when it scrolls into view.
Streams fit the cases where morph is too coarse and Frames too scoped — a notification tray updating across browser tabs, a transcript appending events from a background process, a dashboard card reflecting another user’s change in real time. Frames and morph are pull; the browser asks, the server responds. Streams can push.
Stimulus sits across all three as the thin layer of client-side behavior HTML alone can’t express. The discipline worth maintaining: keep controllers small and narrowly scoped. When a controller starts managing state the server could own, a Frame or Stream is usually the cleaner answer.
Where it strains
Modal and dialog plumbing is the most consistent cost. Focus traps, scroll locks, observers waiting for Frame content — none of this is provided. In this stack it’s Stimulus code, and in complex apps it accumulates.
Multi-step coordination is another pressure point — interdependent dropdowns, wizard steps where earlier answers constrain later ones. Stimulus can handle it, but it strains against the imperative model. Moving rendering to the server via Frames helps but doesn’t eliminate the coordination logic. Worth noting: Evil Martians built a complex report builder wizard for a production finance platform entirely in Rails and Hotwire — each interaction auto-submits the form, a Turbo Stream updates a live preview panel, the server stays in control throughout. The ceiling is higher than it looks.
High-frequency live state is the third. A surface streaming ten events per second is the natural shape of a reactive component framework. Turbo Stream appends work, but the Stimulus controllers around them end up doing the state management a framework would handle structurally.
For those surfaces, if I needed a reactive island, I wouldn’t reach for React. I’d look at Inertia.js — Rails controllers stay server-side, no two-codebase split, adoptable one route at a time. Evil Martians call this the silver toolbox approach: Hotwire for standard surfaces, a deliberate island when a specific surface genuinely needs it. Not one tool everywhere — the right tool per surface.
Those surfaces are narrower than they appear before you ship. Forms, tables, lists, filters, dashboards, navigation — Hotwire covers all of it. Most apps never reach the edges.
Where this runs in production
This isn’t a theoretical stack. Three apps I ship are built entirely on it: Recuerd0, a cross-session knowledge base for AI workflows; Resto, a Kakeibo personal finance app for individuals and couples; and Fragua, an AI agent orchestration platform in private beta that I wrote about recently. All three use the same foundation — Rails 8.1, Hotwire, Maquina Components, Tailwind CSS 4.0, importmap, no Node. The mix of Frames, Streams, and morph shifts per app: Recuerd0 leans on morph and Streams, Resto is mostly morph, Fragua uses Streams heavily for agent output. Same tools, different proportions.
The default, reconsidered
Rails ships single codebases, keeps one developer productive across the full stack, and lets the server stay the source of truth — without giving up the interactive feel that made JavaScript frameworks appealing. The tooling is maturing: HERB brings the editor experience ERB always deserved, Hotwire DevTools brings runtime visibility on par with React DevTools.
The default isn’t “Rails as an API plus a JavaScript framework.” It hasn’t been for a while. Most apps just need to know the choice exists.