+ {comments.map((comment) => {
+ // marked + sanitize-html (~200KB combined) stay server-side.
+ const rawHtml = marked.parse(comment.text || '');
+ const safeHtml = sanitizeHtml(rawHtml, {
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
+ allowedAttributes: {
+ ...sanitizeHtml.defaults.allowedAttributes,
+ img: ['src', 'alt', 'title', 'width', 'height'],
+ },
+ allowedSchemes: ['https', 'http'],
+ });
+
+ return (
+
+
+ {comment.author}
+
+ {new Date(comment.created_at).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+ {/* Content is sanitized via sanitize-html before rendering */}
+ {/* eslint-disable-next-line react/no-danger */}
+
+
+
{comment.text}
+
+ );
+ })}
+
+ {comments.length} comment{comments.length !== 1 ? 's' : ''} rendered on the server using{' '}
+ marked + sanitize-html (never sent to browser)
+
+
+ );
+}
+
+export default CommentsFeed;
diff --git a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx
new file mode 100644
index 000000000..0c7165339
--- /dev/null
+++ b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx
@@ -0,0 +1,96 @@
+'use client';
+
+import React, { useState, Suspense } from 'react';
+import { ErrorBoundary } from 'react-error-boundary';
+import RSCRoute from 'react-on-rails-pro/RSCRoute';
+import { useRSC } from 'react-on-rails-pro/RSCProvider';
+
+// Same shape and dimensions as the rendered LiveActivity card. Local Suspense
+// fallback prevents the RSCRoute suspension from bubbling to an outer
+// boundary, which would collapse the whole page during in-flight fetches.
+const ActivityCardSkeleton = () => (
+
+
+
+ React Server Components Demo
+
+
+ This page is rendered using React Server Components with React on Rails Pro.
+ Server components run on the server and stream their output to the client, keeping
+ heavy dependencies out of the browser bundle entirely.
+
+
+
+
+ {/* Server Info - uses Node.js os module (impossible on client) */}
+
+
+ Server Environment
+
+ Server Only
+
+
+
+
+
+ {/* Interactive toggle - demonstrates mixing server + client components */}
+
+
+ Interactive Client Component
+
+ Client Hydrated
+
+
+
+
+
+ This toggle is a 'use client' component, meaning it ships JavaScript
+ to the browser for interactivity. But the content inside is rendered on the server
+ and passed as children — a key RSC pattern called the donut pattern.
+
+
+ - The
TogglePanel wrapper runs on the client (handles click events)
+ - The children content is rendered on the server (no JS cost)
+ - Heavy libraries used by server components never reach the browser
+
+
+
+
+
+ {/* Client-fetched server component via RSCRoute + ErrorBoundary */}
+
+
+ Live Server Activity
+
+ RSCRoute + ErrorBoundary
+
+
+
+ Click Refresh to fetch a new RSC payload — the server re-renders
+ this section and streams the result back, no client-side JSON parsing or loading
+ state plumbing. Click Simulate Error to make the server component
+ throw; the failure surfaces as ServerComponentFetchError and is
+ caught by <ErrorBoundary>, which renders a Retry button that
+ calls refetchComponent with corrected props.
+
+
+
+
+ {/* Async data fetching with Suspense streaming */}
+
+
+ Streamed Comments
+
+ Async + Suspense
+
+
+
+ Comments come from the Rails controller as props — the canonical React on Rails Pro
+ pattern. The page shell renders immediately while this section streams in
+ progressively as Suspense boundaries resolve.
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ }
+ >
+
+
+
+
+ {/* Architecture explanation */}
+
+
+ What makes this different?
+
+
+
+
Smaller Client Bundle
+
+ Libraries like lodash, marked, and Node.js os module
+ are used on this page but never downloaded by the browser.
+
+
+
+
Direct Data Access
+
+ Server components fetch data by calling your Rails API internally — no
+ client-side fetch waterfalls or loading spinners for initial data.
+
+
+
+
Progressive Streaming
+
+ The page shell renders instantly. Async components (like the comments feed)
+ stream in as their data resolves, with Suspense boundaries showing fallbacks.
+
+
+
+
Selective Hydration
+
+ Only client components (like the toggle above) receive JavaScript.
+ Everything else is pure HTML — zero hydration cost.
+
+
+
+
+
+
+ );
+};
+
+export default ServerComponentsPage;
diff --git a/client/app/packs/stimulus-bundle.js b/client/app/packs/stimulus-bundle.js
index 2664fee2b..07dedc01f 100644
--- a/client/app/packs/stimulus-bundle.js
+++ b/client/app/packs/stimulus-bundle.js
@@ -1,3 +1,5 @@
+'use client';
+
import ReactOnRails from 'react-on-rails-pro';
import 'jquery-ujs';
import { Turbo } from '@hotwired/turbo-rails';
diff --git a/client/app/packs/stores-registration.js b/client/app/packs/stores-registration.js
index d03732dc3..a069ac620 100644
--- a/client/app/packs/stores-registration.js
+++ b/client/app/packs/stores-registration.js
@@ -1,3 +1,6 @@
+// 'use client' keeps this pack and its store imports out of the RSC bundle.
+'use client';
+
import ReactOnRails from 'react-on-rails-pro';
import routerCommentsStore from '../bundles/comments/store/routerCommentsStore';
import commentsStore from '../bundles/comments/store/commentsStore';
diff --git a/config/initializers/react_on_rails_pro.rb b/config/initializers/react_on_rails_pro.rb
index 4e4f03890..fc5f39000 100644
--- a/config/initializers/react_on_rails_pro.rb
+++ b/config/initializers/react_on_rails_pro.rb
@@ -16,4 +16,8 @@
# so a blank env var (.env.example ships with `RENDERER_PASSWORD=`)
# falls back to the dev default, matching the JS side's `||`.
config.renderer_password = ENV["RENDERER_PASSWORD"].presence || "local-dev-renderer-password"
+
+ config.enable_rsc_support = true
+ config.rsc_bundle_js_file = "rsc-bundle.js"
+ config.rsc_payload_generation_url_path = "rsc_payload/"
end
diff --git a/config/routes.rb b/config/routes.rb
index 1d8c7b7a5..353819a32 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,12 +1,15 @@
# frozen_string_literal: true
Rails.application.routes.draw do
+ rsc_payload_route
+
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
# Serve websocket cable requests in-process
# mount ActionCable.server => '/cable'
root "pages#index"
+ get "server-components", to: "pages#server_components"
get "simple", to: "pages#simple"
get "rescript", to: "pages#rescript"
diff --git a/config/webpack/clientWebpackConfig.js b/config/webpack/clientWebpackConfig.js
index 6352208fb..ea5957c0f 100644
--- a/config/webpack/clientWebpackConfig.js
+++ b/config/webpack/clientWebpackConfig.js
@@ -1,6 +1,9 @@
// The source code including full typescript support is available at:
// https://github.com/shakacode/react_on_rails_tutorial_with_ssr_and_hmr_fast_refresh/blob/master/config/webpack/clientWebpackConfig.js
+const path = require('path');
+const { config } = require('shakapacker');
+const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin');
const commonWebpackConfig = require('./commonWebpackConfig');
const { getBundler } = require('./bundlerUtils');
@@ -22,6 +25,16 @@ const configureClient = () => {
// client config is going to try to load chunks.
delete clientConfig.entry['server-bundle'];
+ const clientReferencesDir = path.resolve(config.source_path || 'client/app');
+ clientConfig.plugins.push(
+ new RSCWebpackPlugin({
+ isServer: false,
+ clientReferences: [
+ { directory: clientReferencesDir, recursive: true, include: /\.(js|ts|jsx|tsx)$/ },
+ ],
+ }),
+ );
+
return clientConfig;
};
diff --git a/config/webpack/rscWebpackConfig.js b/config/webpack/rscWebpackConfig.js
new file mode 100644
index 000000000..6d0ac252c
--- /dev/null
+++ b/config/webpack/rscWebpackConfig.js
@@ -0,0 +1,53 @@
+const { default: serverWebpackConfig, extractLoader } = require('./serverWebpackConfig');
+
+const configureRsc = () => {
+ const rscConfig = serverWebpackConfig(true);
+
+ const rscEntry = {
+ 'rsc-bundle': rscConfig.entry['server-bundle'],
+ };
+ rscConfig.entry = rscEntry;
+
+ // Runs before babel/swc (webpack loaders execute right-to-left) to detect
+ // 'use client' directives in raw source before transpilation. Shakapacker
+ // generates rule.use as an array for Babel and as a function for SWC, so
+ // handle both forms.
+ const { rules } = rscConfig.module;
+ rules.forEach((rule) => {
+ if (typeof rule.use === '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)) {
+ const jsLoader = extractLoader(rule, 'babel-loader') || extractLoader(rule, 'swc-loader');
+ if (jsLoader) {
+ rule.use = [...rule.use, { loader: 'react-on-rails-rsc/WebpackLoader' }];
+ }
+ }
+ });
+
+ rscConfig.resolve = {
+ ...rscConfig.resolve,
+ conditionNames: ['react-server', '...'],
+ alias: {
+ ...rscConfig.resolve?.alias,
+ // RSC payload generation doesn't need react-dom/server; importing
+ // it in the react-server environment causes a runtime error.
+ 'react-dom/server': false,
+ },
+ };
+
+ rscConfig.output.filename = 'rsc-bundle.js';
+ return rscConfig;
+};
+
+module.exports = configureRsc;
diff --git a/config/webpack/webpackConfig.js b/config/webpack/webpackConfig.js
index 44327e8b1..b1783513a 100644
--- a/config/webpack/webpackConfig.js
+++ b/config/webpack/webpackConfig.js
@@ -3,6 +3,7 @@
const clientWebpackConfig = require('./clientWebpackConfig');
const { default: serverWebpackConfig } = require('./serverWebpackConfig');
+const rscWebpackConfig = require('./rscWebpackConfig');
const webpackConfig = (envSpecific) => {
const clientConfig = clientWebpackConfig();
@@ -22,11 +23,14 @@ const webpackConfig = (envSpecific) => {
// eslint-disable-next-line no-console
console.log('[React on Rails] Creating only the server bundle.');
result = serverConfig;
+ } else if (process.env.RSC_BUNDLE_ONLY) {
+ // eslint-disable-next-line no-console
+ console.log('[React on Rails] Creating only the RSC bundle.');
+ result = rscWebpackConfig();
} else {
- // default is the standard client and server build
// eslint-disable-next-line no-console
- console.log('[React on Rails] Creating both client and server bundles.');
- result = [clientConfig, serverConfig];
+ console.log('[React on Rails] Creating client, server, and RSC bundles.');
+ result = [clientConfig, serverConfig, rscWebpackConfig()];
}
// To debug, uncomment next line and inspect "result"
diff --git a/package.json b/package.json
index 1ae8c2c79..3f2801592 100644
--- a/package.json
+++ b/package.json
@@ -80,6 +80,7 @@
"prop-types": "^15.8.1",
"react": "~19.0.4",
"react-dom": "~19.0.4",
+ "react-error-boundary": "^4.1.2",
"react-intl": "^6.4.4",
"react-on-rails-pro": "16.6.0",
"react-on-rails-pro-node-renderer": "16.6.0",
diff --git a/renderer/node-renderer.js b/renderer/node-renderer.js
index a4cb3a347..b29a5d032 100644
--- a/renderer/node-renderer.js
+++ b/renderer/node-renderer.js
@@ -41,6 +41,17 @@ const config = {
// deps rely on during SSR. Without URL, react-router-dom's NavLink throws
// `ReferenceError: URL is not defined` via encodeLocation.
additionalContext: { URL, AbortController },
+ // RSC requires a real setTimeout. The renderer's default stubTimers:true
+ // replaces setTimeout with a no-op to prevent legacy SSR from leaking
+ // timers, but React's RSC server renderer uses setTimeout internally for
+ // Flight-protocol yielding — with it stubbed, the RSC stream silently
+ // emits zero chunks and hangs until the Fastify idle timeout fires.
+ stubTimers: false,
+ // Surface console output from async server-component code. Without this,
+ // `console.error` calls from within async Server Components (e.g.
+ // CommentsFeed's catch block) are silently dropped by the VM, making
+ // runtime failures in RSC components invisible.
+ replayServerAsyncOperationLogs: true,
};
reactOnRailsProNodeRenderer(config);
diff --git a/spec/requests/server_components_spec.rb b/spec/requests/server_components_spec.rb
new file mode 100644
index 000000000..5527c218f
--- /dev/null
+++ b/spec/requests/server_components_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe "Server Components" do
+ it "GET /server-components returns the demo page shell" do
+ get "/server-components"
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("React Server Components Demo")
+ end
+
+ describe "RSC payload endpoint" do
+ def parsed_chunks
+ response.body.each_line.filter_map do |line|
+ stripped = line.strip
+ next if stripped.empty?
+
+ JSON.parse(stripped)
+ end
+ end
+
+ def expect_valid_rsc_payload
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq("application/x-ndjson")
+ chunks = parsed_chunks
+ expect(chunks).not_to be_empty
+ expect(chunks.any? { |chunk| chunk.key?("html") }).to be(true)
+ end
+
+ it "streams a valid RSC payload for ServerComponentsPage" do
+ get "/rsc_payload/ServerComponentsPage", params: { props: "{}" }
+ expect_valid_rsc_payload
+ end
+
+ it "streams a valid RSC payload for ServerComponentsPage with populated comments" do
+ now = 1.minute.ago.iso8601
+ comments = [
+ { id: 1, author: "Alice", text: "Hello **markdown**", created_at: now, updated_at: now },
+ ]
+ get "/rsc_payload/ServerComponentsPage", params: { props: { comments: comments }.to_json }
+ expect_valid_rsc_payload
+ end
+
+ it "streams a valid RSC payload for LiveActivity" do
+ get "/rsc_payload/LiveActivity", params: { props: "{}" }
+ expect_valid_rsc_payload
+ end
+ end
+end
diff --git a/spec/system/server_components_demo_spec.rb b/spec/system/server_components_demo_spec.rb
new file mode 100644
index 000000000..6361fc680
--- /dev/null
+++ b/spec/system/server_components_demo_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe "Server Components demo" do
+ before { visit "/server-components" }
+
+ it "renders the four demo sections" do
+ expect(page).to have_selector("h2", text: "Server Environment")
+ expect(page).to have_selector("h2", text: "Interactive Client Component")
+ expect(page).to have_selector("h2", text: "Live Server Activity")
+ expect(page).to have_selector("h2", text: "Streamed Comments")
+ end
+
+ it "shows server-side data in ServerInfo" do
+ expect(page).to have_content("Platform")
+ expect(page).to have_content("Architecture")
+ expect(page).to have_content("Node.js")
+ expect(page).to have_content("CPU Cores")
+ end
+
+ describe "Live Server Activity (RSCRoute)" do
+ it "shows the initial activity card with the live stats labels" do
+ expect(page).to have_content("SERVER TIME")
+ expect(page).to have_content("FREE RAM")
+ expect(page).to have_content("UPTIME (HRS)")
+ expect(page).to have_content("Refresh count: 0")
+ end
+
+ it "updates content when Refresh is clicked" do
+ click_button "Refresh"
+ expect(page).to have_content("Refresh count: 1")
+ end
+
+ it "shows the ErrorBoundary fallback when Simulate Error is clicked, then recovers on Retry" do
+ click_button "Simulate Error"
+ expect(page).to have_content("Server component fetch failed")
+
+ click_button "Retry"
+ expect(page).to have_content("SERVER TIME")
+ expect(page).to have_no_content("Server component fetch failed")
+ end
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index 833bca340..d1e20864a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8745,6 +8745,13 @@ react-dom@~19.0.4:
dependencies:
scheduler "^0.25.0"
+react-error-boundary@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz#bc750ad962edb8b135d6ae922c046051eb58f289"
+ integrity sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==
+ dependencies:
+ "@babel/runtime" "^7.12.5"
+
react-intl@^6.4.4:
version "6.8.9"
resolved "https://registry.npmjs.org/react-intl/-/react-intl-6.8.9.tgz"