docs/oss/migrating/rsc-static-shell-sidecar.md
Use this pattern when a public page can render almost all useful content as static RSC HTML but still needs a few browser behaviors. The goal is to keep the static shell and CSS on the critical path while loading only a small, explicit browser entry for progressive enhancement.
Part 10 of the RSC Migration Series | Previous: RSC Performance Validation Playbook
Use a mostly static shell plus tiny sidecar when:
Do not use it when:
Rails view
|-- renders static RSC component output
|-- appends explicit static shell CSS
|-- opts out of selected global JavaScript packs
|-- emits inert JSON props/context for the sidecar
`-- appends a tiny sidecar JavaScript entry
StaticPublicPage
`-- renders layout, chrome, and page content without browser-only hooks
public-page-effects sidecar
|-- parses JSON props/context
|-- handles URL-driven effects immediately when needed
|-- listens for user intent on static placeholders
`-- lazy-imports React/client islands only on demand
The names above are placeholders. In an app, use page-specific names such as StaticPublicPage,
PublicPageEffects, or public-page-effects rather than copying names from another product.
Until a first-class helper exists, use an explicit Rails layout convention. Keep global CSS and layout markup intact; skip only the selected JavaScript pack on pages that opt in. The broader Pro layout pattern is documented in Page-Level Global JavaScript Opt-Out for Static Shells, and the public follow-up is #4297.
<%# app/views/layouts/application.html.erb %>
<% unless content_for?(:skip_global_javascript) %>
<% append_javascript_pack_tag "global" %>
<% end %>
<%= javascript_pack_tag defer: true %>
<%# app/views/public/home.html.erb %>
<% content_for :skip_global_javascript, "true" %>
<% append_javascript_pack_tag "public-page-effects" %>
Keep the contract narrow:
Avoid mounting a full React on Rails client component just to pass props to the sidecar. Emit inert JSON and let the sidecar parse it.
<script type="application/json" id="public-page-effects-props">
<%= raw json_escape(props.to_json) %>
</script>
The sidecar can also read a small Rails context script when it needs CSRF, locale, currency, or feature flags. Keep the payload serializable and page-specific.
function readJsonScript(id) {
const element = document.getElementById(id);
if (!element?.textContent) return {};
return JSON.parse(element.textContent);
}
const props = readJsonScript('public-page-effects-props');
const context = readJsonScript('public-page-effects-context');
Sidecar rules:
react-dom/client only when user intent or URL state requires it.Render a static placeholder or fallback UI in the RSC HTML. Attach lightweight listeners to it from the sidecar. On first user intent, lazy import the real client island, mount it, and replay the intent.
const searchTarget = document.querySelector('[data-public-search]');
const props = readJsonScript('public-page-effects-props');
let hydrating = false;
async function hydrateSearch(firstEvent) {
const [{ default: SearchIsland }, { createRoot }, React] = await Promise.all([
import('./SearchIsland'),
import('react-dom/client'),
import('react'),
]);
const root = createRoot(searchTarget);
root.render(React.createElement(SearchIsland, { props, firstEventType: firstEvent.type }));
}
async function onIntent(event) {
if (hydrating) return;
hydrating = true;
try {
await hydrateSearch(event);
searchTarget.removeEventListener('click', onIntent);
searchTarget.removeEventListener('focusin', onIntent);
searchTarget.removeEventListener('keydown', onKeydown);
} catch (error) {
console.error('Failed to load search island', error);
} finally {
hydrating = false;
}
}
function onKeydown(event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onIntent(event);
}
if (searchTarget) {
searchTarget.addEventListener('click', onIntent);
searchTarget.addEventListener('focusin', onIntent);
searchTarget.addEventListener('keydown', onKeydown);
}
Keep accessibility and fallback behavior explicit:
button/a, or add
tabindex="0" and an appropriate role to a non-interactive placeholder.A static RSC shell cannot rely on a skipped JavaScript pack to incidentally import required styles. Make CSS delivery explicit:
If the RSC page downloads unexpected CSS or JS through client references, check Chunk Contamination and RSC Stylesheet Injection. Use RSC Client Reference Diagnostics when you need a local asset report for the exact client-reference chunks emitted by the RSC plugin.
A tiny sidecar is ordinary browser JavaScript. It is not an RSC Client Component and it is not an RSC client reference. Sidecar success does not prove RSC client islands will hydrate.
Do not use this pattern as a reason to globally disable RSC client-reference discovery:
clientReferences = [] as a general app optimization.See
Client Reference Scope and Empty clientReferences,
react_on_rails_rsc#134, and
react_on_rails_rsc#145.
Audit the selected global pack before opting a page out:
Move required behavior into the sidecar or a smaller layout-owned script. Do not assume skipped global JavaScript is harmless just because the page still renders.
For each static shell page:
clientReferences was narrowed.Related work: #4295 tracks cached RSC output for static public pages, #4297 tracks the page-level global JavaScript opt-out, and #4299 tracks the performance validation playbook behind this guide.