NextJS Static Render & Performance

Evan Williams
7 min readFeb 16, 2022
Lighthouse scores for FIcarious.com, mobile

This is a follow up to the blog describing SEO work that leveraged NextJS’s static rendering. I highly recommend starting there!

Google adds Core Web Vitals to ranking criteria

In July of 2021, Google started including Core Web Vitals as a criteria for search, giving developers everywhere an excuse to focus on web performance. All the work we had done from November through March now became less critical because the content in the initial page load was being de-ranked due to performance.

At first, this was a daunting problem. We were definitely far from metrics that google would have seen as acceptable. As a result, we were being de-ranked compared to our competitors. At first blush, this seemed like a massive problem and another re-write was inevitable.

As it turned out, however, our decision to move to NextJS & leverage static rendering was a blessing to this problem.

The core of core web vitals

Looking at the key figures of Core Web Vitals (First contentful paint, Largest contentful paint, Cumulative layout shift, and First input delay), it turns out that a statically rendered website is ideal for all of those metrics.

In essence, FCP, LCP and CLS are practically the same problem. If you are able to respond to the request for a webpage with the elements already in the initial page hit, then it is relatively easy to have a strong FCP & LCP (even better if the largest contentful paint is determined to be a text element as opposed to, say, a background image). Since statically rendered pages serve an initial rendering of the React tree, this is the default behavior of our NextJS solution.

Even better, if the bulk of your content above the fold is able to be statically rendered, this means you have near-zero cumulative layout shift. After all, if all the content is there on the initial painting of the page, there’s nothing to re-paint and nothing to shift!

FID is additionally improved by having a smaller execution phase to re-populate the React tree since the browser doesn’t have to append the elements to the mounted node & paint the new elements.

These benefits would have also been realized by Server Side Rendering, but static rendering has an added benefit of being cached for all requests and therefore saving server evaluation & the subsequent document generation. As a result, it can also be efficiently cached. Both of these details help decrease time to interactive as the time to first byte is quicker!

Advanced performance

In my year of developing on a statically rendered NextJS, it has stood up really well to performance efforts!

FCP came out of the box for free, for us. Having content rendered on document load meant that metric got improved immediately.

For those experiences where the largest element painted on page load was something able to be styled via the existing style element in the initial page, LCP also was improved for free!

The great part about NextJS is it gives you the ability to do things like add preload elements into the head for things like images. If you know that an image is going to be necessary in the experience, you can add a <link rel="preload" href="image" /> into the head and get that bad boy requested before the browser even evaluated the DOM & the CSS that may require that image! This can really help LCP for those media rich pages.

CLS gets…interesting. There’s a lot you can do with static rendering to help offset CLS. However, there’s also limitations. Since the content on the page that does need to be dynamic cannot be provided on page load, it’s likely that it will cause some form of CLS. Chrome provides great tools to help identify this layout shift and can give you an idea of how to account for it.

An example of CLS recorded in Chrome’s performance tab

An example: we have a 3 digit number that is populated by an API. We can’t pre-render with a fake number because users could read that before it gets populated. However, we can put non-breaking white space characters that take up roughly 3 digits of space to force the element to be approximately the same size before & after the API response.

These can range from simple fixes like the one above, to pretty complex ones like ensuring that the framework for a page whose content is determined by a hash router is pre-rendered and only the hash based content changes on page load.

Really advanced performance

One of the cool things I identified is something really subtle about React, that can be leveraged in meaningful ways. Nestled in a github issue, one of the maintainers highlights that, if you set dangerouslySetInnerHTML to something other than what the server produced, you can circumvent rehydration.

What this effectively allows you to do, is to short circuit one of the most expensive parts of a pre-rendered page: rehydrating content that doesn’t need it.

My website, ficarious.com, is built by providing Markdown strings to a react-markdown element. These markdown strings can be pretty substantial (I’m a wordy blogger, as you may have recognized by now), and the act of evaluating the string and converting it from Markdown to HTML is pretty substantial. This lead to nearly 800ms of blocking time on hydration. By building a ServerSideOnly component (in this case build time only), I was able to eliminate over 600ms of blocking time.

This approach can be applied to any component in the tree that is entirely static and does not need to be hydrated. This means it must not have any event listeners or statefulness.

Really, really advanced performance

Ok, so now this is where I’m hitting the limits of the system as it stands today. At this point I’ve eliminated the evaluation of the Markdown, but there’s still two things I could continue to eliminate:

  • The markdown being sent over the wire
  • The library to interpret the markdown on the client side

On larger pages, it amounts to about 4.5KB of markdown being included in the page props. These page props are JSON that is inserted into the document for NextJS to then populate into the React hydrate call. They’re necessary for most static rendering to occur, but we know we don’t use the Markdown.

I also know that I don’t need the library that renders the Markdown. React Markdown amounts to 41.55KB and is present on each and every page that leverages markdown (that’s all of them).

These two combined would amount to over 10% of the payload for the entire application!

webpack-bundle-analyzer result

I haven’t found out how to do either yet, but when I do, I will let you know!

Edit! I love doing these. It turns out, all I needed was a night of rest and a coworker to highlight a line in my blog to make the light bulb go off. It turns out, removing the necessary modules was a 2 liner!

Thinking about it critically, we know three things:

  1. The browser is not rehydrating the markdown element
  2. The markdown element is a Javascript module with a targetable name
  3. We can target isServer in our next.config.js to modify the webpack config for each side of the build

Putting these all together I can provide the reasonable solution!

if (!isServer) {
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(
/react-markdown/,
path.join(__dirname, 'empty.js')
)
);
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(
/remark-gfm/,
path.join(__dirname, 'empty.js')
)
);}

The end result is /remark-markdown/ and /remark-gfm/ matches the modules with an empty module (literally an empty file). This replaces the contents of MarkdownConverter inclusive of all its dependencies!

The result? I eliminated the 41.55KB (and then a little bit more) of Javascript from the page!

I also noticed that all of the markdown files produce a module…so going back to that drawing board now!

The future?

I’ve now spent a few thousand words convincing you on the SEO & performance benefits of NextJS’s static rendering…however, hear me out.

React 18’s server components do all the things I just talked about. Don’t have to render the element? Then don’t send anything necessary to render an element. It’s just HTML! For sites like mine that have limited or no API requirements, it will be blazingly fast with almost no JavaScript sent to the page.

I attempted to try it out on the release candidate, but Chakra unfortunately makes that impossible.

--

--