99

Concurrent visitors over the last 30 minutes. (~)

Peak concurrent visitors -
Yesterday: 0 / Today: 0

Optimizing Next.js Bundle Size

Optimizing Next.js Bundle Size

# Why bundle size matters

Bundle size optimization is an important issue in frontend development.
The larger the bundle, the longer the initial page load takes, and this problem stands out even more on mobile devices or slow networks.
It directly affects the bounce rate, the share of users who leave while waiting for a page to load.
Once a page takes more than 2 seconds to load, the bounce rate rises quickly.

The relationship between page loading time and bounce rate

On the other hand, when a smaller bundle makes the page load faster, users can reach the content more smoothly and quickly.
In particular, it can leave a positive impression on the user's first interaction, which can lead to a higher retention rate.

Google evaluates the performance of web pages with the Core Web Vitals metrics and reflects them in search rankings.
In particular, loading-related factors such as LCP, CLS, and INP can be improved by reducing the bundle size.
So reducing the bundle size improves the Core Web Vitals metrics, which can also improve SEO (search engine optimization).

# Bundle size analysis tools

To reduce the bundle size effectively, it is important to know exactly how large the current bundle is.
There are two bundle analysis tools you can use with Next.js, and you choose one based on the bundler your project uses.

  • Turbopack bundle analyzer: It is built into Next.js, so you don't need to install anything.
  • @next/bundle-analyzer: A plugin for the Webpack bundler.

# Turbopack

In Next.js 16, Turbopack is the default bundler.
The Turbopack bundle analyzer is a built-in tool available from Next.js 16.1, and you don't need to install or configure anything.

It is still an experimental feature, so the command and options may change.

When you run the following, the bundle is analyzed and a server for viewing the results starts on port 4000.

BASH
1
npx next experimental-analyze

If you use it often, register it as a script.

/package.json
JSON
1
2
3
4
5
{ "scripts": { "analyze": "next experimental-analyze" } }

Viewing the Turbopack bundle size analysis results in the browser

The Turbopack bundle analyzer is integrated with Turbopack's module graph, so it analyzes the actual build output as is.
In the browser, you can see the following information.

  • Filtering by route, by environment (client/server), and by type (JS/CSS/JSON), plus file search
  • The size of each module, shown as the area of a rectangle in a treemap
  • The import chain, which you can see by clicking a module

The import chain is especially useful for finding why an unexpected module ended up in the client bundle, because it traces imports even across the boundary between Server Components and Client Components and across dynamic imports.
Before running it yourself, you can preview what the screen looks like on the demo page.

To save the results only to files without viewing them in the browser, use the --output (-o) option.
The analysis results are saved in the .next/diagnostics/analyze path.

BASH
1
npx next experimental-analyze --output

If you copy the saved results to another path, you can compare the bundle size before and after optimization or share it with others.

BASH
1
cp -r .next/diagnostics/analyze ./analyze-before-refactor

The available options are as follows.

Option Description
-o, --output Saves only the analysis result files without starting the server.
--port <port> Specifies the port number to serve the analysis results on. Default 4000
--no-mangling Disables name mangling.
--profile Enables React production profiling.

# Webpack

@next/bundle-analyzer is a Webpack plugin that visually shows the size and structure of your bundle.
It does not work with Turbopack, so you need to use the Webpack bundler with the --webpack option when you build.

Install it as follows.

BASH
1
npm i -D @next/bundle-analyzer cross-env

Enable bundle analysis by combining the options in the Next.js config file (next.config.ts) with @next/bundle-analyzer.

/next.config.ts
TS
1
2
3
4
5
6
7
8
9
10
11
import type { NextConfig } from 'next' import withBundleAnalyzer from '@next/bundle-analyzer' const nextConfig: NextConfig = { /* config options here */ } export default withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true', // openAnalyzer: true // default! })(nextConfig)

Analysis is enabled only when the ANALYZE environment variable is true.
With this setup, you can run bundle analysis only when you need it, so it does not slow down development or deployment of the project.

/package.json
JSON
1
2
3
4
5
{ "scripts": { "analyze:webpack": "cross-env ANALYZE=true next build --webpack" } }

Run the script as follows.

BASH
1
npm run analyze:webpack

As in the example images below, you can see the bundle size analysis results in the browser and the terminal.
If you set the openAnalyzer: false option, the bundle size analysis results are not opened in the browser.

Viewing the Webpack bundle size analysis results in the browser

Viewing the bundle size analysis results in the terminal

# Code splitting

