React Streaming and Server Side Rendering (SSR)

With this experiment, we are making a significant change in the way you think about, and build Redwood apps. Redwood now becomes SSR-first, and moves away from the Jamstack model of deploying static web assets to a CDN. Your routes are streamed from the server, then hydrated on the client — leveraging React 18’s new streaming & Suspense capabilities.

Part 1: Let’s start rendering on the server + dynamic <meta> tags for SEO and link sharing

What you’ll be able to do

:white_check_mark: Dev, with streaming + SSR
:white_check_mark: Build, then deploy a prod server
:white_check_mark: Dynamically generate HTML <meta> headers, so that when you share your links on Discord/Twitter/Slack - you can generate link previews

Make sure you’re on the canary version of Redwood (v7.x-canary)

yarn rw upgrade -t canary

And now, let’s setup your project for streaming!

yarn rw exp setup-streaming-ssr

This will create a few files:

  • (overwrite) web/src/entry.client.tsx
  • web/src/entry.server.tsx
  • web/src/components/Document.tsx
    • Note that this now becomes your “index.html”. So if you have any customisations there, you might want to bring them in here.
    • While SSR-Streaming is experimental, please don’t remove the index.html file in web/src/index.html - as this is still required.

It will also add a flag to your redwood.toml that’ll tell Redwood internally that we need to build and run your project differently.

:tada: Tada, that’s it!

Now when you:

  • Run yarn rw dev — it will server-render your pages
  • If you want to run a built version:
    • yarn rw build process will build your project ready for server rendering
    • yarn rw serve will run a web server (along with your usual api server)
  • You can use the meta route hooks (see section below)

Additional Docs

  1. The meta routeHook
  2. Accessing API side from Routehooks

What’s next?

In the next few days, I’ll add a follow up post with Part 2, which brings some really exciting new features:

  • Cells being rendered on the server
  • Ability to generate dynamic meta headers using the results in a Cell
  • CSS-in-JS support (libraries like styled components, emotion) [untested]
  • “Render-as-you-fetch” with Apollo client — to give your app a serious performance boost!
1 Like

Part 2: Rendering Cells on the Server

This is where you start to see more benefits of server rendering+streaming, as we make use of React 18’s Suspense architecture, allowing you to “render-as-you-fetch”. This is very alpha and the steps are likely to change.

What you’ll get
:white_check_mark: Cells Rendering on the server
:white_check_mark: Ability to set dynamic meta tags using MetaTags component (in addition to route hooks) - off the back of a Cell query
:white_check_mark: Stream results in of your Cell, as they’re resolved i.e. render-as-you-fetch. If you have multiple Cells (or indeed if you directly use useSuspenseQuery or useReadQuery) - your pages should feel significantly faster on the first render.

The latest canaries automatically sets up these steps for you. These manual steps are no longer required - but you can open this box to see the instructions **Assuming you’ve setup up part 1 already,** there's 3 more steps:
  1. Swap your ApolloProvider

in your web/src/App.tsx let’s swap where the ApolloProvider is imported from

import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web'
- import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
+ import { RedwoodApolloProvider } from '@redwoodjs/web/dist/apollo/suspense'

/* .... other imports */

