Updated on Jun 12th, 20219 min readreactnextjs

How To Optimize Your Next.js Production Build

Next.js creates an optimized production build of our application out of the box and includes features like image optimization, page pre-rendering, code splitting and bundling. These features are important because website performance is important. Google Search Console now tracks a collection of signals that make up what they call Page Experience with an algorithm update to follow soon.

A portion of Page Experience are Core Web Vitals, which measure metrics such as Largest Contentful Paint and First Input Delay. These can be directly effected by how long it takes a Next.js application to load. Load time is directly affected by the size of JavaScript chunks which are bundled during the build process. Hence, therefore, thus it a good idea to read this article and learn more about analyzing and optimize a Next.js build.

While Google maintains the relevance is the primary factor in page ranking, these performance metrics can differentiate pages with similar relevance. Here is an excerpt from Google's documentation.

While page experience is important, Google still seeks to rank pages with the best information overall, even if the page experience is subpar. Great page experience doesn't override having great page content. However, in cases where there are many pages that may be similar in relevance, page experience can be much more important for visibility in Search.

In addition to search ranking, don't we want the users of our website to have the best experience possible? As web developers don't we want to learn new skills?

This article will describes the steps developers can take to analyze and optimize a Next.js build even more than the popular framework already does. To illustrate this process I will walk through the steps I took with this site.

Install Next Bundle Analyzer

There is a tool called Next Bundle Analyzer I used to visualize and take a more detailed look at the the size of various parts of my application.

Run the following command to add this package.

npm install @next/bundle-analyzer cross-env

Configure Next Bundle Analyzer

Next Bundle Analyzer works with the next build command. It is common practice to use the environment variable ANALYZE to enable the analyzer.

Add a new script to package.json that sets this environment variable and then runs the build command. The cross-env package is used before the environment variable assignment to ensure that the value is set properly across all platforms.

"analyze": "cross-env ANALYZE=true next build"

The next.config.js file must be updated (or created) to enable the bundle analyzer. Add the following code.

const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({ 
  // put the rest of config here
}}

Run The Analyze Script

Run the following command from the project's root directory.

npm run analyze

Assuming there are no errors during the process, Next.js will output a production build of the application. A report will be generated in the terminal and two browser windows should open, each containing a colorful visualization.

Analyze Next Build Logs

Next.js provides useful output to the terminal on every build. The following is this site's build summary prior to any optimization.

Pre Optimization Build Output
Pre Optimization Build Output

This report contains a lot of useful information about the application. Of primary concern is the First Load JS column. The value is represented in kilobytes. Next provides a red, yellow, green color code that indicates if the first load JavaScript is in the recommended range. With many pages over 300 kB and all red or yellow values, it is clear that the website is in need of help.

It is especially important to pay attention to the line labeled + First Load JS shared by all. This code is loaded on every page and generally consists of the components and dependencies from my custom _app.js and Layout components. This includes the navigation, search, footer, modals, and other code needed on all pages. The main takeaway is that optimizing these global components will have a positive impact across all other pages of the website.

This report is valuable because it gives a developer direction on where they should focus their optimization effort. It can also be used to measure progress, as the optimization process is iterative.

Take a quick screenshot of this report or make sure it can be accessed later.

Analyze Next Bundle Visualizations

Next Bundle Analyzer will output 2 data visualization into new browser windows during the build process. There is one chart for the client and one chart for the server. The following are this site's prior to optimization. Chunks of JavaScript are color coded and labeled.

Server Bundle Visualization
Server Bundle Visualization

On the most basic of levels, the server visualization helps illustrate the relative size difference between the various pages served by the application. The chart is clickable, zoomable and allows filtering. The name, size and path of each individual piece is provided. A drawer on the left side of the visualization provides even more functionality, allowing a developer to view their application on a very granular level.

Client Bundle Visualization
Client Bundle Visualization

The client visualization illustrates the various chunks of bundled JavaScript that support the different components and pages of the application. The chart provides insight into both the Next.js and third party dependencies. This gives the developer the ability to make informed value judgements about the usefulness of dependency versus its size.

Take a screenshot or save these HTML files for future reference.

Remove Unneeded Dependencies

Look for large dependency blocks, specifically in the client visualization, and confirm they are necessary for the website to function. If a relatively large block is necessary, do some research to find out if another, smaller package performs has the same or similar functionality.

The first thing I noticed when I analyzed the client visualization for my application was the relative size of Cloudinary Core. Cloudinary is a media management platform I use, and while Cloudinary Core provides useful functionality, my application was only using it to build a URL. In about 5 minutes I had figured out how to accomplish the same thing without the need of the external package. I then removed the import and uninstalled the package.

Another observation I had was the large number and size of dependencies that support parsing Markdown, converting it to HTML, and highlighting code. These are critical however, since my blog posts are written in Markdown, I share a fair amount of code, and enjoy a flexible parser configuration.

Replace Libraries With Your Own Code

If there is a large dependency performing a simple task, perhaps this can be coded without the external package.

In the past I have found even more egregious examples of bloated libraries. I was using a legacy date/time library that did not work well with modern tree shaking. The functionality of this library was critical to my application. In this case I substituted a newer package, which was so small I had to zoom in just to find it.

Implement Dynamic Imports

Perhaps the most effective measure to lower the size of + First Load JS shared by all, as well as individual pages, is to implement dynamic imports for JavaScript. This is a new feature introduced in JavaScript ES2020 that allows large chunks of code to be loaded when they are needed instead of during the initial page load.

Next.js has a built in support for dynamic imports with dynamic. Simply import the module and convert existing components.

import dynamic from 'next/dynamic'

const DynamicFooter = dynamic(() => import('@/components/Layout/Footer'))

