NextJS performances are pretty good out of the box. There are a lot of under the hood optimizations that happen without the user noticing it.

However, as the project grows, so does the bundle. Next will still attempt to have a small bundle, but it cannot fix a poor implementation.

The list of defendants can vary depending on the size of the app. This article will focus on the following points: lodash imports, dependencies updates, proper methods, and components imports and dependencies optimization.

The end goal is to reduce the bundle size and have an app as small as possible to make users and robots happy.

💡

A gist with all the code shared in this article is present at the end of the article

0. Creating a Baseline

Before doing any optimization, it's best to start with a baseline. It will not only help you measure your efforts but also help justify the time spent on a task that might look useless to the untrained eye.

I'll be using a project we are working on at my current work since it didn't see any optimization. The project is built using NextJS 12, Chakra-UI, Fontawesome and contains many dependencies such as Lodash, DayJS, Mixpanel, Google Analytics…

To help with the analysis, I'll be using the following dependencies and packages to analyze and visualize my bundle:

  • next-compose-plugin: that helps manage plugins on Next config file
  • next-bundle-analyzer: that will chart the bundle so we can see what takes up space.
  • source-map-explorer: helps visualize more precisely the content of the bundle. . This package can be globally installed.

Here is my current NextJS config file at the beginning of my investigations. Note that we'll enable the source maps on the production of analysis, but this should be removed at the end.

All the code are in a gist available at the end of the file

To use the next-bundle-analyzer library, we have to add the following command on the package.json: "analyze": "ANALYZE=true next build".

It's now possible to run the following commands to have the initial baseline:

  • pnpm run build: build the project and give information about the first JS load.
  • pnpm run analyze: will give a general idea of the repartition of the bundle.
  • source-map-explorer .next/static/**/*.js: more in-depth information about the package.

Here are the numbers in my case:

  • 2.23 MB bundle size with analyze
  • 2.08 MB bundle size with source-map-explorer
  • 656 kB loaded with 1.9 MB resources on network inspector

On top of those numbers, I ran web.dev/measure tests 5 times to have an average performance score. This number will help us see if we make any real-world improvements or if it's just a smokescreen. I got an average of 69.2.

1. Improve lodash Imports

Loads is a pretty standard library, and the chances that it's lying around in your project are pretty high. With 48 million downloads per week, the library is prevalent, and using it properly is critical and a quick fix.

The following image shows how optimizing imports can significantly impact the size of the files. There is a 10x factor depending on the method used when importing methods.

Obviously, the last one is recommended

We used the second method in our project since we thought it was the correct way to do it. Changing imports is pretty fast, and it only took 5 minutes to make the change in the project.


  • 2.17 MB bundle size with analyze (☺️ -2.691%)
  • 2.02 MB bundle size with source-map-explorer (☺️ -2.885%)
  • 69.2 as average performance score 0
  • 632 kB loaded with 1.8 MB resources on network inspector (☺️ -3.659%)

The changes are pretty marginal, but so is the effort to achieve them. This is why taking the time to make the improvements in the first recommended list.

2. Use Dynamic Imports

Next supports ES2020 dynamic imports. This means it's possible to dynamically import the components that aren't displayed by default.

A modal, an error warning, and a function only triggered with user interaction are all elements that aren't required by default. Dynamic imports provide a simple way to handle those cases.

