rails hotwire frontend

Hotwire and Turbo in Rails: Where Server-Rendered UI Fits

20 min read

Use Hotwire and Turbo in Rails when the server owns page state: frames for scoped updates, streams for live regions, Stimulus for small browser-only behavior, and clear cases where React still wins.

Hotwire and Turbo reactive interface patterns for Ruby on Rails applications

The dashboard looked like a React app because nobody had questioned that choice in years. It had filters, paginated tables, inline edits, live totals, and a small order form. The Rails backend already rendered most of the same HTML for emails and exports, but the browser path went through JSON serializers, a frontend store, and a separate build pipeline.

Moving that dashboard to Hotwire cut the frontend from 3,200 lines of JavaScript to 180 lines of Stimulus controllers. Those numbers are from one screen, not a general benchmark. The useful part was removing the second model of the page. Filters became normal Rails requests inside a Turbo Frame. Order updates became Turbo Streams. Stimulus stayed for the few interactions that were genuinely client-side.

This is the pattern I now reach for in Rails apps that are mostly forms, tables, dashboards, admin screens, and account workflows. I would not use it to replace client-side state that is genuinely the product.

The table below is a decision guide for this class of Rails screen, not a ranking of frontend tools.

Concern Hotwire + Turbo React SPA Vue + Inertia
Frontend bundle size Small Hotwire/Stimulus surface Depends on app and framework Depends on app and framework
Build tooling required Often none beyond Rails defaults Usually Vite or equivalent Usually Vite
State management Server-side (Rails) Redux/Zustand/Context Pinia/Vuex
Real-time updates Built-in (Turbo Streams) Requires extra libraries Requires extra libraries
SEO/SSR Works by default Needs Next.js or SSR setup Needs Nuxt or SSR setup
Learning curve for Rails devs Low High Medium
API layer needed No Yes (JSON API) Optional (Inertia)
Team structure Full-stack Frontend + Backend Full-stack possible

The boundary in this migration was narrow: one dashboard whose state already lived in Rails. We did not move a drawing surface, offline workflow, or multiplayer editor. The parts that stayed in JavaScript were the parts where the browser genuinely owned the moment-to-moment state: dropdowns, a modal, and a debounced search input.

The server-state boundary

Hotwire sends HTML from the server instead of JSON. That removes the client-side model for screens where Rails already owns the data: no API serializer just to render a table, no store just to remember filters, and no virtual DOM just to replace a row.

The whole idea fits in one sentence: the server renders HTML and the browser swaps it into place, so there is no second, client-side model of the page to keep in sync. That is what removes Webpack, Redux, and the serialization layer, not some clever runtime. You write controllers, views, and partials the way you already do, and because the fallback for every interaction is a normal Rails request, pages still work with JavaScript turned off.

For apps that are mostly CRUD over a database, this covers nearly everything the frontend framework was doing.

Use this boundary before choosing a tool:

  • If the server owns the data and the interaction can tolerate a request/response cycle, start with Turbo Frames or Streams.
  • If the browser owns temporary state such as drag position, canvas state, offline edits, or multiplayer presence, keep that state in JavaScript.
  • If both are true, split the surface: Rails renders the durable state, Stimulus owns the small browser-only behavior.

The three primitives I used

This is the smallest Hotwire surface that replaced the React dashboard: Drive for normal navigation, Frames for scoped regions, Streams for server-pushed changes, and Stimulus only where the browser owned a small behavior.

1. Turbo Drive: normal page navigation

Turbo Drive intercepts link clicks and form submissions, fetches the next page in the background, and swaps the <body> instead of doing a full document reload. It is on by default once Turbo is loaded, so navigation stops reparsing CSS and JavaScript on every click. The visible effect is that already-parsed assets and scroll position survive the transition.

<!-- Regular Rails link -->
<%= link_to "Dashboard", dashboard_path %>

<!-- Turbo Drive automatically makes this a fast navigation -->
<!-- No page reload, instant transition -->

2. Turbo Frames: scoped updates

Turbo Frames update specific parts of the page without full refreshes. Wrap any section in a turbo_frame_tag, and clicks within that frame only replace that section - the rest of the page stays untouched.

<!-- app/views/transactions/index.html.erb -->
<div class="page-header">
  <h1>Transactions</h1>
</div>

<%= turbo_frame_tag "transactions_list" do %>
  <%= render @transactions %>
  <%= paginate @transactions %>
<% end %>

<aside class="sidebar">
  <%= render "filters" %>
</aside>

When a user clicks pagination inside the frame, only the transactions_list frame updates. The header and sidebar stay untouched.

3. Turbo Streams: server-pushed updates

Turbo Streams push real-time DOM updates from the server via WebSocket, SSE, or HTTP responses. They support multiple operations - append, prepend, replace, update, remove - across different page elements in a single response.

