Implementing React Server-Side Rendering with Vite in Ruby on Rails (Heroku Deployment)
Dec 18, 2025

Implementing React Server-Side Rendering with Vite in Ruby on Rails (Heroku Deployment)
This article walks through a full, production-ready setup for Server-Side Rendering (SSR) of a React application embedded in a Ruby on Rails project. The solution uses Vite as the build tool, Apollo Client for GraphQL data handling, and a dedicated Node.js SSR server, with deployment targeting Heroku.
Overall Architecture
The SSR implementation is composed of three clearly separated layers:
- Rails (Backend)
Handles incoming HTTP requests, forwards rendering requests to the SSR server, and returns the generated HTML. - Node.js (SSR Server)
Runs an Express server responsible for executing React code and producing static HTML output. - React (Frontend)
The actual UI application, including a dedicated entrypoint designed specifically for server-side rendering.
1. Rails Layer: SsrRenderer Concern
On the Rails side, we encapsulate all SSR-related logic inside a controller concern. This keeps controllers clean and makes SSR optional and configurable.
app/controllers/concerns/ssr_renderer.rb
module SsrRenderer extend ActiveSupport::Concern private def render_ssr(url) # SSR can be disabled (e.g., in development) return [nil, nil, nil] unless ENV.fetch('SSR_ENABLED', 'true') == 'true' ssr_host = ENV.fetch('SSR_HOST', '127.0.0.1') ssr_port = ENV.fetch('SSR_PORT', '4000') # Determine protocol and host for GraphQL endpoint protocol = request.headers['X-Forwarded-Proto']&.split(',')&.first&.strip || request.protocol host = request.headers['X-Forwarded-Host']&.split(',')&.first&.strip || request.host graphql_uri = "#{protocol}://#{host}/graphql" uri = URI.parse( "http://#{ssr_host}:#{ssr_port}/render" \ "?url=#{CGI.escape(url)}" \ "&graphql=#{CGI.escape(graphql_uri)}" \ "&locale=#{I18n.locale}" ) http = Net::HTTP.new(uri.host, uri.port) http.read_timeout = 6 req = Net::HTTP::Get.new(uri.request_uri) req['Cookie'] = request.headers['Cookie'] if request.headers['Cookie'].present? req['X-CSRF-Token'] = form_authenticity_token response = http.request(req) return [nil, nil, nil] unless response.is_a?(Net::HTTPSuccess) json = JSON.parse(response.body) [json['html'], json['state'], json['head']] rescue StandardError => e Rails.logger.error("[SSR HTTP] Failed: #{e.class}: #{e.message}") [nil, nil, nil] endendThis design ensures that if SSR fails for any reason, the application gracefully falls back to client-side rendering.
2. Node.js Layer: SSR Server (Express + Vite)
The SSR server is a lightweight Express application that renders React either via Vite (development) or via a prebuilt bundle (production).
server/ssr-server.mjs
import express from 'express';import { createServer } from 'vite';import path from 'node:path';const isProd = process.env.NODE_ENV === 'production';const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');const app = express();let vite;if (!isProd) { vite = await createServer({ root: rootDir, server: { middlewareMode: true, hmr: false }, appType: 'custom' }); app.use(vite.middlewares);}app.get('/render', async (req, res) => { try { const { url, graphql, locale } = req.query; let render; if (isProd) { const bundlePath = path.resolve( rootDir, 'server/ssr-build/application-server.cjs' ); render = (await import(bundlePath)).render; } else { render = ( await vite.ssrLoadModule( 'app/frontend/entrypoints/application-server.tsx' ) ).render; } const ssrContext = { headers: { cookie: req.headers.cookie, 'x-csrf-token': req.headers['x-csrf-token'], 'x-locale': locale }, graphqlUri: graphql }; const result = await render(url, {}, ssrContext); res.json(result); } catch (e) { console.error('[SSR Error]', e); res.status(500).json({ error: e.message }); }});app.listen(4000, () => { console.log('SSR Server running on port 4000');});3. React Layer: Server-Side Entrypoint
To support GraphQL-based SSR, data must be fetched before rendering the component tree.
app/frontend/entrypoints/application-server.tsx
import React from 'react';import { renderToString } from 'react-dom/server';import { ApolloProvider } from '@apollo/client';import { getDataFromTree } from '@apollo/client/react/ssr';import { StaticRouter } from 'react-router-dom/server';import { HelmetProvider } from 'react-helmet-async';import AppRoutes from '../app/AppRoutes';import { createApolloClient } from '../config/apolloClient';export async function render(url: string, _initialData: any, ssrContext: any) { const client = createApolloClient({ ssrHeaders: ssrContext.headers, ssrUri: ssrContext.graphqlUri }); const helmetContext: any = {}; const app = ( <ApolloProvider client={client}> <HelmetProvider context={helmetContext}> <StaticRouter location={url}> <AppRoutes /> </StaticRouter> </HelmetProvider> </ApolloProvider> ); await getDataFromTree(app); const html = renderToString(app); const initialApolloState = client.extract(); const { helmet } = helmetContext; const head = `${helmet.title.toString()}` + `${helmet.meta.toString()}` + `${helmet.link?.toString() || ''}`; return { html, head, state: `<script>window.__APOLLO_STATE__=${JSON.stringify(initialApolloState)}</script>` };}4. Vite Configuration for SSR
Vite must be explicitly configured to produce a Node-compatible SSR bundle.
vite.ssr.config.mjs
import { defineConfig } from 'vite';import react from '@vitejs/plugin-react';export default defineConfig({ plugins: [react()], ssr: { noExternal: true }, build: { ssr: true, copyPublicDir: false, rollupOptions: { input: 'app/frontend/entrypoints/application-server.tsx', output: { format: 'cjs', entryFileNames: 'application-server.cjs', inlineDynamicImports: true } } }});5. Client-Side Hydration
Once HTML is rendered on the server, the browser must “take over” using hydration.
app/frontend/entrypoints/application.tsx
import { hydrateRoot, createRoot } from 'react-dom/client';import { createApolloClient } from '../config/apolloClient';const client = createApolloClient({ initialApolloState: window.__APOLLO_STATE__});const container = document.getElementById('react_app');if (container && container.hasChildNodes()) { hydrateRoot(container, ( <ApolloProvider client={client}> <BrowserRouter> <AppRoutes /> </BrowserRouter> </ApolloProvider> ));} else if (container) { const root = createRoot(container); root.render(<App />);}6. Localization (i18n) and SSR
Hydration mismatches often occur when the server and client disagree on language.
Solution overview:
- Rails determines I18n.locale.
- The locale is sent to the SSR server.
- React initializes i18n with this locale before rendering.
- The client reads the same locale during hydration.
This guarantees consistent language output.
7. Best Practices and Common Pitfalls
Always use timeouts
Rails should never wait indefinitely for SSR. A short timeout ensures graceful degradation to SPA rendering.
Avoid browser-only globals
Objects like window or document do not exist on the server:
if (typeof window !== 'undefined') { // browser-only logic}Prevent state leakage
Never reuse Apollo or i18n instances between requests. Each SSR request must create fresh instances to avoid leaking user data.
8. Deployment and Operations (Heroku)
In production, the SSR server runs alongside Rails as a parallel process.
Procfile
web: ./bin/web-startbin/web-start
#!/usr/bin/env bashexport SSR_PORT=${SSR_PORT:-4000}node server/ssr-server.mjs & SSR_PID=$!sleep 2bundle exec puma -C config/puma.rb & PUMA_PID=$!wait -n $PUMA_PID $SSR_PIDEnvironment Variables
SSR_ENABLED = true #Enables or disables SSRSSR_PORT4000SSR server port
SSR_HOST = 127.0.0.1 #SSR server host
SSR_DEBUG = 0 #Verbose logging
SSR_CACHE_ENABLED = true #Enables HTML caching
NODE_ENV = production #Production mode
Build Step on Heroku
Make sure the SSR bundle is built during deploy:
yarn frontend:build:ssrThis architecture delivers fast initial page loads, excellent SEO, and a modern React developer experience, all while staying deeply integrated with Ruby on Rails.