Skip to content

Progressive Migration from an Existing Rails App

You do not need to rewrite your entire application to use ruact. This guide shows how to add ruact to an existing Rails app and migrate one page at a time — while all existing pages continue to work.


Strategy overview

ruact is opt-in per action. A controller action that does not call ruact_render is unaffected. This means you can:

  1. Add the gem and the controller concern without touching any existing views
  2. Pick one new feature or page to build with ruact
  3. Gradually migrate other pages when it makes sense

Existing HTML responses, Turbo, Hotwire, and JSON APIs all coexist without conflict.


Step 1: Add the gem

Add to Gemfile:

ruby
gem "ruact"
bash
bundle install

Step 2: Run the install generator

bash
rails generate ruact:install

This creates app/javascript/components/, vite.config.js, and config/initializers/ruact.rb. ruact's bootstrap entry and Flight runtime are hidden plumbing (a virtual module served from the gem), so nothing is written into app/javascript/ except your own components. It does not modify any existing views or controllers.

bash
npm install

Step 3: Add the concern to ApplicationController

ruby
class ApplicationController < ActionController::Base
  include Ruact::Controller
  # ... your existing before_actions, helpers, etc.
end

The concern does nothing until a controller action calls ruact_render. All existing render, redirect_to, and respond_to calls are unaffected.


Step 4: Migrate one action

Choose one action to convert. Change render to ruact_render:

ruby
# Before
def dashboard
  @stats = Stats.current
  render :dashboard
end

# After
def dashboard
  @stats = Stats.current
  ruact_render  # serves Flight payload or HTML shell depending on request type
end

Your existing app/views/dashboard/dashboard.html.erb continues to work. Rails ERB rendering is unchanged — ruact wraps the output in a Flight payload.


Step 5: Update the layout for RSC pages

For pages that use ruact, the HTML layout needs to include the RSC client JavaScript. The install generator creates a dedicated layout or adds to your application layout:

erb
<!-- app/views/layouts/rsc.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>My App</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= vite_client_tag %>
    <%= vite_javascript_tag "application" %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

You can use a separate layout for RSC actions and keep the default layout for legacy pages:

ruby
class DashboardController < ApplicationController
  layout "rsc"

  def index
    ruact_render
  end
end

Step 6: Add client components incrementally

As you migrate pages, extract interactive parts into "use client" components:

app/javascript/components/
├── SearchInput.jsx     ← "use client" — replaced a Stimulus controller
├── ChartWidget.jsx     ← "use client" — was a Turbo Frame
└── DropdownMenu.jsx    ← "use client" — replaced Alpine.js

You do not need to migrate all interactivity at once. Stimulus controllers and Turbo Frames continue to work on non-RSC pages.


Mixing RSC and non-RSC pages

It is perfectly valid to have some pages using ruact and others using traditional Rails HTML:

ruby
class PostsController < ApplicationController
  # RSC page
  def index
    @posts = Post.all
    ruact_render
  end
end

class AdminController < ApplicationController
  # Traditional Rails HTML — unaffected
  def dashboard
    @stats = Stats.current
    render :dashboard
  end
end

The RSC client only intercepts navigation to pages that respond to text/x-component. Navigation to traditional HTML pages causes a full page load.


Common migration patterns

Replacing a Turbo Frame with a server component

Before (Turbo Frame):

erb
<turbo-frame id="comments">
  <%= render @comments %>
</turbo-frame>

After (ruact):

erb
<CommentsList comments={@comments} />

The difference: with Turbo Frames, the server still renders HTML. With ruact, CommentsList is a client component that receives structured data and renders it in React.

Replacing a Stimulus controller with a client component

Before (Stimulus):

erb
<div data-controller="counter" data-counter-initial-value="<%= @count %>">
  <span data-counter-target="display"></span>
  <button data-action="click->counter#increment">+</button>
</div>

After (ruact):

jsx
// app/javascript/components/Counter.jsx
"use client"

export function Counter({ initialValue }) {
  const [count, setCount] = useState(initialValue)
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  )
}
erb
<Counter initialValue={@count} />

Verifying the migration

Run rails ruact:doctor to check your setup:

bash
rails ruact:doctor

The doctor verifies: manifest file exists, Vite is configured, client components are registered, and the Rails integration is correctly configured. See the ruact:doctor reference for details.

Released under the MIT License.