# app/controllers/transactions_controller.rb
def create
  @transaction = Transaction.create(transaction_params)

  respond_to do |format|
    format.turbo_stream
    format.html { redirect_to transactions_path }
  end
end
<!-- app/views/transactions/create.turbo_stream.erb -->
<%= turbo_stream.prepend "transactions_list", @transaction %>
<%= turbo_stream.update "balance", partial: "shared/balance" %>
<%= turbo_stream.replace "notification", partial: "shared/success" %>

When a transaction is created, this:

  1. Prepends it to the transactions list
  2. Updates the account balance
  3. Shows a success notification

All three operations come back in one create.turbo_stream.erb response, which the controller renders the same way it renders an HTML template.

Example: Dashboard with independent regions

A finance dashboard is a good fit when it has several small regions that change independently:

  • Live price updates pushed from the server when a quote changes
  • Real-time order execution
  • Portfolio balance updates
  • Transaction history

Setup: The Layout

<!-- app/views/dashboards/show.html.erb -->
<div class="dashboard-grid">
  <!-- Live Prices -->
  <%= turbo_stream_from "user_#{current_user.id}_prices" %>
  <%= turbo_frame_tag "live_prices" do %>
    <%= render "prices/list", prices: @prices %>
  <% end %>

  <!-- Portfolio Balance -->
  <%= turbo_frame_tag "portfolio_balance",
                       src: portfolio_balance_path do %>
    <%= render @portfolio %>
  <% end %>

  <!-- Order Form -->
  <%= turbo_frame_tag "order_form" do %>
    <%= render "orders/form" %>
  <% end %>

  <!-- Recent Transactions -->
  <%= turbo_stream_from "user_#{current_user.id}_transactions" %>
  <%= turbo_frame_tag "recent_transactions" do %>
    <%= render @recent_transactions %>
  <% end %>
</div>

Live Price Updates

# app/jobs/price_snapshot_job.rb
class PriceSnapshotJob < ApplicationJob
  queue_as :market_data

  def perform(user_id)
    user = User.find(user_id)
    prices = PriceService.current_prices(symbols: user.watchlist_symbols)

    Turbo::StreamsChannel.broadcast_replace_to(
      "user_#{user.id}_prices",
      target: "live_prices",
      partial: "prices/list",
      locals: { prices: prices }
    )
  end
end
<!-- app/views/prices/_list.html.erb -->
<%= turbo_frame_tag "live_prices" do %>
  <div class="prices-grid">
    <% prices.each do |symbol, price| %>
      <div class="price-card" data-symbol="<%= symbol %>">
        <span class="symbol"><%= symbol %></span>
        <span class="price <%= price_change_class(price) %>">
          $<%= number_with_precision(price.current, precision: 2) %>
        </span>
        <span class="change">
          <%= price.change_percent %>%
        </span>
      </div>
    <% end %>
  </div>
  <p class="text-xs text-gray-500">
    Updated <%= time_ago_in_words(Time.current) %> ago
  </p>
<% end %>

The important point is not that Hotwire magically polls a frame. It does not. If the server knows about a change, broadcast a Turbo Stream from the job or model callback that owns that change. If the browser has to poll an external source, use a tiny Stimulus controller that reloads a frame on an interval and be honest that you are polling.

Real-time Order Execution

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    @order = current_user.orders.build(order_params)

    if @order.save
      # Process order asynchronously
      OrderExecutionJob.perform_later(@order.id)

      respond_to do |format|
        format.turbo_stream {
          render turbo_stream: [
            turbo_stream.replace("order_form", partial: "orders/form", locals: { order: Order.new }),
            turbo_stream.prepend("pending_orders", partial: "orders/order", locals: { order: @order }),
            turbo_stream.update("flash", partial: "shared/success", locals: { message: "Order placed successfully" })
          ]
        }
      end
    else
      respond_to do |format|
        format.turbo_stream {
          render turbo_stream: turbo_stream.replace("order_form", partial: "orders/form", locals: { order: @order })
        }
      end
    end
  end
end

Broadcasting Updates from Background Jobs

# app/jobs/order_execution_job.rb
class OrderExecutionJob < ApplicationJob
  def perform(order_id)
    order = Order.find(order_id)

    # Execute order with external API
    result = TradingAPI.execute(order)

    if result.success?
      order.update!(status: :executed, executed_at: Time.current)

      # Broadcast updates to user's dashboard
      broadcast_order_execution(order)
    else
      order.update!(status: :failed)
      broadcast_order_failure(order)
    end
  end

  private

  def broadcast_order_execution(order)
    # Update multiple parts of the UI
    Turbo::StreamsChannel.broadcast_replace_to(
      "user_#{order.user_id}_transactions",
      target: "order_#{order.id}",
      partial: "orders/executed_order",
      locals: { order: order }
    )

    # Update portfolio balance
    Turbo::StreamsChannel.broadcast_update_to(
      "user_#{order.user_id}_portfolio",
      target: "portfolio_balance",
      partial: "dashboards/portfolio_balance",
      locals: { portfolio: order.user.portfolio }
    )

    # Show notification
    Turbo::StreamsChannel.broadcast_append_to(
      "user_#{order.user_id}_notifications",
      target: "notifications",
      partial: "shared/notification",
      locals: { message: "Order executed: #{order.symbol} #{order.quantity} @ #{order.executed_price}" }
    )
  end
