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 key
s 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!