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.


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.


Add to Gemfile:

gem "ruact"
Terminal window
bundle install

Terminal window
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.

Terminal window
npm install

Step 3: Add the concern to ApplicationController

Section titled “Step 3: Add the concern to ApplicationController”
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.


Choose one action to convert. Change render to ruact_render:

# 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.


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:

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:

class DashboardController < ApplicationController
layout "rsc"
def index
ruact_render
end
end

Step 6: Add client components incrementally

Section titled “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.


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

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.


Replacing a Turbo Frame with a server component

Section titled “Replacing a Turbo Frame with a server component”

Before (Turbo Frame):

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

After (ruact):

<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

Section titled “Replacing a Stimulus controller with a client component”

Before (Stimulus):

<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):

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>
)
}
<Counter initialValue={@count} />

Run rails ruact:doctor to check your setup:

Terminal window
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.