Module Bundle Optimization in NextJS

Module bundle optimization using various methods including Next bundle analyzer, React lazy, dynamic import, lightweight library variants, and separate module imports.

Module Bundle Optimization in NextJS

What is Module Bundler?

Using JavaScript files directly imported in HTML for feature implementation allows control over script loading timing through options like defer and async, or by changing script positions to control when they load. However, this approach is far from module optimization. This creates problems where all unnecessary parts of modules must go through transmission/download processes, resulting in increased First Contentful Paint (FCP) times, which serve as user experience metrics.

Module bundlers represented by webpack and vite solve this problem through several methods:

1. Dependency Graph Construction

javascript
1// Starts from the entry point
2import { Button } from './components/button'  // -> finds button.tsx
3import { Card } from '@juun/ui';               // -> finds ui/index.ts
4import { useState } from 'react';             // -> finds react in node_modules
5

Check all import statements to create a dependency map.

2. Tree Shaking & Dead Code Elimination

javascript
1// You import this:
2import { Button } from '@juun/ui';
3
4// but @juun/ui exports a lot of components:
5export { Button, Card, Calendar, CodeBlock, ... };
6
7// Tree shaking should eliminate unused exports
8// BUT: Barrel exports can break this!
9

Remove all unused exports from modules based on the generated dependency map.

3. Code Splitting & Chunking

Divide the tree-shaken code to generate scripts, which are loaded in HTML and organized into chunks.

  • Main chunks: Framework code + essential components
  • Route chunks: Page-specific code
  • Vendor chunks: Third-party libraries
  • Dynamic chunks: Lazy-loaded components

4. Bundle Generation

The JavaScript files processed through the above steps undergo the following treatments to generate the final bundle:

  • Minification: Remove unnecessary whitespace, comments, and shorten variable names
  • Source Mapping: For debugging purpose
  • Asset Fingerprinting: Asset indexing to enable caching

The process of generating the minimum script files necessary for service delivery is called bundling. While bundling provides significant optimization, there are aspects to understand and additional work required to see definitive results.

Bundle Analyzer

Module bundles generated by bundlers are difficult to directly examine due to minification and other optimization processes. Therefore, each bundler provides development support tools for bundle visualization(webpack-bundle-analyzer, vite-bundle-visualizer).

Bundle Analyzer Example

You can examine the composition and size of generated bundles as shown above.

Next Bundle Analyzer

The @next/bundle-analyzer used in this project can be added without major configuration changes.

bash
1# install package
2npm install -D @next/bundle-analyzer cross-env
3

cross-env is a package that helps specify environment variables regardless of OS.

javascript
1import NextBundleAnalyzer from "@next/bundle-analyzer";
2
3// No need to adjust your existing configuration.
4/** @type {import('next').NextConfig} */
5const nextConfig = { ... };
6
7const analyze = process.env.ANALYZE === "true";
8const withBundleAnalyzer = NextBundleAnalyzer({ enabled: analyze });
9
10export default analyze ? withBundleAnalyzer(nextConfig) : nextConfig;
11

As configured above, bundle analyzer inclusion is determined by the flag environment variable value. Without this condition, bundle analyzer would run every time you build.

json
1{
2  ...
3  "scripts": {
4    ...,
5    "analyze": "cross-env ANALYZE=true next build",
6  },
7  ...
8}
9

Finally, by adding scripts to package.json that run according to the set conditions, you can create a process that includes bundle analyzer only when you want to examine bundles selectively. When you run the specified script, three files are generated under the .next/analyze/ directory.

  • nodejs: Bundles of server-side Node.js runtime
  • client: Client-side bundles for browsers
  • edge: Bundles of edge runtime

@next/bundle-analyzer result: client.html

client.html allows you to examine client-side code bundles divided by page routes.

Optimization Methods

Module bundlers reduce script size on their own as mentioned earlier, but scripts can still be large or unnecessary scripts may remain. This web project hosting this article also serves web 3D demos (Cesium Utils, Three.js), causing users who don't visit those pages to download related code bundles. There are three main methods to solve such additional problems.

1. React.lazy & Dynamic Import

The first method includes React.lazy and Dynamic Import. React.lazy is a React API used with Suspense that delays execution of parameter code until component loading. Dynamic Import is syntax that allows asynchronous and dynamic module loading, unlike import statements declared at the top of files.

Dynamic imports allow one to circumvent the syntactic rigidity of import declarations and load a module conditionally or on demand. - mdn web docs

As you can infer from the description, the combination of these two results in delaying module import until component loading. You can delay loading of modules included in specific components.

tsx
1'use client';
2
3import 'public/cesium/Widgets/widgets.css';
4import 'public/cesium/Widgets/lighter.css';
5
6import {
7  CameraEventType,
8  KeyboardEventModifier,
9  Terrain,
10  Viewer,
11} from 'cesium';
12import { Fragment, useEffect } from 'react';
13import {
14  useCesium,
15  Viewer as RViewer,
16  ViewerProps as RViewerProps,
17} from 'resium';
18
19import useViewerStore from '@/stores/slices/viewer';
20
21export interface ViewerProps extends Omit<RViewerProps, 'className'> { ... }
22
23function ViewerContent(props: ViewerProps) {
24  const { viewer } = useCesium();
25  const { setViewer, removeViewer, setIsFlying } = useViewerStore();
26
27  useEffect(() => {
28    ... // initialize viewer
29  }, []);
30}
31
32export default function LazyViewer(props: ViewerProps) {
33  return (
34    <RViewer {...props}>
35      <ViewerContent {...props} />
36    </RViewer>
37  )
38}
39

The above shows a simplified viewer component using cesium and resium. These two have considerable individual module bundle sizes, and importing them in static pages (Next.js standard) means downloading all modules regardless of which page you view.

