Skip to content

SSR Gotchas

Alright, let's talk about one of the biggest gotchas with server-side rendering.

Earlier in the course, we saw how we can persist state in localStorage. For example:

'use client';
function Counter() {
const [count, setCount] = React.useState(() => {
return Number(
window.localStorage.getItem('saved-count') ||
0
);
});
React.useEffect(() => {
window.localStorage.setItem('saved-count', count);
}, [count]);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}

This works fine in a client-side-rendered app (eg. with Parcel), but it blows up when we do this sort of thing in a Client Component in Next, or any other SSR framework.

Do you see the problem? If not, spend a moment or two thinking about it. Why might this break if we try to run this code on the server?

In order to initialize the count state variable during the first render, we run the following code:

window.localStorage.getItem('saved-count');

Here's the problem: There is no window object on the server.

It's a bit weird to think about, but when we server-side-render a React component, that first render happens in a “headless” environment. In Node.js, there is no browser window. There is no DOM.

And even if Node.js did have a fake window object, window.localStorage is designed to read/write data on the user's device. Our Node.js server can't possibly know which value is saved locally on the user's phone or computer!

As a result, Node.js won't be able to complete the server-side render, and it'll serve up a blank HTML file. We've lost all the benefits of SSR!

This is one of two very common with Server Side Rendering:

  1. Trying to access “browser stuff” on the server.
  2. Hydration mismatches.

Let's get some hands-on practice with this stuff. I've created a Next project with this problematic code:

When you try to run a development server, you should see an error in the terminal:

Screenshot of a terminal showing a Reference Error: window is not defined

Spend a few moments seeing if you can fix this problem. You may run into additional confusing errors. That's OK! I'll explain everything in the video below, but my explanation will be clearer and more memorable if you take 5-10 minutes to experiment first.

After you've taken a few moments to experiment, check out this video, where we'll explore the issue in depth:

Video Summary

We see the window is not defined error in the console. In the browser, however, everything seems fine. We can interact with the button, and the saved count variable even persists across page refreshes.

If we disable JS, though, we see the problem: the initial HTML file is empty! An error on the server means that the SSR failed, and so we lose all the benefits of server-side rendering.

How can we fix the problem? It's intuitive to think that the solution should look something like this:

function Counter() {
const [count, setCount] = React.useState(() => {
if (typeof window === 'undefined') {
return 0;
}
return Number(
window.localStorage.getItem('saved-count') || 0
);
});
// ✂️ The rest unchanged
}

On the server, window is undefined, while on the client, window is a JavaScript object. If the server is tripping over that window.localStorage.getItem call, it seems logical that we could solve the problem by skipping that chunk of work on the server.

Unfortunately, this leads to a new error, this time in the browser:

Error: Text content does not match server-rendered HTML.

Warning: Text content did not match. Server: "0" Client: "4"

(There's also a second error, but it's a more generic version of the same thing. We can ignore it.)

This is known as a hydration mismatch, and it's one of the most common stumbling blocks with server-side rendering.

Here's the problem: when this code runs on the server, we generate the following markup:

<button>
Count: 0
</button>

On the client, we generate a sketch during hydration, and that sketch looks like this:

<button>
Count: 4
</button>

