docs / guides
Progressive Migration from an Existing Rails App
Adopt ruact inside an existing Rails app, one component at a time, without a rewrite.
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
Section titled “Strategy overview”ruact is opt-in per controller. The install changes none of your controllers or views; a page renders through ruact once its controller has include Ruact::Controller (and, if the controller declares ruact_pages only:, the action is among them). Every other page stays exactly as it was. This means you can:
- Add the gem without touching any existing controller or view
- 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 keep working as they are. What a ruact page adds is a rule for how people move between it and your other pages — see Moving between ruact pages and yours.
Step 1: Add the gem
Section titled “Step 1: Add the gem”Add to Gemfile:
gem "ruact"bundle installStep 2: Run the install generator
Section titled “Step 2: Run the install generator”rails generate ruact:installThe install runs in island mode: it changes none of your controllers, so no page of yours renders through ruact until you opt it in (step 3). (rails generate ruact:install --app is the other mode — the concern on ApplicationController, every page through ruact — for apps built on ruact from the start.)
This creates app/javascript/components/, vite.config.js and config/initializers/ruact.rb, writes a package.json with React and Vite if the app has none (an existing one is left as it is, and the install names what it needs), sets up Procfile.dev and bin/dev, and runs npm install for you (--skip-npm leaves that to you). An existing vite.config of yours is left as it is: the install prints the lines that load ruact’s Vite plugin from the gem Bundler has. 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.
Step 3: Add the concern to the controller you are migrating
Section titled “Step 3: Add the concern to the controller you are migrating”class DashboardController < ApplicationController include Ruact::Controller # ruact_pages only: %i[index] # optional: only some actionsendOnly this controller changes: every action of it with an .html.erb template now renders through ruact, and every other controller in your app stays exactly as it was. Do not put the concern in ApplicationController while migrating — that converts every page with a template at once (it is what rails generate ruact:install --app does, for apps built on ruact from the start).
Step 4: Migrate one action
Section titled “Step 4: Migrate one action”With the concern in place, the action’s .html.erb renders through ruact on its own — the action itself does not change:
def dashboard @stats = Stats.current # no render call: dashboard.html.erb renders through ruactendAn explicit render :dashboard (or render :new, status: :unprocessable_entity after a failed save) goes through ruact too, and ruact_render is there when you want to be explicit. Your existing app/views/dashboard/dashboard.html.erb continues to work: it is ordinary ERB, and ruact turns its output into a Flight payload.
Step 5: The layout — there is nothing to update
Section titled “Step 5: The layout — there is nothing to update”rails generate ruact:install does not touch your layouts. It sets config.layout = "ruact", and ruact pages render through a layout that ships with the gem: CSRF, CSP, the CSS your client components import, then your stylesheets from config.layout_stylesheets ([:app], or ["application"] on Sprockets). Your existing layouts keep rendering your existing pages.
That layout carries your stylesheets and nothing else of your own <head> — no favicons, fonts, analytics or JavaScript of yours. When a ruact page needs them, copy the layout into your app and change it:
rails generate ruact:layoutapp/views/layouts/ruact.html.erb then takes over from ruact’s, with no setting. Keep <%= ruact_head_assets %> in its <head> and <div id="root"></div> with <%= ruact_js_assets %> in its <body>; rails ruact:doctor checks all three.
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.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
Section titled “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 include Ruact::Controller # this controller's pages render through ruact
def index @posts = Post.all ruact_render endend
class AdminController < ApplicationController # No include — traditional Rails HTML, unaffected def dashboard @stats = Stats.current render :dashboard endendMoving between ruact pages and yours
Section titled “Moving between ruact pages and yours”The page that rendered the document owns its navigation; crossing to the other kind of page is a full page load. A link from a ruact page to one of yours, or from one of yours to a ruact page, loads the destination in full — never a half-rendered page, and never ruact’s router left running inside a Turbo page.
- ruact → yours. ruact’s client router asks the server first, and the server answers “not mine” before any action runs (the navigation boundary). The browser then loads your page once.
- yours (Turbo) → ruact. Turbo fetches the page, sees that ruact wants a full load, and reloads it. That is two GETs of the ruact page — the one cost of this direction.
- Per action. A controller can make only some of its actions ruact pages (
ruact_pages only: %i[show]); the rule holds between the two kinds of actions of one controller too.
Forms that cross follow the same rule:
- A form on a ruact page posting to one of your actions is submitted by the browser. Your action runs once, and what you see is its real response — the redirect, or a
422with its validation errors and what was typed. - A form on a Turbo page posting to a ruact action needs
data-turbo="false". Without it, Turbo fetches the answer, the ruact page asks Turbo to reload, and Turbo reloads the page the form is on — a422’s errors are lost (the action still ran once). With it, the browser submits the form and shows ruact’s answer. In development, ruact logs a warning when this happens.
<%= form_with model: @post, data: { turbo: false } do |form| %>Common migration patterns
Section titled “Common migration patterns”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):
"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
Section titled “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.