This post shows how I integrated Rails and React together, then extracted the approach into a gem called Universal Renderer.
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:
Render a string on the server.
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.
Add the concern to any controller you want to SSR. For the demo I’ll use ApplicationController:
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" endendWhy 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.
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 endendRails.application.routes.draw do get "/dashboard", to: "dashboard#index"
root "application#index" get "*path", to: "application#index"endReact 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.
<!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.
This is the piece people assume is scary-but it’s mostly glue code. Vite-Ruby looks for ssr/ssr.{js,ts,jsx,tsx}:
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
createServerand provide three hooks:
setupreturns the JSX tree plus contextual objects (helmet data, style sheet, queryClient).renderconverts that tree to strings withheadandbodyproperties.cleanupfrees resources.The server listens for requests from Rails and responds with rendered markup.
setup.tsximport { 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.
extractMetaexport 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.
We could let React re-render on the client, but that throws away the whole point of SSR. Instead we hydrate.
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?
Extract the React Query state from the data-state attribute and remove the element to keep the DOM clean.
Use <Hydrate> to hydrate React Query with initial query data.
hydrateRoot sets the existing DOM up instead of re-rendering, so the first paint you ship to the user is the one they keep (if it doesn’t require another re-render on mount).
# Add the gem to your Gemfilegem "universal_renderer"
# Install the gembundle install
# Run the generatorrails generate universal_renderer:install
# Add the NPM packagebun add universal-rendererweb: bin/rails sssr: bun run public/vite-ssr/ssr.mjsOr 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.
https://github.com/thaske/universal_renderer
PRs welcome! Especially around support for other frameworks, cleaner streaming API, and better fallback behavior.
Thanks for reading!