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)

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:

  1. Rails (Backend)
    Handles incoming HTTP requests, forwards rendering requests to the SSR server, and returns the generated HTML.
  2. Node.js (SSR Server)
    Runs an Express server responsible for executing React code and producing static HTML output.
  3. 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]  endend

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

  1. Rails determines I18n.locale.
  2. The locale is sent to the SSR server.
  3. React initializes i18n with this locale before rendering.
  4. 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-start

bin/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_PID

Environment 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:ssr

This architecture delivers fast initial page loads, excellent SEO, and a modern React developer experience, all while staying deeply integrated with Ruby on Rails.