background

How to Add Hotwire to A Legacy Rails Application

A practical guide to modernizing legacy Rails applications with Hotwire—add real-time interactivity and improved UX without rewriting your frontend.

“Simplicity is the ultimate sophistication,” Leonardo da Vinci observed. This principle, articulated five centuries ago, resonates with a challenge many development teams face today: how to modernize legacy applications without adding overwhelming complexity.

Legacy Rails applications often face a familiar challenge. Page loads and form submissions feel sluggish compared to modern single-page applications. Users expect instant feedback, smooth transitions, and interactive interfaces. Meanwhile, applications built on Rails 4 or 5 rely on full page refreshes and jQuery code that becomes harder to maintain over time.

If you’re maintaining such an application, you’ve probably considered a frontend rewrite. Perhaps you’ve looked at React, Vue, or Svelte. The prospect of building a REST API, duplicating validation logic between backend and frontend, and managing two separate codebases, though, can be daunting-and expensive.

Hotwire offers a pragmatic alternative. Rather than replacing server-rendered Rails views with a JavaScript framework, it enhances them with modern interactivity while keeping application logic on the server. We can maintain Rails’ productivity advantages while delivering the responsive user experience that users expect.

This guide walks through the process of adding Hotwire to an existing Rails application incrementally. We’ll approach this systematically, adding features one at a time without disrupting current functionality.

What is Hotwire?

Before we dive into implementation, let’s establish what Hotwire is and where it came from. Hotwire stands for HTML Over The Wire. It’s an approach to building modern web applications that send HTML instead of JSON.1 It consists of three primary components:

Turbo handles navigation and form submissions without full page reloads. Turbo Drive accelerates navigation by replacing the page body instead of reloading everything.2 Turbo Frames update specific sections of a page independently. Turbo Streams enable real-time updates by sending partial HTML updates over WebSocket connections or in response to form submissions.

Stimulus provides a JavaScript framework for adding behavior to HTML.3 Unlike comprehensive frameworks like React or Vue that take over the entire page, Stimulus augments HTML you already control. You add data-controller, data-action, and data-target attributes to your markup, then write small JavaScript controllers that respond to user interactions. This philosophy is closer to progressive enhancement than to single-page application frameworks.

Strada (currently in development) bridges the gap between web and native mobile applications, though it’s less relevant for most legacy application upgrades-we won’t discuss it further in this guide.

Hotwire was extracted from Basecamp and HEY, two production applications serving millions of users. It represents a refinement of ideas Rails developers have used for years-remote forms, AJAX updates, and progressive enhancement-packaged into a cohesive framework.4 Before Hotwire, many Rails developers used similar patterns through libraries like Turbolinks (Turbo Drive’s predecessor), SJR (Server-generated JavaScript Responses), and UJS (Unobtrusive JavaScript). Hotwire consolidates these patterns with modern browser APIs and clearer conventions.

Why Hotwire Works for Legacy Applications

Legacy Rails applications already render HTML on the server. Controllers fetch data, models encapsulate business logic, and views generate markup. This architecture aligns naturally with Hotwire’s philosophy-though as we’ll see, there are some adjustments required.

Adding Hotwire doesn’t require:

Instead, we can incrementally enhance existing functionality. A form that triggers a full page reload can be upgraded to submit via Turbo and replace only the relevant page section. A feature requiring periodic updates can switch from polling to WebSocket updates. An interactive widget can gain Stimulus controllers for progressive enhancement.

This incremental approach reduces risk, though it does require discipline. We can modernize high-value features first, validate the approach with real users, and gradually expand Hotwire usage throughout the application. Existing functionality continues working while we enhance it piece by piece.

Prerequisites and Compatibility

Before adding Hotwire to a legacy Rails application, we should verify compatibility:

Rails version: Hotwire works best with Rails 6.0 or later, though Rails 5.2 applications can use it with minor adjustments. If you’re running Rails 4.x or earlier, upgrading Rails should be the first priority. The security implications alone justify the upgrade effort, and newer Rails versions provide better Hotwire integration. For guidance on Rails upgrades, see our ten-step Rails upgrade guide.

Asset pipeline or Webpacker: Hotwire works with both the traditional Rails asset pipeline and Webpacker. Rails 7 applications can use import maps, but legacy applications-particularly those on Rails 6.x or earlier-typically use one of the older approaches. The installation process varies slightly depending on the asset management strategy in use.

