Layer 3 · the bundler

Your src/ folder is not something a browser can load.

Look at the top of any file in your app. import { Button } from "@/components/Button". import "./globals.css". A .tsx extension. HTML tags inside JavaScript. A browser can run a plain import "./x.js", but not one of these: the @/ alias, a bare package name, a CSS import, types, JSX.

Try it. Serve the file as-is and open it in a tab.

app/page.tsx
import { Button } from "@/components/Button";   // ① what's "@/"?
import "./globals.css";                       // ② importing CSS?
import { format } from "date-fns";             // ③ from where?

export default function Page({ user }: { user: User }) {  // ④ types
  return <Button size="lg">Hi {user.name}</Button>;   // ⑤ HTML in JS
}
What a bundler does

Start at the entry file. Follow every import. Transform each file. Glue the result.

That's the whole job. The bundler reads page.tsx, sees three imports, opens each, sees their imports, and keeps going until it's seen everything reachable. Along the way it strips types, converts JSX, and rewrites paths.

Press Bundle and watch it walk. Then click any file to remove its import, and bundle again.

import graph → one file
bundle.js — empty
0 kB
bundle size
What you just saw

If nothing imports it, it isn't in the bundle.

The bundler only includes code it can reach from the entry. Delete the import and the whole branch falls out. It's why "just install the package" costs nothing until you actually import from it.

Tree-shaking goes one step finer: import { format } from "date-fns" pulls in format and what it needs, not the other 200 functions in the package. The 18 kB node in the demo is the honest size of that slice, not the whole library.

And it's why node_modules being 400 MB doesn't mean your users download 400 MB. They get the reachable slice, minified.

Two modes

next dev and next build are the same bundler in two moods.

dev: bundle only what the current page needs, keep it in memory, don't minify, and when a file changes, swap just that module in the running page. That swap is hot module replacement.

build: bundle everything, minify, split into chunks, hash the filenames for caching, and write it all to .next/. Slow once, so that production is fast forever.

Edit the heading on the right. Watch the counter: it doesn't reset. Only the changed module was replaced; the page's state survived.

hot module replacement

Header.tsx

localhost:3000

My App
state lives here
waiting for a change…
The names you'll see

Five tools, one job, different speeds.

You'll meet all of these in config files, error messages and blog posts. They are interchangeable in the way that engines are: same job, different generation.

Next.js uses Turbopack for both dev and build. Vite is what you'd use for a React app without Next.js. You will never call any of these directly; they hide behind npm run dev.

toolwritten inknown for
webpack (2012)JavaScriptThe original. Powered Next.js for years. Slow but does everything.
esbuild (2020)Go100× faster than webpack. Used inside Vite for dev.
SWC (2019)RustThe transformer (TS → JS, JSX → calls). Inside Next.js.
Vite (2020)JS + esbuild + RollupInstant dev server. Default for non-Next React apps.
Turbopack (2022)RustVercel's webpack successor. Default for dev and build since Next 16.
Up

Now the browser can load the file. What does the file do?

Everything so far has been getting code to the tab. From here up, it's about what the code does once it's there. That starts with React.