Code splitting is a way to split your application's code from one bundle into multiple chunks and load them only when needed.
Instead of loading one large bundle all at once when a page loads, it loads only the code you need, chunk by chunk, which makes it a very effective strategy for improving initial load speed.

Splitting one bundle into multiple chunks.

# Dynamic imports

Next.js applies code splitting per page, so when you navigate to a page, only the code that page needs is loaded.
You can also apply code splitting per component.

Importing components dynamically can speed up the initial load, but the moment a component is loaded dynamically, a network request is made and there is some delay.
So it is best to avoid dynamic imports for key features or parts where an immediate response matters.

In the following code, the Chart component is rendered only when there is chart data fetched from the server, but the component's code is included in the initial bundle by default.

/app/dashboard/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
import Chart from '@/components/Chart' export default async function Dashboard() { const res = await fetch('https://api.heropy.dev/dashboard') const { chartData } = await res.json() return ( <> {/* ... */} {chartData ? <Chart data={chartData} /> : <p>No data..</p>} </> ) }

With React's lazy function and Suspense component, you can import a component dynamically and load it only when it is needed.
The lazy function returns a component, and it imports the actual component dynamically with the JS import function at the moment the returned component is used.
The Suspense component manages the loading state asynchronously while the component is being loaded.

If you use dynamic imports for pages unrelated to the initial load, or for specific features such as modals and chatbots, you can optimize the loading speed.

/app/dashboard/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { lazy, Suspense } from 'react' const Chart = lazy(() => import('@/components/Chart')) export default async function Dashboard() { const res = await fetch('https://api.heropy.dev/dashboard') const { chartData } = await res.json() return ( <> {/* ... */} {chartData ? ( <Suspense fallback={<p>Loading...</p>}> <Chart data={chartData} /> </Suspense> ) : ( <p>No data..</p> )} </> ) }

With the dynamic function provided by Next.js, you get the same effect as using the lazy function and the Suspense component.

/app/dashboard/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import dynamic from 'next/dynamic' const Chart = dynamic(() => import('@/components/Chart'), { loading: () => <p>Loading...</p> }) export default async function Dashboard() { const res = await fetch('https://api.heropy.dev/dashboard') const { chartData } = await res.json() return ( <> {/* ... */} {chartData ? <Chart data={chartData} /> : <p>No data..</p>} </> ) }

If you want to import a component dynamically only on the client, without pre-rendering, set ssr: false.
However, this setting works only in Client Components.

/app/dashboard/page.tsx
TSX
1
2
3
4
5
6
7
8
9
'use client' import dynamic from 'next/dynamic' const Chart = dynamic(() => import('@/components/Chart'), { loading: () => <p>Loading...</p>, ssr: false }) // ...

# Tuning chunk splitting

If you split modules shared by multiple pages into separate chunks, you can reduce duplicate downloads.
In the following example, the sleep utility function is used by both the Dashboard page and the MyAccount page, so you might expect it to be split into its own chunk.

/utils/index.ts
TSX
1
2
3
export async function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
/app/dashboard/page.tsx
TSX
1
2
3
4
5
6
import { sleep } from '@/utils' export default async function DashboardPage() { await sleep(1000) return <h1>Dashboard!</h1> }
/app/myaccount/page.tsx
TSX
1
2
3
4
5
6
import { sleep } from '@/utils' export default async function MyAccountPage() { await sleep(2000) return <h1>My Account!</h1> }

However, if you check the bundle size analysis results, a small module like this is not split into a separate chunk; it stays included in each page's chunk.

This is where you need to understand how Turbopack creates chunks.
While Webpack's splitChunks tends to "split" shared modules into separate chunks, Turbopack's chunk generator works the other way, "merging" small chunks to reduce the number of requests.
You can adjust its criteria with the experimental.turbopackChunking option.

It is still an experimental feature, so the options may change.

/next.config.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
// ... const nextConfig: NextConfig = { experimental: { turbopackChunking: { minChunkSize: 50000, // Chunks smaller than this are merged, default: 50000 maxChunkCountPerGroup: 40, // Maximum number of chunks per chunk group, default: 40 maxMergeChunkSize: 200000 // Chunks larger than this are not merged, default: 200000 } } } // ...

Lowering minChunkSize creates more, smaller chunks, and raising it creates fewer, larger chunks.
minChunkSize is measured in bytes before compression and minification, which is about 5 times the final output size.

Turbopack decides whether to merge chunks by combining maxChunkCountPerGroup, maxMergeChunkSize, and the requestCost option, which estimates the cost of a single request.

