Next.js’s bundle analysis is a powerful tool, but most developers only scratch the surface, using it to spot the biggest offenders without understanding how deeply nested dependencies and server-side code can inflate their client-side JavaScript.

Let’s see it in action. First, you need to build your Next.js application with the analyze flag:

npm run build -- --profile

This command builds your application and generates a analyze directory within your .next folder. Inside, you’ll find a report.html file. Open this file in your browser. You’ll see a treemap visualization of your JavaScript bundles. Each box represents a module, and its size is proportional to its contribution to the total bundle.

Here’s a typical view you might encounter:

Bundle analysis treemap example

This treemap shows you the "what" – what’s taking up space. But to truly reduce your JavaScript size, you need to understand the "why" and the "how."

The core problem Next.js solves with its bundling is code splitting. Instead of sending one giant JavaScript file to the browser, Next.js breaks it down into smaller chunks that are loaded on demand. This improves initial page load performance. However, the analysis tool reveals the unintended consequences of this process: large, unoptimized bundles are still being generated, often due to common misconceptions about what’s actually being sent to the client.

The Mental Model: Client vs. Server Bundles

It’s crucial to distinguish between what Next.js bundles for the server and what it bundles for the client.

  • Server Bundles: These are executed on the server during SSR (Server-Side Rendering) or API routes. They include your page components, data fetching logic (getServerSideProps, getStaticProps), and any server-side dependencies. These do not directly impact client-side download size but can affect server build times and memory usage.
  • Client Bundles: This is the JavaScript that runs in the user’s browser. It includes your React components, client-side logic, and any libraries that are imported and used directly in your UI. The bundle analysis tool primarily visualizes these client-side chunks.

The confusion often arises when a library is imported but only used in a server-only context (e.g., within getServerSideProps). Next.js is smart enough not to include these in the client bundle. However, if a module is imported anywhere that could potentially be client-side (even if it’s not in your current use case), it might end up in the client bundle.

Common Culprits and How to Tackle Them:

  1. Large Libraries with Unused Features: Many libraries are modular. You might only need a small part of a large library (e.g., a specific charting component from Chart.js).

    • Diagnosis: Look for large boxes in the treemap corresponding to known libraries. Drill down into them to see which specific modules are contributing the most.
    • Fix: Use dynamic imports (import()) for specific components or features. For example, instead of import Chart from 'chart.js';, use const Chart = dynamic(() => import('chart.js'));. This tells Next.js to only load this module when it’s actually rendered.
    • Why it works: Dynamic imports create separate JavaScript chunks that are fetched only when the component using them is mounted on the client.
  2. Moment.js and Lodash (Full Imports): These libraries are notorious for their size when imported in their entirety.

    • Diagnosis: Spotting moment or lodash as large top-level modules.
    • Fix:
      • For moment.js, consider using a lighter alternative like date-fns or dayjs. If you must use moment, import only the specific locales you need: import moment from 'moment/moment'; import 'moment/locale/en-gb';.
      • For lodash, use the modular imports: import get from 'lodash/get'; instead of import _ from 'lodash';. Most bundlers, including Next.js, are configured to tree-shake these modular imports effectively.
    • Why it works: Importing only the necessary functions or locales prevents the entire library from being included in your bundle.
  3. Unused Dependencies in package.json: Sometimes, dependencies are left in package.json that are no longer used in the codebase.

    • Diagnosis: While the bundle analysis won’t directly show unused package.json entries, you’ll notice larger-than-expected bundles and might not find obvious culprits within the treemap. Running npm ls or yarn list can help identify installed packages.
    • Fix: Remove unused dependencies from package.json and run npm install or yarn install.
    • Why it works: Removing unused code from your project, even if it’s just a dependency that’s no longer imported, reduces the overall footprint.
  4. Server-Only Code Leaking into Client Bundles: This is the trickiest. If a module is imported in a file that could be client-side, it might get bundled.

    • Diagnosis: You see a large module in the client bundle analysis that you know should only be used server-side (e.g., a database client or a heavy Node.js utility). Check the import chains leading to this module.
    • Fix: Use Next.js’s built-in server-only package. Create a server-only.js file in your lib folder:
      // lib/server-only.js
      // This file is intentionally left empty.
      // It's used to mark modules as server-only.
      
      Then, in the module you want to restrict to the server, add import 'server-only'; at the top.
      // lib/my-server-module.js
      import 'server-only';
      
      export function doServerThing() {
          // ... server-side logic
      }
      
    • Why it works: The server-only package is a special marker. During the build process, Next.js detects this import and ensures that any module importing server-only is never included in client-side JavaScript bundles. If it’s accidentally imported in a client component, the build will fail.
  5. Duplicate Dependencies: Different parts of your application might install the same dependency at different versions, leading to multiple copies in your node_modules and potentially in your bundles.

    • Diagnosis: The treemap might show multiple instances of the same library, or the total size seems inflated beyond what you expect from your direct dependencies. Run npm ls <library-name> or yarn why <library-name> to see where a package is being installed from.
    • Fix: Use npm dedupe or yarn dedupe to resolve duplicate dependencies. You might also need to explicitly manage peer dependencies or use tools like resolutions in yarn or overrides in npm to force a single version.
    • Why it works: Deduplication ensures that only one version of a library is installed and, consequently, bundled, reducing redundancy.
  6. Large Assets (Fonts, Images) Included in JS Bundles: While not strictly JavaScript, sometimes large assets can be included in the bundle if not handled correctly.

    • Diagnosis: You see unusually large chunks in the bundle analysis that don’t correspond to code, or your total bundle size is unexpectedly high, and code analysis doesn’t reveal the cause.
    • Fix: Ensure images are optimized and served efficiently (e.g., using next/image). For fonts, use next/font which automatically handles optimization and deduplication, or ensure fonts are loaded via CSS <link> tags and not imported as JS modules.
    • Why it works: next/font and proper asset handling prevent large binary assets from being embedded directly into JavaScript chunks, allowing them to be loaded efficiently via separate network requests.

By systematically analyzing your Next.js bundles with these points in mind, you can move beyond simply identifying large modules to understanding and fixing the root causes of bloated client-side JavaScript.

The next step after optimizing your bundles is often to consider the impact of client-side data fetching and caching strategies on perceived performance.

Want structured learning?

Take the full Nextjs course →