tsx
1import { lazy, Suspense } from 'react';
2
3import type { ViewerProps } from './viewer';
4
5const LazyViewer = lazy(() => import('./viewer'));
6
7export default function Viewer(props: ViewerProps) {
8  return (
9    <Suspense fallback={<FallbackComponent />}>
10      <LazyViewer {...props} />
11    </Suspense>
12  );
13}
14
15export type { ViewerProps };
16

Here, we create an entry point called index.tsx and lazy import the viewer component. Cesium and resium modules are only loaded when the viewer component loads. import type is not included in runtime code, so you don't need to worry about it.

Note: To separate specific modules, there must be no direct imports in all static pages. This means all components using that module must be made into lazy import format.

2. Barrel Exports

Next is barrel exports, briefly mentioned above inside a code example. First, barrel exports refers to collecting exports scattered across multiple files into an entry point named index for unified management. Using barrel exports can improve code readability, but can be fatal in terms of bundle size. Bundles are generated including all modules imported from entry points.

typescript
1import { Button } from './src/components/button.tsx';
2import { CodeBlock } from './src/components/code-block.tsx';
3import {
4  Popover,
5  PopoverAnchor,
6  PopoverContent,
7  PopoverTrigger,
8} from './src/components/popover.tsx';
9
10export {
11  Button,
12  CodeBlock,
13  Popover,
14  PopoverAnchor,
15  PopoverContent,
16  PopoverTrigger,
17};
18

For example, even if you only import and use Button from the above entry point, the bundle is generated including Button, CodeBlock, Popover, etc.

This project separates basic UI components into a monorepo structure, and organizing all components with barrel exports resulted in all pages' First Load JS capacity exceeding 2MB. Actually importing components individually without going through the entry point confirmed that the homepage (/) First Load JS size decreased from 2.53MB → 853kB, a 66% reduction.

3. Using Lightweight Library Variants

Among the project's UI components, react-syntax-highlighter used in CodeBlock supports over 200 programming languages and occupied a large portion of First Load JS. This project uses only about 10 languages, so including all those languages is unnecessary. Fortunately, this library provides a Light module without included languages along with languages in variants format, allowing direct registration of needed languages.

typescript
1// Before:
2import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
3
4// After:
5import { PrismLight SyntaxHighlighter } from 'react-syntax-highlighter';
6...
7import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
8import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
9...
10
11SyntaxHighlighter.registerLanguage('typescript', typescript);
12SyntaxHighlighter.registerLanguage('tsx', tsx);
13...
14

Applying lightweight library variants by selecting only needed languages resulted in First Load JS size for pages using CodeBlock decreasing from 1.82MB → 1.1MB, about 40% reduction. Libraries providing lightweight versions of modules can significantly reduce bundle size depending on their utilization.

Results

The results obtained in this project through the listed methods are as follows:

Next build result: before and after the optimization

RouteBeforeAfterReduction
home2.53MB853kB66%
blog posts2.55MB1.1MB57%
about, portfolio~ 2.5MB~ 700kB72%

Total Impact Summary of Bundle Optimization

You can confirm that First Load JS size decreased significantly across all routes.

cf. Modular Exports

Module bundle optimization is certainly influenced by project-level optimization techniques, but equally important is whether packages used consider optimization. When publishing packages to npm, or directly managing modules in package format through monorepo structures as in this case, what you must keep in mind is modular export. Modular export refers to providing entry points that bundle package functionality by feature units, allowing package users to import and use only needed functionality.

json
1{
2  ...
3  "exports": {
4    ".": {
5      "types": "./dist/index.d.ts",
6      "require": "./dist/index.cjs",
7      "default": "./dist/index.js"
8    },
9    "./collection": {
10      "types": "./dist/collection/index.d.ts",
11      "import": "./dist/collection/index.js",
12      "require": "./dist/collection/index.cjs"
13    },
14    "./highlight": {
15      "types": "./dist/highlight/index.d.ts",
16      "import": "./dist/highlight/index.js",
17      "require": "./dist/highlight/index.cjs"
18    },
19    "./terrain": {
20      "types": "./dist/terrain/index.d.ts",
21      "import": "./dist/terrain/index.js",
22      "require": "./dist/terrain/index.cjs"
23    },
24    "./utils": {
25      "types": "./dist/utils/index.d.ts",
26      "import": "./dist/utils/index.js",
27      "require": "./dist/utils/index.cjs"
28    },
29    "./viewer": {
30      "types": "./dist/viewer/index.d.ts",
31      "import": "./dist/viewer/index.js",
32      "require": "./dist/viewer/index.cjs"
33    }
34}
35

@juun-roh/cesium-utils package.json: exports

You can achieve modular export by creating entry points that import only modules needed for functionality and specifically specifying exports paths in package.json structure.

Closing

The journey that started to confirm whether lazy import changes to Cesium components would actually be effective took longer than expected. Since I started checking First Load JS size with bundle analysis tools after already introducing lazy import in this project, I couldn't experience its impact firsthand. However, the cautionary points about using barrel exports and the significant impact of using lightweight versions of libraries on bundle size provided important perspectives for UX-focused optimization.

Publishing the @juun-roh/cesium-utils package also made me realize the importance of modular exports, which I had simply followed recommendations for previously, making it meaningful work.

Return to List
DateJune 23, 2025
Tags
Next.jsanalyzerbundlebundlermodulenpmoptimizationperformancetree-shakingvitewebpack
Share article
FacebookFacebook
XX
LinkedInLinkedIn
Bundle Analyzer Example
Bundle Analyzer Result: Client
Before Module Bundle Optimization
After Module Bundle Optimization