If there are pages where visitors usually land first, you can tell Turbopack to merge the chunks of those routes more aggressively.
firstPageLoadPriority is an option that sets how much weight to give the first page load, as a value from 0 to 1.
The higher the value, the more aggressively chunks are merged, so the first load gets faster and page navigation gets slower.
If you're not sure what value to use, try your site's bounce rate.
The bounce rate is the share of visitors who leave after viewing only one page, so if it is high, it may pay off to prioritize the first page load over page navigation.

/next.config.ts
TS
1
2
3
4
5
6
7
8
const nextConfig: NextConfig = { experimental: { turbopackChunking: { priorityRoutes: [/^\/$/, /^\/signin/], // Routes where visitors often land first firstPageLoadPriority: 0.5 // How much weight to give the first page load, 0 to 1 } } }

Merging chunks reduces the number of requests needed for the first page load, but it makes it less likely that chunks can be reused when navigating between pages.
Since this setting trades initial loading performance for navigation performance, it is a good idea to check the results yourself with the bundle analyzer after adjusting the options.

BASH
1
npm run analyze

# Tree shaking

Tree shaking is an optimization technique that reduces the final bundle size by removing unused code.
Next.js uses ESM modules (import/export), so it supports tree shaking by default, but CommonJS modules (require) cannot be tree-shaken.

Removing unused code, like shaking a tree to make its leaves fall

# Optimizing libraries

When you use external libraries, it is a good idea to choose libraries that support ESM, which can be tree-shaken, or lightweight libraries.

The lodash library is a well-known CommonJS module that provides many utility functions.
If you import the whole module, it cannot be tree-shaken, so you need to use a named import or import exactly the module you need.
Some utility functions may not be optimized with a named import.

TS
1
2
3
4
5
6
7
8
// Importing the whole CommonJS module, which cannot be tree-shaken import _ from 'lodash' _.get({}, 'a.b') // Using a named import, or importing only the module you need // import { get } from 'lodash' import get from 'lodash/get' get({}, 'a.b')

You can use lodash-es, the officially supported ESM version of the lodash library.
In many cases, we recommend using lodash-es instead of lodash.

TS
1
2
import { get } from 'lodash-es' get({}, 'a.b')
Using an ESM module that can be tree-shaken

The moment library has very powerful features for handling dates and times, but because it provides so many features, its bundle size is quite large, at over 18KB (parsed size).
As lightweight alternatives, you can use day.js or date-fns.

day.js is used almost the same way as moment and has a much smaller bundle size of 2KB by default, but to use additional features, you need to install and register plugins.
date-fns lets you import only the features you need with named imports, so it can be tree-shaken, but it is used differently from moment.

TS
1
2
3
4
5
6
7
8
import moment from 'moment' moment().format('YYYY-MM-DD') // 2025-12-16 import dayjs from 'dayjs' dayjs().format('YYYY-MM-DD') // 2025-12-16 import { format } from 'date-fns' format(new Date(), 'yyyy-MM-dd') // 2025-12-16

# Checking side effects

A side effect is a situation where a module's behavior depends on the outside environment or affects other modules, making the result of running the code hard to predict.
Code with side effects, such as DOM manipulation like document.querySelector('.hello'), debugging like console.log, use of global variables, and timers, is not removed by tree shaking.
Tree shaking can safely remove only code without side effects, so checking side effects carefully helps you reduce the bundle size effectively.

When choosing a library, it is best to prefer libraries without side effects, and even if a library has side effects, to choose one that documents them clearly.
A library without side effects declares it in its package.json file as follows.
Then tree shaking aggressively removes any unused code in that module.

/package.json
JSON
1
2
3
{ "sideEffects": false }
Without side effects

On the other hand, a module with side effects lists the internal modules that must not be removed, as follows.
UI libraries often include CSS files for global styles.

/package.json
JSON
1
2
3
4
5
6
7
8
9
{ "sideEffects": [ "index.common.js", "polyfills.js", "i18n/*.js", "*.css", "*.scss" ] }
With side effects

# Avoiding barrel files

A barrel file is a way of using modules where multiple modules are bundled and exported from a single file, and you can create one with the re-export syntax, as follows.
Then you can easily import everything at once with a single import statement instead of importing each module one by one.

TypeScript looks for the index.ts file first, so you can omit it from the path.

/utils/index.ts
TS
1
2
3
export { default as sleep } from './sleep' export { default as format } from './format' export { default as parse } from './parse'
TS
1
2
3
4
5
6
// import { sleep } from '@/utils/sleep' // import { format } from '@/utils/format' // import { parse } from '@/utils/parse' import { sleep, format, parse } from '@/utils' // ...

This is one way to make modules easier to use, but using barrel files comes with a big problem.
To use one module's feature from a barrel file, you also import other modules you don't need, which increases runtime loading and build time.
In particular, when this nesting happens many times, where a module exported by a barrel file is itself another barrel file, the build time grows exponentially.
The increase in build time is an even bigger problem in serverless environments where Next.js Server Components are loaded.

The barrel file debacle / Effects of barrel files on performance - @marvinhagemeist

Many of the libraries we can use already use barrel files, re-exporting anywhere from dozens to thousands of modules.
To solve this problem, Next.js provides the optimizePackageImports option.
For the libraries you list in this option, Next.js analyzes the entry file, and if it is a barrel file, it recursively skips the barrel files and optimizes the imports to bring in only the modules you need directly.

/next.config.ts
TS
1
2
3
4
5
6
7
8
9
// ... const nextConfig: NextConfig = { experimental: { optimizePackageImports: ['LIBRARY_NAME'] } } // ...

The lodash-es library re-exports more than 300 modules.

By default, Next.js optimizes commonly used libraries such as lodash-es, antd, and @mui.
For the full list, see the optimizePackageImports option.

If you want to prevent barrel files from being used in your project, you can add an ESLint rule as follows.

BASH
1
npm i -D eslint-plugin-no-barrel-files

Append the flat/recommended config provided by the plugin to your existing configuration.

/eslint.config.mjs
JS
1
2
3
4
5
6
7
8
import { defineConfig } from 'eslint/config' import nextVitals from 'eslint-config-next/core-web-vitals' import noBarrelFiles from 'eslint-plugin-no-barrel-files' export default defineConfig([ ...nextVitals, ...noBarrelFiles.configs['flat/recommended'] ])

This config enables the following two rules together.
If you already use barrel files, both rules will report errors, so you can also pick only the rules you need.

  • no-barrel-files: Prevents creating new barrel files.
  • prefer-source-imports: Makes you import directly from the source module instead of going through a barrel file.

The prefer-source-imports rule finds the source module through the paths setting in tsconfig.json, so it needs the typescript package even in JS projects.

# Deduplication

As the dependency structure of a project gets more complex, the same library can be installed several times in different versions.
With the npm ls (npm list) command, you can check the list of a specific dependency installed in your project.

If you choose to use Tailwind CSS when you create a Next.js project, the postcss library is installed automatically.
If you check that library with the npm ls postcss command, you can see that the postcss library is installed twice, in versions 8.4.31 and 8.4.48, as follows.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
npm ls postcss my-project@0.1.0 ├─┬ next@15.0.3 │ └── postcss@8.4.31 ├── postcss@8.4.48 └─┬ tailwindcss@3.4.14 ├─┬ postcss-import@15.1.0 │ └── postcss@8.4.48 deduped ├─┬ postcss-js@4.0.1 │ └── postcss@8.4.48 deduped ├─┬ postcss-load-config@4.0.2 │ └── postcss@8.4.48 deduped ├─┬ postcss-nested@6.2.0 │ └── postcss@8.4.48 deduped └── postcss@8.4.48 deduped

The npm dedupe command consolidates duplicate versions in the dependency list into the highest possible version.
This reduces duplicate libraries in the node_modules folder as much as possible.

BASH
1
2
npm dedupe added 1 package, removed 1 package, changed 1 package, and audited 370 packages in 2s

If you check the list again, you can see that the postcss library has been consolidated into version 8.4.31.
Every deduplicated library is marked with the deduped keyword.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
npm ls postcss my-project@0.1.0 ├─┬ next@15.0.3 │ └── postcss@8.4.31 deduped ├── postcss@8.4.31 └─┬ tailwindcss@3.4.14 ├─┬ postcss-import@15.1.0 │ └── postcss@8.4.31 deduped ├─┬ postcss-js@4.0.1 │ └── postcss@8.4.31 deduped ├─┬ postcss-load-config@4.0.2 │ └── postcss@8.4.31 deduped ├─┬ postcss-nested@6.2.0 │ └── postcss@8.4.31 deduped └── postcss@8.4.31 deduped

If the versions differ a lot or the dependency relationships between versions are complex, the duplicates are sometimes left as they are.
In the case of the nanoid library, a different version remains even after you try to deduplicate.

BASH
1
2
3
4
5
6
7
npm i nanoid npm dedupe npm ls nanoid my-project@0.1.0 ├── nanoid@5.0.8 └─┬ postcss@8.4.31 └── nanoid@3.3.7