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:
-
LandingPage
with its own layout -
LoginPage
with its own layout -
DashboardPage
,ProfilePage
andTokensPage
which all shareDashboardLayout
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
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
- Change the command for [build] to use
yarn build:ci
(or whatever you called your function) - 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
- 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.
- 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!