Why Migrate to RSC?
React Server Components (RSC) represent a fundamental shift in how React applications render. Instead of shipping all your component code, rendering libraries, and data-fetching logic to the browser, RSC keeps server-only code entirely on the server. The client receives only the rendered output and the minimal JavaScript needed for interactivity.
For React on Rails applications, this means:
- Dramatically smaller client bundles — Libraries like
marked,highlight.js,date-fns, orlodashthat are only needed for rendering stay server-side. This can mean hundreds of kilobytes less JavaScript shipped to users. - Direct access to Rails data — Server components receive props directly from Rails controllers. No API endpoints needed for initial data.
- Streaming with Suspense — Rails can stream async data chunks as they resolve. Users see content progressively instead of waiting for the slowest query.
- Simplified architecture — No need to duplicate data-fetching logic between Rails controllers and React
useEffectcalls.
Prerequisites
Before starting the migration, ensure your project meets these requirements:
# Gemfile
gem 'react_on_rails', '17.0.0.rc.3'
gem 'react_on_rails_pro', '17.0.0.rc.3'
// package.json — Required dependencies
{
"dependencies": {
"react": "19.0.4",
"react-dom": "19.0.4",
"react-on-rails-pro": "17.0.0-rc.3",
"react-on-rails-pro-node-renderer": "17.0.0-rc.3",
"react-on-rails-rsc": "19.0.5-rc.7"
}
}
Important: RSC requires React 19. The RSC bundler APIs are unstable between React minor versions, so pin to React 19.0.x and the React on Rails RC stack's matching
react-on-rails-rscrelease. Thereact-on-rails-rscpackage version tracks the React version it supports.
Understanding the Three-Bundle Architecture
A React on Rails app with RSC uses three separate webpack bundles, each with a distinct purpose:
Client Bundle (client-bundle.js)
Runs in the browser. Contains only 'use client' component implementations and their dependencies. Server component code never appears here — only lightweight references that tell React "this component was rendered on the server."
// app/javascript/packs/client-bundle.js
import ReactOnRails from 'react-on-rails-pro/client';
ReactOnRails.setOptions({
traceTurbolinks: false,
turbo: false,
});
Server Bundle (server-bundle.js)
Runs in the Node renderer for SSR (generating initial HTML). Server components are registered here, but wrapped with RSCRoute — a proxy that delegates to the RSC system rather than including the actual implementation.
// app/javascript/generated/server-bundle-generated.js (auto-generated)
import ReactOnRails from 'react-on-rails-pro';
import registerServerComponent from 'react-on-rails-pro/registerServerComponent/server';
// RSC components — registered via registerServerComponent
// In the SSR bundle, these get wrapped with RSCRoute (not the actual code)
registerServerComponent({ DashboardRSC, BlogPostRSC });
// Client components — registered normally for SSR
ReactOnRails.register({ DashboardSSR, HelloWorld });
RSC Bundle (rsc-bundle.js)
Runs in the Node renderer under the react-server condition. This is the only bundle that contains actual server component implementations. Files with 'use client' are transformed into client references by the RSC WebpackLoader — their code is stripped and replaced with a reference ID.
// In the RSC bundle, 'use client' files become:
// Instead of the actual component code, the bundler emits:
// registerClientReference(proxy, "file-path", "default")
// The actual component code is only in the client bundle.
This three-bundle architecture is what makes RSC's bundle size reduction possible. The RSC bundle renders the server component tree into a serialized payload. The client bundle only receives references to client components it already has, plus the rendered output of server components.
Step 1: Enable RSC in Configuration
Rails Initializers
# config/initializers/react_on_rails.rb
ReactOnRails.configure do |config|
config.server_bundle_js_file = 'server-bundle.js'
config.auto_load_bundle = true
config.components_subdirectory = 'startup'
end
# config/initializers/react_on_rails_pro.rb
ReactOnRailsPro.configure do |config|
config.enable_rsc_support = true
config.rsc_bundle_js_file = 'rsc-bundle.js'
config.server_renderer = 'NodeRenderer'
config.renderer_url = ENV.fetch('RENDERER_URL', 'http://localhost:3800')
config.rsc_payload_generation_url_path = 'rsc_payload/'
end
Webpack RSC Bundle Configuration
Create config/webpack/rscWebpackConfig.js. This is the most critical configuration file for RSC:
// config/webpack/rscWebpackConfig.js
const { default: serverWebpackConfig } = require('./serverWebpackConfig');
const configureRsc = () => {
const rscConfig = serverWebpackConfig(true);
// 1. Change entry point from server-bundle to rsc-bundle
rscConfig.entry = {
'rsc-bundle': rscConfig.entry['server-bundle']
};
// 2. Add the RSC WebpackLoader as a post-processing rule
// enforce: 'post' ensures it runs AFTER SWC/Babel compiles TSX to JS
// This gives the loader clean JavaScript to parse with acorn
rscConfig.module.rules.push({
test: /\.(ts|tsx|js|jsx|mjs)$/,
enforce: 'post',
loader: 'react-on-rails-rsc/WebpackLoader',
});
// 3. Enable react-server resolve condition
// This tells webpack to use RSC-specific package exports
rscConfig.resolve = {
...rscConfig.resolve,
conditionNames: ['react-server', '...'],
alias: {
...rscConfig.resolve?.alias,
// react-dom/server is not needed in the RSC bundle
'react-dom/server': false,
},
};
// 4. Set the output filename
rscConfig.output.filename = 'rsc-bundle.js';
return rscConfig;
};
module.exports = configureRsc;
Why
enforce: 'post'? When using SWC (via Shakapacker), the loader is configured as a function, not an array. The RSC WebpackLoader needs to process JavaScript output, not raw TypeScript/JSX. By adding it as a separate rule withenforce: 'post', it runs after SWC has compiled everything to plain JavaScript.
Add RSC Plugin to Client and Server Configs
The RSCWebpackPlugin must be added to both the client and server webpack configurations:
// In clientWebpackConfig.js
const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin');
// Add to plugins array:
config.plugins.push(new RSCWebpackPlugin({ isServer: false }));
// In serverWebpackConfig.js
config.plugins.push(new RSCWebpackPlugin({ isServer: true }));
The plugin handles two critical tasks:
- Client config: Generates
react-client-manifest.jsonmapping'use client'components to their webpack chunk IDs - Server config: Adds all
'use client'files as entry points to ensure they're included in the client bundle
Process Management (Procfile.dev)
Each bundle needs its own watcher process:
# Procfile.dev
web: bundle exec rails server -p 3000
webpack: bin/shakapacker-dev-server
# Server bundle watcher (separate from dev server)
rails-server-assets: HMR=true SERVER_BUNDLE_ONLY=yes bin/shakapacker --watch
# RSC bundle watcher
rails-rsc-assets: HMR=true RSC_BUNDLE_ONLY=yes bin/shakapacker --watch
# Node renderer for SSR and RSC execution
renderer: node node-renderer.js
Node Renderer Setup
The Node renderer executes both the server bundle (for SSR) and the RSC bundle (for RSC payload generation):
// node-renderer.js
const { reactOnRailsProNodeRenderer } = require('react-on-rails-pro-node-renderer');
const config = {
port: process.env.RENDERER_PORT || 3800,
workersCount: 2,
// Required: React.lazy() uses performance.now() internally
additionalContext: { URL, AbortController, performance },
};
reactOnRailsProNodeRenderer(config);
Step 2: Add 'use client' to Existing Components
This is the most important step when enabling RSC. Once RSC support is active, React treats all files as server components by default. Any component that uses browser APIs, React hooks, or event handlers must be explicitly marked as a client component.
What Needs 'use client'
Add the directive to any file that:
'use client';
// Add this as the VERY FIRST line (before any imports)
// Files that need 'use client':
// - Uses useState, useEffect, useRef, useCallback, useMemo, useContext
// - Uses event handlers (onClick, onChange, onSubmit, etc.)
// - Uses browser APIs (window, document, localStorage, etc.)
// - Uses third-party libraries that use hooks internally
// - Uses @loadable/component or React.lazy()
What Does NOT Need 'use client'
Leave the directive off for components that:
// No 'use client' needed for:
// - Pure presentational components that only receive and render props
// - Components that only use: className, style, children, dangerouslySetInnerHTML
// - Components that import and render other components (composition)
// - Components that call server-side APIs or use async/await
// - Utility functions for rendering (markdown, formatting, etc.)
The 'use client' Boundary Rule
A critical concept: 'use client' creates a boundary. Everything imported by a client component is automatically included in the client bundle, even if those imports don't have the directive themselves.
// BlogPostRSC.tsx — Server component (no directive)
import { renderMarkdown } from './renderMarkdown'; // Stays server-side
import { BlogHeader } from './BlogHeader'; // Stays server-side
import { InteractiveSection } from './InteractiveSection'; // Has 'use client'
// renderMarkdown and BlogHeader code is ONLY in the RSC bundle
// InteractiveSection code is in the client bundle
export default async function BlogPostRSC({ post }) {
const html = renderMarkdown(post.content); // 350KB library, server-only
return (
<div>
<BlogHeader post={post} />
<article dangerouslySetInnerHTML={{ __html: html }} />
<InteractiveSection /> {/* Tiny client component */}
</div>
);
}
// InteractiveSection.tsx — Client component
'use client';
import React, { useState } from 'react';
// This component and ALL its imports go to the client bundle
export function InteractiveSection() {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>Like</button>;
}
Key insight: If BlogHeader was imported inside InteractiveSection.tsx instead of BlogPostRSC.tsx, it would be pulled into the client bundle — even though it's a simple presentational component. Always import shared components from the server component side when possible.
Step 3: Create Your First Server Component
Startup File Convention
React on Rails uses the startup/ directory for component auto-discovery. The file naming convention determines which bundle each component is registered in:
app/javascript/startup/
MyPageRSC.tsx -> RSC bundle (no 'use client' = server component)
MyPageSSR.tsx -> Server + Client bundles ('use client' = client component)
MyPageClient.client.tsx -> Client bundle only (hydration entry)
MyPageClient.server.tsx -> Server bundle only (SSR with ChunkExtractor)
For a server component, the startup file is minimal:
// app/javascript/startup/MyPageRSC.tsx
// No 'use client' directive — this is a server component
export { default } from '../components/MyPageRSC';
This gets auto-registered via registerServerComponent() in the generated server bundle, which handles the three-bundle routing automatically.
The Server Component
// app/javascript/components/MyPageRSC.tsx
// No 'use client' — server component
import React, { Suspense } from 'react';
import { formatDate } from '../utils/formatDate'; // Heavy library, stays server-side
interface Props {
data: { title: string; date: string };
getReactOnRailsAsyncProp: (name: string) => Promise<any>;
}
export default async function MyPageRSC({ data, getReactOnRailsAsyncProp }: Props) {
// This code runs ONLY on the server — never shipped to the browser
const formattedDate = formatDate(data.date);
return (
<div>
<h1>{data.title}</h1>
<time>{formattedDate}</time>
<Suspense fallback={<LoadingSkeleton />}>
<AsyncSection getReactOnRailsAsyncProp={getReactOnRailsAsyncProp} />
</Suspense>
</div>
);
}
The Controller
# app/controllers/pages_controller.rb
class PagesController < ApplicationController
include ReactOnRailsPro::RSCPayloadRenderer
include ReactOnRailsPro::AsyncRendering
enable_async_react_rendering only: [:show_rsc]
def show_rsc
@page_data = { title: 'My Page', date: Date.today.iso8601 }
stream_view_containing_react_components(template: 'pages/show_rsc')
end
end
Three controller concerns are required:
RSCPayloadRenderer— Handles RSC payload generation and thersc_payload/endpointAsyncRendering— Provides streaming support and theenable_async_react_renderingclass methodstream_view_containing_react_components— Replaces the defaultrenderto enable HTTP streaming
The View Template
<%%= stream_react_component_with_async_props(
"MyPageRSC",
props: { data: @page_data },
prerender: true
) do |emit|
# This block runs in a background thread
# Each emit.call sends a named data chunk to the React component
sleep 2 # Simulate slow database query
emit.call("related_items", {
items: ExpensiveQuery.find_related(@page_data[:id])
})
end %>
Step 4: Implement Streaming with Async Props
The streaming pattern is what makes RSC truly powerful in Rails. Instead of waiting for all data before rendering, Rails streams data chunks as they resolve. React renders each chunk into the appropriate Suspense boundary.
How the Emit Pattern Works
Timeline:
0ms Rails starts streaming HTML + RSC payload
0ms React renders server component with Suspense fallbacks
50ms User sees the page with loading skeletons
1500ms emit.call("related_posts", data) sends data
1500ms React replaces <Suspense> fallback with actual content
The Async Server Component
The component that receives streamed data uses getReactOnRailsAsyncProp — a function injected by React on Rails that returns a Promise resolving when Rails calls emit.call with the matching prop name:
// app/javascript/components/AsyncRelatedPostsRSC.tsx
// No 'use client' — server component
import React from 'react';
interface Props {
getReactOnRailsAsyncProp: (propName: string) => Promise<any>;
}
export default async function AsyncRelatedPostsRSC({
getReactOnRailsAsyncProp
}: Props) {
// This await resolves when Rails calls emit.call("related_posts", data)
const data = await getReactOnRailsAsyncProp('related_posts');
return (
<section>
<h2>Related Posts</h2>
{data.posts.map(post => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</article>
))}
</section>
);
}
Composing with Suspense
Wrap async components in <Suspense> to show fallback UI while data streams in:
// In the parent server component:
import React, { Suspense } from 'react';
import AsyncRelatedPostsRSC from './AsyncRelatedPostsRSC';
import { RelatedPostsSkeleton } from './RelatedPostsSkeleton';
export default async function BlogPostRSC({ post, getReactOnRailsAsyncProp }) {
return (
<div>
<article>{/* ... main content ... */}</article>
{/* User sees RelatedPostsSkeleton immediately */}
{/* Replaced with real content when emit.call resolves */}
<Suspense fallback={<RelatedPostsSkeleton />}>
<AsyncRelatedPostsRSC
getReactOnRailsAsyncProp={getReactOnRailsAsyncProp}
/>
</Suspense>
</div>
);
}
Multiple Concurrent Streams
You can emit multiple async props independently. Each resolves its own Suspense boundary:
<%%= stream_react_component_with_async_props(
"DashboardRSC",
props: { user: @user },
prerender: true
) do |emit|
# These can resolve in any order — each streams independently
Thread.new { emit.call("notifications", Notification.recent(@user.id)) }
Thread.new { emit.call("analytics", Analytics.summary(@user.id)) }
Thread.new { emit.call("recommendations", Recommender.for(@user.id)) }
end %>
Step 5: Migration Strategy for Existing Pages
You don't need to migrate everything at once. React on Rails supports all three rendering patterns side by side:
Pattern 1: Keep as Client Component (No Change)
For pages that are highly interactive (forms, real-time updates, complex state), keep them as client components. No migration needed.
// startup/FormPage.tsx
'use client';
export { default } from '../components/FormPage';
Pattern 2: Convert to RSC (Server Rendering, Zero Client JS)
For pages that are primarily content (blogs, documentation, product pages), convert to RSC. The component code and all rendering libraries stay server-side.
// Before (client component — all libraries shipped to browser):
'use client';
import { marked } from 'marked';
import hljs from 'highlight.js';
export default function BlogPost({ post }) {
const html = marked(post.content); // 350KB shipped to client
return <article dangerouslySetInnerHTML={{ __html: html }} />;
}
// After (server component — libraries stay on server):
// No 'use client'
import { marked } from 'marked';
import hljs from 'highlight.js';
export default async function BlogPost({ post }) {
const html = marked(post.content); // 350KB stays server-side
return <article dangerouslySetInnerHTML={{ __html: html }} />;
}
Pattern 3: Hybrid (Server Component with Client Islands)
The most common pattern. The page shell is a server component, with small 'use client' components for interactive elements:
// Server component (page shell)
import { HeavyChartLibrary } from 'chart-lib'; // Server-only
import { InteractiveFilter } from './Filter'; // 'use client'
import { ShareButton } from './ShareButton'; // 'use client'
export default async function AnalyticsPage({ data }) {
const chartSvg = HeavyChartLibrary.render(data); // Server-only rendering
return (
<div>
<InteractiveFilter /> {/* ~3KB client JS */}
<div dangerouslySetInnerHTML={{ __html: chartSvg }} />
<ShareButton url={data.url} /> {/* ~1KB client JS */}
</div>
);
}
// Total client JS: ~4KB instead of ~200KB+
Common Migration Patterns
Converting useEffect Data Fetching to Async Props
Before (client-side fetching):
'use client';
import { useState, useEffect } from 'react';
export default function Dashboard({ userId }) {
const [stats, setStats] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}/stats`)
.then(r => r.json())
.then(setStats);
}, [userId]);
if (!stats) return <Spinner />;
return <StatsGrid stats={stats} />;
}
After (RSC with streaming):
// No 'use client'
import { Suspense } from 'react';
export default async function Dashboard({ userId, getReactOnRailsAsyncProp }) {
return (
<Suspense fallback={<Spinner />}>
<AsyncStats getReactOnRailsAsyncProp={getReactOnRailsAsyncProp} />
</Suspense>
);
}
// Separate async server component
async function AsyncStats({ getReactOnRailsAsyncProp }) {
const stats = await getReactOnRailsAsyncProp('user_stats');
return <StatsGrid stats={stats} />;
}
<%%= stream_react_component_with_async_props(
"Dashboard",
props: { userId: @user.id },
prerender: true
) do |emit|
stats = UserStats.compute(@user.id)
emit.call("user_stats", stats)
end %>
Handling Components That Need Both Server and Client Behavior
Sometimes a component needs server-side rendering for heavy computation but client-side interactivity for user actions. Split it into two:
// ServerRenderedChart.tsx — No 'use client' (server component)
import { ChartLibrary } from 'heavy-chart-lib'; // 500KB, server-only
interface Props {
data: number[];
}
export function ServerRenderedChart({ data }: Props) {
const svg = ChartLibrary.renderToSVG(data);
return (
<div>
<div dangerouslySetInnerHTML={{ __html: svg }} />
<ChartControls data={data} />
</div>
);
}
// ChartControls.tsx — 'use client' (client component)
'use client';
import { useState } from 'react';
export function ChartControls({ data }: { data: number[] }) {
const [zoom, setZoom] = useState(1);
// Lightweight client-side interactions only
return (
<div>
<button onClick={() => setZoom(z => z * 1.2)}>Zoom In</button>
<button onClick={() => setZoom(z => z / 1.2)}>Zoom Out</button>
</div>
);
}
Verifying Your Migration
Check Bundle Sizes in DevTools
The most tangible benefit of RSC is measurable in the browser's Network tab:
- Before RSC: Open DevTools Network tab, filter by JS. Note the total JavaScript transferred.
- After RSC: Same page with RSC. The server-only libraries should be completely absent from the JS waterfall.
For example, a blog page using marked (50KB) and highlight.js (300KB+):
- Client component version: ~350KB+ of rendering libraries in the JS payload
- RSC version: 0KB of those libraries — only the tiny interactive components (~2-3KB)
Verify Streaming Behavior
- Open the Network tab and watch the HTML response
- The initial response arrives immediately with Suspense fallbacks
- Additional
<script>tags stream in as eachemit.callresolves - React swaps the fallbacks with real content — no full-page re-render
Check Server Component Isolation
In the browser console, try accessing a server-only module:
// This should NOT be available in the browser:
import('highlight.js').then(m => console.log(m));
// If RSC is working correctly, this import will fail or not exist
// in the client bundle at all
Troubleshooting
"Module not found" in RSC Bundle
If the RSC bundle fails to compile with module resolution errors, ensure the conditionNames includes 'react-server':
// rscWebpackConfig.js
rscConfig.resolve.conditionNames = ['react-server', '...'];
The '...' is important — it tells webpack to also check the default conditions after checking react-server.
Hydration Mismatches
If you see hydration errors after migrating to RSC, the most common cause is a component that uses browser APIs without 'use client'. Check for:
window,document,localStoragereferencesuseEffect,useState, or other hooks- Event handler props (
onClick,onChange)
All of these require the 'use client' directive.
SWC Loader Compatibility
When using SWC (the default transpiler in Shakapacker), the RSC WebpackLoader must run after SWC compilation. Shakapacker configures SWC as a function-based loader, not an array:
// Shakapacker sets: rule.use = (info) => ({ loader: 'swc-loader', options })
// NOT: rule.use = [{ loader: 'swc-loader', options }]
// This is why the RSC loader must be a SEPARATE rule with enforce: 'post'
// rather than being appended to the existing SWC rule
rscConfig.module.rules.push({
test: /\.(ts|tsx|js|jsx|mjs)$/,
enforce: 'post',
loader: 'react-on-rails-rsc/WebpackLoader',
});
Performance: Script Loading Strategy
For streaming pages, use async (not defer) for script loading. The defer attribute delays execution until the entire HTML document is parsed — which defeats the purpose of streaming, since streamed chunks arrive after the initial HTML.
# config/initializers/react_on_rails.rb
ReactOnRails.configure do |config|
# Use :async for streaming pages (default in Shakapacker >= 8.2.0)
config.generated_component_packs_loading_strategy = :async
end
Node Renderer Context
If server components crash with ReferenceError: performance is not defined, add missing globals to the Node renderer's VM context:
// node-renderer.js
const config = {
additionalContext: {
URL,
AbortController,
performance, // Required by React.lazy() in development
},
};
Summary
Migrating to RSC with React on Rails is an incremental process:
- Enable RSC support — Configure the three-bundle architecture with webpack and Rails initializers
- Add
'use client'to existing components — Mark all interactive components explicitly - Convert content-heavy pages — Move rendering-only pages to server components
- Add streaming for slow data — Use
stream_react_component_with_async_propswith Suspense - Measure the impact — Check bundle sizes and streaming behavior in DevTools
The key mental model: server components are the default, client components are the opt-in exception. Push interactivity to the leaves of your component tree, and let the server handle everything else. Your users get faster page loads, less JavaScript to parse, and a progressively-rendered experience — all while your Rails backend remains the single source of truth for data.