A long-lived project that still receives updates
Provides helper methods and configuration to forward rendering requests from a Rails app to an external SSR server and return the response.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Runtime

~> 2.24
>= 7.1, < 9.0
 Project Readme

UniversalRenderer

CI

Gem Version NPM Version

A streamlined solution for integrating Server-Side Rendering (SSR) into Rails applications.

Overview

UniversalRenderer helps you forward rendering requests to external SSR services, manage responses, and improve performance, SEO, and user experience for JavaScript-heavy frontends. It works seamlessly with the universal-renderer NPM package.

Features

  • Streaming SSR support
  • Configurable SSR server endpoint and timeouts
  • Simple API for passing data between Rails and your SSR service
  • Automatic fallback to client-side rendering if SSR fails
  • View helpers for easy integration into your layouts

Installation

  1. Add to your Gemfile:

    gem "universal_renderer"
  2. Install:

    $ bundle install
  3. Run the generator:

    $ rails generate universal_renderer:install

Configuration

Configure in config/initializers/universal_renderer.rb:

UniversalRenderer.configure do |config|
  config.ssr_url = "http://localhost:3001"
end

Basic Usage

After installation, you can pass data to your SSR service using add_prop in your controllers:

class ProductsController < ApplicationController
  enable_ssr # enables SSR controller-wide

  def show
    @product = Product.find(params[:id])

    # We can use the provided add_prop method to set a single value.
    add_prop(:product, @product.as_json)

    # We can use the provided push_prop method to push multiple values to an array.
    # This is useful for pushing data to React Query.
    push_prop(:query_data, { key: ["currentUser"], data: current_user.as_json })

    fetch_ssr # or fetch on demand

    # @ssr will now contain a UniversalRenderer::SSR::Response which exposes
    # `.head`, `.body` and optional `.body_attrs` values returned by the SSR
    # service.
  end

  def default_render
    # If you want to re-use the same layout across multiple actions.
    # You can also put this in your ApplicationController.
    render "ssr/index"
  end
end
<%# "ssr/index" %>

<%# Inject SSR snippets using the provided helpers %>
<%# When streaming is enabled these render HTML placeholders %>
<%# Otherwise they output the sanitised HTML returned by the SSR service %>

<%= content_for :head do %>
  <%= ssr_head %>
<% end %>

<div id="root">
  <%= ssr_body %>
</div>

Setting Up the SSR Server

To set up the SSR server for your Rails application:

  1. Install the NPM package in your JavaScript project:

    $ npm install universal-renderer
    # or
    $ yarn add universal-renderer
    # or
    $ bun add universal-renderer
  2. Create a setup function at app/frontend/ssr/setup.ts:

    import {
      HelmetProvider,
      type HelmetDataContext,
    } from "@dr.pogodin/react-helmet";
    import { QueryClient, QueryClientProvider } from "react-query";
    import { StaticRouter } from "react-router";
    import { ServerStyleSheet } from "styled-components";
    
    import App from "@/App";
    import Metadata from "@/components/Metadata";
    
    export default function setup(url: string, props: any) {
      const pathname = new URL(url).pathname;
    
      const helmetContext: HelmetDataContext = {};
      const sheet = new ServerStyleSheet();
      const queryClient = new QueryClient();
    
      const { query_data = [] } = props;
      query_data.forEach(({ key, data }) => queryClient.setQueryData(key, data));
      const state = dehydrate(queryClient);
    
      const app = sheet.collectStyles(
        <HelmetProvider context={helmetContext}>
          <Metadata url={url} />
          <QueryClientProvider client={queryClient}>
            <StaticRouter location={pathname}>
              <App />
            </StaticRouter>
          </QueryClientProvider>
          <template id="state" data-state={JSON.stringify(state)} />
        </HelmetProvider>,
      );
    
      return { app, helmetContext, sheet, queryClient };
    }
  3. Update your application.tsx to hydrate on the client:

    import { HelmetProvider } from "@dr.pogodin/react-helmet";
    import { hydrateRoot } from "react-dom/client";
    import { BrowserRouter } from "react-router";
    import { Hydrate, QueryClient, QueryClientProvider } from "react-query";
    import App from "@/App";
    import Metadata from "@/components/Metadata";
    
    const queryClient = new QueryClient();
    
    const stateEl = document.getElementById("state");
    const state = JSON.parse(stateEl?.dataset.state ?? "{}");
    stateEl?.remove();
    
    hydrateRoot(
      document.getElementById("root")!,
      <HelmetProvider>
        <Metadata url={window.location.href} />
        <QueryClientProvider client={queryClient}>
          <Hydrate state={state}>
            <BrowserRouter>
              <App />
            </BrowserRouter>
          </Hydrate>
        </QueryClientProvider>
      </HelmetProvider>,
    );
  4. Create an SSR entry point at app/frontend/ssr/ssr.ts:

    import { head, transform } from "@/ssr/utils";
    import { renderToString } from "react-dom/server.node";
    import { createServer } from "universal-renderer";
    
    const app = await createServer({
      setup: (await import("@/ssr/setup")).default,
    
      render: ({ app, helmet, sheet }) => {
        const root = renderToString(app);
        const styles = sheet.getStyleTags();
        return {
          head: head({ helmet }),
          body: `${root}\n${styles}`,
        };
      },
    
      cleanup: ({ sheet, queryClient }) => {
        sheet?.seal();
        queryClient?.clear();
      },
    });
    
    app.listen(3001);
  5. Build the SSR bundle:

    $ bin/vite build --ssr
  6. Start your servers:

    web: bin/rails s
    ssr: bin/vite ssr

Contributing

Contributions are welcome! Please follow the coding guidelines in the project documentation.

License

Available as open source under the terms of the MIT License.