February 16, 2026 · 14 min read
Hotwire, Turbo, and Stimulus: The Rails 8 Frontend Stack for SaaS in 2026
Jeronim Morina
Founder, Omaship
You do not need React, Vue, or Svelte to build a modern SaaS. Rails 8 ships with Hotwire -- a suite of tools that delivers fast, reactive user interfaces using server-rendered HTML. No JavaScript build step. No client-side state management. No hydration bugs. Just HTML over the wire, with surgical DOM updates that feel instant.
Hotwire is not new. Basecamp, HEY, and Shopify have used it in production for years. But with Rails 8, it has matured into a frontend stack that handles everything a SaaS dashboard needs: real-time updates, inline editing, dynamic forms, live search, and multi-step workflows. All without writing a single line of React.
The three pieces of Hotwire
Hotwire is not one thing. It is three complementary tools, each solving a specific problem:
- Turbo Drive: Intercepts link clicks and form submissions, fetches the response via AJAX, and replaces the page body without a full reload. Every link in your Rails app becomes an SPA-style navigation for free. Zero configuration.
- Turbo Frames: Scopes page sections into independently updatable regions. Click a link inside a frame, and only that frame updates. Edit a resource inline. Load content lazily. Paginate without reloading the page. Each frame is a self-contained unit of interactivity.
- Turbo Streams: Delivers fine-grained DOM updates -- append, prepend, replace, remove, update -- either in response to a form submission or via WebSocket. A new comment appears for all connected users instantly. A deleted row disappears without a page reload.
And when you need custom JavaScript behavior that none of these cover:
-
Stimulus: A lightweight JavaScript framework for adding behavior to server-rendered HTML. It connects JavaScript controllers to DOM elements via
data-controllerattributes. No virtual DOM. No component lifecycle. Just targeted JavaScript that enhances existing HTML.
Turbo Drive: SPA navigation for free
The moment you create a Rails 8 application, every link click and form submission is intercepted by Turbo Drive. Instead of a full page reload, Turbo Drive:
- Fetches the new page via
fetch() - Replaces the
<body>with the response - Merges any new
<head>elements - Updates the browser URL and history
The result feels like a single-page application, but you write zero client-side routing code. Your Rails controllers return normal HTML responses. Turbo Drive handles the rest.
For SaaS applications, this means your dashboard navigation, settings pages, and CRUD interfaces feel snappy without any JavaScript framework. Page transitions happen in milliseconds because Turbo Drive prefetches links on hover and caches pages aggressively.
Turbo Frames: inline editing and lazy loading
Turbo Frames are where Hotwire becomes genuinely powerful for SaaS interfaces. Wrap any section of your page in a <turbo-frame> tag, and it becomes an independent unit that can update without affecting the rest of the page.
Pattern 1: Inline editing
Click to edit a resource without navigating away. This is the most common SaaS interaction pattern, and Turbo Frames make it trivial:
# app/views/ships/show.html.erb
<%= turbo_frame_tag @ship do %>
<h2><%= @ship.name %></h2>
<p><%= @ship.description %></p>
<%= link_to "Edit", edit_ship_path(@ship) %>
<% end %>
# app/views/ships/edit.html.erb
<%= turbo_frame_tag @ship do %>
<%= form_with model: @ship do |form| %>
<%= form.text_field :name %>
<%= form.text_area :description %>
<%= form.submit "Save" %>
<%= link_to "Cancel", ship_path(@ship) %>
<% end %>
<% end %>
Click "Edit" and the show content is replaced with the edit form -- inside the same frame. Submit the form, and the updated content appears. No page reload. No JavaScript. The controller is a standard Rails resourceful controller with no special Turbo handling.
Pattern 2: Lazy loading
Load expensive content after the initial page render. Dashboards with multiple widgets, activity feeds, and analytics panels benefit from this pattern:
<%= turbo_frame_tag "activity_feed", src: activity_feed_path, loading: :lazy do %>
<p class="text-oma-text-tertiary">Loading activity...</p>
<% end %>
The frame shows "Loading activity..." immediately, then fetches the real content asynchronously. The main page loads fast, and secondary content fills in as it becomes available. No Suspense boundaries. No loading state management. One attribute: loading: :lazy.
Pattern 3: Modal-free dialogs
Instead of building a modal system with JavaScript, use Turbo Frames with the target attribute to load content into a designated area:
<!-- Navigation link targets the detail frame -->
<%= link_to ship.name, ship_path(ship),
data: { turbo_frame: "detail_panel" } %>
<!-- Detail panel updates without page reload -->
<%= turbo_frame_tag "detail_panel" do %>
<p class="text-oma-text-tertiary">Select an item to view details</p>
<% end %>
Click a list item, and its details appear in the panel. No modal library. No overlay management. No z-index battles. The frame loads the show view, scoped to just the frame content.
Turbo Streams: real-time updates
Turbo Streams go beyond frames. They let you make surgical changes to any part of the page -- not just within a frame. The seven stream actions are:
| Action | What it does | SaaS use case |
|---|---|---|
append |
Add content at the end of a target | New message in a chat, new log entry |
prepend |
Add content at the beginning | New notification at top of list |
replace |
Replace entire target element | Updated row in a data table |
update |
Replace inner HTML of target | Update a counter or status badge |
remove |
Remove target from DOM | Delete a row, dismiss a notification |
before |
Insert before target | Insert item above a specific row |
after |
Insert after target | Add inline feedback after a form field |
Streams can be delivered in two ways: as an HTTP response (after a form submission) or over WebSocket (for real-time updates from background jobs or other users).
Form response streams
# app/controllers/ships_controller.rb
def create
@ship = Current.user.ships.build(ship_params)
if @ship.save
respond_to do |format|
format.html { redirect_to @ship }
format.turbo_stream
end
else
render :new, status: :unprocessable_entity
end
end
# app/views/ships/create.turbo_stream.erb
<%= turbo_stream.prepend "ships_list", @ship %>
<%= turbo_stream.update "ship_count",
"#{Current.user.ships.count} ships" %>
<%= turbo_stream.replace "new_ship_form",
partial: "ships/form", locals: { ship: Ship.new } %>
One form submission triggers three DOM updates: the new ship is prepended to the list, the counter updates, and the form resets. All in a single HTTP response. No full page reload. No client-side state to synchronize.
WebSocket streams for real-time
When a background job finishes or another user makes a change, broadcast updates to all connected clients:
# app/models/ship.rb
class Ship < ApplicationRecord
broadcasts_to ->(ship) { [ship.user, :ships] }
end
# app/views/ships/index.html.erb
<%= turbo_stream_from Current.user, :ships %>
<div id="ships_list">
<%= render @ships %>
</div>
When a Ship is created, updated, or destroyed -- from any source (web UI, API, background job) -- Turbo broadcasts the change to all connected clients. The list updates automatically. With Solid Cable, this works over your database with no Redis pub/sub required.
Stimulus: JavaScript sprinkles, not JavaScript frameworks
Some interactions genuinely need JavaScript. Dropdown menus, clipboard copying, character counters, auto-growing text areas, drag-and-drop. Stimulus handles these with a pattern that is radically simpler than React components:
# app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source", "button"]
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
this.buttonTarget.textContent = "Copied!"
setTimeout(() => {
this.buttonTarget.textContent = "Copy"
}, 2000)
}
}
# In your ERB template
<div data-controller="clipboard">
<input data-clipboard-target="source"
value="<%= @ship.api_key %>"
readonly>
<button data-clipboard-target="button"
data-action="click->clipboard#copy">
Copy
</button>
</div>
The HTML declares which controller to use, which elements are targets, and which actions trigger which methods. The JavaScript is minimal and focused. No component tree. No props. No state management library. Just a controller that does one thing well.
Common Stimulus patterns for SaaS
- Toggle controller: Show/hide elements (dropdowns, accordions, mobile menus)
- Debounce controller: Rate-limit search inputs before firing server requests
- Auto-submit controller: Submit forms on change (filter dropdowns, toggle switches)
- Character counter controller: Show remaining characters in text areas
- Sortable controller: Drag-and-drop reordering with Sortable.js
- Confirmation controller: "Are you sure?" dialogs for destructive actions
Each controller is typically 10-30 lines of JavaScript. They compose naturally -- an element can have multiple controllers. They connect and disconnect automatically as elements enter and leave the DOM, which means they work seamlessly with Turbo's page updates.
Hotwire vs React: the SaaS founder's perspective
The framework debate is usually framed as a technical choice. For SaaS founders, it is a business decision. Here is how the two stacks compare on the metrics that matter:
| Hotwire + Rails | React + API | |
|---|---|---|
| Time to build a feature | Fast -- one language, one codebase | Slower -- API + frontend + state sync |
| AI agent compatibility | Excellent -- conventions are predictable | Variable -- many patterns, many choices |
| JavaScript bundle size | ~20KB (Turbo + Stimulus) | 100-500KB+ (React + router + state) |
| Build step | None (importmaps) or minimal | Required (Webpack/Vite/Turbopack) |
| SEO | Server-rendered by default | Requires SSR/SSG setup |
| Real-time updates | Built-in (Turbo Streams + Cable) | Custom WebSocket implementation |
| Hiring difficulty | Rails developers (smaller pool) | React developers (larger pool) |
| Maintenance burden | Low -- stable API, rare breaking changes | High -- ecosystem churn, frequent updates |
For most SaaS dashboards, admin panels, and CRUD-heavy applications, Hotwire is faster to build and easier to maintain. React shines for highly interactive applications like design tools, spreadsheets, or collaborative editors where complex client-side state is genuinely necessary.
The honest take: if your SaaS is a dashboard with forms, tables, charts, and settings pages -- which describes 90% of B2B SaaS -- Hotwire does everything you need with a fraction of the complexity. If you are building Figma, use React.
Turbo morphing in Rails 8: the upgrade you did not know you needed
Rails 8 added Turbo morphing, which changes how page updates work. Instead of replacing the entire <body>, Turbo can now morph the existing DOM to match the new HTML. This preserves:
- Scroll position: The page stays where it was
- Form state: Partially filled forms are not reset
- CSS transitions: Animations are not interrupted
- Focus state: The active element stays focused
# Enable morphing for page refreshes
<head>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
</head>
One line in your layout. Turbo morphing is opt-in, but once enabled, it makes Turbo Drive feel remarkably smooth. Combined with Turbo Streams for real-time broadcasts, your entire UI stays in sync without the user ever noticing a page transition.
Practical SaaS patterns with Hotwire
Live search with debounce
<%= form_with url: ships_path, method: :get,
data: { controller: "debounce", debounce_wait_value: 300,
action: "input->debounce#search",
turbo_frame: "ships_list" } do |form| %>
<%= form.search_field :q,
placeholder: "Search ships...",
value: params[:q] %>
<% end %>
<%= turbo_frame_tag "ships_list" do %>
<%= render @ships %>
<% end %>
Type in the search field, and after 300ms of inactivity, the form submits via Turbo Frame. The results list updates without a page reload. The controller returns the same index view -- it does not know or care that the request came from a Turbo Frame.
Multi-step forms (wizards)
<%= turbo_frame_tag "wizard" do %>
<%= form_with url: onboarding_step_path(step: 1) do |form| %>
<!-- Step 1 fields -->
<%= form.submit "Next" %>
<% end %>
<% end %>
Each step submits to a controller that validates the current step and renders the next one inside the same Turbo Frame. Back buttons work naturally because each step is a separate URL. No client-side wizard state. No local storage. No losing progress on page refresh.
Flash messages that auto-dismiss
<!-- app/javascript/controllers/auto_dismiss_controller.js -->
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 5000 } }
connect() {
this.timeout = setTimeout(() => {
this.element.classList.add("opacity-0", "transition-opacity")
setTimeout(() => this.element.remove(), 300)
}, this.delayValue)
}
disconnect() {
clearTimeout(this.timeout)
}
}
A 10-line Stimulus controller that auto-dismisses flash messages after 5 seconds with a fade-out animation. Attach it with data-controller="auto-dismiss". Works with Turbo Drive navigation because Stimulus controllers connect/disconnect automatically as the DOM changes.
Common Hotwire gotchas
Hotwire is straightforward, but there are patterns that trip up newcomers:
-
Frame IDs must match: When a Turbo Frame fetches a URL, it looks for a
<turbo-frame>with the same ID in the response. If the IDs do not match, nothing updates. This is the most common source of "nothing happens when I click" bugs. -
Forms need error responses with 422 status: If validation fails, return
render :new, status: :unprocessable_entity. Turbo Drive only replaces the page body on 4xx/5xx responses if the status is 422 or 500. A 200 response with error messages triggers a redirect instead. -
Turbo morphing does not update inline styles: If your server response changes inline
style=attributes, morphing may not apply them. Use CSS classes instead, or adddata-turbo="false"on forms where this matters. -
Third-party JavaScript may conflict: Libraries that manipulate the DOM (rich text editors, chart libraries, date pickers) need to be initialized and cleaned up properly. Use Stimulus controllers with
connect()anddisconnect()callbacks to manage their lifecycle.
The bottom line
Hotwire is not a compromise. It is a deliberate architectural choice that trades client-side complexity for server-side simplicity. For SaaS applications -- which are fundamentally CRUD interfaces with real-time sprinkles -- this trade-off is overwhelmingly positive.
Your Rails controllers already know how to render HTML. Turbo Drive makes that HTML feel like an SPA. Turbo Frames let you update pieces of the page independently. Turbo Streams push changes in real-time. And Stimulus handles the 5% of interactions that genuinely need JavaScript. That is the entire frontend stack. No webpack config. No component library. No state management debate. Just Rails, shipping features.
AI coding agents love this stack. Hotwire follows predictable patterns that Claude Code and Cursor understand instantly. There is no client-side routing to configure, no API serialization to maintain, no hydration mismatch to debug. Your AI agent writes a controller, a view, and maybe a Stimulus controller -- and the feature works. That is the Rails 8 frontend story, and it is a good one.
Ship with Hotwire from day one
Omaship comes with Rails 8, Hotwire, Turbo, Stimulus, and Tailwind CSS preconfigured. Turbo morphing, Solid Cable for WebSockets, and AI-agent-friendly conventions -- all ready to go from your first deploy.