When the React team introduced the concept of Suspense it was—and is—as a beta. One of the missing pieces was suspense-compatible server-side rendering (SSR): official support is expected in late 2019 in tandem with a new asynchronous server-side renderer.

In the meantime a couple of community members have developed asynchronous suspense-compatible render-to-string implementations: react-async-ssr and react-ssr-prepass. Both implementations assume that the suspenseful fetching happens asynchronously, and both take tree-walking approaches to rendering (the former by implementing a new renderer and the latter by implementing a visitor to walk the tree before rendering).

These are good solutions if you’re in an asynchronous environment when server-side rendering: for example running Node.js and pre-rendering within the Node.js environment. However if you’re pre-rendering from a host language such as Ruby or Python (normally via an embedded V8 library) then these solutions are unnecessarily complex and don’t pair well with the synchronous execution of functions/methods/etc. from those languages.

The following lays out a simpler approach for doing suspense-compatible server-side rendering in a synchronous host language environment.

Synchronous callbacks

We can start by exposing a data fetching function from the host language to the embedded language; in this example using Ruby and the mini_racer gem.

find_widget_by_id = ->(id) do
  Widget.find_by_id(id)&.as_json
end

context = MiniRacer::Context.new
# Bundle is the output from the bundler, eg. Webpack, Sprockets, etc.
context.eval(bundle_source, filename: bundle_path)
context.attach('HostApi.find_widget_by_id', find_widget_by_id)

In our front-end when server-side rendering we’ll now be able to call global.HostApi.find_widget_by_id. V8’s execution will halt, control will be passed back to Ruby to execute the find_widget_by_id proc, and then control will be returned to V8 with the proc’s return value.

Our JavaScript code will therefore be executing synchronously when we call that function.

Environment-aware resources

When we implement our resource in the front-end we’ll now have two code-paths to take: a synchronous one if we’re server-side rendering, and an asynchronous one if we’re browser rendering.

import { unstable_createResource } from "react-cache"
import { fetchWidgetById } from "..."

function createServerSideResource() {
  const cache = new Map()
  return {
    read: id => {
      if (cache.has(id)) {
        return cache.get(id)
      } else {
        const widget = global.HostApi.find_widget_by_id(id)
        if (!widget) {
          throw new NotFoundError(...)
        }
        cache.set(id, widget)
        return widget
      }
    },
  }
}

const WidgetResource = isServerSide()
  ? createServerSideResource()
  : unstable_createResource(fetchWidgetById)

export default WidgetResource

Now in a component we can use WidgetResource.read in both server-side and browser environments.

import WidgetResource from "./WidgetResource"

export default function Widget({ id }) {
  const widget = WidgetResource.read(id)
  return <span>{widget.name}</span>
}

What about errors?

For error handling when server-side I generally let the error bubble up through the stack, catch and report it to the error monitoring service of choice, and then have the pre-rendering silently fail. That way for the user the page just takes slightly longer to load, which I believe is better than loading with error content briefly visible and then—if the browser render succeeds—flashing over to the non-error content.

This is okay because pre-rendering is an optimization. It’s there to make the experience faster for users and provide better SEO, but its results are not authoritative. A single-page app with pre-rendering can still function without pre-rendering.