const App = () => (
  <FatalErrorBoundary page={FatalErrorPage}>
    <RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
        <RedwoodApolloProvider useAuth={useAuth}>
          <Routes />
/*...... */

This will enable your Cells to render under Suspense.

  1. Now let’s install the experimental apollo client that we need for streaming and ssr

Although it says “NextJS” - the specific version is an experimental build specific to Redwood while we work out all the details with the Apollo team :slight_smile:

yarn workspace web add @apollo/experimental-nextjs-app-support@0.0.0-commit-5141fa5

Note the version number 0.0.0-commit-5141fa5 is important!

:checkered_flag: Et voila. You’ve succesfully enabled streaming+ssr capabilities for your Cells!

Some Gotchas!

  • If you are using queryResult in your Cells, you may notice API changes here. This is still in flux, and changing day-to-day: regardless, please let me know if you were expecting APIs here that you can’t implement any other way
  • Auth currently does not work with server side rendering, however your app will automatically revert to client side rendering, if any queries fail to render on the server
  • Under the hood, Cells now fetch with useBackgroundQuery and useReadQuery - this enables Suspense and “render-as-you-fetch” - but it does change how you reason about some of the lifecycle.

What’s next?
I’ll post some videos next week or so, showing some of the cool things you can achieve with Suspense enabled cells, and streaming them!


Hey Danny,

Thanks for all your work on this. I’m about to start another a new redwood project. Would you take the the leap and use SSR or play it safe and stick with old school RW for now?

Thanks for all your work on this. I’m about to start another a new redwood project. Would you take the the leap and use SSR or play it safe and stick with old school RW for now?

Hi Shan, it depends on what your appetite for dealing with changes are. While SSR+Streaming is in the experimental stage, we will make changes under hood and we cannot guarantee that function signatures, etc. won’t change - it won’t be part of semantic versioning, so sometimes we will break things even in minor versions/patches.

That being said, all you have to do to switch the experimental.streamingSsr flag in your redwood.toml, and switch the Apollo provider to toggle between stable and experimental.

We are grateful for any feedback in the experimental stage, and it would be your chance to shape these features (and call out missing ones) - so when this does become stable, you’ll be among the first to ship with these benefits without needing to adjust your code!

1 Like

Hey @danny, super excited for SSR in Redwood! Have just given this a go and so far I’ve run into a couple of issues:

  1. I’m using the supertokens auth client, and when I ran yarn rw dev it was throwing errors like
    Error: If you are using this package with server-side rendering, please make sure that you are checking if the window object is defined..
    After some debugging, I found out this was because I was using the isBrowser boolean from @redwoodjs/prerender/browserUtils to determine when to initialize the client-side supertokens client. This works fine when prerendering routes, but it turns out isBrowser evaluates to true when server side rendering. After changing this to use typeof window !== 'undefined' instead of isBrowser, everything worked fine.
  1. After the above change, yarn rw dev works perfectly, and yarn rw build does too, but when I run yarn rw serve and attempt to access a page, the FE server errors with:
Error: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object.
    at Ng (/Users/willks/projects/redwood-saas-starter/node_modules/react-dom/cjs/react-dom-server.node.production.min.js:130:301)
    at Z (/Users/willks/projects/redwood-saas-starter/node_modules/react-dom/cjs/react-dom-server.node.production.min.js:131:159)
    at Jg (/Users/willks/projects/redwood-saas-starter/node_modules/react-dom/cjs/react-dom-server.node.production.min.js:122:403)
    at Ng (/Users/willks/projects/redwood-saas-starter/node_modules/react-dom/cjs/react-dom-server.node.production.min.js:124:214)
    at Z (/Users/willks/projects/redwood-saas-starter/node_modules/react-dom/cjs/react-dom-server.node.production.min.js:131:159)
    at Kg (/Users/willks/projects/redwood-saas-starter/node_modules/react-dom/cjs/react-dom-server.node.production.min.js:123:260)
    at Ng (/Users/willks/projects/redwood-saas-starter/node_modules/react-dom/cjs/react-dom-server.node.production.min.js:124:446)
    at Z (/Users/willks/projects/redwood-saas-starter/node_modules/react-dom/cjs/react-dom-server.node.production.min.js:131:159)
    at Lg (/Users/willks/projects/redwood-saas-starter/node_modules/react-dom/cjs/react-dom-server.node.production.min.js:136:170)
    at Ng (/Users/willks/projects/redwood-saas-starter/node_modules/react-dom/cjs/react-dom-server.node.production.min.js:125:172)
1 Like

Thanks for the feedback @dambusm

Looks like we’ll have to go through our SSR/prerender helpers to make sure they still work. I bet this will be even more obvious as we build out our RSC support :slight_smile:

For the rw serve issue it would really help a lot if you could provide us with some code we can run to reproduce the problem. Could you please create a small reproduction and put it on GitHub?

I too am experiencing an issue trying to setup SSR.
I see this after the build has succeeded when I try to run the app.

web | 8:37:36 AM [vite] Error when evaluating SSR module /auth.tsx: failed to import "@clerk/clerk-react"
web | |- /Users/nathanmacfarlane/projects/dwelller/node_modules/@clerk/clerk-react/dist/esm/index.js:1
web | import "./chunk-UKSPFOP7.js";
web | ^^^^^^
web |
web | SyntaxError: Cannot use import statement outside a module
web |     at internalCompileFunction (node:internal/vm:73:18)
web |     at wrapSafe (node:internal/modules/cjs/loader:1176:20)
web |     at Module._compile (node:internal/modules/cjs/loader:1218:27)
web |     at Module._extensions..js (node:internal/modules/cjs/loader:1308:10)
web |     at Module.load (node:internal/modules/cjs/loader:1117:32)
web |     at Module._load (node:internal/modules/cjs/loader:958:12)
web |     at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:169:29)
web |     at (node:internal/modules/esm/module_job:194:25)

