Server-Side Rendering with Rails and React

Getting Rails and React to play nice.

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…). It’s fast and it just makes sense.

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 fixed with SSR. It can give you initial data on page load, better integration for things like title and meta, and anything else you could possibly want. Many React libraries support SSR.

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.

Architecture

Before diving in, let’s understand what we’re building. For SSR to work, we need two different entrypoints:

  • A server entrypoint for initial rendering on the server
  • A client entrypoint for hydrating the app on the client

Client Entrypoint

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.

// app/frontend/App.tsx

export default const App = () => (
  // No QueryClientProvider or BrowserRouter here
  <Routes>
    <Route path="/dashboard" element={<Dashboard />} />
  </Routes>
);

Then, the client entrypoint:

// app/frontend/entrypoints/client.tsx

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__.

Server Entrypoint

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:

// app/frontend/entrypoints/server.tsx

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.

Putting the Server in Server-Side Rendering

We need a simple HTTP server.

// app/frontend/ssr/ssr.ts

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.

Putting it on Rails

Now let’s connect everything to Rails via the Ssr service:

# app/services/ssr.rb

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
  end
end

This service makes a POST request to our Express server with the path and props, then returns the response as a hash.

Controllers

Our SsrController handles common logic for initializing @props and triggering SSR.

# app/controllers/ssr_controller.rb

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"
  end
end

For 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]:

# app/controllers/dashboard_controller.rb

class DashboardController < SsrController
  def index
    @props[:query_data] << {
      key: %w[posts recent],
      data: PostSerializer.many(current_user.posts),
    }
  end
end

Here, 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.

Views

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.

<!-- app/views/layouts/application.html.erb -->

<!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.

<!-- app/views/ssr/index.html.erb -->

<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.

Building and Running

You’ll need to build the server before running it.

bin/vite build --ssr    # Build the server
bin/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:

// 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:

# Procfile

web: bundle exec puma -C config/puma.rb
ssr: bin/vite ssr
worker: bundle exec sidekiq

There You Have It

Server-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.

universal_renderer gem

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 [email protected] or @thaske_ on X.

Thanks for reading!