Skip to content

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.


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:

  1. Add the gem without touching any existing controller or view
  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 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.


Add to Gemfile:

gem "ruact"
Terminal window
bundle install

Terminal window
rails generate ruact:install

The 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 actions
end

Only 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).


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 ruact
end

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

Terminal window
rails generate ruact:layout

app/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.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
include Ruact::Controller # this controller's pages render through ruact
def index
@posts = Post.all
ruact_render
end
end
class AdminController < ApplicationController
# No include — traditional Rails HTML, unaffected
def dashboard
@stats = Stats.current
render :dashboard
end
end

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 422 with 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 — a 422’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| %>

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.