How RSC Works
With Rails
A visual guide to how React Server Components stream data from your Rails backend to the browser — step by step.
Rails Prepares the Data
Your controller fetches lightweight metadata as sync props. Heavy data is deferred to the streaming block — it runs concurrently in a background thread while the page shell streams.
def show_rsc
@product = find_product
# Only lightweight metadata as sync props
# (~2KB instead of ~28KB)
@data = serialize(@product)
.except(:description, :specs)
stream_view_containing_react_components(
template: "products/show_rsc"
)
end
<%= stream_react_component_with_async_props(
"ProductPageRSC",
props: { product: @data }
) do |emit|
# Background thread — streams while
# browser already renders the shell
emit.call("details", @product.details)
emit.call("reviews", @product.reviews)
emit.call("related", @product.related)
end %>
What happens at this step
Controller fetches the product and serializes only the essential metadata (name, price, images) as sync props. Heavy data (description, reviews, related) is excluded.
stream_view_containing_react_components starts an HTTP streaming response — the browser receives the first bytes immediately.
The do |emit| block runs in a background thread. Each emit.call sends a named data chunk to the Node renderer as soon as its query completes.
React Renders on the Server
Server Components receive sync props instantly and render the page shell. Async components await streamed data from Rails and render as it arrives.
// Server component — no 'use client'
export default function ProductPageRSC(
{ product, getReactOnRailsAsyncProp }
) {
return (
<>
<Hero product={product} />
<Suspense fallback={<Skeleton />}>
<AsyncDetails {...props} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<AsyncReviews {...props} />
</Suspense>
</>
);
}
// Server component — async
// Runs on server, 0 JS to client
export default async function AsyncReviews(
{ getReactOnRailsAsyncProp: get }
) {
// Resolves when Rails calls
// emit.call("reviews", data)
const { reviews } = await get('reviews');
return <ReviewList reviews={reviews} />;
}
// Suspense shows <Skeleton /> until
// this component's await resolves
What happens at this step
The Node renderer receives sync props and starts rendering ProductPageRSC. The Hero section renders instantly with product metadata.
React hits <Suspense> boundaries. Each async component calls getReactOnRailsAsyncProp which returns a Promise that resolves when Rails emits the data.
React renders the skeleton fallbacks for each Suspense boundary and streams the shell HTML to the browser. The user sees the page structure immediately.
HTML Streams to the Browser
As each emit.call fires in Rails, the corresponding Suspense boundary resolves. React streams the real content as an HTML chunk that replaces the skeleton in-place.
The key insight
Each emit.call in Rails directly resolves a getReactOnRailsAsyncProp Promise in React. The prop name must match exactly:
Three Rendering Modes
React on Rails Pro supports all three approaches. Choose per page, migrate incrementally.
Full SSR
All data fetched on server. Full HTML returned at once. All JS shipped to client for hydration.
Client + Loadable
Code-split with @loadable/component. SSR critical parts, lazy-load the rest on client.
RSC Streaming
RECOMMENDEDServer components stream progressively. Only interactive JS ships to client. Zero-cost rendering.
Server vs Client Boundary
Most of your component tree stays on the server. Only interactive islands — buttons, filters, forms — ship JavaScript to the browser.
7 of 9 components never ship JS to the browser
Only ImageGallery and AddToCart ship interactive code
Markdown, charts, date formatting stay server-side
Start Building with RSC
See the performance difference yourself. React on Rails Pro makes RSC work seamlessly with your Rails app.