Ruby version: While Hotwire itself doesn’t impose strict Ruby version requirements, Rails 6.0 requires Ruby 2.5 or later. Rails 6.1 requires Ruby 2.5.0 or later, and Rails 7.0 requires Ruby 2.7.0 or later. If you’re running an older Ruby version, you’ll need to upgrade it before proceeding.

Browser support: Turbo and Stimulus support modern browsers including recent versions of Chrome, Firefox, Safari, and Edge. Internet Explorer 11 is not supported. If an application must support IE11, Hotwire may not be the right choice without significant polyfills-though given IE11’s end-of-life status, this is rarely a concern for new development in 2026.

Step One: Add Hotwire to the Gemfile

We’ll start by adding the Hotwire Rails gem to the application. This gem bundles Turbo and Stimulus with Rails-specific integration:

# Gemfile
gem 'hotwire-rails'

Run bundle install to install the gem and its dependencies.

For Rails 6 applications using Webpacker, we’ll also need to install the JavaScript packages:

yarn add @hotwired/turbo-rails @hotwired/stimulus

Rails 7 applications using import maps can skip the Yarn step-the gem handles JavaScript dependencies automatically through the asset pipeline.

After installing the gem, run the installer:

rails hotwire:install

This generator creates configuration files, adds necessary JavaScript imports, and sets up the basic Hotwire infrastructure. The exact changes depend on the Rails version and asset management approach in use, but typically include:

We should review the changes the generator made. In particular, check app/javascript/application.js (or the equivalent entry point) to verify that it includes lines similar to:

import "@hotwired/turbo-rails"
import "./controllers"

The first line loads Turbo Drive, Frames, and Streams. The second loads all Stimulus controllers from the controllers directory.

Step Two: Enable Turbo Drive for Faster Navigation

With Hotwire installed, Turbo Drive activates automatically. When users click links, Turbo Drive intercepts the navigation, fetches the new page via AJAX, and replaces the page body rather than performing a full reload. This preserves JavaScript state, eliminates the white flash between pages, and makes navigation noticeably faster.

For many applications, this works immediately without code changes-particularly if the application already followed Rails conventions and avoided inline JavaScript. However, we’re likely to encounter a few compatibility issues:

JavaScript initialization code that runs on page load may not execute on subsequent Turbo Drive navigations. For example, this jQuery initialization is problematic:

// Don't do this
$(document).ready(function() {
  $('.datepicker').datepicker();
});

The ready event only fires on the initial page load, not on Turbo Drive navigations. We can fix this by listening for the turbo:load event instead:

// Do this instead
document.addEventListener('turbo:load', function() {
  $('.datepicker').datepicker();
});

This works, though migrating this initialization to a Stimulus controller is a better long-term solution-Stimulus handles lifecycle management automatically.

Third-party analytics or tracking scripts may count page views incorrectly. Many analytics tools expect traditional page loads. You’ll need to configure them to recognize Turbo Drive navigation as page views. For Google Analytics, this might look like:

document.addEventListener('turbo:load', function() {
  if (typeof gtag === 'function') {
    gtag('config', 'GA_MEASUREMENT_ID', {
      'page_path': window.location.pathname
    });
  }
});

Forms that redirect after submission work correctly by default. Turbo Drive follows the redirect and updates the page content. However, if you have forms that rely on a full page reload to reset JavaScript state, you might need to adjust them.

Disabling Turbo Drive selectively: Some pages may be incompatible with Turbo Drive-perhaps they use complex JavaScript that’s difficult to migrate. You can disable Turbo Drive for specific links or forms:

<%= link_to "Old JavaScript Page", legacy_path, data: { turbo: false } %>

Or disable it for an entire page:

<%# In your layout or specific view %>
<meta name="turbo-visit-control" content="reload">

After enabling Turbo Drive, test your application thoroughly. Navigate through common user flows and verify that pages render correctly, forms submit properly, and JavaScript functionality works as expected. Use your browser’s network tab to confirm that navigation uses AJAX requests rather than full page loads.

Step Three: Add Your First Turbo Frame

Turbo Frames let you update specific page sections independently-this is where Hotwire’s benefits become tangible to users.

