SWC and Turbopack
See what SWC's transforms do to your files, how Turbopack builds one module graph with layers, reuses its work, keeps a cache, and runs Fast Refresh.
- 01Read what the compiler didFind a file's compiled copies in .next, and name the SWC transforms Next.js runs, with the checks that stop a build.
- 02Explain Turbopack's graphSay how one module graph holds the server, SSR and browser layers, and what happens at a 'use client' boundary.
- 03Explain how Turbopack reuses workDescribe turbo-tasks, the filesystem cache in .next/cache, and how an edit reaches the browser through Fast Refresh.
The tea page, compiled for three layers
Step 3 of next build is one call: turbopackBuild. Two pieces of Rust do the work inside it. SWC is a compiler: a program that reads code and writes other code. It turns each TypeScript and JSX file into plain JavaScript, and runs Next.js's own changes on the way. Turbopack is the bundler: it follows the imports from every route, decides which files need compiling and for where, and writes the chunks. Both ship in one native file that Next.js installs for your computer, the @next/swc-… package. The source quoted is Next.js 16.4.3's, at the tag v16.4.3.
The tea shop's page for each tea has one Client Component: the Add to cart button. This build turns minifying off (lines 7 to 9 of next.config.ts), so the code it writes keeps its names and line breaks. The build prints that as ⨯ turbopackMinify under "Experiments": the ⨯ means the setting is off. The recorder kept the files the build wrote for add-to-cart.tsx.
app/add-to-cart.tsx"use client"; import { useState } from "react"; export function AddToCart() { const [added, setAdded] = useState(false); return ( <button onClick={() => setAdded(true)}> {added ? "Added" : "Add to cart"} </button> );}The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
Line 1 of app/add-to-cart.tsx marks it as a Client Component. In .next, the build wrote it three times. The first copy is in the server's chunk for the tea page, where Server Components run:
// This file is generated by next-core EcmascriptClientReferenceModule.
var __TURBOPACK__imported__module__11857__ = __turbopack_context__.i(11857);
;
const AddToCart = (0, __TURBOPACK__imported__module__11857__["registerClientReference"])(function() {
throw new Error("Attempted to call AddToCart() from the server but AddToCart is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");
}, "[project]/app/add-to-cart.tsx", "AddToCart");
__turbopack_context__.s([
"AddToCart",
0,
AddToCart
]);There's no button here. Line 1 says Next.js's Rust code generated this module (next-core is its crate). Lines 4 to 6 make AddToCart a client reference: registerClientReference gets a function that throws if the server ever calls it, the file's id [project]/app/add-to-cart.tsx, and the export's name. When a Server Component renders <AddToCart />, React puts this reference into the RSC payload (the React Server Components payload: the description of the rendered tree that the server sends the browser) instead of running the component. Lines 7 to 11 export it. The other two copies are the component itself:
var __TURBOPACK__imported__module__87924__ = __turbopack_context__.i(87924);
var __TURBOPACK__imported__module__72131__ = __turbopack_context__.i(72131);
"use client";
;
;
function AddToCart() {
const [added, setAdded] = (0, __TURBOPACK__imported__module__72131__["useState"])(false);
return /*#__PURE__*/ (0, __TURBOPACK__imported__module__87924__["jsx"])("button", {
onClick: ()=>setAdded(true),
children: added ? "Added" : "Add to cart"
});
}
__turbopack_context__.s([
"AddToCart",
0,
AddToCart
]);var __TURBOPACK__imported__module__43476__ = __turbopack_context__.i(43476);
var __TURBOPACK__imported__module__71645__ = __turbopack_context__.i(71645);
"use client";
;
;
function AddToCart() {
const [added, setAdded] = (0, __TURBOPACK__imported__module__71645__["useState"])(false);
return /*#__PURE__*/ (0, __TURBOPACK__imported__module__43476__["jsx"])("button", {
onClick: ()=>setAdded(true),
children: added ? "Added" : "Add to cart"
});
}
__turbopack_context__.s([
"AddToCart",
0,
AddToCart
]);Both are the same component: line 7 calls useState, and lines 8 to 11 return the button, written as calls to jsx instead of JSX, with the types gone. The first runs on the server to make the page's first HTML. The second is in a file under /_next/static/chunks/, which the browser downloads to make the button work. Look at lines 1 and 2: the numbers are module ids, the names Turbopack gives modules, and React's are different in the two files, because each copy imports the React built for its own runtime. Line 3 is your "use client", left in place.
page.tsx imports a "use client" file, a transition puts a client reference in the Server Component layer and compiles the real file again in the SSR layer and the browser layer.A theater puts on a play where one scene is played by a touring company. The program printed for the audience doesn't hold that scene's lines: it names the scene and who performs it. The theater still reads the scene through once, and the touring company gets its own full script to take on the road. One office prepares all three, and when the author changes a page, it retypes only the scripts that page appears in.
- In real life: The play's program, which says "Scene 2: performed by the touring company"
- In Next.js: The client reference in the Server Component layer
- In real life: The rehearsal script, read through once on stage
- In Next.js: The SSR copy, which makes the first HTML
- In real life: The touring company's script, which travels with them
- In Next.js: The browser's copy, in
/_next/static/chunks/ - In real life: The office that prepares every script, and keeps the ones nobody changed
- In Next.js: Turbopack, with SWC doing the typing
SWC compiles and transforms each file; Turbopack builds one module graph for the server and the browser, compiles each file once for every layer that needs it, and remembers every result so it can redo only what changed. Chunk names, module ids and the shape of compiled code are this version's internals.
SWC transforms in next-swc
A compiler like SWC works in three stages:
- Parse: read the file's text into a tree, the AST (abstract syntax tree), where each node is one piece of code: an import, a function, a JSX element.
- Transform: walk the tree and change it, or check it. A transform is one such pass.
- Print: write the tree back out as JavaScript.
Removing TypeScript's types and turning JSX into jsx() calls are SWC's own transforms. Next.js adds more, written in Rust in the next-custom-transforms crate (a crate is a Rust package). This is its list of modules, one per transform or helper:
pub mod cjs_finder;
pub mod cjs_optimizer;
pub mod debug_fn_name;
pub mod debug_instant_stack;
pub mod disallow_re_export_all_in_page;
pub mod dynamic;
pub mod empty_gsp;
pub mod fonts;
pub mod import_analyzer;
pub mod lint_codemod_comments;
pub mod middleware_dynamic;
pub mod next_ssg;
pub mod optimize_barrel;
pub mod optimize_server_react;
pub mod page_config;
pub mod pure;
pub mod react_server_components;
pub mod server_actions;
pub mod shake_exports;
pub mod strip_page_exports;
pub mod track_dynamic_imports;
pub mod warn_for_edge_runtime;
//[TODO] PACK-1564: need to decide reuse vs. turbopack specific
pub mod named_import_transform;Not all of them run on every file. Turbopack picks them per layer: the rules for the server's layers are in get_next_server_transforms_rules (next_server/transforms.rs), and the browser's in next_client/transforms.rs. For example, empty_gsp and track_dynamic_imports run only with Cache Components on (lines 157 to 165). Each row is one transform the tea shop met, what it does, and where you can see it:
| Transform | What it does | Where you see it |
|---|---|---|
react_server_components | Checks each file against its layer: what a Server Component or a Client Component may import and export. | The build errors in the playground: next/headers in a Client Component, runtime with Cache Components. |
| Client directive (Turbopack's) | In the Server Component layer, replaces a "use client" file with a small module that points across to its client copy. | The client reference in the Server Component layer's chunk. |
server_actions | Turns "use server" and "use cache" functions into references with ids. | The server reference manifest, in Inside next build. |
empty_gsp | With Cache Components, adds a function to each page or layout with generateStaticParams, to throw a clear error if it lists nothing. | __next_create_empty_gsp_error in the page's compiled code. |
fonts | Rewrites a next/font call into an import that the font loader handles during the build. | Fonts in Next.js shows the result: font files and a class name. |
| styled-jsx | Scopes the CSS in a <style jsx> tag to its component with a generated class name. | className: "jsx-…" in the recorded button. |
optimize_barrel, dynamic, track_dynamic_imports | Import only what's used from big packages, make next/dynamic work, and follow import() for Cache Components. | Smaller chunks; Shrinking the JavaScript bundle measures them. |
A transform that adds code
The first copy's chunk also holds the tea page. Here is its module, compiled:
function generateStaticParams() {
return teas.map((tea)=>({
id: tea.id
}));
}
async function TeaPage({ params }) {
const { id } = await params;
return /*#__PURE__*/ (0, __TURBOPACK__imported__module__7997__["jsxs"])("main", {
children: [
/*#__PURE__*/ (0, __TURBOPACK__imported__module__7997__["jsx"])("h1", {
children: teas.find((tea)=>tea.id === id)?.name
}),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__7997__["jsx"])(__TURBOPACK__imported__module__87122__["AddToCart"], {})
]
});
}
const __next_create_empty_gsp_error = function generateStaticParams() {
return new Error('When using Cache Components, all `generateStaticParams` functions must return at least one result. This is to ensure that we can perform build-time validation that there is no other dynamic accesses that would cause a runtime error.\n\nLearn more: https://nextjs.org/docs/messages/empty-generate-static-params');
};Lines 1 to 16 are your generateStaticParams and TeaPage, with the types removed. Lines 17 to 19 aren't yours: empty_gsp added a second function called generateStaticParams, exported as __next_create_empty_gsp_error (empty_gsp.rs, lines 223 to 264). If your generateStaticParams returns an empty list, the build throws the error this function makes. The playground's last version shows it.
A transform that checks
react_server_components changes nothing in Turbopack: it only checks. Its own comment says so, since Turbopack makes the client references itself (react_server_components.rs, lines 1171 to 1177). Turbopack adds it to the rules of each App Router layer, for example the SSR layer's (next_server/context.rs, lines 680 to 683) and the Server Component layer's (lines 768 to 771). There it checks your own modules, knowing which layer they're in, and skips files in node_modules (lines 743 to 746). It compares each module's imports against two lists:
invalid_server_imports: vec![
atom!("client-only").into(),
atom!("react-dom/client").into(),
atom!("react-dom/server").into(),
atom!("next/router").into(),
],
invalid_client_imports: vec![
atom!("server-only").into(),
atom!("next/headers").into(),
atom!("next/root-params").into(),
],Lines 1 to 6: a Server Component may not import client-only, React's browser and server renderers, or the Pages Router's next/router. Lines 8 to 12: a Client Component may not import server-only, next/headers or next/root-params. (Server-only code and environment variables uses server-only for exactly this.) The same transform refuses a runtime export when Cache Components is on (lines 935 to 945). Each version below changes one file; guess which ones build:
app/add-to-cart.tsx"use client"; import { useState } from "react"; export function AddToCart() { const [added, setAdded] = useState(false); return ( <button onClick={() => setAdded(true)}> {added ? "Added" : "Add to cart"} </button> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled- Experiments (use with caution): ⨯ turbopackMinify Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (5/5) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ /teas/[id] ├ ◐ /teas/[id] ├ ○ /teas/green └ ○ /teas/mint ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content # What .next/cache holds: 23 files, 20.7 MB (deeper folders added up).next/cache/.previewinfo 262 bytes.next/cache/.rscinfo 102 bytes.next/cache/.tsbuildinfo 121.1 kB.next/cache/turbopack/v16.4.3-29bbeab0/ 20.6 MB, 20 filesEvery file passes the checks. add-to-cart.tsx starts with "use client", and it imports only what a Client Component may use.
The two checks stop the build inside Creating an optimized production build: they run while Turbopack compiles. The empty list compiles fine, and fails one step later, at Collecting page data, when the build calls the function empty_gsp added. Both errors point at your own line: SWC keeps the position of every node, so the error can quote the file.
A transform that rewrites
styled-jsx lets a Client Component write CSS in a <style jsx> tag. Here's the button with its own color, and what the styled-jsx transform made of it in the browser's chunk:
"use client";
import { useState } from "react";
export function AddToCart() {
const [added, setAdded] = useState(false);
return (
<>
<button onClick={() => setAdded(true)}>
{added ? "Added" : "Add to cart"}
</button>
<style jsx>{`
button {
color: seagreen;
}
`}</style>
</>
);
}
function AddToCart() {
const [added, setAdded] = (0, __TURBOPACK__imported__module__71645__["useState"])(false);
return /*#__PURE__*/ (0, __TURBOPACK__imported__module__43476__["jsxs"])(__TURBOPACK__imported__module__43476__["Fragment"], {
children: [
/*#__PURE__*/ (0, __TURBOPACK__imported__module__43476__["jsx"])("button", {
onClick: ()=>setAdded(true),
className: "jsx-19c65401acc9b848",
children: added ? "Added" : "Add to cart"
}),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__43476__["jsx"])(__TURBOPACK__imported__module__37902__["default"], {
id: "19c65401acc9b848",
children: "button.jsx-19c65401acc9b848{color:#2e8b57}"
})
]
});
}
__turbopack_context__.s([
"AddToCart",
0,
AddToCart
]);Line 7 gave the button a class the transform made up, jsx- and a hash of the CSS. Lines 10 to 13 replaced the <style jsx> tag with styled-jsx's own component, and line 12 holds the CSS, rewritten so button only matches elements with that class. The color became #2e8b57, the same color written as hex: the CSS was minified even with JavaScript minifying off. The transform is wired in at styled_jsx.rs, and the next/font one at next_font.rs (Fonts in Next.js shows what it leads to).
Turbopack's incremental computation
A build is thousands of small jobs: read a file, parse it, transform it, resolve an import, make a chunk. Turbopack runs them on a library of its own, turbo-tasks. Its README (turbopack/crates/turbo-tasks/README.md) describes it in a few ideas:
- A Rust function marked
#[turbo_tasks::function]is memoized: called again with the same arguments, it returns the saved result instead of running. One call, with its arguments, is a task. - A task keeps its result in a cell (
Vc, a value cell). When a task reads another task's cell, turbo-tasks writes down that it did. Those notes make the task graph. - When a file changes, the tasks that read it are marked dirty, and so are the tasks that read their cells, up to the top. Only dirty tasks run again; the README calls it a bottom-up rebuild.
- A task that runs again and gets the same result as before doesn't disturb its readers: by default, a cell compares the new value with the old one before it replaces it (turbo-tasks/src/lib.rs, lines 199 to 214).
The idea fits in a page. This is a small model of it in JavaScript, written for this page (it isn't Turbopack's code). Press Edit & run to run it:
// A small model of turbo-tasks, written for this page.
const files = { "teas.ts": "green,mint", "add-to-cart.tsx": "Add to cart" };
const tasks = {
"parse teas.ts": () => read("teas.ts").split(","),
"parse add-to-cart.tsx": () => read("add-to-cart.tsx"),
"page chunk": () => `${read("parse teas.ts").length} teas`,
"button chunk": () => `<button>${read("parse add-to-cart.tsx")}</button>`,
"app": () => `${read("page chunk")} + ${read("button chunk")}`,
};
const results = new Map();
const readers = new Map();
const dirty = new Set();
let running = null;
function read(name) {
if (!readers.has(name)) readers.set(name, new Set());
if (running) readers.get(name).add(running);
if (name in files) return files[name];
if (results.has(name) && !dirty.has(name)) {
console.log("reused", name);
return results.get(name);
}
const caller = running;
running = name;
const value = tasks[name]();
running = caller;
dirty.delete(name);
results.set(name, value);
console.log("ran", name);
return value;
}
function edit(file, text) {
files[file] = text;
const stack = [...(readers.get(file) ?? [])];
while (stack.length > 0) {
const name = stack.pop();
if (dirty.has(name)) continue;
dirty.add(name);
stack.push(...(readers.get(name) ?? []));
}
}
console.log(read("app"));
edit("add-to-cart.tsx", "Add to basket");
console.log(read("app"));Lines 3 to 9 are the tasks: two parse a file, two make a chunk from what was parsed, and app joins the chunks. Every task gets what it needs through read. Lines 16 and 17 write down who read what. Lines 19 to 22 return a saved result when the task isn't dirty. Lines 23 to 30 run the task and save its result. edit, on lines 33 to 42, changes a file and marks dirty each task that read it, then each task that read those. Line 44 builds everything: five ran lines. Line 45 changes the button's text. Line 46 asks again: page chunk is reused, and only the three tasks above the edited file run.
add-to-cart.tsx. The edit marks the task that read the file dirty, then each task that read its result, up to app. Asking for app again runs the 3 shaded tasks and reuses the other 2.Pick another edit and watch which tasks run again:
// A small model of turbo-tasks, written for this page.const files = { "teas.ts": "green,mint", "add-to-cart.tsx": "Add to cart" };const tasks = { "parse teas.ts": () => read("teas.ts").split(","), "parse add-to-cart.tsx": () => read("add-to-cart.tsx"), "page chunk": () => `${read("parse teas.ts").length} teas`, "button chunk": () => `<button>${read("parse add-to-cart.tsx")}</button>`, "app": () => `${read("page chunk")} + ${read("button chunk")}`,};const results = new Map();const readers = new Map();const dirty = new Set();let running = null; function read(name) { if (!readers.has(name)) readers.set(name, new Set()); if (running) readers.get(name).add(running); if (name in files) return files[name]; if (results.has(name) && !dirty.has(name)) { console.log("reused", name); return results.get(name); } const caller = running; running = name; const value = tasks[name](); running = caller; dirty.delete(name); results.set(name, value); console.log("ran", name); return value;} function edit(file, text) { files[file] = text; const stack = [...(readers.get(file) ?? [])]; while (stack.length > 0) { const name = stack.pop(); if (dirty.has(name)) continue; dirty.add(name); stack.push(...(readers.get(name) ?? [])); }} console.log(read("app"));edit("add-to-cart.tsx", "Add to basket");console.log(read("app"));Console, after line 46
reused page chunkran parse add-to-cart.tsxran button chunkran app2 teas + <button>Add to basket</button>
The edit on line 45 marked the tasks that read add-to-cart.tsx dirty, and every task above them. Asking for app again ran only those, and reused the rest.
Editing teas.ts reruns the other side of the graph and reuses button chunk. Editing nothing runs nothing. Turbopack does the same with real tasks: the button's file is parsed once for each layer, and every page that imports it reuses that work.
One graph for server, client and edge
A module graph is every file of the app and the imports between them. With webpack, Next.js runs separate compilers for the server and the browser. Turbopack builds one graph, split into layers: a layer is a set of settings for compiling (which transforms run, how imports resolve, which runtime the code is for). The App Router has these three, named in crates/next-api/src/app.rs:
| Layer | Its name in errors | What it compiles |
|---|---|---|
app-rsc | Server Component | Your pages, layouts and the Server Components they import. Code here never reaches the browser. |
app-ssr | Client Component SSR | Each Client Component again, run on the server to make the first HTML. |
app-client | Client Component Browser | Each Client Component again, for the browser's chunks. |
Pages and layouts start in app-rsc. The question is what happens at an import of a "use client" file. In app-rsc, Turbopack's client directive transform replaces the whole file with a module of two lines: an import of the same file, marked with a transition named next-ecmascript-client-reference, and an export of what it imports (client.rs, server_to_client_proxy.rs). A transition is a rule that says: compile the file at the other end of this import in other layers. This is the one Next.js sets for that name (app.rs, lines 354 to 392):
let client_module = this.client_transition.process(
client_source,
module_asset_context,
ReferenceType::Entry(EntryReferenceSubType::AppClientComponent),
);
…
let ssr_module = this.ssr_transition.process(
source,
module_asset_context,
ReferenceType::Entry(EntryReferenceSubType::AppClientComponent),
);Lines 1 to 5 compile the file for the browser, with the client transition. Lines 7 to 11 compile the same file again for SSR. Then the transition returns a client reference module that holds both (lines 109 to 118), and that module is what the Server Component layer gets: the first copy you read, with registerClientReference. So one import makes the three copies, and everything the button imports is followed in the SSR and browser layers only. How the reference becomes a row in the RSC payload, and the client reference manifest (a manifest is a lookup file the build generates; this one maps each client reference to its chunks), are in 'use client' and 'use server' under the hood.
And the edge
The graph has layers for the Edge runtime too: app-edge-route, and edge versions of the layers above (app.rs, line 444 on). The server's contexts include Middleware, for middleware.ts (next_server/context.rs, lines 80 to 109). With Cache Components on, as in this course, a route can't ask for Edge: the playground's A route that asks for Edge stops the build. proxy.ts runs on Node.js and can't choose its runtime. The Edge runtime is taught in proxy.ts and runtimes under the hood.
The filesystem cache
turbo-tasks keeps its task graph in memory while it runs. The filesystem cache writes it to disk, so the next run can load the tasks and their results instead of starting from nothing. Two settings control it, and in Next.js 16.4.3 both are on by default (config-shared.ts, lines 2494 and 2495):
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
// Both are true by default in Next.js 16.4.3.
turbopackFileSystemCacheForDev: true,
turbopackFileSystemCacheForBuild: true,
},
};
export default nextConfig;Line 6 is for next dev, whose cache lives in .next/dev/cache/turbopack. Line 7 is for next build, in .next/cache/turbopack. The docs' version history at the tag (turbopackFileSystemCache.mdx, lines 57 to 64): the cache became the default for next dev in v16.1.0, and for next build in v16.3.0. turbopackBuild reads the setting (impl.ts, lines 67 and 68), and the Rust side opens cache/turbopack inside .next (turbopack_ctx.rs, lines 325 to 332). Compare the two builds:
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ cacheComponents: true, partialPrefetching: true, experimental: { turbopackMinify: false, },}; export default nextConfig;$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled- Experiments (use with caution): ⨯ turbopackMinify Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (5/5) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ /teas/[id] ├ ◐ /teas/[id] ├ ○ /teas/green └ ○ /teas/mint ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content # What .next/cache holds: 23 files, 20.7 MB (deeper folders added up).next/cache/.previewinfo 262 bytes.next/cache/.rscinfo 102 bytes.next/cache/.tsbuildinfo 121.1 kB.next/cache/turbopack/v16.4.3-29bbeab0/ 20.6 MB, 20 filesAfter the build, .next/cache holds turbopack/v16.4.3-29bbeab0/: Turbopack's saved work, about 21 MB in 20 files on the computer the lessons were recorded on.
With the cache on, .next/cache has turbopack/v16.4.3-29bbeab0/. The folder is named after the Next.js version and its commit: a cache from another version is never read, and Turbopack keeps at most one other version's folder for a while, none on CI (db_versioning.rs, lines 34 to 40). Inside are the files of turbo-persistence, Turbopack's on-disk store: .sst files of keys and values, .meta files that help find them, a LOG, and CURRENT, which says which files belong to the last finished save (its README, lines 13 to 24). This is the tea shop's:
{"max_sequence_number":18,"commit_time":"2026-10-30T17:01:21.964517Z"}Files are named by number, and max_sequence_number is the newest number that belongs to the cache; commit_time is when that save finished. A second build reading the cache isn't shown: each example is built once, in an empty project. The docs say later builds start warm when .next/cache is still there. Two things follow:
next build's clean step keeps.next/cache(Inside next build), so building twice on one machine reuses it.- A CI job or a Docker build that starts from an empty folder never has it, unless it saves and restores
.next/cache. In GitHub Actions, that's a cache step before the build:
- uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: |
nextjs-${{ hashFiles('package-lock.json') }}-Line 3 names the folder to save and restore. Line 4 is the key it saves under, from the lockfile and the commit. Lines 5 and 6 restore the newest cache for the same lockfile when there's no exact match. If your builds can't keep the folder, the docs (turbopackFileSystemCache) suggest turning turbopackFileSystemCacheForBuild off, so the build doesn't spend time writing a cache nobody reads.
Fast Refresh: an edit in next dev
next dev uses the same Turbopack, watching your files. When you save, the open page updates without a reload, and a Client Component keeps its state when that's safe. That's Fast Refresh. This part is described from the source, not recorded: a recording can't edit a file while next dev runs. In four steps:
- The edit marks tasks dirty. The file watcher reports the change, and turbo-tasks marks the tasks that read the file dirty, as in the model.
- Turbopack computes an update. It runs the dirty tasks and works out which modules in which chunks changed.
- The dev server sends it. The page keeps a WebSocket open to the dev server: a connection that stays open, so the server can send messages any time. For changed client modules, it sends a
TURBOPACK_MESSAGEwith the updates (hot-reloader-turbopack.ts, lines 990 to 996). For a Server Component, it sendsSERVER_COMPONENT_CHANGES(lines 926 to 936). - The browser applies it. For client updates, Turbopack's runtime in the page swaps in the new modules (hot-reloader-app.tsx, lines 408 to 422). For Server Component changes, the router fetches a new RSC payload for the page, inside a transition (lines 424 to 450): no reload, and Client Components on the page keep their state.
next dev, a save marks Turbopack's tasks dirty, and the dev server sends the open page what changed over a WebSocket (a connection that stays open). A Client Component is swapped in place by React Refresh. A Server Component change makes the router fetch a new RSC payload.Whether a client edit keeps state is decided in the browser, in registerExportsAndSetupBoundaryForReactRefresh (dev-base.ts, lines 234 to 287). A module whose exports are all React components is a refresh boundary: it accepts its own update, and React Refresh re-renders its components with their state. A module that exports anything else can't; the update moves up to the files that import it. If it reaches a file nothing can accept, the page reloads. Next.js's Fast Refresh docs describe the same rules from the outside:
- Edit
add-to-cart.tsx, which exports only a component: the button changes, andaddedstays. - Edit a file that exports a value too: that file and the files that import it run again.
- Edit a file used outside React: the page reloads.
- State resets in class components, after an edit to a component written as an anonymous arrow function, and in a file with
// @refresh reset.
Fast Refresh is only for next dev. next build writes no update code, and production pages don't open the WebSocket. Debugging Next.js shows the dev overlay that the same connection drives.
Reading compiled code and compiler errors
Some habits that come from knowing what SWC and Turbopack do:
- Find a file's compiled copy by what it holds. Chunk names are made up per build. Search
.next/server/chunksand.next/static/chunksfor a string your code has, like a button label. To read the result, build once withexperimental.turbopackMinify: false, then remove it. - Read a compiler error from the bottom up. In the recorded
next/headerserror, the import trace lists./app/add-to-cart.tsx, then./app/teas/[id]/page.tsx: the file with the problem, then the page that imports it. The fix is in the first file, or in where the client boundary sits. - Expect each Client Component twice on the server side. A Client Component's code runs on the server too, to make the first HTML. Code that touches
windowwhile it renders fails there. - Keep
.next/cachebetween builds, or turn the build cache off where it can't be kept. - Know the fallback.
next build --webpackandnext dev --webpackstill use webpack, for a project with its own webpack settings.
Sort what happened by the part that did it:
- The build stops:
next/headersin a"use client"file. <style jsx>becomesclassName: "jsx-…".- One
"use client"file shows up in three chunks. - The Server Component layer gets a reference instead of the button's code.
.next/cache/turbopackholds 20 files after one build.- After upgrading Next.js, the build starts cold again.
- You edit the button's label and its
addedstate stays. - You edit a Server Component and the page updates without a reload.
Sort each thing that happened by the part of the toolchain that did it.
Common mix-ups
- "SWC and Turbopack are the same thing." No. SWC compiles one file at a time. Turbopack decides which files to compile, for which layers, and puts the results into chunks. Turbopack uses SWC.
- "A Client Component is compiled once, for the browser." No. It's compiled for the browser and again for SSR, and the Server Component layer gets a client reference in its place.
- "The server has a Client Component's code, so a Server Component can call it." No. In the Server Component layer, it's a reference whose function throws if called. Server Components can only render it, or pass it as a prop.
- "Turbopack recompiles everything on each save." No. It runs only the tasks marked dirty by the change, and reuses the rest.
- "The build cache is only for next dev." No. Since Next.js 16.3,
next buildkeeps one too, in.next/cache/turbopack. - "Fast Refresh always keeps state." No. It keeps a function component's state when that's safe; other edits re-run importers or reload the page.
- "The module ids in compiled code are stable." No. Ids and chunk names are this build's; never depend on them.
Practice exercises
Answer each one first, then use the hints.
This is AddToCart in the Server Component layer of the tea shop's build. What is it?
// This file is generated by next-core EcmascriptClientReferenceModule.
var __TURBOPACK__imported__module__11857__ = __turbopack_context__.i(11857);
;
const AddToCart = (0, __TURBOPACK__imported__module__11857__["registerClientReference"])(function() {
throw new Error("Attempted to call AddToCart() from the server but AddToCart is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");
}, "[project]/app/add-to-cart.tsx", "AddToCart");
__turbopack_context__.s([
"AddToCart",
0,
AddToCart
]);A client reference. registerClientReference wraps a function that throws if the server calls it, with the file's id and the name AddToCart. React puts the reference into the RSC payload, and the browser loads the real component.
In the model, the edit now changes teas.ts instead of the button. How many tasks print ran after the edit?
// A small model of turbo-tasks, written for this page.
const files = { "teas.ts": "green,mint", "add-to-cart.tsx": "Add to cart" };
const tasks = {
"parse teas.ts": () => read("teas.ts").split(","),
"parse add-to-cart.tsx": () => read("add-to-cart.tsx"),
"page chunk": () => `${read("parse teas.ts").length} teas`,
"button chunk": () => `<button>${read("parse add-to-cart.tsx")}</button>`,
"app": () => `${read("page chunk")} + ${read("button chunk")}`,
};
const results = new Map();
const readers = new Map();
const dirty = new Set();
let running = null;
function read(name) {
if (!readers.has(name)) readers.set(name, new Set());
if (running) readers.get(name).add(running);
if (name in files) return files[name];
if (results.has(name) && !dirty.has(name)) {
console.log("reused", name);
return results.get(name);
}
const caller = running;
running = name;
const value = tasks[name]();
running = caller;
dirty.delete(name);
results.set(name, value);
console.log("ran", name);
return value;
}
function edit(file, text) {
files[file] = text;
const stack = [...(readers.get(file) ?? [])];
while (stack.length > 0) {
const name = stack.pop();
if (dirty.has(name)) continue;
dirty.add(name);
stack.push(...(readers.get(name) ?? []));
}
}
console.log(read("app"));
edit("teas.ts", "green,mint,oolong");
console.log(read("app"));3. The edit marks parse teas.ts dirty, then page chunk, then app. Asking again runs those three and reuses button chunk. Press Edit & run to see the lines.
A Client Component in the tea shop imports a file that starts with import "server-only". Which list in react_server_components.rs makes the build fail?
invalid_server_imports: vec![
atom!("client-only").into(),
atom!("react-dom/client").into(),
atom!("react-dom/server").into(),
atom!("next/router").into(),
],
invalid_client_imports: vec![
atom!("server-only").into(),
atom!("next/headers").into(),
atom!("next/root-params").into(),
],invalid_client_imports, at lines 710 to 714 of react_server_components.rs. server-only is its first entry, so a Client Component that imports it, directly or through a file it imports, stops the build, as the next/headers version did.
With these settings, where does next build keep Turbopack's cache?
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
// Both are true by default in Next.js 16.4.3.
turbopackFileSystemCacheForDev: true,
turbopackFileSystemCacheForBuild: true,
},
};
export default nextConfig;.next/cache/turbopack, inside a folder named after the version: v16.4.3-29bbeab0 in the recording. The clean step keeps .next/cache, so a second build on the same machine can read it.
In the Server Component layer, this file's code is replaced before it's compiled. Which transformer in Turbopack's source does that?
"use client";
import { useState } from "react";
export function AddToCart() {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(true)}>
{added ? "Added" : "Add to cart"}
</button>
);
}
ClientDirectiveTransformer, in turbopack/crates/turbopack-ecmascript-plugins/src/transform/directives/client.rs. If the file is a client module, it replaces the whole program with create_proxy_module: an import of the same file with the next-ecmascript-client-reference transition, and an export of it.
In next dev, you change the heading in this Server Component and save. Which message does the browser get, and what does it do with it?
import { AddToCart } from "../../add-to-cart";
import { teas } from "../../teas";
export function generateStaticParams() {
return teas.map((tea) => ({ id: tea.id }));
}
export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {
const { id } = await params;
return (
<main>
<h1>{teas.find((tea) => tea.id === id)?.name}</h1>
<AddToCart />
</main>
);
}
SERVER_COMPONENT_CHANGES. The dev server sends it over the WebSocket, and the page calls the router's hmrRefresh(), which fetches a new RSC payload for the page inside a transition. The button keeps its added state.
Check your understanding
For each question, ask which layer the code is in, and which task had to run.
Question 1 of 7In the Server Component layer's chunk, what is
AddToCart?Choose an answer to see the explanation.
Question 2 of 7Where did
__next_create_empty_gsp_errorin the page's compiled code come from?Choose an answer to see the explanation.
Question 3 of 7After the first build, you edit
add-to-cart.tsx. Which of turbo-tasks' tasks run again?Choose an answer to see the explanation.
Question 4 of 7Where does
next buildkeep Turbopack's filesystem cache in Next.js 16.4.3?Choose an answer to see the explanation.
Question 5 of 7The tea shop is built with
turbopackFileSystemCacheForBuild: false. What's different in.next/cache?Choose an answer to see the explanation.
Question 6 of 7When does Fast Refresh keep a component's state after you save the file?
Choose an answer to see the explanation.
Question 7 of 7How many times is the button's file compiled in the tea shop's build?
Choose an answer to see the explanation.
Key takeaways
- SWC parses each file into a tree, runs transforms over it, and prints JavaScript. Next.js adds its own transforms, in Rust.
- Some transforms check (
react_server_components), some add code (empty_gsp), some rewrite (styled-jsx,next/font), and Turbopack picks them per layer. - Turbopack builds one module graph with layers. At a
"use client"import, a transition compiles the file for SSR and the browser, and gives the Server Component layer a client reference. - turbo-tasks memoizes every task, records what it read, and after a change reruns only the dirty tasks.
- The filesystem cache saves that work in
.next/cache/turbopack, on by default for builds since 16.3. Keep the folder between builds to use it. - Fast Refresh sends updates over a WebSocket in
next dev; a refresh boundary keeps state, a Server Component edit refetches the payload.
Remember the one-liner.
SWC rewrites each file; Turbopack decides which files, for which layer, and remembers what it already did.
Coming next: 'use client' and 'use server' under the hood. It follows the tea shop's client reference into the RSC payload, and Server Action ids into their manifest.