Next.js Turbopack: 13.9s vs. webpack's 18.3s, Same Commit
A measured head-to-head on 538 pages, plus the config trap: Turbopack serializes options to Rust, so an imported MDX plugin now fails the build outright.
Turbopack is the Rust bundler built into Next.js, and since Next 16 it is the default for next dev and next build. On this repository — Next.js 16.2.9, 546 prerendered pages, same machine, same commit — it compiles in 12.1s against webpack's 17.9s, and finishes the whole build in 27s against 42s.
The speed is the headline and the least interesting part. The part that costs you an afternoon is that Turbopack's config is serialized across a JavaScript/Rust boundary, which quietly changes what you are allowed to put in next.config.ts. It is also the wrong place to look for load-time wins, since a faster bundler does not change what the bundle contains — Qwik vs. Next.js takes that half of the question, and Vite vs. Vue covers why build-tool and framework questions get conflated in the first place.
The measurement
Both builds were run back to back in the same container, .next deleted first, on the commit that published this post. Each was run twice; the two runs agreed within 0.3s, and the first is reported here:
| Turbopack | webpack | |
|---|---|---|
| Command | next build | next build --webpack |
| Compile | 12.1s | 17.9s |
| Static generation (546 pages, 3 workers) | 6.7s | 8.4s |
| Total wall clock | 27s | 42s |
That is a 1.5x compile speedup and 1.6x on wall clock. Real, worth having, and still below the 2–5x figures you'll see quoted — because this site is 111 product pages and 199 blog posts generated from typed data, so a large share of its build is React rendering 546 routes, not bundling. (Rendering those routes at build time rather than in the browser is the whole reason the build has that shape — a client-rendered SPA has nothing to generate.) Static generation is the same work under either bundler; it dropped only because the process had less other work competing for three cores.
The lesson in the split: bundler choice speeds up the compile step. If your build is dominated by generateStaticParams fan-out, your ceiling is set by page count and worker count, not by Rust.
The config trap: options must be serializable
This site compiles its blog from MDX, which means next.config.ts passes plugins to the MDX loader. Here is the working configuration:
// next.config.ts
const withMDX = createMDX({
options: {
remarkPlugins: ["remark-gfm"],
rehypePlugins: ["rehype-slug"],
},
});
export default withMDX(nextConfig);
The plugins are named as strings. Every MDX tutorial written before Turbopack became the default passes imported functions instead:
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm], // a function reference
rehypePlugins: [rehypeSlug],
},
});
Turbopack runs the MDX pipeline in Rust. Config has to cross the JS/Rust boundary, and a JavaScript function cannot be serialized to send across it. So the imported-function form cannot work — the only question is how it fails.
We tested all four combinations on this repo rather than trusting the folklore:
| Plugins written as | Turbopack | webpack |
|---|---|---|
["remark-gfm"] (string) | builds, 3 tables in the prerendered HTML | builds, 3 tables |
[remarkGfm] (function) | build fails | builds, 3 tables |
The Turbopack failure is loud, which is the good news:
Error: loader /path/to/node_modules/@next/mdx/mdx-js-loader.js
for match "{*,next-mdx-rule}" does not have serializable options.
Ensure that options passed are plain JavaScript objects and values.
This is worth flagging because the failure mode changed. Earlier Turbopack versions accepted the function-reference config, built successfully, and silently dropped the plugin — which for remark-gfm means MDX falls back to CommonMark, which has no table syntax, so every | a | b | row renders as a paragraph of literal pipes. A green build and quietly broken content. Our own next.config.ts comment still described that older behaviour; on 16.2.9 it is a hard, immediate error instead. If you are debugging this on an older Next, check the rendered output, not the exit code.
The webpack column is the control that proves the cause: identical config, identical repo, and the function form builds fine there because webpack keeps everything in one JavaScript process. Anything you inherited from a webpack-era tutorial that passes a callback, a plugin instance, or a regex-carrying object into loader options is a candidate for this error.
Why the dev server needs unsafe-eval and production doesn't
Turbopack's dev server evaluates modules with eval to make Fast Refresh work. That collides with a Content-Security-Policy, and the fix is in this repo's config:
// Next.js/Turbopack dev (Fast Refresh) evaluates modules via eval; production
// (`next start`) is eval-free. Allow 'unsafe-eval' ONLY in development so local
// HMR works without weakening the shipped production policy.
const scriptSrcEval =
process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : "";
The point is the conditional. A CSP that permits 'unsafe-eval' unconditionally — which is what you get if you add it once to stop the dev console screaming — ships that permission to production, where the bundled output never needed it. The full policy this expands into is walked through in the Next.js CSP post.
The @theme reload gotcha
The one dev-server behaviour on this project that reliably wastes time: Turbopack does not hot-reload Tailwind v4 @theme changes.
Add --color-brand-500 to the @theme block in src/app/globals.css, save, and bg-brand-500 still won't exist. The utility isn't missing because you typed it wrong; the token that generates it was added after the dev server built its CSS, and the change doesn't invalidate what it needs to. Restart npm run dev and it appears.
This bites hardest because it looks exactly like a typo, so the instinct is to re-check the class name five times before restarting. Editing a value on an existing token is usually fine; adding or renaming a token is what needs the restart. The token system itself is covered in Tailwind design tokens.
What you can and can't configure
| Thing | Under Turbopack |
|---|---|
| Loader options | Must be JSON-serializable — strings, plain objects, arrays |
| MDX remark/rehype plugins | Name them as strings |
webpack: (config) => … in next.config.ts | Ignored; it's a webpack-only escape hatch |
| Custom loader rules | Supported via the turbopack config key, with the same serializability rule |
next build --webpack | Still available, useful precisely as the control in an experiment like the one above |
The general rule that falls out: if the value can't survive JSON.stringify, Turbopack can't receive it. Functions, class instances, and anything carrying a closure are out.
Mistakes and troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
does not have serializable options on build | A function reference passed into loader options | Pass the plugin's package name as a string |
Markdown tables render as literal | pipes | remark-gfm dropped — MDX is CommonMark, which has no tables | String-name the plugin, then confirm <table> is in the prerendered HTML |
| A new Tailwind utility silently doesn't exist | @theme token added after the dev server started | Restart npm run dev |
| CSP blocks the dev server but production is fine | Turbopack Fast Refresh uses eval | Gate 'unsafe-eval' on NODE_ENV === "development" |
A webpack: config key stopped taking effect | Turbopack doesn't read it | Port the rule to the turbopack key, or opt that build back to --webpack |
| Build is barely faster than webpack | Your build is dominated by static generation, not compilation | Measure the two phases separately before blaming the bundler |
Frequently asked questions
Is Turbopack the default in Next.js 16?
Yes — both next dev and next build use it, and --webpack is the opt-out. The build output prints which one ran: ▲ Next.js 16.2.9 (Turbopack) or (webpack).
How much faster is Turbopack, really? On this repo, 1.5x on compile and 1.6x on total build. The larger numbers you'll see quoted are usually compile-only, or measured on apps where compilation is the whole build. Measure your own two phases — the build output prints "Compiled successfully in Xs" and "Generating static pages … in Ys" separately.
Can I still use webpack?
Yes, next build --webpack. It's also the fastest way to tell a Turbopack-specific problem from a real one: if a build fails under Turbopack and passes under webpack with no other change, you're looking at a bundler-boundary issue, most likely serialization.
Why did my MDX plugin stop working after upgrading Next? Almost certainly the function-reference config above. Older versions dropped the plugin silently; current ones raise the serializable-options error. Switch to string names.
Does Turbopack change the output I ship?
Not in a way that should alter behaviour — it's a build-time tool. Both builds here produced the same 546 prerendered routes with the same table markup. The dev-only eval difference is the one runtime-visible distinction, and it never reaches production.
Templates in this post
ASoc Sage is a supplements storefront across eight wellness categories with a best-seller grid, a slide-out cart drawer, an ingredient guide and verified-buyer reviews. ASoc Satchel is a handmade leather-goods store with Women's and Men's collections, wired-up cart, search and wishlist, plus a workshop journal. ASoc Spark markets AI-powered smart-home gadgets with six service blocks, a three-step onboarding flow and three-tier monthly/yearly pricing.
Browse the full sets: Next.js shop templates, Tailwind shop templates.