Consider a common pattern in legacy Rails applications: an index page listing records with inline editing. Without Turbo Frames, clicking “Edit” navigates to a separate page, then “Submit” redirects back to the index. With Turbo Frames, the edit form appears in place, and submission updates just that section.

Let’s implement this for a list of products:

<%# app/views/products/index.html.erb %>
<h1>Products</h1>

<% @products.each do |product| %>
  <%= turbo_frame_tag "product_#{product.id}" do %>
    <%= render 'product', product: product %>
  <% end %>
<% end %>

The turbo_frame_tag wraps each product in a Turbo Frame with a unique ID. Now create the product partial:

<%# app/views/products/_product.html.erb %>
<div class="product">
  <h2><%= product.name %></h2>
  <p><%= product.description %></p>
  <p>Price: <%= number_to_currency(product.price) %></p>
  <%= link_to "Edit", edit_product_path(product) %>
</div>

When a user clicks “Edit”, Turbo intercepts the request and looks for a frame with ID product_#{product.id} in the response. Update the edit view to provide this:

<%# app/views/products/edit.html.erb %>
<%= turbo_frame_tag "product_#{@product.id}" do %>
  <h2>Edit Product</h2>
  <%= form_with(model: @product) do |form| %>
    <%= form.label :name %>
    <%= form.text_field :name %>

    <%= form.label :description %>
    <%= form.text_area :description %>

    <%= form.label :price %>
    <%= form.number_field :price %>

    <%= form.submit "Save" %>
    <%= link_to "Cancel", product_path(@product) %>
  <% end %>
<% end %>

The frame ID matches between index and edit views. When the edit page loads, Turbo extracts the matching frame and replaces the corresponding frame on the index page. The edit form appears in place without navigation.

For the update action, ensure it redirects back to the show page (or renders the product partial) on success:

# app/controllers/products_controller.rb
def update
  @product = Product.find(params[:id])

  if @product.update(product_params)
    redirect_to @product
  else
    render :edit, status: :unprocessable_entity
  end
end

Create a show view that renders the same frame:

<%# app/views/products/show.html.erb %>
<%= turbo_frame_tag "product_#{@product.id}" do %>
  <%= render 'product', product: @product %>
<% end %>

Now the complete flow works:

  1. User clicks “Edit” on a product
  2. Turbo fetches the edit page and replaces just that product’s frame with the edit form
  3. User modifies the product and clicks “Save”
  4. Turbo submits the form
  5. Controller processes the update and redirects to the show page
  6. Turbo extracts the frame from the show page and replaces the form with the updated product display

All of this happens without full page reloads and without the URL changing. The rest of the page-other products, navigation, sidebar-remains untouched. On a typical connection, this reduces the interaction time from 500-1000ms (full page reload) to 100-200ms (frame replacement).

Step Four: Enhance Interactivity with Stimulus

Turbo handles most server interactions, but we still need JavaScript for client-side behavior-dropdowns, modals, form validation, keyboard shortcuts, and similar features. Stimulus provides a structured way to add this behavior.

Let’s add a Stimulus controller for a collapsible section. Generate a new controller:

rails generate stimulus collapsible

This creates app/javascript/controllers/collapsible_controller.js:

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

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

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

Use this controller in a view:

<div data-controller="collapsible">
  <button data-action="click->collapsible#toggle">
    Toggle Details
  </button>

  <div data-collapsible-target="content" class="hidden">
    <p>These details are hidden by default.</p>
    <p>Click the button to toggle visibility.</p>
  </div>
</div>

Breaking this down:

When the user clicks the button, Stimulus calls the toggle method, which toggles the hidden class on the content element. Simple, declarative, and testable-no global selectors or manual event binding required.

Stimulus controllers can be more sophisticated. Here’s a controller for autosaving form data:

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

export default class extends Controller {
  static targets = ["form", "status"]
  static values = { url: String, interval: Number }

  connect() {
    this.timeout = null
    this.formTarget.addEventListener("input", this.scheduleAutosave.bind(this))
  }

  disconnect() {
    if (this.timeout) clearTimeout(this.timeout)
  }

  scheduleAutosave() {
    if (this.timeout) clearTimeout(this.timeout)

    this.timeout = setTimeout(() => {
      this.save()
    }, this.intervalValue || 2000)
  }

