Back/Module A-11 Build Engine Internals — Turbopack, SWC, and Memory Optimisation
Module A-11·26 min read

Why Turbopack replaced Webpack, incremental computation model, SWC transforms, turbopack config and filesystem cache, module graph anatomy, diagnosing OOM build failures, and pageExtensions for non-standard project layouts.

A-11 — Build Engine Internals: Turbopack, SWC, Bundle Analysis

Who this is for: Architects who want to understand what happens between npm run build and the deployable output — how Turbopack replaced Webpack, why SWC is faster than Babel, how the module graph is constructed, and how to diagnose and fix build performance problems that appear when a codebase scales.


Why the Build Stack Changed

For four years, Next.js used Webpack 5 as its bundler and Babel as its JavaScript transformer. Both were fine at small scale. Both had known limitations at large scale:

Webpack's problem: Webpack rebuilds the module graph on every save. In a large application, the graph might have 10,000 modules. Even incremental builds re-analyse significant portions of this graph. Teams with large codebases saw 30-60 second hot reload times.

Babel's problem: Babel is written in JavaScript and processes files one-at-a-time. TypeScript type stripping, JSX transformation, decorator transforms — Babel does each file sequentially. SWC does the same transformations in Rust, parallelised across all CPU cores, 10-100x faster.

The replacement strategy: SWC for JavaScript/TypeScript transformation (already the default in Next.js 12+), Turbopack for bundling (stable in Next.js 15 for development, nearing stability for production).


SWC — The Transformation Layer

SWC (Speedy Web Compiler) is a Rust-based JavaScript/TypeScript compiler. It replaced Babel as Next.js's transform layer in version 12.

What SWC handles:

  • TypeScript → JavaScript (type stripping, not type checking)
  • JSX → React.createElement calls
  • ES2022+ → target ES version
  • Import path transforms (absolute imports, module aliases)
  • Emotion/styled-components transforms (when configured)
  • React Compiler transforms (when enabled)

