Next.js Compilation & Bundling
From Source Code to Production, Explained
LinkedIn Hook
You run
next build. It finishes in 45 seconds.But do you actually know what happened during those 45 seconds?
Most developers treat the Next.js build process like a black box. Code goes in, a
.next/folder comes out, and somehow it works. But when an interviewer asks "How does Next.js optimize your bundle?" or "What is the difference between Turbopack and Webpack?" — the black box becomes a problem.Here is what actually happens: your JSX gets compiled to JavaScript, your imports get analyzed for tree shaking, every route gets its own code-split chunk, and the entire output is organized into a
.next/directory with a structure most developers have never explored.In this lesson, I break down the full compilation pipeline, compare Turbopack vs Webpack with real tradeoffs, explain automatic code splitting, walk through the
.next/build output directory, and give you interview-ready answers for every bundling question.The developers who understand the build are the ones who debug production issues in minutes, not days.
Read the full lesson -> [link]
#NextJS #JavaScript #WebDevelopment #InterviewPrep #Frontend #Webpack #Turbopack #Performance
What You'll Learn
- The full Next.js compilation pipeline — what happens from source file to production output
- Turbopack vs Webpack — architecture, speed, tradeoffs, and when each is used
- Automatic code splitting — how Next.js breaks your app into optimized chunks per route
- Tree shaking — how unused code gets eliminated and why it matters for bundle size
- The
.next/directory anatomy — whatnext buildactually produces and why each folder exists - How to analyze and debug your bundle size like a senior developer
The Factory Analogy
Imagine you are running a car factory.
You have raw materials arriving — steel, rubber, glass, electronics. That is your source code: JSX, TypeScript, CSS modules, images, third-party packages.
The factory does not ship raw materials to customers. It goes through a pipeline:
- Smelting (Compilation) — Raw steel is melted and purified. Your TypeScript and JSX are compiled into plain JavaScript that browsers understand.
- Assembly (Bundling) — Individual parts are assembled into complete units. Your hundreds of module files are bundled into optimized chunks.
- Quality Control (Optimization) — Defective parts are removed, excess weight is trimmed. Dead code is eliminated (tree shaking), chunks are minified, and assets are compressed.
- Shipping (Output) — Finished cars are organized into shipping containers. Your optimized code is written to
.next/with server bundles, client bundles, and static assets separated.
The bundler — whether Webpack or Turbopack — is the entire factory floor. It orchestrates every step. And understanding this factory is what separates a developer who deploys from a developer who optimizes.
The Compilation Pipeline
When you run next build, here is the exact sequence:
Step 1: TypeScript and JSX Compilation
Next.js uses SWC (Speedy Web Compiler) — a Rust-based compiler that replaced Babel as the default in Next.js 12. SWC is roughly 17x faster than Babel for single-threaded compilation and 70x faster when parallelized.
What SWC does:
- Converts TypeScript to JavaScript (type-stripping, not type-checking)
- Transforms JSX into
React.createElement()calls (or the JSX runtime equivalent) - Downlevels modern syntax if needed for target browsers
- Applies Next.js-specific transforms (automatic
Reactimport,next/dynamichandling)
// What you write (TypeScript + JSX)
interface Props {
title: string;
count: number;
}
export default function Dashboard({ title, count }: Props) {
return (
<div className="dashboard">
<h1>{title}</h1>
<span>{count} items</span>
</div>
);
}
// What SWC compiles it to (simplified)
export default function Dashboard({ title, count }) {
return jsx("div", {
className: "dashboard",
children: [
jsx("h1", { children: title }),
jsx("span", { children: `${count} items` }),
],
});
}
// Types are stripped, JSX is transformed, output is plain JS
Important: SWC does not type-check your code. It strips types for speed. Type checking happens separately via tsc (which is why next build runs type checking as a separate step before bundling).
Step 2: Module Resolution and Dependency Graph
The bundler crawls your entire application starting from every entry point — each page.js, layout.js, and route.js file in your app/ directory. It follows every import statement to build a complete dependency graph: a map of which file depends on which other files.
app/page.js
-> imports components/Header.js
-> imports next/link
-> imports utils/cn.js
-> imports components/Hero.js
-> imports framer-motion
-> imports next/image
-> imports lib/data.js
-> imports prisma client (server only)
This graph is critical. It determines:
- Which code belongs to which route (code splitting boundaries)
- Which code is server-only vs client-only (the
"use client"boundary) - Which exports are actually used (tree shaking candidates)
Step 3: Code Splitting
Next.js automatically code-splits your application at route boundaries. Each route gets its own JavaScript bundle containing only the code that route needs.
app/
page.js -> produces chunk for /
about/page.js -> produces chunk for /about
blog/[slug]/page.js -> produces chunk for /blog/:slug
dashboard/
layout.js -> produces shared chunk for /dashboard/*
page.js -> produces chunk for /dashboard
settings/page.js -> produces chunk for /dashboard/settings
The result: when a user visits /about, they download only the JavaScript for the about page — not the dashboard code, not the blog code. This happens automatically with zero configuration.
Next.js also creates shared chunks for code that multiple routes import. If both /about and /blog import the same <Footer> component, it goes into a shared chunk that is loaded once and cached.
Step 4: Tree Shaking
Tree shaking eliminates code that is imported but never actually used. It works by analyzing ES module static imports.
// utils/math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) { return a / b; }
// page.js - only uses `add`
import { add } from "./utils/math";
export default function Page() {
return <p>{add(2, 3)}</p>;
}
// After tree shaking: subtract, multiply, divide are removed
// They never appear in the production bundle
Tree shaking does not work with:
require()(CommonJS) — dynamic, not statically analyzable- Side-effectful imports (
import "./styles.css"— kept because the import itself does something) - Barrel files that re-export everything (
export * from) — partially analyzable but often defeats tree shaking in practice
This is why Next.js and the broader ecosystem push for ES modules. And it is why import * as utils from "./utils" followed by utils.add() can sometimes prevent tree shaking — the bundler may not be able to prove the other exports are unused.
Step 5: Minification and Compression
The final step before output:
- Minification — variable names shortened, whitespace removed, dead branches eliminated. SWC handles this (replaced Terser for speed).
- Compression — Gzip or Brotli compression for network transfer. The server (or CDN) handles this, not the build step itself, but Next.js optimizes output to compress well.
Turbopack vs Webpack
This is a high-frequency interview topic. Know the differences cold.
Webpack — The Incumbent
Webpack has been Next.js's bundler since the beginning. It is mature, battle-tested, and has a massive plugin ecosystem. Next.js uses Webpack for production builds (next build) as of Next.js 14/15.
How Webpack works:
- Reads the entire dependency graph
- Bundles everything into chunks based on code splitting rules
- Applies loaders (transforms) and plugins (optimizations)
- Outputs the final bundles
The problem: Webpack bundles everything from scratch on startup and re-bundles large portions on file changes. For large applications with thousands of modules, this means dev server startup times of 30-60+ seconds and HMR (Hot Module Replacement) updates that take 2-5 seconds.
Turbopack — The Successor
Turbopack is Next.js's new bundler, written in Rust. It was introduced in Next.js 13 and is the default dev server bundler starting in Next.js 15.
How Turbopack is fundamentally different:
-
Incremental computation — Turbopack only computes what changed. It uses a fine-grained caching model (inspired by the Salsa framework from Rust-Analyzer) where the result of every function is cached. Change one file, and only the computations affected by that file re-run.
-
Lazy bundling — In dev mode, Turbopack does not bundle your entire application on startup. It only bundles the page you are actually viewing. Navigate to
/about? Only then does it bundle the about page. -
Native speed — Written in Rust, not JavaScript. This gives 10-100x speed improvements on raw compilation tasks compared to JavaScript-based tools.
# Enable Turbopack in development (Next.js 13-14)
next dev --turbo
# In Next.js 15+, Turbopack is the default for dev
next dev
# Production builds still use Webpack (as of Next.js 15)
next build
The Comparison Table
Webpack Turbopack
================================================================
Language | JavaScript | Rust
Dev startup | Bundles everything | Lazy — bundles on demand
HMR speed | 1-5s (large apps) | <200ms (large apps)
Production build | YES (default) | Not yet (coming)
Plugin ecosystem | Massive (thousands) | Limited (growing)
Custom config | next.config.js | Limited overrides
Stability | Battle-tested | Stable for dev
When used | next build | next dev (default in 15+)
================================================================
Key interview point: Turbopack is not a drop-in replacement for Webpack. It does not support all Webpack plugins or loaders. For production builds, Webpack remains the default. Turbopack's goal is to eventually replace Webpack entirely, but as of Next.js 15, it handles development only.
The .next/ Directory — What next build Actually Produces
Most developers have never opened their .next/ folder. In an interview, knowing what lives inside signals deep understanding.
.next/
|-- build-manifest.json # Maps routes to their JS chunks
|-- react-loadable-manifest.json # Dynamic import mappings
|
|-- server/
| |-- app/ # Server-rendered route handlers
| | |-- page.js # Server component for /
| | |-- about/
| | |-- page.js # Server component for /about
| |-- chunks/ # Server-side code chunks
| |-- app-paths-manifest.json # Route -> file mapping
|
|-- static/
| |-- chunks/
| | |-- webpack-[hash].js # Webpack runtime
| | |-- main-[hash].js # Next.js client runtime
| | |-- pages/ # Per-page client bundles
| | |-- app/ # Per-route client bundles (App Router)
| |-- css/ # Extracted CSS files
| |-- media/ # Optimized fonts, images
| |-- [buildId]/ # Versioned static assets
|
|-- cache/
| |-- webpack/ # Persistent build cache
| |-- images/ # Optimized image cache
| |-- fetch-cache/ # Cached fetch() responses
|
|-- types/ # Generated TypeScript types
|-- trace # Build performance trace
What matters for interviews:
server/ — Contains the server-side rendered versions of your routes. Server Components execute here. This code never reaches the browser. If you console.log in a Server Component, the log appears in your terminal, not the browser console.
static/chunks/ — Contains the client-side JavaScript. This is what gets sent to browsers. Each route has its own chunk, plus shared chunks for common code. The [hash] in filenames enables aggressive caching — change the code, the hash changes, browsers fetch the new version.
cache/ — Persistent cache that survives between builds. This is why your second next build is often faster than the first. Delete .next/cache/ to force a clean build (useful for debugging cache-related issues).
build-manifest.json — The map that tells Next.js "when the user navigates to /about, load these specific JS chunks." This powers the automatic code splitting and prefetching.
The Build Output Summary
When next build finishes, it prints a route summary:
Route (app) Size First Load JS
-------------------------------------------------------
/ 5.2 kB 89 kB
/about 1.8 kB 85 kB
/blog/[slug] 3.1 kB 87 kB
/dashboard 12.4 kB 96 kB
+ First Load JS shared by all 83.6 kB
chunks/webpack-[hash].js 1.2 kB
chunks/main-[hash].js 28.4 kB
chunks/[hash].js 54 kB
o (Static) prerendered as static content
f (Dynamic) server-rendered on demand
Size = the unique JavaScript for that specific route. First Load JS = Size + shared chunks. This is what a user downloads on their first visit to that route.
The o (static) vs f (dynamic) markers tell you which routes were pre-rendered at build time and which will be server-rendered per request. This directly connects to the rendering strategies you will study in Chapter 2.
How Next.js Optimizes Beyond the Basics
Automatic Prefetching
Next.js prefetches the JavaScript for linked routes when <Link> components become visible in the viewport. By the time a user clicks a link, the code is already downloaded.
import Link from "next/link";
// When this link scrolls into view, Next.js prefetches
// the /about route's JS chunk in the background
export default function Nav() {
return <Link href="/about">About</Link>;
}
// The user clicks -> instant navigation (JS already loaded)
Dynamic Imports for Heavy Components
When a component is large but not immediately needed, use next/dynamic to load it on demand:
import dynamic from "next/dynamic";
// This component's code is NOT included in the initial bundle
// It loads only when the component renders
const HeavyChart = dynamic(() => import("../components/Chart"), {
loading: () => <p>Loading chart...</p>,
ssr: false, // Skip server rendering for client-only components
});
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<HeavyChart />
</div>
);
}
// Result: faster initial page load, chart loads separately
Package Import Optimization
Next.js 13.1+ added optimizePackageImports in next.config.js. For large libraries like lodash, @mui/material, or lucide-react, this ensures tree shaking works even when the library's structure would normally defeat it:
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ["lucide-react", "@mui/material"],
},
};
// Now this import only bundles the icons you actually use
import { Home, Settings, User } from "lucide-react";
// Without optimization: could pull in ALL 1000+ icons
// With optimization: only Home, Settings, User are bundled
Common Mistakes
-
Never inspecting the build output. Running
next buildand ignoring the route summary table means you miss bloated routes. If a route shows 150 kB First Load JS, something is wrong — a massive library is likely being pulled into the client bundle. Read the output every time you build. -
Importing server-only code in Client Components. If a Client Component imports a module that uses
fs,prisma, or database drivers, the bundler tries to include that code in the client bundle and fails. Use theserver-onlypackage to catch this at build time:import "server-only"at the top of server-only modules. -
Using
require()instead ofimport. CommonJSrequire()defeats tree shaking entirely. The bundler cannot statically analyze which exports are used, so it includes everything. Always use ES moduleimportstatements. -
Barrel files that re-export everything. A file like
components/index.tsthat doesexport { Button } from "./Button"; export { Modal } from "./Modal"; export { Table } from "./Table"can cause the bundler to pull in all components even when you only import one. Import directly from the component file when bundle size matters. -
Assuming Turbopack and Webpack behave identically. Code that works in
next dev(Turbopack) might behave differently innext build(Webpack) due to different module resolution, different plugin support, or different handling of edge cases. Always test withnext buildbefore deploying.
Interview Questions
Q: What is the difference between Turbopack and Webpack in Next.js?
Turbopack is a Rust-based incremental bundler used for development in Next.js 15+. It uses lazy compilation (only bundles the page you visit) and fine-grained caching for near-instant HMR. Webpack is the mature JavaScript-based bundler still used for production builds. Turbopack is faster for development but does not yet support all Webpack plugins or production builds.
Q: How does Next.js handle code splitting?
Next.js automatically code-splits at route boundaries. Each
page.jsin theapp/directory produces its own JavaScript chunk. Shared code used by multiple routes is extracted into common chunks. Users only download the JavaScript needed for the current route, plus shared runtime code. You can further split withnext/dynamicfor heavy components.
Q: What is tree shaking and what can prevent it from working?
Tree shaking is the process of eliminating unused exports from the final bundle. The bundler analyzes ES module import/export statements to determine which exports are actually referenced. It fails with CommonJS
require()(not statically analyzable), barrel files that re-export everything, side-effectful imports, and dynamic property access on imported namespaces.
Q: What does the .next/ folder contain after next build?
It contains
server/(server-rendered route handlers and Server Component code that never reaches the browser),static/(client-side JavaScript chunks, CSS, fonts, and versioned assets),cache/(persistent build cache, image optimization cache, and fetch cache), plus manifest files likebuild-manifest.jsonthat map routes to their required JS chunks.
Q: How would you investigate and reduce a large bundle size in Next.js?
First, read the
next buildoutput to identify which routes have large First Load JS. Then use@next/bundle-analyzerto visualize which packages contribute to the bundle. Common fixes: replace heavy libraries with lighter alternatives, usenext/dynamicfor components not needed on initial load, add large icon/component libraries tooptimizePackageImports, ensure you are using ES module imports for tree shaking, and move server-only code behind the"use client"boundary correctly.
Quick Reference -- Cheat Sheet
COMPILATION PIPELINE
================================================================
Source (.tsx/.jsx)
-> SWC Compile (Rust, 17x faster than Babel)
-> Dependency Graph (follow all imports)
-> Code Split (per-route chunks)
-> Tree Shake (remove unused exports)
-> Minify (SWC, shorter names, no whitespace)
-> .next/ output (server/ + static/ + cache/)
================================================================
TURBOPACK vs WEBPACK
================================================================
Turbopack Webpack
Language Rust JavaScript
Used in next dev (15+) next build
Startup Lazy (fast) Full bundle (slow)
HMR <200ms 1-5s
Plugins Limited Thousands
================================================================
CODE SPLITTING
================================================================
Automatic Every page.js/route.js = own chunk
Shared chunks Common imports extracted automatically
Dynamic import next/dynamic for on-demand loading
Prefetching <Link> prefetches visible route chunks
================================================================
.next/ DIRECTORY
================================================================
server/ Server Components, route handlers
static/chunks/ Client JS bundles (hashed filenames)
static/css/ Extracted CSS
cache/ Build cache, image cache, fetch cache
build-manifest Route -> chunk mapping
================================================================
TREE SHAKING KILLERS
================================================================
require() CommonJS = no static analysis
export * Barrel files defeat optimization
Side effects import "./file" always kept
Dynamic access obj[key] on namespace imports
================================================================
BUNDLE ANALYSIS
================================================================
next build Route size summary
@next/bundle-analyzer Visual treemap of bundles
optimizePackageImports Fix large library imports
next/dynamic + ssr:false Lazy load heavy components
import "server-only" Prevent server code in client
================================================================
Previous: Lesson 1.3 -- Project Structure & Conventions Next: Lesson 2.1 -- Server-Side Rendering (SSR)
This is Lesson 1.4 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.