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.
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.
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.
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.
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.
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.
A closer look at the build report number before and after optimization.
Page | Before | After | Difference |
---|---|---|---|
Shared By All | 162 | 117 | 45 |
/ (home page) | 201 | 123 | 78 |
/_app | 162 | 117 | 45 |
/404 | 166 | 118 | 48 |
/about | 320 | 119 | 201 |
/api/newsletter | 162 | 117 | 45 |
/blog | 204 | 117 | 87 |
/blog/[slug] | 361 | 123 | 238 |
/connect/google | 163 | 118 | 45 |
/courses | 198 | 118 | 80 |
/frequently-asked-questions | 322 | 120 | 202 |
/privacy | 169 | 119 | 50 |
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.
The original server build consisted of 14 separate chunks and the optimized version has 41.
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.