Pre-rendering with react-snap & Redwood

How to pre-render your redwood app for SEO and that instant load experience

We’ve been using Redwood to build out Tape.sh, and it’s been a fantastic experience so far. This post is half tutorial and half “show and tell”

We’re currently in beta, so any feedback from RW community is appreciated

Tldr;

Setup react snap with redwood specific config, add a utility function to check whether the app is being prerendered and use the golden rules. Remember to configure netlify correctly, otherwise you’ll see weird rendering issues!

Golden Rules

  • On pages that don’t require login, no need to add any extra code
  • On pages that require login, modify the Layout to check for prerendering user agent and show a loader instead of the page content

Structure of App

We’re using Redwood and this prerendering technique on https://tape.sh - and this post talks about how we set it up for Tape.

So a little bit about how the redwood app is structured first, to frame this tutorial.

We have:

  1. LandingPage with its own layout
  2. LoginPage with its own layout
  3. DashboardPage, ProfilePage and TokensPage which all share DashboardLayout

This follows the pattern that redwood sets up for us, which is going to be very helpful in prerendering.

Step 1: Installing and configuring react-snap

React snap is very easy to setup with create-react-app, but it needs a little bit more tweaking to work with redwood

Let’s install react-snap, so that we can easily trigger the build from the root, let’s just place it on the root (open to suggestions here)

yarn add -W --dev react-snap

Change the root package.json to include scripts:

"scripts": {
.

"build:snap": "yarn rw build && react-snap",
"build:ci": "yarn rw db up --no-db-client && yarn build:snap",
.
.
}

and let’s add the config for react-snap

// Still in package.json
.
"reactSnap": {
    "source": "web/dist",
    "inlineCss": true // this is experimental!
	"headless": true, // set to false to try locally
},
"scripts": {
.
.
}

We’re going to use these commands in the netlify build later

The options for react-snap aren’t documented that well, but helpfully the source shows all the available options

stereobooster/react-snap

Step 2: Pre-render static pages

We need to modify the web entry file to hydrate the app if needed, instead of simply rendering so let’s modify web/src/index.js to look like this:

// put the app JSX into a variable so we can reuse it
const App = (
  <FatalErrorBoundary page={FatalErrorPage}>
    <CustomAuthProvider type="jwt"> 
      <RedwoodProvider>
        <Routes />
      </RedwoodProvider>
    </CustomAuthProvider>
  </FatalErrorBoundary>
)

const rootElement = document.getElementById('redwood-app')

// this is the important bit
if (rootElement.hasChildNodes()) {
  ReactDOM.hydrate(App, rootElement)
} else {
  ReactDOM.render(App, rootElement)
}

And we’re good!

Now if you run yarn build:snap (which we configured earlier) - react-snap will crawl through your dist folder, and prerender what it can. This is important to note, if your index.html doesn’t have an href to another page, it won’t be pre-rendered.

Let’s check what we’ve done so far

cd web/dist/
serve #use a static file server to see how your prerendering went

I use an extension to disable JS in the browser, to see if pre-rendering really worked. If you see the whole page (but missing some interactivity), you’re good!

Remember you can also set the headless flag to false in the react-snap configuration to see what’s happening as react-snap crawls through you pages

…But wait!

Now this is great for “static” pages like the landing page, or the login page, but it looks like the pages which require auth are going to show errors by default

Step 3: Setup pre-rendering in logged in pages

But first a little note:

After spending a bit of time, playing around with different ways of pre-rendering, I realised something important - react-snap works on the files that are already built by redwood. Which means any code we add for pre-rendering, we should be happy for it to be bundled into the release bundle.

I thought it best to scope the code changes to the web part of redwood only, because pre-rendering is something only the frontend should worry about.

a. Detect pre-rendering

Let’s add a utility function that lets us work out if the app is being pre-rendered

web/src/utils/prerenderUtils.js

// Place this wherever you put your utils!
export const isPrerendering = () => navigator.userAgent === 'ReactSnap'

This works because react-snap when running puppeteer helpfully sets the useragent to ReactSnap

b. Pretend you’re logged in when pre-rendering

In our router, we’re using <Private unauthenticated="login"> to make sure users get redirected to the login page if they’re unauthenticated.

So for this step, we want to use the isPrerendering function to detect react-snap, and pretend we’re logged in. We use custom auth in Tape, so we already have a custom auth client, and a custom auth provider. If you’re using something like Auth0, it might be useful to compose the existing authClient, with your extensions

The authClient has this signature as of redwood 0.12:

// From
// https://github.com/redwoodjs/redwood/blob/0bc1b23e5bb5d1735699f797e61d80ba0fac16b0/packages/auth/src/authClients/index.ts#L40-L49
export interface AuthClient {
  restoreAuthState?(): void | Promise<any>
  login(options?: any): Promise<any>
  logout(options?: any): void | Promise<void>
  getToken(): Promise<null | string>
  /** The user's data from the AuthProvider */
  getUserMetadata(): Promise<null | SupportedUserMetadata>
  client: SupportedAuthClients
  type: SupportedAuthTypes
}

And we want to modify or hook into the getCurrentUser function. Other functions removed for brevity!

import { isPrerendering } from 'src/utils/prerenderUtils'

export const createAuthClient = () => {
  return {
    login: /* -- */
    logout: /* -- */,
    restoreAuthState: /* --*/,
    getToken: /* -- */

    // Lets modify this ->
    currentUser: () => {
      // Use prerender check from the function we wrote earlier
      if (isPrerendering()) {
        console.warn(':::WARN!::: Prerender mode')

        // Return your pretend user here
        return {
          name: 'Code Ninja',
        }
      }

      // Continue doing what you usually do
      // if using Auth0, maybe you can call the Auth0 currentUser() here?
    },

  }
}

Why the Code Ninja bit? Try going to https://tape.sh/dashboard - it shows Code Ninja on the nav bar, then flicks to your name. I just like developer jokes I guess…

c. Loaders for page content

Now for the rest of the page. Remember golden rule no. 2? Let’s modify the DashboardLayout to show a skeleton loader

const DashboardLayout = ({ children }) => {
  const { loading } = usePageLoadingContext()

  return (
    <Layout>
      <Sider theme="dark" width={280} collapsible breakpoint="md">
        {/* Our sidebar code here */}
      </Sider>
      <Layout css={{ padding: '0 24px 24px' }}>
        <Content>
        {/* === Heres the interesting bit!! === */}
          {loading || isPrerendering() ? <SkeletonLoaders /> : children}
        {/* === Heres the interesting bit!! === */}
        </Content>

        <Footer css={{ textAlign: 'center' }}>
         {/*  */}
        </Footer>
      </Layout>
    </Layout>
  )
}

const SkeletonLoaders = () => (
  <>
    <Skeleton active />
    <Skeleton active />
    <Skeleton active />
  </>
)

What this does is that it’ll display a skeleton loader either when the page is loading, or when its pre-rendering.

Let’s take a peek at the magic then (I throttled my network to show the pre-rendered page):

That wasn’t so bad was it? But just before you push to master… remember we have to configure netlify as well.

If we took a peek at the dist folder, you’ll notice how react-snap generates html files for each of your paths, so we have to make sure Netlify uses the correct intitial HTML

Setup for netlify builds and redirects

In netlify.toml

  1. Change the command for [build] to use yarn build:ci (or whatever you called your function)
  2. Lets add the redirects
#this is the default
[[redirects]]
  from = "/"
  to = "/index.html"
  status = 200

# add the new prerendered pages ->
[[redirects]]
  from = "/dashboard"
  to = "dashboard/index.html"
  status = 200

[[redirects]]
  from = "/login"
  to = "login/index.html"
  status = 200

# Add fallback, in our case dashboard made sense
[[redirects]]
  from = "/*"
  to = "dashboard/index.html"
  status = 200

And finally, we changed all the <Link>s on the LandingPage to <a href> tags. This may or may not be necessary, but I found that the experience feels smoother than the white flash we receive from the redwood routing. (Hoping to get this solved soon!)

Conclusions and Caveats

I’ve noticed a couple of things that don’t work quite well yet, and hoping to solve soon

  1. On Firefox, it seems hydration causes a full repaint on the homepage (and a jump to top), especially when the JS bundle isn’t cached. If you have a slow internet connection (like me), you notice this in particular.
  2. We still get white flashes when navigating between the pages on the dashboard. This shouldn’t happen according to the redwood docs and isn’t related to pre-rendering. Any tips here from the community?

Hope this walkthrough / half tutorial will help someone else attempting the same!

Other thoughts:

  • react-snap is a little limited in terms of configurability (at the gain of simpler to use), if pre-rendering was built into RW, I think it might be better to use something with more control at the pupeteer level
  • it looks like almost all of these steps could be replicated using a generator in rw cli, which is an exciting prospect!
8 Likes

@thedavid, @mojombo - here’s the writeup of how we managed to do pre-rendering with react snap (as promised on our conversation during prisma day).

Hopefully this feeds into your decisions for RW, keen to stay involved :slight_smile:

3 Likes