RSC Migration: Preparing Your App
This guide walks you through the infrastructure changes needed to prepare an existing React on Rails application for React Server Components (RSC). After completing these steps, your app works exactly as before -- no component behavior changes -- but the RSC pipeline is in place so you can begin migrating components to Server Components.
Part 1 of the RSC Migration Series | Next: Component Tree Restructuring
What This Guide Covers
You will:
- Install the RSC npm package
- Enable RSC support in the Rails configuration
- Mount the RSC payload route
- Create the RSC webpack bundle and add the RSC plugin to existing bundles
- Add
'use client'to all your registered component entry points - Switch views and controllers to streaming rendering
After these steps, every component is still a Client Component (because of the 'use client' directives), so the app behaves identically to before. The subsequent guides in this series cover how to progressively remove 'use client' and convert components to Server Components.
Prerequisites
Before starting, ensure you have:
- React on Rails Pro 4+ with React on Rails 15+
- React 19 (
reactandreact-domboth at 19.x) - Node renderer configured and running (RSC requires server-side JavaScript execution via the node renderer, not ExecJS). If you're still using ExecJS, migrate to the node renderer first -- see Node Renderer Basics.
- Shakapacker (or webpack configured via Shakapacker)
- Node.js 20+
Step 1: Install the RSC Package
Install react-on-rails-rsc, which provides the webpack loader, webpack plugin, and RSC client/server runtime:
yarn add react-on-rails-rsc
# or: npm install react-on-rails-rsc
# or: pnpm add react-on-rails-rsc
Verify that react and react-dom are at version 19 and that the versions match:
yarn why react
# Should show 19.x
yarn why react-on-rails-rsc
# Major.minor should match react (e.g., both 19.1.x)
If you're on React 18 or earlier, upgrade first -- RSC requires React 19.
Version requirements: Use
react-on-rails-rsc19.0.4 or later -- earlier versions (19.0.0 through 19.0.3) vendored older builds ofreact-server-dom-webpackthat were updated in 19.0.4 with upstream security patches. The major and minor versions ofreact-on-rails-rscmust match yourreactversion (e.g.,react19.1.x requiresreact-on-rails-rsc19.1.x).
Step 2: Configure Rails for RSC
Update your React on Rails Pro initializer:
# config/initializers/react_on_rails_pro.rb
ReactOnRailsPro.configure do |config|
# --- Required changes ---
# Enable the RSC pipeline (default: false)
config.enable_rsc_support = true
# Enable promise-based rendering for streaming support (default: false)
# Only takes effect when server_renderer is "NodeRenderer"
config.rendering_returns_promises = true
# --- Already correct defaults (shown for visibility) ---
# RSC bundle filename -- must match your webpack output filename
# Default: "rsc-bundle.js" -- no change needed if you follow the standard setup
config.rsc_bundle_js_file = "rsc-bundle.js"
# URL path for RSC payload requests from the browser
# Default: "rsc_payload/" -- no change needed
config.rsc_payload_generation_url_path = "rsc_payload/"
# Manifest files generated by RSCWebpackPlugin
# Defaults match the plugin's output filenames -- no change needed
config.react_client_manifest_file = "react-client-manifest.json"
config.react_server_client_manifest_file = "react-server-client-manifest.json"
# ... your existing config (server_renderer, renderer_url, etc.)
end
The base gem's configuration (ReactOnRails.configure) does not require RSC-specific changes. If you're using Shakapacker >= 8.2.0 with Pro, generated_component_packs_loading_strategy already defaults to :async, which is optimal for streaming.
Step 3: Mount the RSC Payload Route
The RSC payload route serves the RSC payload when the browser navigates to a page with Server Components. Add it to your routes:
# config/routes.rb
Rails.application.routes.draw do
rsc_payload_route
# ... your existing routes
end
This mounts a GET /rsc_payload/:component_name endpoint that React on Rails Pro uses internally. The default path (rsc_payload/) matches the rsc_payload_generation_url_path config. If your app has a route conflict at that path, you can customize both:
# Custom path (use the same value in both places, with trailing slash)
rsc_payload_route path: "flight-payload/"
# config/initializers/react_on_rails_pro.rb
config.rsc_payload_generation_url_path = "flight-payload/"
Step 4: Set Up the RSC Webpack Bundle
RSC requires a third webpack bundle alongside your existing client and server bundles. This RSC bundle contains only Server Component code -- the webpack loader strips out 'use client' files and replaces them with client references.
Custom webpack configs: The examples below assume you're using the default webpack configs generated by the React on Rails installer. If your webpack setup is substantially different (custom Webpack configs, different file structure, different loaders), read the "What this does" callouts in each subsection -- they explain the underlying intent of each change so you can apply the same logic to your own config.
4a. Create the RSC webpack config
What this does (for custom configs): You need a third webpack config that produces an rsc-bundle.js file. This RSC bundle is essentially a copy of your server bundle with three modifications:
- Add
react-on-rails-rsc/WebpackLoaderto the JavaScript loader chain (after babel-loader or swc-loader). This loader intercepts files containing'use client'and replaces their exports with lightweight client reference stubs instead of actual component code. This is what keeps client code out of the RSC bundle. - Add
react-servertoresolve.conditionNames. This tells webpack to use thereact-serverexport condition frompackage.jsonwhen resolving modules -- React itself ships different entry points for the RSC environment vs the normal server environment. - Alias
react-dom/servertofalse. The RSC bundle generates RSC payloads (a serialization format), not HTML. Importingreact-dom/serverin the RSC environment causes a runtime error, so it must be excluded.
The entry point should be the same file as your server bundle (typically server-bundle.js), just with a different output filename (rsc-bundle.js). Do not add RSCWebpackPlugin to this config -- only the client and server bundles need it.
Create config/webpack/rscWebpackConfig.js:
// React Server Components webpack configuration
// Creates the RSC bundle based on the server webpack config
// See: ../pro/react-server-components/how-react-server-components-work.md
const serverWebpackModule = require('./serverWebpackConfig');
// Backward compatibility:
// - New Pro config exports: { default: configureServer, extractLoader }
// - Legacy config exports: module.exports = configureServer
const serverWebpackConfig = serverWebpackModule.default || serverWebpackModule;
const extractLoader =
serverWebpackModule.extractLoader ||
((rule, loaderName) => {
if (!Array.isArray(rule.use)) return null;
return rule.use.find((item) => {
const testValue = typeof item === 'string' ? item : item.loader;
return testValue && testValue.includes(loaderName);
});
});
const configureRsc = () => {
// Pass true to skip RSCWebpackPlugin - RSC bundle doesn't need it
const rscConfig = serverWebpackConfig(true);
// Rename the entry from `server-bundle` to `rsc-bundle` so webpack
// produces a different output filename, while keeping the same entry
// point file used by the server bundle.
const rscEntry = {
'rsc-bundle': rscConfig.entry['server-bundle'],
};
rscConfig.entry = rscEntry;
// Add the RSC WebpackLoader to the JS rule's loader chain.
// This loader replaces 'use client' files with registerClientReference
// proxies in the RSC bundle.
// Webpack loaders execute right-to-left, so appending makes the RSC
// loader run first (before babel/swc).
const { rules } = rscConfig.module;
rules.forEach((rule) => {
if (typeof rule.use === 'function') {
// SWC transpiler: rule.use is a function
const originalUse = rule.use;
rule.use = function rscLoaderWrapper(data) {
const result = originalUse.call(this, data);
const resultArray = Array.isArray(result) ? result : result ? [result] : [];
const resolvedRule = { use: resultArray };
const jsLoader =
extractLoader(resolvedRule, 'babel-loader') || extractLoader(resolvedRule, 'swc-loader');
if (jsLoader) {
return [...resultArray, { loader: 'react-on-rails-rsc/WebpackLoader' }];
}
return result;
};
} else if (Array.isArray(rule.use)) {
// Babel transpiler: rule.use is a static array
const jsLoader = extractLoader(rule, 'babel-loader') || extractLoader(rule, 'swc-loader');
if (jsLoader) {
rule.use = [...rule.use, { loader: 'react-on-rails-rsc/WebpackLoader' }];
}
}
});
// Add the `react-server` condition to the resolve config.
// This tells webpack (and React) that this bundle targets the RSC environment.
// The `...` retains default conditions (e.g., `node` for server target).
rscConfig.resolve = {
...rscConfig.resolve,
conditionNames: ['react-server', '...'],
alias: {
...rscConfig.resolve?.alias,
// Ignore react-dom/server in RSC bundle -- it's not needed for
// RSC payload generation and importing it causes a runtime error
'react-dom/server': false,
},
};
// Update the output filename
rscConfig.output.filename = 'rsc-bundle.js';
return rscConfig;
};
module.exports = configureRsc;
Mutation safety note: This example assumes
serverWebpackConfig(true)returns a fresh config object per call. If your setup reuses shared config objects, clonemodule.rules/rule.usebefore mutating them inconfigureRsc.React aliases note: If your webpack config deduplicates React with aliases (common in pnpm/monorepo setups), you must override those aliases in the RSC config to point to the react-server entry files. Directory-path aliases bypass webpack's
conditionNamesresolution. Add these to thealiasblock above:alias: {
...rscConfig.resolve?.alias,
react: require.resolve('react/react.react-server.js'),
'react/jsx-runtime': require.resolve('react/jsx-runtime.react-server.js'),
'react/jsx-dev-runtime': require.resolve('react/jsx-dev-runtime.react-server.js'),
'react-dom/server': false,
},
4b. Add RSCWebpackPlugin to the server webpack config
What this does (for custom configs): The server bundle needs to know which components are Client Components so it can generate proper references during SSR. RSCWebpackPlugin with isServer: true scans your source files for 'use client' directives and produces a react-server-client-manifest.json -- a mapping of client component module IDs to their chunk locations. If your server webpack config is completely custom, add new RSCWebpackPlugin({ isServer: true, clientReferences: [...] }) to its plugins array. The clientReferences array tells the plugin which directories to scan for 'use client' files -- point it at your app's source directory. Also, modify your server config function to accept a parameter so the RSC config (step 4a) can reuse it without the plugin.
The server bundle needs RSCWebpackPlugin to generate the react-server-client-manifest.json. Update your serverWebpackConfig.js to accept an rscBundle parameter and conditionally add the plugin:
// config/webpack/serverWebpackConfig.js
const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin');
const configureServer = (rscBundle = false) => {
// ... your existing server config setup ...
// Add RSCWebpackPlugin only for the server bundle (not the RSC bundle)
if (!rscBundle) {
serverWebpackConfig.plugins.push(
new RSCWebpackPlugin({
isServer: true,
clientReferences: [{ directory: './client/app', recursive: true, include: /\.(js|ts|jsx|tsx)$/ }],
}),
);
}
// ... rest of your server config ...
return serverWebpackConfig;
};
module.exports = {
default: configureServer,
extractLoader, // Export if you have this utility function
};
clientReferences: If omitted, the plugin defaults to scanning the entire project root recursively ({ directory: ".", recursive: true, include: /\.(js|ts|jsx|tsx)$/ }), which works but is slow on large codebases. Settingdirectoryto your app's source directory (e.g.,'./client/app') limits the scan to only the files that could contain'use client'directives.
4c. Add RSCWebpackPlugin to the client webpack config
What this does (for custom configs): The client bundle needs its own manifest so React on Rails Pro can map client component references (from the RSC payload) to the actual client-side chunks the browser should load. RSCWebpackPlugin with isServer: false produces a react-client-manifest.json. If your client webpack config is completely custom, add new RSCWebpackPlugin({ isServer: false, clientReferences: [...] }) to its plugins array. Use the same clientReferences as the server config -- both must scan the same source directory.
The client bundle needs RSCWebpackPlugin to generate the react-client-manifest.json:
// config/webpack/clientWebpackConfig.js
const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin');
const configureClient = () => {
// ... your existing client config ...
clientConfig.plugins.push(
new RSCWebpackPlugin({
isServer: false,
clientReferences: [{ directory: './client/app', recursive: true, include: /\.(js|ts|jsx|tsx)$/ }],
}),
);
return clientConfig;
};
4d. Update the build pipeline
What this does (for custom configs): Before RSC, you built 2 webpack bundles (client + server). Now you need to build 3 (client + server + RSC). However your build is orchestrated -- a multi-compiler config array, separate webpack commands, a CI script, or a tool like ServerClientOrBoth.js -- add the RSC config as a third entry. You also need a separate watcher process in development for the RSC bundle (similar to how you already watch the server bundle separately). Add an environment variable guard (e.g., RSC_BUNDLE_ONLY) so you can build each bundle independently in dev, and build all three together in production.
Update ServerClientOrBoth.js (or your equivalent webpack entry point) to include the RSC bundle:
// config/webpack/ServerClientOrBoth.js
const clientWebpackConfig = require('./clientWebpackConfig');
const { default: serverWebpackConfig } = require('./serverWebpackConfig');
const rscWebpackConfig = require('./rscWebpackConfig');
const webpackConfig = (envSpecific) => {
const clientConfig = clientWebpackConfig();
const serverConfig = serverWebpackConfig();
const rscConfig = rscWebpackConfig();
if (envSpecific) {
envSpecific(clientConfig, serverConfig);
}
let result;
if (process.env.WEBPACK_SERVE || process.env.CLIENT_BUNDLE_ONLY) {
result = clientConfig;
} else if (process.env.SERVER_BUNDLE_ONLY) {
result = serverConfig;
} else if (process.env.RSC_BUNDLE_ONLY) {
result = rscConfig;
} else {
// Build all three bundles
result = [clientConfig, serverConfig, rscConfig];
}
return result;
};
module.exports = webpackConfig;
Add the RSC bundle watcher to your Procfile.dev:
# Procfile.dev
rails: rails s -p 3000
webpack-dev-server: HMR=true bin/shakapacker-dev-server
rails-server-assets: HMR=true SERVER_BUNDLE_ONLY=yes bin/shakapacker --watch
rails-rsc-assets: HMR=true RSC_BUNDLE_ONLY=yes bin/shakapacker --watch
node-renderer: node client/node-renderer.js
For full webpack configuration details, including the technical background on how the RSC loader, plugin, and manifests work together, see How React Server Components Work.
Step 5: Add 'use client' to All Registered Component Entry Points
This is the critical step that preserves your app's existing behavior. By adding 'use client' to every component entry point, you tell the RSC infrastructure: "all these components are Client Components." The app works exactly as before -- nothing renders on the server differently.
Later, when you're ready to migrate a component to a Server Component, you'll remove its 'use client' directive. That's covered in Component Tree Restructuring Patterns.
Where to add 'use client'
The directive goes on the component source files that are registered with React on Rails -- not on the bundle entry files (client-bundle.js, server-bundle.js).
Pattern A: Manual registration (component imported into bundle)
If your bundle file imports components and registers them:
// client-bundle.js -- do NOT add 'use client' here
import ReactOnRails from 'react-on-rails/client';
import ProductPage from '../components/ProductPage';
import CartPage from '../components/CartPage';
ReactOnRails.register({ ProductPage, CartPage });
Add 'use client' to each component file:
// components/ProductPage.jsx
'use client';
import React, { useState } from 'react';
// ... rest of your component
// components/CartPage.jsx
'use client';
import React from 'react';
// ... rest of your component
Do the same for any component files imported in your server-bundle.js.
Pattern B: Auto-bundling (single file per component)
If you use auto_load_bundle with a components_subdirectory, each component has a file in that directory:
app/javascript/src/
└── ProductPage/
└── ror_components/ # your components_subdirectory
└── ProductPage.jsx # ← add 'use client' here
└── CartPage/
└── ror_components/
└── CartPage.jsx # ← add 'use client' here
// app/javascript/src/ProductPage/ror_components/ProductPage.jsx
'use client';
import ProductPage from '../components/ProductPage';
export default ProductPage;
Pattern C: Auto-bundling with .client / .server file pairs
If you use separate files for client and server rendering:
app/javascript/src/
└── ReduxApp/
└── ror_components/
├── ReduxApp.client.jsx # ← add 'use client' here
└── ReduxApp.server.jsx # ← add 'use client' here too
Add 'use client' to both the .client AND .server files:
// ReduxApp.client.jsx
'use client';
import React from 'react';
import { Provider } from 'react-redux';
// ... client-side rendering logic (hydrateRoot, etc.)
// ReduxApp.server.jsx
'use client';
import React from 'react';
import { Provider } from 'react-redux';
// ... server-side rendering logic (returns JSX)
Why both? The
.clientand.serversuffixes control which webpack bundle includes the file --.client.jsxgoes into the client bundle,.server.jsxgoes into the server bundle (and RSC bundle). This is a React on Rails auto-bundling concept that predates React Server Components.The
'use client'directive controls RSC classification -- whether the RSC infrastructure treats the component as a Client Component or a Server Component. These are two independent systems.A
.server.jsxfile is not a React Server Component. It's a file included in the server bundle. Without'use client', the RSC infrastructure would try to treat it as a Server Component, which would break your existing rendering logic.For more on this distinction, see File Suffixes vs. RSC Directive.
What about the bundle entry files?
Adding 'use client' to client-bundle.js or server-bundle.js would technically work -- it would make all imported components Client Components, achieving the same immediate effect. However, we recommend placing the directive on individual component files instead. The reason is forward-looking: when you later want to convert a specific component to a Server Component (by removing 'use client'), you need granular control per component. If the directive is only on the bundle entry file, you'd have to move it to every individual component file at that point anyway.
Verification
After adding 'use client' to all entry points, rebuild all three bundles and verify the app works as before. If you're using auto-bundling, the generated packs should still use ReactOnRails.register (not registerServerComponent) for every component.
Step 6: Switch to Streaming Rendering
Replace synchronous view helpers and controller rendering with their streaming equivalents.
6a. Update controllers
For each controller that renders React components, include the ReactOnRailsPro::Stream concern and replace render with stream_view_containing_react_components:
Before:
class ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
# Implicit render of products/show template
end
end
After:
class ProductsController < ApplicationController
include ReactOnRailsPro::Stream
def show
@product = Product.find(params[:id])
stream_view_containing_react_components(template: "products/show")
end
end
ReactOnRailsPro::Stream automatically includes ActionController::Live, which enables HTTP streaming. The stream_view_containing_react_components method renders the template and streams the response progressively as React components resolve.
Which actions need this? Only actions whose views use
stream_react_component. If an action's view only usesreact_component(and you haven't migrated it yet in the step below), it can keep the standardrender.
6b. Update view helpers (optional now)
This step can be done now for all components at once, or deferred and done per-component as you migrate each one to a Server Component. Either way works — react_component continues to function alongside the RSC pipeline.
In each view, replace react_component with stream_react_component:
Before:
<%# app/views/products/show.html.erb %>
<h1><%= @product.name %></h1>
<%= react_component("ProductPage",
props: { product: @product.as_json },
prerender: true) %>
After:
<%# app/views/products/show.html.erb %>
<h1><%= @product.name %></h1>
<%= stream_react_component("ProductPage",
props: { product: @product.as_json },
prerender: true) %>
stream_react_component automatically sets prerender: true and enables immediate_hydration for optimal selective hydration. The component renders identically -- the difference is that the response is now streamed, which will matter when you start adding Suspense boundaries and async Server Components.
6c. Update script loading in layouts (recommended)
For streaming to deliver its full performance benefit, script tags should use async loading so the browser can hydrate components as they arrive:
<%# app/views/layouts/application.html.erb %>
<%= javascript_pack_tag('client-bundle', 'data-turbo-track': 'reload', async: true) %>
The async: true attribute enables React 18's Selective Hydration -- each component becomes interactive as soon as its code loads, without waiting for the entire page to finish streaming.
If you use auto_load_bundle with Shakapacker >= 8.2.0 and React on Rails Pro, the generated_component_packs_loading_strategy already defaults to :async, so auto-generated pack tags are already configured correctly.
Verification Checklist
After completing all steps, verify everything works:
- All three webpack bundles build without errors (client, server, RSC)
-
react-client-manifest.jsonandreact-server-client-manifest.jsonare generated in your webpack output directory -
rsc-bundle.jsis generated in yourserver_bundle_output_pathdirectory - The app starts without errors (
bin/devor equivalent) - Pages render identically to before the migration
- The browser Network tab shows chunked transfer encoding on pages with
stream_react_component - The
/rsc_payload/route is accessible (returns an error like "component not found" for unknown components -- that's expected)
If everything passes, your app is ready for RSC migration. The infrastructure is in place -- you can now begin converting components to Server Components by removing their 'use client' directives and restructuring them, as described in the next guide.
Next Steps
- Component Tree Restructuring Patterns -- how to restructure your component tree for RSC, starting with the top-down migration strategy
- Context, Providers, and State Management -- how to handle Context and global state
- Data Fetching Migration -- migrating from useEffect to server-side fetching