Architecting NextJS Applications for Better Performance
Posted on Oct 2nd, 2024 in JAM Stack
Explore how to optimize React 18 applications using Next.js for better performance and SEO. It covers the use of Server Components to reduce JavaScript on the client, improve Core Web Vitals (CWV), and enhance User Experience (UX). Key strategies include isolating client components, minimizing their size, and balancing client-server rendering. It also highlights Next.js’s render lifecycle and provides tips for building faster, SEO-friendly applications.
With the introduction of Server Components into React 18, and NextJS’s rapid adoption of them, modern react architecture has gotten more challenging to get right. This is especially true of SEO sensitive applications, and applications where good CWV and UX is important (which should be almost all applications). In order to take full advantage of this paradigm shift, you need to build you react application differently than you would for a traditional React SPA application.
On top of server components and in React 18, NextJS also introduces some limitations and considerations that you must be aware of to build trully optimal experiences for your users. For example, learning how NextJS determines which bits assets need to be included into your client bundle can lead to incredible wins.
Summary
- Minimizing the amount of work a browser has to render a NextJS page improves our CWV
- This is achievable by reducing the JS that is served to the client and reducing the amount of work Next must do to hydrate React on the client.
- In general, there are three rules of thumb that ensure only what is needed is delivered to the client:
- Have a hard isolation between client and server components
- Try to Isolate Client Components to Leaf Nodes
- Keep Client Components as thin as possible
The NextJS Render lifecycle
The standard NextJS render path can be thought of as an extension of the browser’s critical rendering path. To render a Next app on the browser, the following steps must be taken:
- Browser loads Server Side Rendered HTML, CSS and JavaScript
- Browser builds out its initial Render Tree
- After the Javascript assets are loaded, parsed and executed, Client Side React is hydrated
- The page is now interactive and ready for use.
What is the Browser’s Render Tree?
The Browser’s Render Tree combines the Document Object Model (DOM) and the CSS Object Model (CSSOM) to instruct the browser on how to render your page. It’s a key part of the browser’s critical rendering path, meaning it must be built before the page can appear. Since this process blocks rendering until completion, it directly affects your Core Web Vitals.
To build the Browser’s Render Tree, the browser first loads your HTML from the server. It then lexes and parses the HTML into the DOM (for more on lexing and parsing, check out my series on compilers). While parsing the HTML, the browser also loads other assets, such as CSS. When it encounters a <link>
or <style>
tag, the browser pauses Render Tree construction until the CSS is parsed into the CSSOM.
Part of generating the render tree is also discovering, parsing, and executing the Javascript it discovers while generating the DOM. This part has two important rules:
- JS execution blocks on the CSSOM
- DOM Construction Blocks on JS execution (unless the JS is marked as async)
How NextJS plays into this
NextJS adds an additional step over the traditional Browser Render path by introducing a new step: React Hydration. In order to hydrate the client, react takes the following steps:
-
- Parse the Container’s HTML into a React Fiber Tree
- Render out the application’s expected React Fiber Tree
- Diff the Two and reconcile the difference
- Update the container like a normal React render
NextJS marks all of its JS assets as async
, which will prevent these scripts from mostly blocking the critical render path. While this may seem great at first, this could impact our CWV pretty hard. Firstly, if the client side bundle does something such as add an element, or change some CSS state, then you can really hurt your LCP and CLS scores. This is also one of the reasons why MUI had an issue with flashing Styles.
<script type="text/javascript" async="" src="https://www.googletagmanager.com/gtag/js?id=G-RB0PVDWB5R&l=dataLayer&cx=c"></script>
<script src="/_next/static/chunks/main-app.js?v=1727918250767" async=""></script>
<script src="/_next/static/chunks/app-pages-internals.js" async=""></script>
<script src="/_next/static/chunks/app/layout.js" async=""></script><script src="/_next/static/chunks/app/(nav)/posts/%5Bcategory%5D/%5Bslug%5D/page.js" async=""></script>
<script src="/_next/static/chunks/app/(nav)/layout.js" async="">
Secondly, the delayed loading of the assets can impact Interaction to Next Paint (INP). This metric measures the time it takes for a user’s interaction (such as typing, or clicking on a drop down menu) to trigger a new paint on the screen (a typeahead being shown, or the drop down opening). Since the CSS and the HTML are rendered on the server, the browser builds the object Models, and all of the JS assets are rendered as async
so the browser will not block rendering for the assets. Now a button may be rendered for a user before the JS is loaded that actually handles the interaction, making the button unusable before the JavaScript is loaded, parsed, and executed.
So what does this mean?
In order to build applications with great performance we need to focus on three core areas:
-
- Minimize the number of critical path blocking resources
- Reduce the size of assets loaded for interactive elements, particularly Javascript assets
- Reduce the amount of work react needs to do hydrate the page for initial load.
Due to the way NextJS operates, and the abstractions it provides, developers do have control over these areas. By intentionally architecting our application to properly take advantage of server and client components, we can easily assert this control and build extremely performant applications.
Interleaving Client and Server components
One of the things that throws new NextJS developers for a loop is how Client and Server components interact in order to deliver an HTML page. Unlike vanilla client side react, passing children has huge implications for what Next determines client and server Javascript. Take the following project for example: https://github.com/radding/next-js-client-server-demonstration
In this project, we have two pages that do example the same thing: /client-example
and /server-example
. Both of these pages has a button that when clicked modifies the state in the component to iterate some state. In vanilla React, you may do something like this:
'use client'
import { useState } from "react";
import { Explaination } from "./Explaination";
import { SillyFib } from "./SillyFibb";
export const ClientExample = () => {
const [buttonCount, setButtonCount] = useState(0);
return (
<main>
<h1>Hello, you pressed this button {buttonCount} times!</h1>
<button onClick={() => setButtonCount(buttonCount + 1)}>Press Me!</button>
<Explaination inClientExample />
<div>
<SillyFib number={10} inClientExample />
</div>
</main>
);
};
This works fine for simple React applications. However in the Next JS world, this component treat the <Explanation />
and <SillyFib />
component as other Client components, even though both of which could simply be server components. Bundling this Javascript has the potential to harm all of our core areas:
-
- It increases the number of critical rendering resources
- It unintentionally increases the size of the frontend assets needed to run the page
- It increases the amount of work React needs to do to hydrate a page.
If we simply created a new Client Component like this:
"use client";
import { PropsWithChildren, useState } from "react";
export const ServerExample = (props: PropsWithChildren) => {
const [buttonCount, setButtonCount] = useState(0);
return (
<main>
<h1>Hello, you pressed this button {buttonCount} times!</h1>
<button onClick={() => setButtonCount(buttonCount + 1)}>Press Me!</button>
{props.children}
</main>
);
};
And render the class like this:
import { Explaination } from "../components/Explaination";
import { ServerExample } from "../components/ServerExample";
import { SillyFib } from "../components/SillyFibb";
export default async function Page() {
return (
<ServerExample>
<Explaination inClientExample={false} />
<div>
<SillyFib number={10} inClientExample={false} />
</div>
</ServerExample>
);
}
Next will now understand exactly what is client and what is server. The HTML rendered by both of these examples are exactly the same, but the JS bundle is smaller and the amount of executing JS is also much smaller for the server example. Even in this contrived example, I was able to reduce LCP, FCP and INP by ~25%. In a more complicated project, these wins will be even more pronounced.
Three Architectural Rules of Thumb to follow
Have a hard isolation between client and server components
Server Components can directly Render Client components and server components, where as Client components can only directly Render other Client components, unless the server components are passed into the client as props (for example the children prop). This means you should keep your Client components as thin as possible to achieve your client side needs, and pass all of your other components as server components.
Try to Isolate Client Components to Leaf Nodes
Push all of our client components as far down the NextJS render tree as you can. Most of the elements that need client side functionality will naturally be closer to the edges of the page rather than further up the tree closer to the root. A client component may have a few children, but if the whole page is wrapped in a child component, that typically points to an architectural deficiency. Notable exceptions to this could include Context providers for things like analytics or monitoring.
Keep Client Components as thin as possible
Similar to the single responsibility principle, strive to keep client components focused only on what is needed to achieve the client side interaction that is needed. Focus on the smallest amount of code to maintain client side state, the fewest number of HTML elements needed, and render everything else as a server component. A classic example of this is a dropdown menu. Instead of making the entire component a client component, make the button and the html element that shows/hides the content a client component, then make the content of the drop down should be a server component.
By understanding how a browser and NextJS works together to render your application, you can make decisions and solutions that make a page extremely performant, SEO friendly and simple to maintain and architect. By focusing on limiting the number, size, and scope of client components, you can have extremely quick and small pages. Even a small example like the one shared boasts massive improvements in CWV by simply following these rules.