Practical tips to make your NextJS app faster

Masoud Bonabi
7 min readOct 6, 2023

--

HTML & CSS have regular grammars. and most programming languages like JS are context-free. Chomsky-hierarchy.svg | credited: Wikipedia | CC BY-SA 3.0

The story behind

One of Snapp’s ventures invited me the other day to their office to talk about the improvement of the performance of their NextJS application. Their application wasn’t slow but they had dedicated a lot of resource units for their NextJS pods on their K8s production nodes. And each pod was able to respond to only 3 requests per second! In this article I’m gonna cover some key points I advised them to do.

Introduction

Server-side rendered apps in general might have two main bottlenecks that could affect First Paint (which is the same as First Contentful Paint for a NextJS app):

  1. Data fetching (or any kind of I/O)
  2. HTML generation (aka rendering)

These two steps are essential to get the content delivered to the user and implementing them without paying enough attention to performance will lead to some serious problems in the production environment later on that I mentioned some of them above.

What NextJS is and What It Isn’t

NextJS’s sole purpose is to help you with Server-Side Rendering in order to get a better rank in search results of Search Engines. It’s not about the:

  • image, font, or styling optimizations
  • Route handling, advanced routing & nested layouts
  • its cool middlewares
  • fast compile time

Each of these other purposes can be achieved by some other techniques to prevent the addition of NextJS to the circuit.

Here are some examples of the businesses where NextJS can be useful:

  • An online shop: It’s crucial to land the users on our PDP pages for the business to help the users.
  • Medium (a side question: Does Google buy a premium version of Medium in order to be able to index all the articles? :D)
  • Most of the websites that are built by Content Management Systems (another side question: Is SEO important for a proficient well-known doctor’s personal website? Or does he/she only want the website for his/her personal branding purposes?)

A general rule of thumb for developers is to avoid complexities of any kind (especially adding third-party libraries) as much as possible unless there’s a solid reason for that!

Here are some examples of where NextJS and its SSR aren’t appreciated!

  • A P2P chat app
  • Your company’s Back-Office
  • An internal Customer Relationship Management system of a company
  • A monitoring service! (Like a local instance of your tech company’s Sentry or Graphana panel)
  • Huge general platforms (like Instagram, Facebook, or WeChat) — Their business forces them to ignore other platforms

In the cases mentioned above, NextJS won’t be a good option for you or your company. The only exceptional case is when your company’s front-end human resources only have knowledge of NextJS (and not Vite, CRA, or anything else) and you have a hard deadline that prevents your admirable resources from getting started with these great tools.

This article’s example

In order to show you how these tricks can be implemented, I need to demonstrate a simple example first. So, I went to NextJS’s installation documentation and initiated a basic NextJS app. Then I found some free API on the internet and added a single file src/app/artworks/page.js with these contents:

const apiLink = "https://api.artic.edu/api/v1/artworks/search";

function getArtworks(category) {
return fetch(`${apiLink}?q=${category}&limit=30`)
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch data");
}
return res.json();
})
.then((res) => res.data);
}

export default function ArtworksSSR({ params }) {
const category = params.category || "cats";
return getArtworks(category).then((data) => (
<Artworks category={category} data={data} />
));
}

function Artworks({ category, data }) {
return (
<>
<h1>{category}</h1>
<main style={{ display: "flex", flexWrap: "wrap", gap: "2rem" }}>
{data.map((artwork) => (
<Artwork key={artwork.id} data={artwork} />
))}
</main>
</>
);
}

function Artwork({ data }) {
return (
<div style={{ flex: "1 1 30%", padding: "1rem" }}>
<img
style={{ width: "100%", height: "100px" }}
src={data.thumbnail.lqip}
alt={data.thumbnail.alt_text}
/>
<div>{data.title}</div>
</div>
);
}

As you can see, I’ve defined four separate functions:

  • getArtworks: Which uses fetch function to get the data I need from the mentioned API.
  • ArtworksSSR: This is the default export of my function that tells NextJS to render it on the server. It uses getArtworks to get data and then render it inside my UI component.
  • Artworks: Populates my main tag with the data.
  • Artwork: A simple presentational component to render each item.

Now that we have a baseline and the context, let’s get to the practical tips.

1. Use SSR only for crawler bots (or at least only un-authenticated users)

SSR is a CPU-intensive task. I’m not gonna directly point to the reason. But here is a related fun answer that might make you interested to investigate more on the reason!

If your NextJS server is not able to generate a lot of HTML responses per second, a simple solution is to make it skip most of the requests by making contentless unpopulated HTML responses.

Example of conditional SSR based on the request’s user-agent

There are multiple ways to detect if a request is coming from the crawler of a Search Engine. But for the purposes of this article, I’m gonna use the most basic solution.