end

Because the job broadcasts over each user's stream, the executed order, portfolio balance, and notification all update on their own for anyone currently viewing that dashboard, with no page reload and no polling.

Inline editing without a client store

Turbo Frames handle inline editing by scoping navigation to the frame. Clicking "Edit" loads edit_transaction_path into the same turbo_frame_tag, so the display row is replaced by the form, and saving replaces it back with the updated row. There is no modal and no custom controller, because the frame boundary is what does the swapping.

<!-- app/views/transactions/_transaction.html.erb -->
<%= turbo_frame_tag dom_id(transaction) do %>
  <div class="transaction-row">
    <div class="transaction-details">
      <span class="date"><%= transaction.date.strftime("%b %d, %Y") %></span>
      <span class="description"><%= transaction.description %></span>
      <span class="amount"><%= number_to_currency(transaction.amount) %></span>
    </div>
    <div class="actions">
      <%= link_to "Edit", edit_transaction_path(transaction), class: "btn-sm" %>
    </div>
  </div>
<% end %>
<!-- app/views/transactions/edit.html.erb -->
<%= turbo_frame_tag dom_id(@transaction) do %>
  <%= form_with model: @transaction do |f| %>
    <div class="inline-edit-form">
      <%= f.text_field :description, class: "form-input" %>
      <%= f.text_field :amount, class: "form-input" %>

      <div class="actions">
        <%= f.submit "Save", class: "btn-primary" %>
        <%= link_to "Cancel", transaction_path(@transaction), class: "btn-secondary" %>
      </div>
    </div>
  <% end %>
<% end %>

The only moving part is the shared dom_id(transaction) frame name on both the row partial and the edit view. Matching frame IDs are what let the response replace the right row and leave the others alone.

Adding Interactivity with Stimulus

Stimulus handles the 5-10% of interactions that genuinely need client-side JavaScript - dropdowns, modals, debounced inputs, and client-side validation. It keeps JavaScript organized in small controllers tied to HTML elements via data attributes.

// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["menu"]

  toggle() {
    this.menuTarget.classList.toggle("hidden")
  }

  hide(event) {
    if (!this.element.contains(event.target)) {
      this.menuTarget.classList.add("hidden")
    }
  }
}
<!-- app/views/shared/_user_menu.html.erb -->
<div data-controller="dropdown" data-action="click@window->dropdown#hide">
  <button data-action="click->dropdown#toggle" class="user-avatar">
    <%= current_user.avatar %>
  </button>

  <div data-dropdown-target="menu" class="dropdown-menu hidden">
    <%= link_to "Profile", profile_path %>
    <%= link_to "Settings", settings_path %>
    <%= link_to "Logout", logout_path, data: { turbo_method: :delete } %>
  </div>
</div>

That is the whole Stimulus posture: a few small controllers where the browser genuinely has to react on its own, and nothing anywhere else.

Performance checks before moving another screen

Turbo 8 prefetches a link's target after the pointer hovers it for about 100ms, so the next page is often already fetched by the time the user clicks. This is on by default; you do not enable it per link. What you control is opting out the links where a background fetch is wasteful, like a slow report or a destructive action. Add data-turbo-prefetch="false" to the link (in Rails, data: { turbo_prefetch: "false" }), or drop a <meta name="turbo-prefetch" content="false"> in the head to disable it for the whole page:

<%= link_to "Generate report",
            expensive_report_path(@transaction),
            data: { turbo_frame: "modal", turbo_prefetch: "false" } %>

Prefetch is opt-out, not opt-in. The prefetch request carries an X-Sec-Purpose: prefetch header, so the server can tell a hover-triggered fetch from a real click if a given endpoint should not run on hover.

2. Lazy Loading Frames

Defer non-critical content by lazy loading frames - content loads only when scrolled into view, reducing initial page load time:

<%= turbo_frame_tag "analytics",
                     src: analytics_path,
                     loading: :lazy do %>
  <!-- Shows while loading -->
  <%= render "loading_skeleton" %>
<% end %>

3. Debouncing Searches

Combine Stimulus with Turbo Frames for real-time search that updates as you type. A 300ms debounce prevents excessive requests while keeping the interface responsive:

// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form", "results"]

  search() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => {
      this.formTarget.requestSubmit()
    }, 300)
  }
}
<%= form_with url: search_path,
              method: :get,
              data: {
                controller: "search",
                turbo_frame: "search_results",
                search_target: "form"
              } do |f| %>
  <%= f.search_field :q,
                      data: { action: "input->search#search" },
                      placeholder: "Search transactions..." %>
<% end %>

<%= turbo_frame_tag "search_results" do %>
  <!-- Results appear here -->
<% end %>

Testing Hotwire Features

Turbo Frames and Streams work with plain Rails system tests - no async mocking, no special configuration. Tests read like user interactions:

# test/system/transactions_test.rb
class TransactionsTest < ApplicationSystemTestCase
  test "creating a transaction updates the list" do
    visit transactions_path

    click_on "New Transaction"

    within "#new_transaction" do
      fill_in "Description", with: "Coffee"
      fill_in "Amount", with: "5.50"
      click_on "Create"
    end

    # Turbo Stream automatically updates the list
    assert_selector "#transactions_list", text: "Coffee"
    assert_selector "#transactions_list", text: "$5.50"

    # Balance updated
    assert_selector "#balance", text: "$494.50" # assuming starting balance
  end

  test "editing a transaction inline" do
    transaction = transactions(:one)

    visit transactions_path

    within dom_id(transaction) do
      click_on "Edit"

      fill_in "Description", with: "Updated description"
      click_on "Save"

      # Frame updates inline
      assert_text "Updated description"
    end
  end
end

When NOT to Use Hotwire

Avoid Hotwire for highly interactive applications requiring complex drag-and-drop, canvas manipulation, or real-time collaboration - those genuinely need client-side state that HTML-over-the-wire cannot carry. Offline-first apps are out for the same reason: every interaction here assumes a server round trip. Native mobile apps need native code, though Turbo Native narrows that gap. And if you already have a working React codebase, migrating purely to remove React is usually not worth the effort and risk.

What the migration proved

Migrating this one live dashboard from React to Hotwire cut the JavaScript surface and removed the dashboard-only JSON API. That is the claim the example supports. It does not prove Hotwire is better for every Rails frontend; it proves that this screen did not need a separate client-side model.

Before (React + Rails API):

  • Frontend bundle: 340kb (gzipped)
  • Build time: 45 seconds
  • Lines of JavaScript: ~3,200
  • API endpoints: 24
  • Team shape: frontend/backend handoff for every dashboard change

After (Hotwire):

  • Frontend bundle: 28kb (Hotwire + Stimulus)
  • Build time: 0 seconds (no build)
  • Lines of JavaScript: ~180 (Stimulus controllers)
  • API endpoints: 0 (just Rails views)
  • Team shape: Rails changes owned end to end by full-stack developers

The important deletion was not "React." It was the duplicate representation of the same screen: Rails partials for emails and exports, plus React components and serializers for the browser. Once Rails owned the HTML path again, the remaining JavaScript had a clearer job: dropdowns, modals, and debounced inputs.

The first Rails screen I would migrate

Do not migrate an entire frontend because Hotwire sounds simpler. Pick one page where Rails already owns the data and the user is waiting on a full reload or a small React island. My first candidate would be an admin table with filters, pagination, and one inline edit, because it exercises frames, streams, and Stimulus without touching product-critical browser state.

1. Setup (Rails 8+)

# Already included in Rails 8
# For Rails 7, add:
bundle add hotwire-rails
rails hotwire:install

2. Replace one slow boundary

  • Replace one full page reload with Turbo Frame
  • Add one Turbo Stream action
  • Leave the rest of the page alone

3. Stop after the first page

  • Measure bundle size and request count before and after.
  • Keep React/Vue where the browser still owns state.
  • Move the next screen only if the first one removed a real maintenance cost.

4. Keep the references nearby

The trade-off I would accept

What you accept with Hotwire is a different ownership model, not a smaller React. Rails owns the state, the server renders the HTML, and the browser asks for replacement fragments instead of rebuilding the page from JSON. That trade works well for SaaS products, admin panels, internal tools, e-commerce back offices, and finance dashboards, the apps where the real work is data correctness rather than custom canvas interactions.

I would not use it for offline-first apps, complex drag-and-drop builders, multiplayer editing, or UI where most of the value lives in client-side state. I also would not rewrite a healthy React app just to remove React. The best Hotwire migrations start where the React layer is mostly translating Rails data back into the HTML Rails could have rendered directly.

For the database side of that decision, my post on database optimization techniques for Rails covers indexing strategies, query optimization, and N+1 elimination patterns that pair well with server-rendered interfaces.


If a Rails screen feels like a small SPA only because it has filters, pagination, and inline edits, I would review that page before touching the rest of the frontend: which frame owns each interaction, which updates need streams, and which bits still deserve Stimulus. I help with that kind of Hotwire migration in Rails frontend work.

Further Reading