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:
- Add the gem and the controller concern without touching any existing views
- Pick one new feature or page to build with ruact
- 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:
gem "ruact"bundle installStep 2: Run the install generator
rails generate ruact:installThis 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.
npm installStep 3: Add the concern to ApplicationController
class ApplicationController < ActionController::Base
include Ruact::Controller
# ... your existing before_actions, helpers, etc.
endThe 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:
# 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
endYour 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:
<!-- 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
endStep 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.jsYou 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:
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
endThe 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):
<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
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} />Verifying the migration
Run rails ruact:doctor to check your setup:
rails ruact:doctorThe 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.