  async save() {
    const formData = new FormData(this.formTarget)

    this.statusTarget.textContent = "Saving..."

    try {
      const response = await fetch(this.urlValue, {
        method: "PATCH",
        body: formData,
        headers: {
          "X-CSRF-Token": document.querySelector("[name='csrf-token']").content
        }
      })

      if (response.ok) {
        this.statusTarget.textContent = "Saved"
        setTimeout(() => {
          this.statusTarget.textContent = ""
        }, 2000)
      } else {
        this.statusTarget.textContent = "Error saving"
      }
    } catch (error) {
      this.statusTarget.textContent = "Error saving"
    }
  }
}

Use it in a form:

<%= form_with(model: @draft,
              data: {
                controller: "autosave",
                autosave_url_value: autosave_draft_path(@draft),
                autosave_interval_value: 3000
              }) do |form| %>

  <%= form.text_area :content, rows: 10 %>

  <div data-autosave-target="status"></div>
<% end %>

As the user types, the controller debounces input events and automatically saves form data every three seconds. The status target displays feedback. This pattern replaces the complex jQuery solutions common in legacy applications-often reducing 50-100 lines of jQuery to 30-40 lines of more maintainable Stimulus code.

Step Five: Add Real-Time Updates with Turbo Streams

Turbo Streams enable server-initiated updates to specific page elements. This is useful for real-time features-notifications, live dashboards, collaborative editing, and activity feeds.

Two delivery mechanisms exist: ActionCable (WebSockets) for real-time push updates, and direct responses to form submissions for immediate feedback.

Let’s implement a notification system that pushes updates to users. First, create a Turbo Stream template for rendering notifications:

<%# app/views/notifications/_notification.turbo_stream.erb %>
<%= turbo_stream.prepend "notifications" do %>
  <div class="notification" id="<%= dom_id(notification) %>">
    <%= notification.message %>
    <%= link_to "Dismiss", notification, method: :delete %>
  </div>
<% end %>

In your layout, add a container for notifications:

<%# app/views/layouts/application.html.erb %>
<div id="notifications"></div>

Subscribe users to their notification channel:

<%# app/views/layouts/application.html.erb %>
<% if user_signed_in? %>
  <%= turbo_stream_from "notifications_#{current_user.id}" %>
<% end %>

When a notification is created, broadcast it to the user:

# app/models/notification.rb
class Notification < ApplicationRecord
  belongs_to :user

  after_create_commit -> {
    broadcast_prepend_to(
      "notifications_#{user_id}",
      target: "notifications",
      partial: "notifications/notification",
      locals: { notification: self }
    )
  }
end

Now when a notification is created for a user, it automatically appears in their notifications area without polling or manual refresh.

Turbo Streams also work with form submissions. Consider a comments section that should update immediately when a user posts:

# app/controllers/comments_controller.rb
def create
  @comment = @post.comments.build(comment_params)

  if @comment.save
    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to @post }
    end
  else
    render :new, status: :unprocessable_entity
  end
end

Create a Turbo Stream response:

<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.prepend "comments" do %>
  <%= render @comment %>
<% end %>

<%= turbo_stream.replace "new_comment" do %>
  <%= render "comments/form", post: @post, comment: Comment.new %>
<% end %>

This response performs two actions: prepends the new comment to the comments list and replaces the form with a fresh one. Both updates happen instantly when the form submits.

Step Six: Handle Forms and Validation

Legacy Rails applications often rely on full page reloads for form validation feedback. Hotwire improves this experience while maintaining server-side validation.

When a form submission fails validation, render the form again with error messages and a 422 Unprocessable Entity status code. Turbo will replace the form with the error-annotated version:

# app/controllers/products_controller.rb
def create
  @product = Product.new(product_params)

  if @product.save
    redirect_to @product, notice: "Product created successfully"
  else
    render :new, status: :unprocessable_entity
  end
end

The form template remains standard Rails:

<%# app/views/products/new.html.erb %>
<%= form_with(model: @product) do |form| %>
  <% if @product.errors.any? %>
    <div class="errors">
      <h2><%= pluralize(@product.errors.count, "error") %> prevented saving:</h2>
      <ul>
        <% @product.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <%= form.label :name %>
  <%= form.text_field :name, class: @product.errors[:name].any? ? 'error' : '' %>

  <%= form.label :price %>
  <%= form.number_field :price, class: @product.errors[:price].any? ? 'error' : '' %>

  <%= form.submit %>
<% end %>