export default function ArtworksSSR({ params }) {
const category = params.category || "cats";

const headersList = headers();
const userAgent = headersList.headers["user-agent"];

if (userAgent.match(/googlebot|google\.com\/bot\.html/i)) {
return getArtworks(category).then((data) => (
<Artworks category={category} data={data} />
));
}

// For clients other than googlebot
return <Artworks category={category} />;
}

Note that I’m not passing data prop to my UI component at the latest line. I’ll fetch it once my bundle is loaded on the client side instead.

I’m gonna keep the code as simple as possible, and that’s why I’ll be using useState and useEffect to do my data fetching. That’s why I’ll have to separate the UI component into a different file so that I can mark it as a “client component” for NextJS. For a real-world application, you might wanna use react-query, or some other techniques. anyways, here is my src/app/artworks/component.js:

'use client'

import { useEffect, useState } from "react";
import getArtworks from "./api";

export default function Artworks({ category, data }) {
const [artworks, setArtworks] = useState(data || [])

useEffect(() => {
if (data) return

getArtworks(category).then(setArtworks)
}, [category])

return (
<>
<h1>{category} </h1>
<main style={{ display: "flex", flexWrap: "wrap", gap: "2rem" }}>
{artworks.map((artwork) => (
<Artwork key={artwork.id} data={artwork} />
))}
</main>
</>
);
}

function Artwork({ data }) {
return (
<div style={{ flex: "1 1 30%", padding: "1rem" }}>
<img
style={{ width: "100%", height: "100px" }}
src={data.thumbnail.lqip}
alt={data.thumbnail.alt_text}
/>
<div>{data.title}</div>
</div>
);
}

Now that Artworks is a client component, NextJS will serialize its props to pass it to the component once it’s rendered on the client. But if my component does not receive the data prop, it will attempt to make the request itself.

The last newly created file has nothing new src/app/artworks/api.js:

const apiLink = "https://api.artic.edu/api/v1/artworks/search";

export default function getArtworks(category) {
return fetch(`${apiLink}?q=${category}&limit=30`)
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch data");
}
return res.json();
})
.then((res) => res.data.filter((d) => d.thumbnail));
}

I only have to separate this function so that I can use it both in my server and client components.

This technique will decrease the server’s load by limiting some I/O tasks (data fetching) and preventing it from generating an excessive HTML response.

2. Try to keep your data fetching as asynchronous as possible

Implement the previously discussed technique and your server’s performance will boost dramatically. But it might not be enough! If your application makes 10 subsequent requests (API calls) for each web page that Googlebot is asking for, your response time would roughly be the sum of the response time for those API calls, plus the time needed to generate the HTML string. In order to make it less time-consuming, try to keep those API calls parallel.

You’ve probably heard about this tip before, so I’m not gonna say a lot about it. Just take a look at this code:


export async function getServerSideProps({
req,
res,
}: {
req: NextRequest;
res: NextResponse;
}) {
const token = req.cookies[`${process.env['TOKEN']}`];
const queryClient = getQueryClient();
await Promise.all([
queryClient.prefetchQuery(['skills-query', ''], () =>
api.get_skills({ token })
),
queryClient.prefetchQuery('profile', () =>
api.get_profile({ token, role: req.cookies['role'] })
),
queryClient.prefetchQuery('get-languages', () =>
api.get_languages({ token })
),
queryClient.prefetchQuery('expertise', () =>
api.get_expertise({ token })
),

queryClient.prefetchQuery('certificate-question', () =>
api.get_certificate_questions({ token })
),
]);

return {
props: {
dehydratedState: dehydrate(queryClient)
},
};
}

It’s using Promise.all() to ensure all the requests are made asynchronously and nothing is dependent on the others.

3. Add Cache

Another useful technique for websites with a lot of web pages is to use a cache. You can implement some sort of caching mechanism to your generated HTML to make sure that the CPU won’t be bothered to re-generate the same content. Just remember to make sure that:

  1. The cache keys are unique enough to prevent any kind of data duplication.
  2. You have a cache invalidation mechanism to update the HTML response whenever your data is changed.

There are only two hard things in Computer Science: cache invalidation and naming things.

— Phil Karlton

4. Scale your NextJS application with PM2

PM2 is an advanced, production process manager for NodeJS. Its key features include:

  • process management (for each NextJS instance on the server)
  • memory management & max memory reload
  • log management
  • monitoring

As one of the NextJS processes is busy with a request, you can configure your PM2 to give the next request to another instance. This will help you handle more and more requests simultaneously.

Conclusion

These techniques should be enough for a common production environment developed with NextJS as they will optimize your code and its CPU & I/O usage. But they still might not fix every project!

For example, imagine retrieving a million rows of a MySQL table directly from NextJS’s server and then filtering out the unwanted results! :D

All I’m saying is that performance is an advanced topic and if you don’t investigate your code enough, no techniques or magics might be able to help you!

--

--