The Build Pipeline: webpack Config, Code Generation, and Three-Compiler Architecture
Prerequisites
- ›Articles 1-2: Architecture overview and server boot sequence
- ›Webpack concepts — loaders, plugins, compilation phases, module graph
- ›Understanding of module resolution and code splitting
- ›Familiarity with React Server Components server/client boundaries
The Build Pipeline: webpack Config, Code Generation, and Three-Compiler Architecture
Running next build orchestrates one of the most complex build pipelines in the JavaScript ecosystem. It discovers routes from the filesystem, generates entry point code that wires framework infrastructure to user components, runs three separate webpack compilations (client, server, edge), produces over a dozen manifests, and optionally prerenders every static page. This article traces the entire pipeline.
Build Orchestration: build/index.ts
The build entry point is build/index.ts — a ~4,330-line file that sequences the entire build process. The high-level flow:
flowchart TD
Start["next build"] --> Config["Load & Validate Config"]
Config --> Env["Load .env files"]
Env --> FindPages["Find pages/ and app/ directories"]
FindPages --> CustomRoutes["Load custom routes\n(rewrites, redirects, headers)"]
CustomRoutes --> Entries["Collect Entrypoints\n(entries.ts)"]
Entries --> Compile["Run Webpack Compilation\n(3 compilers)"]
Compile --> Manifests["Generate Manifests\n(pages, routes, prerender, etc.)"]
Manifests --> StaticGen["Static Generation\n(prerender pages)"]
StaticGen --> Export["Export static files"]
Export --> Output["Write build output\n(.next/)"]
The build starts with config loading (the same loadConfig() we covered in Article 1), then discovers the pages/ and app/ directories. It loads custom routes from next.config.js rewrites/redirects/headers, collects all entrypoints from the filesystem, and then kicks off the Webpack compilation.
After compilation, the build performs static generation — rendering every page that can be prerendered to produce HTML and RSC data. This is where ISR pages get their initial cache entries, and where PPR pages get their static shells.
Entrypoint Collection and Code Generation
The entries.ts module discovers routes from the filesystem and maps them to Webpack entry points. For each route, it generates entry point code using build templates — a code generation system that avoids runtime reflection.
flowchart LR
FS["Filesystem\n(app/dashboard/page.tsx)"] --> Entries["entries.ts\n(route discovery)"]
Entries --> Template["Build Template\n(app-page.ts)"]
Template --> EntryCode["Generated Entry\n(imports user code +\nframework wrappers)"]
EntryCode --> Webpack["Webpack Entry"]
The build templates live in build/templates/:
| Template | Purpose |
|---|---|
app-page.ts |
Wraps App Router page components with AppPageRouteModule |
app-route.ts |
Wraps App Router route handlers with AppRouteRouteModule |
pages.ts |
Wraps Pages Router pages with SSR/SSG infrastructure |
pages-api.ts |
Wraps Pages Router API routes |
middleware.ts |
Wraps middleware with the edge runtime adapter |
edge-ssr-app.ts |
Edge runtime SSR for App Router |
The app-page.ts template is particularly interesting. It imports AppPageRouteModule (the class from Article 3), the user's loaderTree, and the vendored React instances, then wires them together. The template uses import attributes like with { 'turbopack-transition': 'next-ssr' } to ensure modules are loaded with the correct bundler configuration for their target environment.
This code generation approach is a key design decision. Rather than using runtime reflection (require(userPagePath) at request time), Next.js generates static imports at build time. This enables dead code elimination, tree shaking, and proper module graph analysis — the bundler can see exactly what each entry point depends on.
Tip: When debugging build issues, look at the generated entry point code in
.next/server/app/[route]/page.js. It shows exactly how the template connected your page component to the framework.
Webpack Configuration Factory
The webpack-config.ts factory (~2,930 lines) generates three distinct Webpack configurations — one for each compiler target we introduced in Article 1:
flowchart TD
Factory["webpack-config.ts\ngetBaseWebpackConfig()"] --> Client["Client Config"]
Factory --> Server["Server Config"]
Factory --> Edge["Edge Server Config"]
Client --> ClientBundle["Browser JavaScript\n- React client components\n- Client-side router\n- CSS/assets"]
Server --> ServerBundle["Node.js Bundle\n- RSC rendering\n- API routes\n- SSR"]
Edge --> EdgeBundle["Edge Bundle\n- Middleware\n- Edge routes\n- No Node.js APIs"]
subgraph "Key Differences"
ClientDiff["Client:\n- target: 'web'\n- externals: none\n- splits code by page"]
ServerDiff["Server:\n- target: 'node'\n- externals: node_modules\n- bundled React"]
EdgeDiff["Edge:\n- target: 'webworker'\n- polyfilled APIs\n- size-constrained"]
end
The three configurations share a common base but differ significantly in:
- Target:
'web'(client),'node'(server),'webworker'(edge) - Externals: The server compiler externalizes
node_modules(they're available at runtime), while the client compiler bundles everything. The edge compiler bundles selectively, polyfilling Node.js APIs that aren't available. - Module resolution: The server uses the
react-servercondition for RSC modules, while the client uses the standardbrowsercondition. This is how'use client'boundaries work — the same package resolves to different code depending on the compiler. - Webpack layers: The server compiler uses layers (
WEBPACK_LAYERS) to isolate RSC code from SSR code, ensuring server-only imports don't leak into client bundles.
The configuration also sets up optimization — code splitting for the client, server-specific chunk strategies, and CSS extraction. For development, it adds React Fast Refresh via ReactRefreshWebpackPlugin.
Key Webpack Plugins: Flight Manifest and Client Entry
Two plugins are critical to making React Server Components work across the server/client boundary:
FlightManifestPlugin
The flight-manifest-plugin.ts creates the Client Reference Manifest — a mapping that tells the server how to reference client components. When a Server Component renders a client component, it doesn't render the component's code — it emits a reference. This manifest maps those references to the actual client-side chunk files.
flowchart LR
ServerComp["Server Component\n(renders <ClientButton />)"] --> Ref["Client Reference\n{id: 'Button', chunks: [...]}"]
Ref --> Manifest["Client Reference Manifest\n(flight-manifest.json)"]
Manifest --> ClientChunk["Client Chunk\n(Button.js)"]
subgraph "Server Bundle"
ServerComp
Ref
end
subgraph "Client Bundle"
ClientChunk
end
Manifest -.->|"Maps references\nto chunks"| ServerComp
Manifest -.->|"Tells browser\nwhat to load"| ClientChunk
FlightClientEntryPlugin
The flight-client-entry-plugin.ts walks the module graph to discover 'use client' boundaries. When it finds a module with the 'use client' directive, it creates a client-side entry point for that module and all its dependencies. This is how the "client component" abstraction works at the bundler level — the plugin automatically splits the module graph at 'use client' boundaries.
The plugin also handles Server Actions ('use server') by creating the Server Reference Manifest — the inverse mapping that tells the client how to call server functions.
Together, these two plugins are the bridge between React Server Components (a runtime concept) and Webpack (a build-time tool). They analyze the module graph to understand the server/client boundary, then produce the manifests that both runtimes use to reference code across that boundary.
The App Loader: Filesystem to Module Graph
The next-app-loader is a Webpack loader that transforms the app/ directory convention into the loaderTree data structure consumed by the rendering engine. For a directory structure like:
app/
layout.tsx
page.tsx
dashboard/
layout.tsx
page.tsx
loading.tsx
error.tsx
The loader produces a tree like:
graph TD
Root["['', { layout: './app/layout.tsx', children: ... }]"] --> Dashboard["['dashboard', { layout: './app/dashboard/layout.tsx',\nloading: './app/dashboard/loading.tsx',\nerror: './app/dashboard/error.tsx',\nchildren: ... }]"]
Dashboard --> Page["['page', { page: './app/dashboard/page.tsx' }]"]
Each node in the loader tree is a tuple of [segment, modules, children]. The modules object contains references to the conventional files — layout.tsx, page.tsx, loading.tsx, error.tsx, template.tsx, not-found.tsx. This structure is serialized into the webpack module graph and later consumed by create-component-tree.tsx (Article 3) to build the React element tree.
The loader also handles parallel routes (directory names starting with @), route groups (directory names in parentheses), and intercepting routes — all by interpreting the filesystem conventions and producing the appropriate tree structure.
Tip: If you're debugging why a layout or error boundary isn't working, look at the loader tree. You can find it in the compiled server output by searching for
loaderTreein the generated page modules. The tree structure directly maps to how your components will be nested.
Alternative Bundlers: Turbopack and Rspack
The Webpack-based pipeline described above is the "reference implementation." Turbopack and Rspack plug in as alternatives at the compilation step:
flowchart TD
Build["build/index.ts"] --> BundlerCheck{"Which bundler?"}
BundlerCheck -->|Webpack| WebpackConfig["webpack-config.ts\n(3 compilers)"]
BundlerCheck -->|Turbopack| TurbopackBuild["turbopack-build/\n(Rust compilation)"]
BundlerCheck -->|Rspack| RspackBuild["next-rspack/\n(Rspack compilation)"]
WebpackConfig --> Manifests["Shared Manifest Format"]
TurbopackBuild --> Manifests
RspackBuild --> Manifests
Manifests --> StaticGen["Static Generation\n(shared across bundlers)"]
Manifests --> ServerRuntime["Server Runtime\n(shared across bundlers)"]
The key insight is that all three bundlers produce the same manifest format. The server runtime doesn't care which bundler produced the manifests — it reads build-manifest.json, pages-manifest.json, client-reference-manifest.json, etc., regardless of the build tool.
Turbopack's build path lives in build/turbopack-build/ and delegates to the Rust crates in crates/next-core. It uses the same entrypoint discovery and code generation templates but runs the actual bundling through Turbopack's Rust engine — which is significantly faster due to incremental computation and parallelism.
Rspack, living in packages/next-rspack, is API-compatible with Webpack. It reuses much of the same webpack-config.ts configuration (Rspack is designed to be a drop-in replacement), but runs Rspack's Rust-based compiler instead of Webpack's JavaScript-based one.
The three-bundler strategy is pragmatic: Turbopack is the future (fastest, purpose-built for Next.js), Webpack is the proven present (most ecosystem compatibility), and Rspack is the pragmatic middle ground (fast, Webpack-compatible). The shared manifest format is what makes this multi-bundler strategy possible without duplicating the server runtime.
What's Next
We've traced the build pipeline from filesystem discovery through code generation and compilation to manifest output. In the final article, we'll explore the caching architecture that spans the entire system — from the response cache that deduplicates concurrent requests, through the incremental cache that persists ISR pages, to the use cache directive's cache handler interface and the tag-based revalidation system.