The rule is pretty simple. Everything that is conditionally displayed can be dynamically imported. Look for all the {VARIABLE && … in your code, and you should see some components that can be changed.

In that case, the ServiceBadge will only be loaded if we're on mobile

Besides, look at the methods that are executed on user inputs. Imports what they need inside them instead of at the top of the file. This way, you'll only load the methods when you need them instead of every time.

We only load the toast if the user clicks on the logout button

  • 2.21 MB bundle size with analyze (+1.843%)
  • 2.06 MB bundle size with source-map-explorer (+1.980%)
  • 72 as the average performance score (+2.2)
  • 568 kB loaded with 1.6 MB resources on network inspector (🤯 -10.127%)

Dynamically importing the components has an enormous impact on the size of the loaded JS when opening the website. This simple change reduced the initial load by 10%, which is significant.

The reduction alone explains why the performance score improved by more than 2 points. Using dynamics imports definitely helps, and the result will be even more significant with a more complex page.

3. Analyze the Bundle

This step is the one that can have the most significant impact, and it's also the one that can take the longest time since it will depend on many factors. Thanks to the bundle-analyzer and source-map-explorer, we have a clear view of what's happening in the bundle and what could be changed.

There are two places to look at at this stage: if there is a large file imported and if there are a lot of smaller files. I don't know how much an optimized bundle should weigh or how many files it should contain. For sure, both numbers should be as little as possible.

During my investigation, I discovered that some libraries were expensive and that react-syntax-highlight had a lot of files that were taking up a lot of space. You can see them in the following screenshots.

Validator definitively takes too much space and all the react-syntax-highlight seems fishy!

The analysis told me that I have to look at some libraries: validator, framer, mixpanel, fontawesome, and react-code-blocs.

I won't go into too many details in this article, but here is a quick summary of what I did for every library:

  • validator: changed the way I import methods and use lodashisEmpty instead of the one from the validator.
  • framer: did some vanilla animation for more specific elements and kept everything as it is for the more complex one.
  • mixpanel: removed the dependency since the data wasn't used, and we had other tools for that
  • fontawesome: sadly, I wasn't able to reduce the size of the imports
  • react-code-blocs: removed the library and directly used react-syntax-highlight instead since it was possible to only import what was required.

Besides those actions, I did some library cleaning and replaced them with vanilla TypeScript since they didn't provide much improvement. I also took the time to update every package to its latest version in hopes of gains (thanks to ncu).

Finally, I performed some code analysis and removed the old and unused code. This step might not yield a smaller bundle, but it's definitely positive for code quality and ease of use.

  • 1,59 MB bundle size with analyze (🔥🔥 -28,054%)
  • 1,45 MB bundle size with source-map-explorer (🔥🔥 -29,612%)
  • 73 as average performance score (+1)
  • 489 kB loaded with 1,3MB resources on network inspector (🔥🔥 -13,908%)

I had a feeling that this step would be the one that brought the most results, but I wasn't expecting such an improvement!

Shaving 30% of the bundle size by simply making smarter choices when it comes to library selection of feature implementation seems crazy.

4. Do Not Use Server Data in useEffects

Next offers the possibility of fetching data on the server and using it on the client. This helps speed the page since data bandwidth is probably faster than your internet connection. Besides, having the data on page loads reduces the number of network calls.

Next, even offer the option to do incremental static generation, and it's a feature I highly recommend since it allows near-instant page load while ensuring that data stays fresh.

Getting data from the server is excellent. However, it would be a shame to use that data and manipulate it on the client.

Doing so means that Next won't be able to compile the page on the server. Some components will be loaded on the client resulting in layout shift or poorer performances.


Consider the following example. We fetch articles on the server, incrementally regenerate the page every 15 minutes (thanks to the revalidate: 900).

However, we have a useEffect which is guaranteed to run on the client that sets a state where the article is saved. This is bad since Next won't be able to build this page and have it pre-generated.

Instead, this is what should have been done. Directly access the data in the return statement, and the article will be present when the page is built on the server.c

One last note: it's expected that some data must be manipulated on the client. However, there are cases where the data is fetched from an API and displayed (as it's the case in the above examples). In those cases, please don't manipulate the data on the client and directly display it, otherwise the data won't be present on page load.

Conclusion

The following table shows how our different actions impacted the bundle size or the performance score. Not every step has the same impact, and the results you might experience will significantly vary.

There are two apparent winners, using dynamic imports for UI elements that aren't displayed by default and investigating the libraries that weigh down the bundle.

Dynamic imports are an excellent NextJS feature, and it allows for a more minor downloaded javascript. It was possible to reduce by 64 kB the JavaScript downloaded when loading the homepage, and the difference can be even more significant for more complex pages resulting in a near-instant page load.

Analyzing the bundle is by far the task that took me the longest. Fixing some libraries can result in lib change, making things harder. It's important to stay rational and not spend countless hours trying to remove one library your project depends too much on.

I have to admit that improving lodash imports had a marginal impact. However, the size change will be dependent on how much the library is used on your project. Besides, it's a quick job, and it would be a shame to not keep the old imports.

Final build output. The result might not be very impressive but you can feel them!

The investigations I conducted were quite exciting and helped me understand many things regarding Next and bundling. It was a fantastic learning experience that will benefit my team and our customer. I can already see room elements that have to change so we can deliver even faster websites!


I hope you find exciting elements and that you'll be able to reduce the loading time of your website! Don't hesitate to share other tips that I didn't cover in this article. You can also register for my newsletter to receive an email when I publish a new article!


All the code of the screenshots can be found in this gist