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

  1. Minimizing the amount of work a browser has to render a NextJS page improves our CWV
  2. 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.
  3. In general, there are three rules of thumb that ensure only what is needed is delivered to the client:
    1. Have a hard isolation between client and server components
    2. Try to Isolate Client Components to Leaf Nodes
    3. 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:

  1. Browser loads Server Side Rendered HTML, CSS and JavaScript
  2. Browser builds out its initial Render Tree
  3. After the Javascript assets are loaded, parsed and executed, Client Side React is hydrated
  4. 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:

  1. JS execution blocks on the CSSOM
  2. 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:

    1. Parse the Container’s HTML into a React Fiber Tree
    2. Render out the application’s expected React Fiber Tree
    3. Diff the Two and reconcile the difference
    4. 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&amp;l=dataLayer&amp;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:

    1. Minimize the number of critical path blocking resources
    2. Reduce the size of assets loaded for interactive elements, particularly Javascript assets
    3. 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:

    1. It increases the number of critical rendering resources
    2. It unintentionally increases the size of the frontend assets needed to run the page
    3. 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.

Read More Related Posts

Read Latest Posts

Server Side State management in NextJS: a deep dive into React Cache

Posted in NextJS on Oct 18th, 2024

In previous posts, I explored how Server Components in NextJS improve performance by offloading more rendering to the server. Managing server-side state differs from client-side, as it’s static and immutable during a single render. NextJS provides tools like cache, unstable_cache, and patched fetch to manage this state efficiently, reducing prop drilling and simplifying code. These tools offer caching across requests, but require a shift from traditional client-side approaches for better performance

Read More->

NextJS foot guns: Over-reliance on Client Side State

Posted in NextJS on Oct 10th, 2024

This article builds on “Architecting NextJS Applications for Better Performance,” focusing on optimizing state management in NextJS projects. It explores the pitfalls of overusing Context providers and client-side state, which can bloat bundles and hurt performance. By strategically identifying which parts of your app should be client or server components, you can leverage NextJS’s capabilities to build faster, more efficient applications.

Read More->

Architecting NextJS Applications for Better Performance

Posted in JAM Stack on Oct 2nd, 2024

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.

Read More->

Authorization in GraphQL

Posted in GraphQL on Jun 8th, 2020

GraphQL is a great new technology. It fills many gaps the REST leaves. However, there are challengs with GraphQL. One of those challenges are authorization and authentication.

Read More->