Turbo automatically handles the form submission, receives the 422 response, and replaces the form with the error-annotated version. No JavaScript required.

For more sophisticated inline validation, combine Stimulus with Turbo Frames. Create a validation endpoint:

# config/routes.rb
post 'products/validate/:field', to: 'products#validate_field', as: :validate_product_field
# app/controllers/products_controller.rb
def validate_field
  @product = Product.new(product_params)
  @product.valid?

  @field = params[:field]
  @errors = @product.errors[@field]

  render turbo_stream: turbo_stream.replace(
    "#{@field}_validation",
    partial: "products/field_validation",
    locals: { field: @field, errors: @errors }
  )
end

In the form, add validation containers and Stimulus behavior:

<%= form_with(model: @product, data: { controller: "inline-validation" }) do |form| %>
  <div>
    <%= form.label :name %>
    <%= form.text_field :name,
                        data: {
                          action: "blur->inline-validation#validate",
                          inline_validation_field_param: "name"
                        } %>
    <div id="name_validation"></div>
  </div>
<% end %>

The Stimulus controller triggers validation on blur:

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

export default class extends Controller {
  validate(event) {
    const field = event.params.field
    const form = this.element
    const formData = new FormData(form)

    fetch(`/products/validate/${field}`, {
      method: "POST",
      body: formData,
      headers: {
        "X-CSRF-Token": document.querySelector("[name='csrf-token']").content,
        "Accept": "text/vnd.turbo-stream.html"
      }
    })
  }
}

Now users receive immediate feedback as they complete form fields, while validation logic remains on the server.

Step Seven: Migrate Legacy JavaScript

Legacy Rails applications typically accumulate JavaScript across multiple patterns-jQuery plugins, inline script tags, CoffeeScript files, and asset pipeline manifests. Migrating this to Stimulus controllers provides structure and maintainability.

Identify JavaScript behaviors that would benefit from migration. Look for:

For each behavior, create a Stimulus controller that encapsulates the functionality. Here’s a typical jQuery pattern:

// Old jQuery approach
$(document).ready(function() {
  $('.modal-trigger').click(function(e) {
    e.preventDefault();
    var targetModal = $(this).data('modal');
    $('#' + targetModal).show();
  });

  $('.modal-close').click(function() {
    $(this).closest('.modal').hide();
  });
});

Convert this to a Stimulus controller:

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

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

  open() {
    this.dialogTarget.style.display = "block"
  }

  close() {
    this.dialogTarget.style.display = "none"
  }
}

Update the markup:

<div data-controller="modal">
  <button data-action="click->modal#open">Open Modal</button>

  <div data-modal-target="dialog" style="display: none;">
    <div class="modal-content">
      <h2>Modal Title</h2>
      <p>Modal content goes here.</p>
      <button data-action="click->modal#close">Close</button>
    </div>
  </div>
</div>

This approach is clearer, more testable, and doesn’t require global selectors or initialization code.

You don’t need to migrate all JavaScript immediately. Focus on:

  1. Code that breaks with Turbo Drive
  2. Features you’re actively modifying
  3. Particularly fragile or problematic jQuery code

Leave stable, working JavaScript alone until you have reason to touch it.

Step Eight: Test Thoroughly

Hotwire changes how your application behaves in subtle ways. Comprehensive testing prevents regressions.

System tests verify end-to-end behavior including Turbo interactions. Rails system tests use Capybara and a headless browser:

# test/system/products_test.rb
require "application_system_test_case"

class ProductsTest < ApplicationSystemTestCase
  test "editing a product inline" do
    product = products(:one)

    visit products_path

    within "#product_#{product.id}" do
      click_link "Edit"

      # Form should appear without navigation
      assert_selector "form"

      fill_in "Name", with: "Updated Name"
      click_button "Save"

      # Updated product should appear without page reload
      assert_text "Updated Name"
      assert_no_selector "form"
    end
  end
end

Stimulus controller tests verify JavaScript behavior. Use the Stimulus testing library:

// test/controllers/collapsible_controller.test.js
import { Application } from "@hotwired/stimulus"
import CollapsibleController from "../../app/javascript/controllers/collapsible_controller"

