Reducing your Next.js bundle size

  • Next.js
  • Performance
  • Optimization

Written on

Having a small JavaScript bundle size is important for having better performance when loading your web apps. The less Javascript to download and wait for, the better!

While developing my blog using Next.js, I periodically (manually) run yarn build to make sure the bundle size hasn't increased too much. For my website/blog I know this means around 70KBs.

Last time I ran it, I was surprised to see this:

Build Size - Before
Build Size - Before

That's 164KBs, more than double what I expected it to be!

Since this was happening just for the Blog pages, my intuition told me that the issue was related to the Markdown parser.

It's better to have more information and certainty before jumping into doing a few fixes in the dark, so I decided to start by inspecting the Webpack bundle.

Inspecting the Webpack Bundle

One of the advantages of Next.js is its big ecosystem of plugins and examples. For analyzing the bundle size, there's a plugin @next/bundle-analyzer, that uses webpack-bundle-analyzer under the hood.

Setting it up is simple, by following their README or the analyze-bundles example.

Install @next/bundle-analyzer:

yarn add -D @next/bundle-analyzer

Modify your next.config.js to include the plugin in your build process:

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})
// replace the {} argument with an existing NextJS config, if applicable
module.exports = withBundleAnalyzer({})

If you run your build command with the environment variable ANALYZE=true, it will generate a visualizer for your client and server bundles. Since my blog is fully static, I only care about the client bundle (the server bundle wouldn't be downloaded by the user either way, but it's also good to keep it small, to decrease its memory footprint, in case you use server-side rendering).

ANALYZE=true yarn build

Here's what it looked like:

Analyzer - Before
Analyzer - Before

Interpreting the results

Each box shows us a few modules that are contributing to the big bundle size. The bigger the box, the bigger the module.

We can see a bunch of boxes that are part of Next.js. Those are small enough to not be a worry and are essential parts of the client-side navigation, so they make sense in our client bundle.

There's a big react-dom box which could be replaced by Preact (a mostly compatible, smaller alternative to React). I prefer to keep using React since Next.js has first-class support for it (I don't want to run into any obscure issues due to updates to Preact or Next.js). There's a good example on how to use Preact in Next.js, if you are interested in minimizing the bundle size further.

There are two big modules that I could potentially move out of the client bundle:

  • sanitize-html
  • marked

Why were they loaded into the client module in the first place?

These two modules are used by the Markdown parser. When I first created my blog, I've decided to parse Markdown on the client when navigating between pages in the SPA flow.

By implementing it that way, only the Markdown in text format would be downloaded, instead of parsing it on the server, which would bring the rendered HTML over the network.

This saves a few bytes when navigating between pages (since it won't have all the HTML tags), but what I didn't consider at the time, it does so at the expense of a considerable increase in bundle size since it will have to bundle sanitize-html and marked.

Moving these modules out of the client

The solution is to render the HTML for the Markdown in getStaticProps, instead of rendering it on a React component. I've removed the useMarkdown hook on the BlogMarkdown component, and instead, it just renders HTML content as is. It assumes the HTML is already sanitized by the server, to prevent XSS attacks.

export default function BlogMarkdown({ content }) {
  return (
    <div
      dangerouslySetInnerHTML={{
        __html: content,
      }}
    />
  )
}

The Markdown related modules (marked, sanitize-html, and hljs) are now contained into the markdownProcessor.

The markdownProcessor is used as the contentProcessor of a BlogPost, in its load() method.

And this processor is only called on the getStaticProps method of my BlogPost page.

These are the specific steps I took to address the issue. A general guideline to follow is this:

Consider moving your big modules out of the React components, and into getStaticProps or getServerProps when appropriate, to keep the client bundle size small.

Results

After doing this change, the bundle analyzer reported the following:

Analyzer - After
Analyzer - After

And Next.js build script confirmed that the bundle size decreased significantly:

Build Size - After
Build Size - After

There's no silver bullet

While monitoring and keeping the client bundle size small is generally a good thing (not just for user experience reasons, but also because it will make you think about your dependencies), there might be cases where it makes more sense to keep things on the client-side.

It's up you to to identify these opportunities and act on them. By using Next.js and webpack-bundle-analyzer you’ll act with the right information, and make the changes that matter to reduce your bundle size effectively.