Any ideas?

1 Like

@nathanmacfarlane My knee jerk reaction is “ESM issue”. But not sure if that’s actually true or not. I’ll ask around

@nathanmacfarlane The team agrees – it’s probably because we lack full ESM support. There is currently work ongoing to fix this, but it’s not done yet

Makes sense. Thanks for following up. @Tobbe!

@Tobbe One more question for you.
I can successfully deploy to with SSR enabled on 7.0.0-canary.285. But when I try to upgrade to the latest canary build 7.0.0-canary.399, when I fly deploy I see this error:

  + dirname .fly/
  + .fly/
  + node ./node_modules/.bin/rw-serve-fe --port 8910
  + node ./node_modules/.bin/rw-server api
  [HPM] Proxy created: /  ->
  [HPM] Proxy rewrite rule created: "^/.redwood/functions" ~> ""
  Starting API Server...
  Importing Server Functions...
  Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/node/app/web/dist/server/Document.js' imported from /home/node/app/node_modules/@redwoodjs/vite/dist/streaming/createReactStreamingHandler.js
      at new NodeError (node:internal/errors:405:5)
      at finalizeResolution (node:internal/modules/esm/resolve:327:11)
      at moduleResolve (node:internal/modules/esm/resolve:946:10)
      at defaultResolve (node:internal/modules/esm/resolve:1132:11)
      at nextResolve (node:internal/modules/esm/loader:163:28)
      at ESMLoader.resolve (node:internal/modules/esm/loader:835:30)
      at ESMLoader.getModuleJob (node:internal/modules/esm/loader:424:18)
      at ESMLoader.import (node:internal/modules/esm/loader:524:22)
      at importModuleDynamically (node:internal/modules/cjs/loader:1188:29)
      at importModuleDynamicallyWrapper (node:internal/vm/module:429:21) {

Is this another ESM issue?

I think Danny just opened a PR for something Document related. Just saw it on passing, so could be wrong, but might be worth checking out

Hi @nathanmacfarlane, sorry I broke this in my last pr. Latest upgrade should fix it!

Hey @nathanmacfarlane, are you happy to share your config? I’m having trouble getting mine deploying correctly.

So I am going through this with Render trying to get a demo working, but having some trouble on settings.

Normally Render would point at index.html. Is that still the case here or should it point at Document.tsx which actually is now next to index.html in file structure vs. in web/src/components?

Then right now the web side build commands are as follows for build:

corepack enable && yarn install && yarn rw deploy render web

with the api side being~

corepack enable && yarn && yarn rw build api


yarn rw deploy render api

Does that also seem correct?

I have the test repo here using Supabase for auth and a Neon postgres db:

Hi Ryan,

Appreciate you experimenting with this!

I’ve not actually attempted to deploy on render - but I don’t believe the out of the box setup is going to work - because the stable versions of Redwood deploy as an SPA (rather than a server). You’ll notice this in the render.yaml where it says:

- name: ${PROJECT_NAME}-web
  type: web
  env: static # 👈 this here
  buildCommand: ...

You may want to try the following:

  1. Create a new service on Render for the FE server, with env: node
  2. The build command should be:
    corepack enable && yarn install && yarn rw build web
  3. Make sure you define a start command. The YAML should look something like this:
- name: ${PROJECT_NAME}-web-server
  type: web
  plan: free
  env: node
  region: oregon
  buildCommand: corepack enable && yarn install &&  yarn rw build web
  startCommand: yarn rw serve web
  1. You won’t need a redirect for Document

Please do let us know how you get on - I’ve not tried this myself yet so expect we may go back and forth a bit! Let me know if it would be helpful to connect on discord and then document the findings on here after - thank you!

What are we trying to do?

For an intuitive understanding - what we’re attempting to do here is deploy two services - one for the web server (which allows SSR/streaming) and one for the API (your graphql). This is a departure from how we used to deploy Redwood apps, because with the SPA architecture we didn’t need a web server, just a way of serving static file assets.

1 Like

So I actually got it working and was debugging auth on why it wasn’t working only to reread here that’s to be expected haha.
I needed to upgrade account because it was maxing out the 512MB instances, but that may be something I am doing or not doing on setup. Not sure it is that I am running two big Node instances now, but it also scaled back down to being able to use the 512MB instance after, but I could see it spike to 700-850MB install

Render would automatically shutdown the instance for going over the 512 so if you look at the setup below, I actually have the web side as “plan:standard” to get the memory upgrade.

I did yarn rw serve api and yarn rw serve web for each respective side in the render.yaml which now looks like this (note you will switch out your {PROJECT_NAME}:

  - name: {PROJECT_NAME}-api
    type: web
    plan: free
    env: node
    region: oregon
    buildCommand: corepack enable && yarn && yarn rw build api
    startCommand: yarn rw serve api

      - key: DATABASE_URL
          name: SSRTest-db
          property: connectionString

  - name: {PROJECT_NAME}-web-server
    type: web
    plan: standard
    env: node
    region: oregon
    buildCommand: corepack enable && yarn install && yarn rw build web
    startCommand: yarn rw serve web

      - key: DATABASE_URL
          name: SSRTest-db
          property: connectionString

  - name: SSRTest-db
    region: oregon

I made sure to have the flag in the redwood.toml:

  enabled = true

~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~
So then auth doesn’t work as expected because we now actually have two servers and it needs the info from the api side. That means I had to update the RedwoodApolloProvider in web/src/App.tsx

const App = () => (
  <FatalErrorBoundary page={FatalErrorPage}>
    <RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
            httpLinkConfig: { credentials: 'include' },
              <Routes />

We will also need to edit our GraphQL handler in api/src/functions/graphql.ts to add in a cors option:

export const handler = createGraphQLHandler({
  loggerConfig: { logger, options: {} },
  cors: {                                              // Added in for CORS
    origin: 'https://{PROJECT_NAME}', // Added in for CORS
    credentials: true,                                 // Added in for CORS
  },                                                   // Added in for CORS
  onException: () => {
    // Disconnect from your database with an unhandled exception.

and then finally we need to change our redwood.toml to show we have a separate api directory:

# USE THIS FOR LOCAL DEV apiUrl = "/.redwood/functions"
  title = "Redwood App"
  port = 8910
  apiUrl = "https://{PROJECT_NAME}"
  includeEnvironmentVariables = ["SUPABASE_URL","SUPABASE_KEY"]
  port = 8911
  open = true
  versionUpdates = ["latest"]

  enabled = true

Note you will have to switch this back when doing local dev.

This will include our credentials and stop CORS errors with our API and WEB on difference sites now.

~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~
Otherwise this looks like it’s working:

I’ll try out some more next with Cells and Streaming

It’s hopefully coming really soon now though!

1 Like

Nice looks like I am messing with this at the right time haha.

1 Like