(It's 4 here because that was the value stored in my localStorage. It could be any number, depending on the saved value.)

During hydration, React creates a sketch of the DOM structure, and then tries to “fit” it onto the real DOM:

Static
Interactive
React Logo

Hydration is different from a typical re-render. In a re-render, React does a reconciliation, trying to find any differences between the before/after sketches. With hydration, however, React assumes that the sketch perfectly matches the server-generated DOM. It's not looking to find and fix any discrepancies.

Now, somewhat miraculously, it can sometimes bend the DOM into the shape of the hydration sketch. Here's a new visualization demonstrating this:

Static
Interactive
React Logo

This is far from fool-proof, however. Every now and then, React gets it wrong, and totally breaks the layout.

This happened to me a few years ago on my blog:

Screenshot of my blog, with the elements all jumbled up like a Picasso painting

By default, hydration mismatches are really hard to troubleshoot and fix. To prevent this from being a major headache, React will explicitly throw an error when a hydration mismatch occurs.

Here's the golden rule: The first render needs to produce the exact same markup on the server and on the client. The SSR'ed HTML should be the same as the hydration sketch.

Alright, let's consider how to fix this for our Counter example.

We can't access localStorage on the server, and so we can't possibly know what the saved value is. As a result, the first render needs to assume that the count should be 0:

// Always initialize `count` to 0:
const [count, setCount] = React.useState(0);

When we do this, we solve the original ReferenceError as well as the hydration mismatch, but we've broken the core functionality: we aren't restoring from localStorage!

Let's add an effect that takes care of this:

React.useEffect(() => {
const savedValue =
window.localStorage.getItem('saved-count');
if (savedValue === null) {
return;
}
setCount(Number(savedValue));
}, []);

This effect has no dependencies, and so it only runs after the first render.

If saved-count exists, we'll convert it to a number (all localStorage values are strings, like "4"), and set it into state.

This works because effects only run on the client. During SSR, none of the code inside useEffect runs.

There's still some work to do in terms of UX, but this is the fundamental strategy when it comes to hydration mismatches:

  • Ensure that the server-generated HTML is identical to the hydration sketch
  • Immediately after the first render, perform whatever work is required on the client to update the UI (eg. read from localStorage).

Here's the new “Hydration visualization” showing what happens when there's a mismatch:

Static
Interactive
React Logo

(This visualization shows the best-case scenario, where React is able to bend the DOM into the right shape; this isn't always the case, however!)

Two-pass rendering

In the video above, we solve our problem by setting a default value of “0”. This creates a flicker, since the count flips to the saved value after hydration:

I see this sort of thing all the time on the web. Most commonly, websites will show the “logged out” state for the first second or two, before flipping to the correct logged-in state.

For example, The Guardian:

Or Airbnb:

The fundamental challenge here is the same: When we do the first render, we don't have all of the information we need. And so, we pick some sort of “default” state. AirBnb and The Guardian, for example, decide to assume the user is not logged in.

I think there's a better approach though. Let's take a lesson from cereal manufacturers.

I noticed something while I was at the grocery store. The expiration dates are printed separately, after the fact:

The top of a Lucky Charms box, showing how the expiration date is stamped imprecisely onto a large blue rectangleThe top of a Cheerios box, matching the Lucky Charms box with a blue rectangle and stamped expiration date

Cereal boxes are produced in bulk, potentially months in advance, possibly contracted out to a different company. When the boxes are first printed, we have no idea when the cereal in that particular box will expire. The cereal probably doesn't even exist yet!

And so, we leave a blank blue rectangle which will eventually show an expiration date.

Then, when the cereal has been produced and has been packed into the box, we can stamp on the particular expiration date for each box.

I call this a two-pass strategy. First we print the generic parts, the cultural-appropriation leprechauns and the talking bees. Then, when we have the specific information for each box, we do a second pass and fill in those details.

This is my preferred stategy for these types of situations. Rather than render something which is potentially wrong (like the number 0 in our Counter button), what if we render a placeholder?

For example, why not render a loading spinner inside our button, for that first render?

Here's what this looks like in code:

'use client';
import React from 'react';
import Spinner from '../Spinner';
function Counter() {
const [count, setCount] = React.useState(null);
React.useEffect(() => {
const savedValue = window.localStorage.getItem('saved-count');
setCount(savedValue ? Number(savedValue) : 0)
}, []);
React.useEffect(() => {
if (typeof count === 'number') {
window.localStorage.setItem('saved-count', count);
}
}, [count]);
return (
<button
className="count-btn"
onClick={() => setCount(count + 1)}
>
Count:{' '}
{typeof count === 'number' ? count : <Spinner />}
</button>
);
}
export default Counter;

We initialize the count variable to null. In that first pass, we don't yet know if the user has a saved value or not, and so we aren't ready to show any number.

Inside the <button>, we render count if it's a number. Otherwise, we show a loading spinner.

After hydration, the effect runs. We check if we have a saved value. If so, that becomes the new value for count. Otherwise, we set it to 0. Either way, count becomes a number, and the loading indicator is swapped out for the correct value.

This feels a lot more honest to me. Instead of picking the most likely value and showing it to everyone (even when it's not the correct value), we're letting folks know to hang on a sec while we update the UI.