Use a dynamic component just like any other component and Next.js takes care of the rest. Under the hood, Next.js will adjust the JavaScript chunks and load the dynamic component separately, when it is needed.

Next.js also provides the ability to supply a simpler loading component, as a second argument, to display before the more complex component is imported. Here is an example from the documentation.

const DynamicComponentWithCustomLoading = dynamic(
 () => import('@/components/Layout/Footer'),
  { loading: () => <p>...</p> }
)

Start by converting layout components or other components that appear on multiple pages to dynamic imports. After that move on to the individual pages, paying close attention to components that are under the fold or require user interaction to appear. These are prime candidates to be converted.

This is the actual Layout component from this blog.

Layout.js
import { Fragment, useEffect, useRef } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import throttle from 'lodash.throttle'

import Meta from '@/components/Layout/Meta'
import Navigation from '@/components/Layout/Navigation'
import StickyBar from '@/components/Layout/StickyBar'
import Main from '@/components/Layout/Main'

import { useAppDispatch } from '@/lib/context'
import { initializeUser, setProgress } from '@/lib/context/actions'
import { getPageHeight, gtagPageview } from '@/lib/utils'
import { CLIENT_URL } from '@/lib/constants'

const DynamicProgress = dynamic(() => import('@/components/Layout/Progress')) 
const DynamicFooter = dynamic(() => import('@/components/Layout/Footer')) 
const DynamicBackToTop = dynamic(() => import('@/components/Layout/BackToTop')) 
const DynamicContactModal = dynamic(() => import('@/components/ContactModal')) 

function Layout({ children, pageProps }) {
  const dispatch = useAppDispatch()
  const router = useRouter()
  const main = useRef()

  useEffect(() => {
    initializeUser(dispatch)
  }, [])

  useEffect(() => {
    const onScroll = throttle(() => {
      if (
        ['/', '/blog', '/about', '/frequently-asked-questions', '/privacy'].includes(
          router.pathname
        )
      ) {
        setProgress(dispatch, 0)
      } else {
        const { scrollY, innerHeight } = window
        const pageHeight = getPageHeight()
        let progress = !scrollY
          ? 0
          : scrollY + innerHeight >= pageHeight
          ? 100
          : Math.round(((scrollY + innerHeight * (scrollY / pageHeight)) / pageHeight) * 100)

        setProgress(dispatch, progress)
      }
    }, 100)

    window.addEventListener('scroll', onScroll)

    // Google Analytics page view
    gtagPageview(`${CLIENT_URL}${router.pathname}`)

    return () => {
      window.removeEventListener('scroll', onScroll)
    }
  }, [router.pathname])

  return (
    <Fragment>
      <Meta pageProps={pageProps} />
      <Navigation />
      <StickyBar />
      <DynamicProgress /> 
      <Main ref={main}>{children}</Main>
      <DynamicFooter recentPosts={pageProps?.recentPosts} />
      <DynamicBackToTop />
      <DynamicContactModal /> 
    </Fragment>
  )
}

export default Layout

In the code above I have converted 4 major layout components to be imported dynamically. These components include the footer, progress bar and modals that aren't needed when the page loads.

Continue to audit the entire application following the guidelines I have outlined.

Build The Application Again

After trimming unneeded dependencies and leveraging dynamic imports rebuild the Next.js application with the bundle analyzer. Hopefully, there will be a notable difference in the shared first load JavaScript as well as the first load JavaScript for each individual page.

It is okay, and encouraged to iterate several times. That is, to make a handful of changes and rebuild. Check the output metrics and repeat. Be sure to note any changes in website performance.

Review Next Build Logs

The build report in the terminal should show some level of improvement. The color code is helpful, but saving the initial report allows the developer to calculate exactly how much progress is being made.

Post Optimization Build Output
Post Optimization Build Output

A closer look at the build report number before and after optimization.

PageBeforeAfterDifference
Shared By All16211745
/ (home page)20112378
/_app16211745
/40416611848
/about320119201
/api/newsletter16211745
/blog20411787
/blog/[slug]361123238
/connect/google16311845
/courses19811880
/frequently-asked-questions322120202
/privacy16911950

Substantial reductions in first load JavaScript are evident across all pages, with the largest improvements in the individual blog post page template. This is a favorable outcome since blog post pages are the most relevant content and should be the content driving organic traffic to the website.

Review Next Bundle Analyzer Visualizations

The bundle visualizations should help the developer understand how trimming dependencies and dynamic imports change the JavaScript structure of the Next.js build. There should be many more chunks of code, with a smaller average size.

Post Optimization Server Visualization
Post Optimization Server Visualization

The original server build consisted of 14 separate chunks and the optimized version has 41.

Post Optimization Client Visualization
Post Optimization Client Visualization

The original client build consisted of 25 separate chunks and the optimized version has 52.

Final Thoughts

It is easy to overlook analyzing and optimizing a Next.js build. Afterall, time and effort are required and if a website is working the temptation is to move on to whatever is next (no pun intended). As it turns out the optimization process isn't as difficult as it first appears to be and can actually be rewarding. I enjoy transforming the Next.js build logs to green, and manipulating the JavaScript chunks that underpin my website. I think the visualizations are a great tool and are key to the feasibility of this whole thing.

This optimization process only gets more important as a website grows in size and complexity. The libraries we all download and use everyday don't coming with "Nutrition Information" printed on the back. The truth is, many developers (myself included) are not always fully aware of code tucked away in their node_modules folders. Ideally, going through the steps outlined in this article will make us all more aware, deepen our understanding of web development, and if nothing else, shrink the size of our first load JavaScript.

Benjamin Brooke Avatar
Benjamin Brooke

Hi, I'm Ben. I work as a full stack developer for an eCommerce company. My goal is to share knowledge through my blog and courses. In my free time I enjoy cycling and rock climbing.