This post got some flack over on r/rails over the use of React with Rails. This is not about whether you should use React with Rails. There’s a time and place for everything, and I think Rails views are great for many use cases. This post is about how to get better integration between Rails and React if you are using them together. Now without further ado…
inertia-rails has been getting a lot of buzz lately. One of its best features is SSR, out of the box. But I don’t want to rewrite my existing app in Inertia.
What if you just want better integration in your existing Rails/React app? I really like React Router (and apparently so do Next.js users…) because it’s fast and just works. Or what about the sane data-fetching patterns of React Query? I don’t want to mix data-loading via props on the server with client-side API requests. I want to keep everything in the same place.
A lot of these pain points can be addressed with SSR. You get initial data on page load, better integration for things like title and meta, and more. Many React libraries support SSR today.
So, how do you add SSR to an existing Rails app?
This is a minimally viable example. It’s only meant to illustrate the concepts.
Before diving in, let’s understand what we’re building. For SSR to work, we need two different entrypoints:
First, we’ll need to handle providers like QueryClientProvider or BrowserRouter differently on the server than on the client.
You’ll need to lift these providers up into a client-specific entrypoint, out of the component with all your routes.
export default const App = () => ( // No QueryClientProvider or BrowserRouter here <Routes> <Route path="/dashboard" element={<Dashboard />} /> </Routes>);Then, the client entrypoint:
import { hydrateRoot } from "react-dom/client";import { Hydrate, QueryClient, QueryClientProvider } from "react-query";import { BrowserRouter } from "react-router";
import App from "@/App";
const queryClient = new QueryClient();const state = window.__INITIAL_STATE__ ?? {};
hydrateRoot( document.getElementById("root")!, <QueryClientProvider client={queryClient}> <Hydrate state={state}> <BrowserRouter> <App /> </BrowserRouter> </Hydrate> </QueryClientProvider>);We hydrate the existing root instead of creating a new one. This helps us avoid an unnecessary re-render. We’ll receive the initial state for React Query via window.__INITIAL_STATE__.
The server entrypoint will be a function because our initial render depends on the URL, plus any initial props. Both path and props will be passed to our SSR server by Rails.
Let’s take a look at our render function:
import { renderToString } from "react-dom/server";import { dehydrate, QueryClient, QueryClientProvider } from "react-query";import { StaticRouter } from "react-router";import { ServerStyleSheet } from "styled-components";
import App from "@/App";
function render(path = "/", props = {}) { const sheet = new ServerStyleSheet(); const queryClient = new QueryClient();
const { query_data } = props; query_data.forEach(({ key, data }) => { queryClient.setQueryData(key, data); });
const app = renderToString( sheet.collectStyles( <QueryClientProvider client={queryClient}> <StaticRouter location={path}> <App /> </StaticRouter> </QueryClientProvider> ) );
const styles = sheet.getStyleTags(); const content = [styles, app].join("\n"); const state = dehydrate(queryClient);
return { content, state };}
export { render };We create a query client so we can set the query data ahead of time. We’ll have some initial data provided by Rails. I’ve structured the query_data as an array of objects with keys and data.
Then we use ServerStyleSheet from styled-components to collect the styles as we generate the HTML string.
window.location won’t be available during SSR, so we use StaticRouter to tell React Router the URL.
ReactDOM.renderToString transforms the JSX to a string.
We need a simple HTTP server.
import express from "express";
const app = express();
app.use(express.json({ limit: "5mb" }));
app.post("/", async (req, res) => { const { render } = await import("@/entrypoints/server.tsx");
const { path, props } = req.body; const { content, state } = render(path, props);
res.json({ content, state });});
app.use((e, req, res, next) => { res.status(500).send([e.message, e.stack].join("\n"));});
app.listen(3001);We set up an Express app and configure it with high limits for JSON payloads to accommodate larger initial query data. The root endpoint returns whatever our render function produces.
Now let’s connect everything to Rails via the Ssr service:
class Ssr class << self
SSR_TIMEOUT = 3 SSR_SERVER_URL = "http://localhost:3001/"
def call(path, props) uri = URI.parse(SSR_SERVER_URL)
http = Net::HTTP.new(uri.host, uri.port) http.open_timeout = SSR_TIMEOUT http.read_timeout = SSR_TIMEOUT
request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json") request.body = { path:, props: }.to_json
response = http.request(request) JSON.parse(response.body).with_indifferent_access rescue => e { content: "", state: "" } end endendThis service makes a POST request to our Express server with the path and props, then returns the response as a hash.
Our SsrController handles common logic for initializing @props and triggering SSR.
class SsrController < ApplicationController before_action :initialize_query_data
private
def initialize_query_data @props = { query_data: [] } end
def default_render @ssr = Ssr.call(request.path, @props) render "ssr/index" endendFor each set of routes that need to provide additional data to React, we can create a controller that inherits from SsrController.
Actions append the data they need to @props[:query_data]:
class DashboardController < SsrController def index @props[:query_data] << { key: %w[posts recent], data: PostSerializer.many(current_user.posts), } endendHere, the index action in DashboardController adds the current user’s posts to @props[:query_data], which will be available to React Query in our render function.
We have a basic layout that includes meta tags and stylesheets, and a React-specific template that handles the SSR content.
The application layout should look familiar. We still use Vite’s helpers.
<!DOCTYPE html><html lang="en"> <head> <title>App</title>
<%= favicon_link_tag vite_asset_path 'images/favicon.ico' %> <%= vite_stylesheet_tag 'application' %>
<%= csrf_meta_tags %> <%= csp_meta_tag %>
<%= yield :head %>
<%= vite_typescript_tag 'client.tsx', defer: true %> </head>
<body> <%= yield %> </body></html>The ssr/index template is where the magic happens.
<div id="root"> <%= sanitize @ssr[:content], scrubber: SsrScrubber.new %></div>
<script> window.__INITIAL_STATE__ = <%= raw j(@ssr[:state].to_json) %>;</script>We sanitize with a custom scrubber and use raw(j(...)) to avoid XSS attacks.
You’ll need to build the server before running it.
bin/vite build --ssr # Build the serverbin/vite ssr # Run it!To build the SSR server automatically during rails assets:precompile, you can enable the ssrBuildEnabled option in your Vite Ruby configuration. Add the following to your config/vite.json:
{ "all": { "sourceCodeDir": "app/frontend", "watchAdditionalPaths": [], "ssrBuildEnabled": true, // <--- Add this },}If you’re using a Procfile, you’ll need to add the following:
web: bundle exec puma -C config/puma.rbssr: bin/vite ssrworker: bundle exec sidekiqServer-side rendering might seem complicated, but I think it’s worth doing for better integration with React. We can enjoy the productivity of Rails while still delivering the performance benefits of modern frontends.
After building this into our own Rails app, I realized it was a lot of boilerplate. I decided to move the code into a gem called universal_renderer.
It integrates server-side rendering into Rails and works seamlessly with the universal-renderer NPM package.
It has controller helpers for passing props, view helpers for integrating into your templates, automatic fallback to client-side rendering if SSR fails, and streaming responses for fast TTFB (especially with Bun).
It’s a work in progress, and I’d love to hear from you if you have ideas for contributions.
Reach me at me@thaske.com or @thaske_ on X.
Thanks for reading!