UniversalRenderer
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
-
Add to your Gemfile:
gem "universal_renderer"
-
Install:
$ bundle install
-
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:
-
Install the NPM package in your JavaScript project:
$ npm install universal-renderer # or $ yarn add universal-renderer # or $ bun add universal-renderer
-
Create a
setup
function atapp/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 }; }
-
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>, );
-
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);
-
Build the SSR bundle:
$ bin/vite build --ssr
-
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.