UniversalRenderer
Server-Side Rendering for Rails apps with a React front end.
Overview
UniversalRenderer runs your React app in a small Node/Bun service and splices the
result into your Rails layout. It is two halves of one contract: the
universal_renderer gem talks to the universal-renderer NPM package.
The design assumption is retrofitting: you have a working client-rendered app, you want the first paint to be server-rendered, and you cannot afford for SSR to become a source of outages. So every failure path — service down, timeout, 500, not configured — falls back to client-side rendering silently, and the instrumentation exists so "silently" does not mean "invisibly".
Version compatibility
The two packages share a wire format, so upgrade them together.
| Gem | NPM package | Notes |
|---|---|---|
0.8.x |
0.8.x |
Puma plugin replaces bin/web; self-contained, production SSR builds |
0.7.x |
0.7.x |
payload, conditional enable_ssr, concurrency, prepare
|
0.5.x |
0.6.x |
Installation
# Gemfile
gem "universal_renderer"bundle install
bun add universal-renderer # or npm / yarn
bin/rails generate universal_renderer:installThe generator writes the initializer, the two renderer entry points, the SSR Vite
build, and the assets:precompile hook, and adds plugin :universal_renderer to
config/puma.rb to run the renderer in production. Pass
--frontend-dir=app/client if your JavaScript is not in app/frontend.
Rendering from a controller
There are two ways in, and they are equally supported.
Declarative, when the whole action is server-rendered:
class ArticlesController < ApplicationController
enable_ssr only: :show, unless: -> { current_user.present? }
def show
@article = Article.find(params[:id])
add_query_data(["articles", @article.slug], article_json)
render "common/js_only"
end
endenable_ssr takes only:, except:, if:, and unless: (Symbol or Proc,
evaluated against the controller), plus streaming: true.
Imperative, when the decision or the data depends on request state:
def show
@article = Article.find(params[:id])
return render("common/js_only") unless public_view?
add_query_data(["articles", @article.slug], article_json)
add_prop(:feature_flags, enabled_flags)
render_ssr
render "common/js_only"
endrender_ssr fetches once, memoizes, and returns the payload — or nil, which is
your signal to let the client-rendered path stand. It works whether or not the
controller called enable_ssr.
Props
| Method | Effect |
|---|---|
add_prop(key, value) |
Sets one prop. Also takes a hash. |
push_prop(key, value) |
Appends to an array prop. |
add_query_data(key, data) |
Adds a React Query cache entry under the react_query prop. |
ssr_props |
The accumulated hash, if you need to inspect or merge it. |
Rendering in the layout
<head>
<%= ssr_head %>
<%= ssr_payload %>
</head>
<body class="app" <%= ssr_body_attributes %>>
<div id="root"><%= ssr_body %></div>
<%# Server-rendered pages hydrate; everything else boots a client render. %>
<%= vite_typescript_tag ssr? ? "hydrate.tsx" : "application.tsx" %>
</body>Every helper is a no-op when there is nothing to emit, so the layout needs no
conditionals. ssr? is there for the decisions that are not about emitting HTML —
picking an entry point, skipping a preload — and it never requires reading an
instance variable. It is true for streamed pages too, which are server-rendered
even though the payload arrives after the layout.
ssr_payload emits whatever your render callback returned as payload, as an
inert <script type="application/json">, escaped. That is the channel for
hydration state, because the interesting state is only known after the render: a
dehydrated query cache, the class names a CSS-in-JS library already wrote into
head. Do not hand-roll a script tag inside head.
Configuration
UniversalRenderer.configure do |c|
c.url = ENV.fetch("UNIVERSAL_RENDERER_URL", "http://localhost:3001")
c.timeout = 3
c.stream_path = "/stream" # must match the Node side's `paths`
c.http.pool_size = 5
endThe gem never reads ENV itself; bind whatever keys you like in the initializer.
The suggested prefix is UNIVERSAL_RENDERER_*.
| Option | Default | Notes |
|---|---|---|
url |
nil |
Blank disables SSR entirely. |
timeout |
3 |
Open and read timeout, seconds. |
render_path |
nil |
nil uses the path already in url. |
stream_path |
"/stream" |
Must match the Node side. |
sanitize |
true |
See below. |
scrubber |
Scrubber.new |
Any Loofah::Scrubber. |
auto_include |
true |
false to include Renderable per controller instead. |
on_error |
nil |
->(error, context) { ... } |
Sanitization
ssr_head and ssr_body run the render through Loofah by default.
Be clear about what that buys you. The bundled scrubber is a blocklist, and
a blocklist over the whole HTML grammar cannot be a boundary against
attacker-controlled markup: the sanitizer and the browser have to agree on how
the document parses, and elements that switch parsing context are how they stop
agreeing. The scrubber removes the ones that are known to do this (noscript,
the MathML integration points) and pins them with tests, but treat it as defense
in depth over HTML your own renderer produced — escape untrusted data inside
the render, which React already does unless you reach for
dangerouslySetInnerHTML.
It also parses and rewrites the whole document on the Rails side of every
request, which eats into the latency SSR is meant to buy. c.sanitize = false
is a reasonable trade once you are confident about what the renderer emits. It
defaults to on because the cost is bounded and the mistake it catches is not.
Observability
Every failed render is a silent fallback, so without a signal you cannot tell a healthy renderer from one that has been down for a week:
ActiveSupport::Notifications.subscribe("render.universal_renderer") do |event|
StatsD.timing("ssr.duration", event.duration,
tags: ["outcome:#{event.payload[:outcome]}"])
end
c.on_error = ->(error, context) { Sentry.capture_exception(error, extra: context) }outcome is one of :ok, :not_configured, :http_error, :timeout, :error.
The notification fires even when no renderer URL is configured; on_error is
reserved for failures after a configured render is attempted.
The renderer
The render config
One module holds the whole render (app/frontend/ssr/config.ts by convention).
Both entry points load it.
import { dehydrate } from "@tanstack/react-query";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router";
import { ServerStyleSheet } from "styled-components";
import type { SsrConfig } from "universal-renderer";
import { hydrateReactQuery } from "universal-renderer/react-query";
import { setBrowserLocation } from "./globals";
import App from "@/App";
import { preloadRoute, queryClient } from "@/App";
export default {
setup: async (url, props) => {
const { pathname, search } = new URL(url);
setBrowserLocation(url);
queryClient.clear();
hydrateReactQuery(props, queryClient);
await preloadRoute(pathname);
const sheet = new ServerStyleSheet();
const app = sheet.collectStyles(
<StaticRouter location={`${pathname}${search}`}>
<App />
</StaticRouter>,
);
return { app, sheet, props, state: dehydrate(queryClient) };
},
prepare: (context) => {
context.previousFlags = { ...FEATURE_FLAGS };
Object.assign(FEATURE_FLAGS, context.props.feature_flags);
},
render: ({ app, sheet, state }) => ({
body: renderToString(app),
head: sheet.getStyleTags(),
payload: { state },
}),
cleanup: ({ sheet, previousFlags }) => {
Object.assign(FEATURE_FLAGS, previousFlags);
sheet.seal();
queryClient.clear();
},
} satisfies SsrConfig<any>;The four hooks are not arbitrary:
-
setupis async and must not touch module-level state. It awaits things — a lazy route chunk, a fetch — and a mutation made before an await point stays visible for as long as the await lasts. -
prepareis sync and runs immediately before the render, with no await in between. This is where shared singletons get mutated. -
renderproduces the HTML and the hydration payload. -
cleanupalways runs, and runs before the next render starts. Undopreparehere.
Concurrency
Renders are serialized by default. An app retrofitted with SSR keeps request-scoped state in module-level singletons — a store, a query client, a mutable feature-flag object, a CSS-in-JS registry — and two renders interleaving through those is not a slow page, it is one visitor's data in another visitor's HTML. Scale out with more renderer processes; the Puma plugin starts one per web server, so SSR capacity tracks your web dyno count.
Raise concurrency (or pass "unbounded") only once you have verified the render
touches no shared mutable state.
The renderer admits at most ten waiting requests per concurrency slot by
default. It returns 503 when that queue is full and removes requests that
disconnect while waiting, so a Rails timeout cannot leave stale renders ahead
of live traffic. Configure queueLimit only if the default does not fit your
traffic and render latency.
Render timeout and health
Serializing renders has a cost worth naming: a slot is never taken back from a
running render — it is still touching module state, and handing that slot on is
the interleaving concurrency exists to prevent — so one render that never
settles ends the renderer. At concurrency: 1 the queue fills, everything
after it gets 503, and Rails falls back to client rendering indefinitely.
Two separate settings bound it, because "over budget" and "never finishing" are different questions:
-
renderTimeout(default2500ms,falseto disable) answers the caller with504instead of hanging. For a streaming render it caps time to first byte, not the whole response. -
stallAfterMs(default30000ms,falseto disable) is how long one render may hold its slot beforeGET /healthreturns503with{ status: "STALLED", renders: {...} }.
Do not collapse these into one value. A streaming response holds its slot until
its last chunk, so a stall threshold near renderTimeout reports perfectly
healthy streams as stalled and invites a supervisor to restart the renderer
mid-response. Raise stallAfterMs above your slowest legitimate stream; the 30s
default is also roughly where Heroku's router and most ALB defaults have already
abandoned the request.
Nothing inside the process can clear a stuck render, so the health signal is the point: the Puma plugin polls it and restarts the renderer without touching the app server.
Development sets both to false — a breakpoint in the render outlasts any
production budget, and a 504 or a restart there is noise.
Keep c.timeout above renderTimeout. The defaults are 3s and 2.5s, so
the renderer gives up before Rails falls back. Disconnected requests are dropped
from the queue, but a render already running cannot be taken back; preserve that
ordering when tuning either timeout.
Entry points
Production runs the prebuilt bundle:
// app/frontend/ssr/server.ts
import "./globals";
const { default: config } = await import("./config");
const { startServer } = await import("universal-renderer");
await startServer(config);Development loads the config through Vite, so the app graph gets the same transforms the client dev server gives it and edits need no rebuild:
// app/frontend/ssr/dev.ts
import "./globals";
const { startDevServer } = await import("universal-renderer/dev");
await startDevServer({ entry: "app/frontend/ssr/config.ts" });These must not be the same file. A Vite dev server transforming modules per render is the single largest cost in the SSR path.
Note the dynamic imports. The app graph touches browser globals while its
modules evaluate, and static imports are all evaluated before the entry body runs,
so ./globals would land too late.
Browser globals
renderToString does not run effects, so useEffect is safe. It does still
evaluate every module in the graph and every render body, and a client-first app
reaches for window, document, localStorage, or navigator in both — a
singleton assigning window.myThing at module scope, a component reading
window.innerWidth while rendering. None of that survives under Node, and none
of it is React's problem to solve.
The package does not ship a shim for this, deliberately. Which globals a graph
touches is a property of that graph, not of SSR, so a library version would be
guesses: too small to boot your app and too large to reason about. The generator
scaffolds app/frontend/ssr/globals.ts in your app instead, as a starting point
you own, edit, and delete if it turns out you need none of it. It carries the
reasoning, including the two things that bite:
- Defining
windowmakestypeof window === "undefined"false process-wide. That check is how libraries detect a server, so they all take the browser path. Prefer fixing the module that reaches for the DOM. - Some libraries decide once, at module-evaluation time, whether they are in a
browser (
typeof window !== "undefined" ? null : {...}). Imported after your globals, such a library loses its server API for good.
The SSR build
// vite.config.ssr.mts
import react from "@vitejs/plugin-react";
import { defineSsrConfig } from "universal-renderer/vite";
export default defineSsrConfig({
entry: "app/frontend/ssr/server.ts",
plugins: [react()],
});defineSsrConfig exists because a Rails SSR build has six settings that are each
wrong by default and each fail silently — you get a bundle, it just renders the
wrong thing. Note in particular what is absent: vite-plugin-rails, which is
built for the client manifest pipeline and overrides entrypoints and outDir. List
only the plugins the render itself needs; your client build keeps using
vite.config.mts unchanged. See the function's docs for the others.
The bundle lands at ssr-build/server.mjs, deliberately outside public/ — it is
server code and must not be web-servable. It is self-contained: dependencies are
bundled (ssr.noExternal: true), so it runs from a slug whose node_modules was
pruned after the build, and process.env.NODE_ENV is replaced at build time, as
Vite does for client builds (the NODE_ENV the build runs with, or
"production"), so React does not run its development build on a host that never
sets NODE_ENV. If the renderer does start on a development build, startServer
logs a warning saying so.
Keep client-only packages that cannot be bundled, such as anything with a native
module, out with ssr.external:
export default defineSsrConfig({
entry: "app/frontend/ssr/server.ts",
plugins: [react()],
// Lazy, client-only, and its `canvas` dependency is native.
ssr: { external: ["react-pdf", "pdfjs-dist"] },
});Running it
# Procfile.dev
web: bin/rails s
vite: bin/vite dev
ssr: bun app/frontend/ssr/dev.tsIn production the renderer runs beside your app server on the same host rather than as a separate process type, because PaaS process types get no routable address for each other. If the renderer dies, requests fall back to client rendering; that is a degraded page, not an outage, so it must not take the app server down. Something still has to bring the renderer back.
With Puma
# config/puma.rb
if ENV.fetch("RAILS_ENV", "development") == "production"
plugin :universal_renderer
universal_renderer port: 3001 # optional, after the plugin line
endYour Procfile stays web: bundle exec puma -C config/puma.rb. Keep the plugin to
production: in development, Procfile.dev runs the development renderer on the
same port.
Puma runs plugins in its master process, so a clustered Puma gets one renderer per server, not one per worker. The plugin:
- starts the renderer in its own process group, so a wrapper script cannot leave a child holding the port;
- restarts it when it exits, waiting 1, 2, 4, up to 30 seconds while it keeps dying at boot, and 1 second again once it has run for a minute;
- restarts it when
/healthfails three checks in a row, 10 seconds apart (a stalled render, a blocked event loop); - stops it with Puma, and replaces it on a hot (
USR2) or phased (USR1) restart, so a phased restart also picks up a new bundle; - skips it, with a log line, when the bundle has not been built.
Each start, exit and restart is logged with a [universal_renderer] prefix. Each
option falls back to an environment variable, then to the default:
| Option | Variable | Default | Purpose |
|---|---|---|---|
runtime |
SSR_RUNTIME |
bun |
Set to node if not Bun. |
bundle |
SSR_BUNDLE |
ssr-build/server.mjs |
Built renderer entry. |
port |
SSR_PORT |
3001 |
Passed to the renderer as SSR_PORT. |
Other app servers
Run bun ssr-build/server.mjs beside the app server under your own process
supervisor, and restart it when it exits or GET /health stops returning 200.
Sorbet
The engine includes Renderable into ActionController::Base through
ActiveSupport.on_load, which happens at runtime and so is invisible to Sorbet.
Add a shim, or every render_ssr / add_prop call is an undefined-method error:
# sorbet/rbi/shims/universal_renderer.rbi
# typed: true
class ActionController::Base
include UniversalRenderer::Renderable
endStreaming
Streaming is opt-in per controller:
enable_ssr streaming: trueThe layout emits <!-- SSR_HEAD --> / <!-- SSR_BODY --> markers (ssr_head
and ssr_body do this for you), Rails posts the rendered layout to the SSR
service as template, and the service streams the substituted document back.
Provide streamCallbacks in the render config to enable the endpoint. A failed
stream falls back to a normal blocking render.
Development
bundle install && bun install
bundle exec rspec # gem
bundle exec rubocop
cd universal-renderer && bun run test && bunx tsc --noEmitLicense
MIT