Introducing the Universal Renderer gem

May 20, 2025

This post shows how I integrated Rails and React together, then extracted the approach into a gem called Universal Renderer.


How does this work?

A 30-second refresher on SSR

React can render an app to a string. Instead of shipping an empty index.html and waiting for the browser to build the UI, we pre-render that markup on the server and let the client “hydrate” it so event listeners attach and everything becomes interactive.

We therefore need only two primitives:

  1. Render a string on the server.

  2. Insert that string into a Rails view.

Because we’re still using ERB templates, we still get CSRF tags, Vite helpers, and other Rails niceties.

Props? React Query state? Pre-generated meta tags? All of the above.


Enter: Universal Renderer

Controllers

Add the concern to any controller you want to SSR. For the demo I’ll use ApplicationController:

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
respond_to :html, :json
enable_ssr # enables SSR controller-wide
before_action :add_current_user_to_props
before_action :add_initial_posts_to_props
private
def add_current_user_to_props
add_prop(:current_user, current_user.as_json)
end
def add_initial_posts_to_props
push_prop(:query_data, {
key: ["posts", "recent", 1],
data: { posts: PostSerializer.many(@posts) }
})
end
def default_render
render "ssr/index"
end
end

Why the helpers? add_prop sets a scalar prop. push_prop appends to an array-like prop, ideal for multiple React Query caches. The enable_ssr method enables SSR for the controller and provides automatic fallback to client-side rendering if SSR fails.

Example controller

class DashboardController < ApplicationController
def index
@posts = current_user.posts.not_archived.page(1).per(10)
push_prop(:query_data, {
key: ["posts", "recent", 1],
data: { posts: PostSerializer.many(@posts) }
})
fetch_ssr # fetch SSR on demand
end
end

Routes

Rails.application.routes.draw do
get "/dashboard", to: "dashboard#index"
root "application#index"
get "*path", to: "application#index"
end

React side:

import { Routes, Route } from "react-router";
const App = () => (
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/" element={<Home />} />
</Routes>
);
export default App;

Heads-up, for now you define routes in both Rails and React Router. Generating the React routes from Rails metadata is on the roadmap; PRs welcome.

Views

app/views/ssr/index.html.erb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<%= ssr_head %>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= vite_client_tag %>
<%= vite_typescript_tag "application.tsx" %>
</head>
<body>
<div id="root">
<%= ssr_body %>
</div>
</body>
</html>

The ssr_head and ssr_body helpers inject the SSR content returned from the server. When streaming is enabled, these render HTML placeholders; otherwise they output the sanitized HTML returned by the SSR service.


The server that does the side rendering

This is the piece people assume is scary-but it’s mostly glue code. Vite-Ruby looks for ssr/ssr.{js,ts,jsx,tsx}:

app/frontend/ssr/ssr.ts
import { renderToString } from "react-dom/server.node";
import { createServer } from "universal-renderer";
import { extractMeta } from "@/ssr/utils";
await createServer({
port: 3001,
setup: (await import("@/ssr/setup")).default,
render: ({ jsx, helmetContext, sheet }) => {
const body = renderToString(jsx);
const styles = sheet.getStyleTags();
const head = extractMeta(helmetContext);
return {
head,
body: `${styles}\n${body}`,
};
},
cleanup: ({ sheet, queryClient }) => {
sheet.seal();
queryClient.clear();
},
});

What’s happening? We call createServer and provide three hooks:

The server listens for requests from Rails and responds with rendered markup.

setup.tsx

import {
HelmetProvider,
type HelmetDataContext,
} from "@dr.pogodin/react-helmet";
import { dehydrate, QueryClient, QueryClientProvider } from "react-query";
import { StaticRouter } from "react-router";
import { ServerStyleSheet } from "styled-components";
import App from "@/App";
import Metadata from "@/components/Metadata";
export default function setup(url: string, props: any) {
const pathname = new URL(url).pathname;
const { query_data = [] } = props;
const helmetContext: HelmetDataContext = {};
const queryClient = new QueryClient();
const sheet = new ServerStyleSheet();
query_data.forEach(({ key, data }) => {
queryClient.setQueryData(key, data);
});
const state = dehydrate(queryClient);
const jsx = sheet.collectStyles(
<HelmetProvider context={helmetContext}>
<Metadata url={url} />
<QueryClientProvider client={queryClient}>
<StaticRouter location={pathname}>
<App />
</StaticRouter>
</QueryClientProvider>
<template id="state" data-state={JSON.stringify(state)} />
</HelmetProvider>
);
return { jsx, helmetContext, sheet, queryClient };
}

setup returns everything render and cleanup will need.

extractMeta

export function extractMeta(context: any) {
return [
context.helmet.title?.toString(),
context.helmet.priority?.toString(),
context.helmet.meta?.toString(),
context.helmet.link?.toString(),
context.helmet.script?.toString(),
context.helmet.style?.toString(),
].join("\n");
}

Simple helper: stringify everything Helmet collected so Rails can inline it.


Hydration (client-side)

We could let React re-render on the client, but that throws away the whole point of SSR. Instead we hydrate.

app/frontend/entrypoints/application.tsx
import { HelmetProvider } from "@dr.pogodin/react-helmet";
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { Hydrate, QueryClient, QueryClientProvider } from "react-query";
import App from "@/App";
import Metadata from "@/components/Metadata";
const stateEl = document.getElementById("state");
const state = JSON.parse(stateEl?.dataset.state ?? "{}");
stateEl?.remove();
const queryClient = new QueryClient();
hydrateRoot(
document.getElementById("root")!,
<HelmetProvider>
<Metadata url={window.location.href} />
<QueryClientProvider client={queryClient}>
<Hydrate state={state}>
<BrowserRouter>
<App />
</BrowserRouter>
</Hydrate>
</QueryClientProvider>
</HelmetProvider>
);

What’s going on here?


Add it, build it, run it

  1. Install dependencies:
Terminal window
# Add the gem to your Gemfile
gem "universal_renderer"
# Install the gem
bundle install
# Run the generator
rails generate universal_renderer:install
# Add the NPM package
bun add universal-renderer
  1. Run both processes:
Terminal window
web: bin/rails s
ssr: bun run public/vite-ssr/ssr.mjs

Or foreman, overmind. Whatever you prefer.

On production running on Bun, we’re seeing 200-300 ms TTFB for authenticated pages with tons of data. Not bad.


There it is

https://github.com/thaske/universal_renderer

PRs welcome! Especially around support for other frameworks, cleaner streaming API, and better fallback behavior.

Thanks for reading!