describe("CollapsibleController", () => {
  beforeEach(() => {
    document.body.innerHTML = `
      <div data-controller="collapsible">
        <button data-action="click->collapsible#toggle">Toggle</button>
        <div data-collapsible-target="content" class="hidden">Content</div>
      </div>
    `

    const application = Application.start()
    application.register("collapsible", CollapsibleController)
  })

  it("toggles content visibility", () => {
    const button = document.querySelector("button")
    const content = document.querySelector("[data-collapsible-target='content']")

    expect(content.classList.contains("hidden")).toBe(true)

    button.click()
    expect(content.classList.contains("hidden")).toBe(false)

    button.click()
    expect(content.classList.contains("hidden")).toBe(true)
  })
})

Manual testing remains essential. Navigate through your application using different browsers and devices. Test with browser DevTools to simulate slow networks and verify that loading states display appropriately.

Common Pitfalls and Solutions

Even with careful implementation, we’re likely to encounter a few common issues:

Turbo Drive caching causes stale data: Turbo Drive caches pages for back/forward navigation-this improves performance but can cause issues with frequently changing data. If a page displays data that updates often, we can mark it as no-cache:

<meta name="turbo-cache-control" content="no-cache">

This tells Turbo Drive to always fetch fresh content for this page.

ActionCable connections fail in production: Production servers need to support WebSockets for ActionCable to work. If using Nginx, we need to add WebSocket upgrade headers to the configuration:

location /cable {
  proxy_pass http://backend;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
}

Without these headers, WebSocket connections will fail and real-time features won’t work.

Flash messages disappear on Turbo Frame updates: Flash messages stored in the session aren’t automatically included in Turbo Frame responses-only the frame content is sent. We have two options: include flash messages in the frame response itself, or broadcast them via Turbo Streams to a dedicated flash message container.

Third-party JavaScript libraries conflict with Turbo: Libraries that assume a traditional page load lifecycle may behave incorrectly with Turbo Drive. For example, some analytics libraries, chat widgets, or form libraries expect document.ready events. We have a few options:

The first two options maintain the benefits of Turbo Drive. The third option is a last resort when integration proves too difficult.

Measuring the Impact

After implementing Hotwire, we should measure the improvements objectively:

Navigation speed: Use browser DevTools or Real User Monitoring tools to measure time-to-interactive before and after enabling Turbo Drive. In our experience, improvements typically range from 30-70% faster navigation, though the exact numbers depend on the application’s structure and network conditions.

Perceived performance: Survey users about application responsiveness. The elimination of white flashes between pages and instant form feedback often improves perceived speed-even when measurable metrics show modest improvements.

Code maintainability: Track the amount of JavaScript code required for features. Hotwire typically reduces frontend code volume by moving logic to the server, though this varies depending on the complexity of the features being implemented.

Development velocity: Monitor how long it takes to add new features. The elimination of API development and frontend/backend coordination overhead can accelerate feature delivery, though the learning curve for Hotwire should be factored into initial projects.

Moving Forward

Adding Hotwire to a legacy Rails application can modernize the user experience without requiring a complete rewrite. By incrementally adopting Turbo Drive for navigation, Turbo Frames for page sections, Turbo Streams for real-time updates, and Stimulus for JavaScript behavior, we can transform a traditional server-rendered application into a more responsive experience-while maintaining the maintainability advantages of server-side logic.

We recommend starting small. Enable Turbo Drive and verify that navigation works smoothly. Add a Turbo Frame to a high-value feature and gather user feedback. Implement a Stimulus controller for a piece of jQuery functionality that’s been problematic. Each improvement builds confidence and provides value independently.

The goal isn’t to use every Hotwire feature everywhere immediately-that would defeat the purpose of an incremental approach. The goal is sustainable modernization that delivers user value while maintaining development velocity. Hotwire provides the tools to achieve both, though success depends on thoughtful application and realistic expectations.

If you’re facing challenges modernizing a legacy Rails application or need help implementing Hotwire effectively, get in touch.

Footnotes

  1. Hotwire website. (n.d.). Hotwire: HTML Over The Wire. Retrieved from https://hotwire.dev

  2. Turbo Handbook. (n.d.). Turbo Drive. Retrieved from https://turbo.hotwired.dev

  3. Stimulus Handbook. (n.d.). Stimulus: A modest JavaScript framework. Retrieved from https://stimulus.hotwired.dev

  4. Hotwire Rails Guide. (n.d.). Getting Started with Hotwire in Rails. Retrieved from https://hotwire.dev/guides/rails