+ {recentComments.map((comment) => {
+ // Render markdown on the server using marked + sanitize-html.
+ // sanitize-html strips any dangerous HTML before rendering.
+ // These libraries (combined ~200KB) never reach the client.
+ 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}
+
+ );
+ })}
+
+ {recentComments.length} comment{recentComments.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/ServerInfo.jsx b/client/app/bundles/server-components/components/ServerInfo.jsx
new file mode 100644
index 000000000..4475eb826
--- /dev/null
+++ b/client/app/bundles/server-components/components/ServerInfo.jsx
@@ -0,0 +1,58 @@
+// Server Component - uses Node.js os module, which only exists on the server.
+// This component and its dependencies are never sent to the browser.
+
+import React from 'react';
+import os from 'os';
+import _ from 'lodash';
+
+async function ServerInfo() {
+ const serverInfo = {
+ platform: os.platform(),
+ arch: os.arch(),
+ nodeVersion: process.version,
+ uptime: Math.floor(os.uptime() / 3600),
+ totalMemory: (os.totalmem() / (1024 * 1024 * 1024)).toFixed(1),
+ freeMemory: (os.freemem() / (1024 * 1024 * 1024)).toFixed(1),
+ cpus: os.cpus().length,
+ hostname: os.hostname(),
+ };
+
+ // Using lodash on the server — this 70KB+ library stays server-side
+ const infoEntries = _.toPairs(serverInfo);
+ const grouped = _.chunk(infoEntries, 4);
+
+ const labels = {
+ platform: 'Platform',
+ arch: 'Architecture',
+ nodeVersion: 'Node.js',
+ uptime: 'Uptime (hrs)',
+ totalMemory: 'Total RAM (GB)',
+ freeMemory: 'Free RAM (GB)',
+ cpus: 'CPU Cores',
+ hostname: 'Hostname',
+ };
+
+ return (
+
+
+
+ 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
+
+
+
+
+
+ {/* Async data fetching with Suspense streaming */}
+
+
+ Streamed Comments
+
+ Async + Suspense
+
+
+
+ Comments are fetched directly on the server using the Rails API.
+ The page shell renders immediately while this section streams in progressively.
+
+
+ {[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..cecf0a958 100644
--- a/client/app/packs/stores-registration.js
+++ b/client/app/packs/stores-registration.js
@@ -1,3 +1,5 @@
+'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/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);