What SWC does not do:

  • Type checking (that's tsc --noEmit, run separately in CI)
  • Bundling (that's Webpack or Turbopack)
  • Custom Babel plugins (Babel is no longer in the chain when SWC is active)

The custom Babel plugin problem: if your project uses a Babel plugin that SWC doesn't have a native equivalent for, you have to keep Babel in the chain — which means losing SWC's speed advantage for those transforms. This is why teams with custom Babel plugins see slower builds than teams that migrated fully.

ts
// next.config.ts — disable SWC if you have incompatible Babel transforms const config: NextConfig = { swcMinify: true, // use SWC for minification (default true in Next.js 13+) // To keep Babel (opt-out of SWC): // Create .babelrc or babel.config.js — Next.js automatically falls back to Babel // when it detects a Babel config file };

For styled-components and Emotion, SWC has built-in transforms that are faster than the Babel plugins:

ts
// next.config.ts const config: NextConfig = { compiler: { styledComponents: true, emotion: true, // removeConsole: { exclude: ['error'] }, // strip console.log in production }, };

Turbopack — The Bundler Replacement

Turbopack (also written in Rust) is the replacement for Webpack. Its architectural difference: incremental computation with fine-grained caching.

Webpack's model: build the entire module graph, apply transforms, produce bundles. Incremental builds re-analyse changed modules and their transitive dependents.

Turbopack's model: every module and every function on every module is a cacheable unit of computation. When a file changes, only the computation units that depend on that specific file are re-evaluated. The cache is persistent across restarts — a restart after Turbopack has warmed its cache is nearly as fast as a hot reload.

The practical result: a codebase that took 30 seconds for a hot reload with Webpack might take 500ms with Turbopack, because Turbopack doesn't re-evaluate the 9,800 modules that didn't change.

Enable Turbopack for development:

bash
next dev --turbopack # or in package.json: # "dev": "next dev --turbopack"

Turbopack is the default for next dev in Next.js 15. For next build (production), Turbopack is still in progress — production builds use Webpack by default until Turbopack production build reaches parity.


Turbopack Configuration

Turbopack configuration lives in next.config.ts under the turbopack key:

ts
// next.config.ts const config: NextConfig = { turbopack: { // Custom module resolution aliases resolveAlias: { '@': './src', 'lodash': 'lodash-es', // use ESM version of lodash }, // Custom file extensions to resolve resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.json'], // Custom loaders (replaces Webpack loaders for Turbopack) rules: { '*.svg': { loaders: ['@svgr/webpack'], as: '*.js', }, }, }, };

The Webpack loader compatibility note: Turbopack cannot use Webpack loaders directly. If your project uses custom Webpack loaders (SVG transforms, MDX loaders, etc.), you need to find or create Turbopack-compatible versions. This is the primary migration blocker for complex projects.


Bundle Analysis

Before optimising, you need to see what's in your bundle. @next/bundle-analyzer produces a visual map of every module in every chunk:

bash
npm install @next/bundle-analyzer
ts
// next.config.ts import bundleAnalyzer from '@next/bundle-analyzer'; const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true', }); const config: NextConfig = { // your config }; export default withBundleAnalyzer(config);
bash
ANALYZE=true npm run build

This opens two HTML files in your browser — one for the client bundle, one for the server bundle. Each file appears as a rectangle sized proportionally to its contribution to the total bundle size.

What to look for:

  • Large utility libraries used for one functionmoment (200KB gzipped) when you only need date formatting. Replace with date-fns (tree-shakeable) or a one-liner.
  • Duplicate modules — the same library appearing multiple times, often because different parts of the tree import different versions.
  • The node_modules section — if third-party code dominates, look for lighter alternatives.
  • Your own code appearing unexpectedly large — usually means a large JSON file or SVG is being bundled inline.

The bundlePagesRouterDependencies Migration Problem

Migrating from Pages Router to App Router often surfaces a bundle size regression: code that was previously Server-only in Pages Router (run only in Node.js) might accidentally be included in the client bundle in the App Router if import boundaries aren't explicit.

The server-only package prevents this at the module level:

ts
// lib/db.ts import 'server-only'; // throws if this module is imported by client code import { PrismaClient } from '@prisma/client'; export const db = new PrismaClient();

If a Client Component accidentally imports from lib/db.ts, the build fails with a clear error. Without server-only, Prisma would end up in the client bundle — you'd see it in the bundle analysis as a mysterious 500KB addition.


Build Performance Profiling

When builds are slow, the first step is measuring where the time goes:

bash
# Profile the build (Next.js 15+) NEXT_PROFILE=true npm run build

This produces a .next/profile-events.json file you can load in Chrome DevTools' Performance tab or analyse with speedscope. The profile shows which transforms and route compilations take the most time.

Common culprits:

  • Type checking during buildtsc running as part of next build. Move type checking to a separate CI step: npm run typecheck before npm run build.
  • Large generateStaticParams outputs — generating 100k static pages at build time. Use ISR or PPR with on-demand generation instead.
  • Slow MDX/content transforms — transforming thousands of markdown files. Cache the transform output.
  • Missing barrel file optimisation — importing from index.ts files that re-export hundreds of modules causes the bundler to analyse all of them, even if only one is needed.
ts
// next.config.ts — optimise barrel imports (also called package imports) const config: NextConfig = { experimental: { optimizePackageImports: ['@radix-ui/react-icons', 'lucide-react', 'date-fns'], }, };

optimizePackageImports tells Next.js to import directly from submodules rather than through the barrel index, eliminating the "analyse 500 modules to find the 3 you need" problem.


TypeScript Build Integration

The next build command includes TypeScript checking by default. In large codebases, this can add 60+ seconds to CI build time.

Options:

  1. Keep it (default) — safest, catches type errors before deployment
  2. Ignore during build, check in CI separately:
ts
// next.config.ts const config: NextConfig = { typescript: { // Allows production builds to succeed even if there are TypeScript errors // (use this only if you have a separate typecheck step in CI) ignoreBuildErrors: true, }, };

The correct CI pipeline when using ignoreBuildErrors:

yaml
steps: - run: npm run typecheck # npx tsc --noEmit - run: npm run lint - run: npm run test - run: npm run build # fast build, no type check

This runs type checking in parallel with other checks, potentially cutting CI time significantly.


Where We Go From Here

A-12 focuses on the user experience side of performance: Core Web Vitals engineering — LCP, INP, CLS, and the specific Next.js patterns that affect each metric. After A-11's understanding of how bundles are built, A-12 explains how those bundles affect the metrics that Google measures and users feel.

Discussion