Turbopack внутри Next: один граф на сервер и клиент

сборщики≈ 13 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Всю часть вы запускали next dev и next build, и за этими командами работала сборка, которую статья про фреймворки описала одной строкой таблицы: Turbopack настроен заранее. Две прошлые статьи разобрали сборку на части: свой сборщик строил граф, реестр и куски, компилятор файла переводил файл по дереву. Сегодня — что стоит за командами Next: какой сборщик назван в первой строке лога, почему у Turbopack один граф на сервер и клиент, как он ленится и кеширует, чем иначе устроен Vite и что изменилось по сравнению с webpack, которым Next собирался до Turbopack и собирается до сих пор, если попросить.

Разделы темы:·

Приложение — то же, что в статье про свой сборщик: страница app/page.tsx рисует клиентский компонент Price из ui/price.tsx, где стоит "use client", а Price берёт rub из lib/money.ts. Рядом вторая страница, app/admin/page.tsx, — один заголовок. Приложение собрано дважды: командой next build и командой next build --webpack. Прогноз: чем будут отличаться два лога?

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5, typescript 6.0.3, NEXT_TELEMETRY_DISABLED=1; первая сборка нового проекта — отсюда строки про tsconfig; macOS arm64, Node 26.5.0, 14.09.2026; замер time в конце лога опущен, папка с идентификатором сборки в выводе find заменена словом buildId
▲ Next.js 16.3.5 (Turbopack)
✓ Running next.config took 6ms
Creating an optimized production build ...
✓ Compiled successfully in 1502ms
Running TypeScript ...
We detected TypeScript in your project and reconfigured your tsconfig.json file for you.
The following suggested values were added to your tsconfig.json. These values can be changed to fit your project's needs:
- allowJs was set to true
- include was updated to add '.next/dev/types/**/*.ts'
Finished TypeScript in 1002ms ...
Collecting page data using 5 workers ...
Generating static pages using 5 workers (0/4) ...
Generating static pages using 5 workers (1/4)
Generating static pages using 5 workers (2/4)
Generating static pages using 5 workers (3/4)
✓ Generating static pages using 5 workers (4/4) in 257ms
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /admin
○ (Static) prerendered as static content
$ find .next/static -type f
.next/static/<buildId>/_buildManifest.js
.next/static/<buildId>/_clientMiddlewareManifest.js
.next/static/<buildId>/_ssgManifest.js
.next/static/chunks/0cegfsgm6lvdz.js
.next/static/chunks/0cz1d0mv5g_q7.js
.next/static/chunks/0uk_002q-0_-r.js
.next/static/chunks/27t_qfc-3_lzs.js
.next/static/chunks/3fntmmi971322.js
.next/static/chunks/3l04zcqx63h3y.js
.next/static/chunks/turbopack-36t3xf3k-le2x.js
shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5 в режиме --webpack, то же приложение; macOS arm64, Node 26.5.0, 14.09.2026; имена app/page-… и main-app-… зависят от пути к проекту — у вас будут свои
▲ Next.js 16.3.5 (webpack)
✓ Running next.config took 6ms
Creating an optimized production build ...
✓ Compiled successfully in 1985ms
Running TypeScript ...
Finished TypeScript in 816ms ...
Collecting page data using 5 workers ...
Generating static pages using 5 workers (0/4) ...
Generating static pages using 5 workers (1/4)
Generating static pages using 5 workers (2/4)
Generating static pages using 5 workers (3/4)
✓ Generating static pages using 5 workers (4/4) in 278ms
Finalizing page optimization ...
Collecting build traces ...
Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /admin
○ (Static) prerendered as static content
$ find .next/static/chunks -name "*.js" | head
.next/static/chunks/4bd1b696-92152b0f5947070d.js
.next/static/chunks/794-fa85041d1eeb8167.js
.next/static/chunks/app/admin/page-3f8ab9df1e641efa.js
.next/static/chunks/app/layout-3f8ab9df1e641efa.js
.next/static/chunks/app/page-c7cc6525f8ec2e9b.js
.next/static/chunks/framework-0f597d6e68ffafed.js
.next/static/chunks/main-app-b45fc17c8c802cde.js
.next/static/chunks/webpack-70c336f9a46f13b1.js
…

Первая строка называет сборщик: (Turbopack) без флага и (webpack) с флагом --webpack. В 16-й версии Next Turbopack — сборщик по умолчанию и для next build, и для next dev, а webpack остался запасным путём. Дальше логи почти совпадают. Running TypeScript — отдельный шаг, та же проверка типов, что у tsc: сам сборщик типы только стирает. Таблицу маршрутов в конце читать учит статья про режимы отрисовки.

Различаются куски. webpack собирает имя из маршрута и хеша — app/page-c7cc6525f8ec2e9b.js, а Turbopack называет куски непрозрачно — 0uk_002q-0_-r.js, и по имени не понять, что внутри. Что лежит в этом куске, покажет следующий раздел.

Отдельной программы Turbopack в проекте нет. Пакет turbopack на npm — заглушка, а сам сборщик скомпилирован в нативный модуль Next: пакет @next/swc-… под вашу платформу, в котором живёт и компилятор файла SWC. Где такого модуля нет, например на FreeBSD, Next берёт сборку SWC на WebAssembly, и Turbopack там не работает — остаётся --webpack. Сам webpack Next тоже везёт внутри себя, ставить его отдельно не нужно.

Сайт, который вы читаете, тоже собирает Next:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · лог сборки сайта учебника, next 16.3.2, typescript 6.0.3; macOS arm64, 14.09.2026; подготовка песочницы перед сборкой и таблица маршрутов опущены
▲ Next.js 16.3.2 (Turbopack)
✓ Running next.config.ts took 72ms
Creating an optimized production build ...
✓ Compiled successfully in 1729ms
Running TypeScript ...
Finished TypeScript in 1132ms ...
Collecting page data using 9 workers ...
Generating static pages using 9 workers (0/693) ...
Generating static pages using 9 workers (173/693)
Generating static pages using 9 workers (346/693)
Generating static pages using 9 workers (519/693)
✓ Generating static pages using 9 workers (693/693) in 3.2s
Finalizing page optimization ...
…

Флаг --turbopack остался в скрипте с тех пор, когда Turbopack для сборки включали руками. В 16-й версии он ничего не меняет — первая строка та же, (Turbopack), — и из скриптов сайта его убрали вместе с этой главой. Нужен этот флаг в одном случае, о нём — в разделе про webpack. А заканчивается сборка сайта не сервером, а папкой файлов: учебник собран статическим экспортом.

В статье про границу клиента вы считали клиентскую часть обходом графа: всё, до чего дотянутся импорты файла с "use client", уезжает в браузер. В Next этот обход делает сборщик, и вопрос у него не «браузер или сервер». ui/price.tsx нужен трём сторонам. Серверным компонентам — ссылкой: на месте Price в их выводе стоит ссылка на модуль, а не его код. Серверной отрисовке первого HTML — кодом: кнопку нужно нарисовать в разметку. И браузеру — кодом, который эту кнопку оживит.

Turbopack держит все три стороны в одном графе модулей и помечает каждый модуль слоем. Пометки видны в идентификаторах модулей, которые dev-сервер пишет в свои файлы. Прогноз: сколько записей с разными пометками окажется у ui/price.tsx и в каких слоях будет lib/money.ts, который импортирует только Price?

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5 (Turbopack), папка .next/dev после next dev и заходов на / и /admin; macOS arm64, Node 26.5.0, 14.09.2026
2 [project]/app/admin/page.tsx [app-rsc] (ecmascript)
2 [project]/app/page.tsx [app-rsc] (ecmascript)
2 [project]/lib/money.ts [app-client] (ecmascript)
2 [project]/lib/money.ts [app-ssr] (ecmascript)
5 [project]/ui/price.tsx [app-client] (ecmascript)
5 [project]/ui/price.tsx [app-rsc] (client reference proxy)
2 [project]/ui/price.tsx [app-rsc] (ecmascript)
2 [project]/ui/price.tsx [app-ssr] (ecmascript)

Слоёв три: [app-rsc] — серверные компоненты, [app-ssr] — отрисовка клиентских компонентов в HTML на сервере, [app-client] — браузер. А записей у ui/price.tsx четыре: в слое серверных компонентов он встречается дважды. Запись (client reference proxy) — та самая ссылка-заглушка, которую получает page.tsx вместо кода. Вторая, (ecmascript), — модуль того же файла в том же слое; зачем он там, листинг не говорит, и гадать не будем. Числа слева — сколько раз строка нашлась в файлах сборки, это счётчик uniq -c, а не число слоёв.

lib/money.ts есть только в [app-ssr] и [app-client]: серверные компоненты его не импортируют, он пришёл вслед за Price. Страницы — только в [app-rsc]. Это та же заразность по импортам, только посчитанная сборщиком: всё ниже границы ушло в клиентские слои, а по другую сторону осталась ссылка. Граница режет граф, а не папки проекта: один и тот же файл лежит в нескольких слоях, если до него дотягиваются с разных сторон.

Итог разреза — манифест клиентских ссылок. В статье про свою границу читатель находил по строке I модуль и брал его из бандла через манифест. Вот манифест, строка I и кусок в настоящей сборке того же приложения:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · next 16.3.5 (Turbopack), react 19.3.0; выдержки из папки .next после сборки, строка потока обрезана на списке кусков; macOS arm64, Node 26.5.0, 14.09.2026; лог сборки опущен
.next/server/app/page_client-reference-manifest.js, запись clientModules:
[project]/ui/price.tsx {"id":20501,"name":"*","chunks":["/_next/static/chunks/3fntmmi971322.js","/_next/static/chunks/0uk_002q-0_-r.js"],"async":false}
.next/server/app/index.rsc, строка с клиентской ссылкой (grep, обрезано на списке кусков):
4:I[20501,["/_next/static/chunks/3fntmmi971322.js","/_next/static/chunks/0uk_002q-0_-r.js"]
.next/static/chunks/0uk_002q-0_-r.js целиком (315 байт):
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,20501,t=>{"use strict";var e=t.i(43476),i=t.i(71645);t.s(["Price",0,function({base:t}){let[c,o]=(0,i.useState)(1);return(0,e.jsxs)("button",{onClick:()=>o(c+1),children:[c," × ",t+" ₽"]})}],20501)}]);

Через все три выдержки идёт одно число — 20501. В манифесте это id модуля ui/price.tsx и два куска, которые нужно загрузить. Строка 4:I[20501,[…]] в потоке несёт тот же id и те же куски — ровно то, что в статье про свою границу было упрощено до одного адреса. А кусок 0uk_002q-0_-r.js кладёт в массив globalThis.TURBOPACK функцию модуля с тем же номером: это реестр из статьи про свой сборщик, где этот кусок уже встречался.

Манифест пишет сборка, а не React: только она знает, в какие куски легли модули. В режиме webpack его пишет плагин Next — к этому вернёмся в разделе про webpack.

Граф на три слоя — много работы, и dev-сервер не делает её целиком. Он собирает то, что открыли в браузере, и не повторяет то, что уже сделал. Оба правила видны на модели. Ключ работы — файл, сторона и хеш содержимого; функция hash из статьи про свой сборщик едет в подложке. compile идёт по импортам от открытого маршрута, а на файле с "use client" отдаёт серверу заглушку и переходит на клиентскую сторону. Сторон в модели две, а не три слоя: для ключа этого хватает.

Прогноз: сколько работ насчитает первый заход на /, второй заход и заход после правки в money? И соберётся ли /admin?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Четыре, ноль, одна. Первый заход прошёл страницу, заглушку Price для сервера, сам Price и money на клиентской стороне; клиентская сторона в модели одна, а в листинге слоёв ей соответствуют два слоя, [app-ssr] и [app-client]. Второй заход нашёл все четыре ключа в done. После правки новый хеш только у money, и новый ключ только у него: ключ Price собран из его собственного текста, а текст не менялся. Каскада из статьи про свой сборщик здесь нет — ключ работы не вписан ни в чей текст. /admin не собирался: туда никто не заходил.

Это модель ключа, ленивости и разреза, а не движок. У настоящего Turbopack кеш хранит результаты вплоть до отдельных функций, а куски собираются отдельно от компиляции модулей.

Настоящий dev-сервер ведёт себя так же. Лог next dev, пока в браузере открывали /, ещё раз / и потом /admin:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5 (Turbopack), то же приложение, заходы из Chrome 152; macOS arm64, Node 26.5.0, 14.09.2026; строка о файлах AGENTS.md и CLAUDE.md, которые next dev пишет в проект, опущена
▲ Next.js 16.3.5 (Turbopack)
- Local: http://127.0.0.1:3600
- Network: http://127.0.0.1:3600
✓ Ready in 251ms
✓ Running next.config took 6ms
GET / 200 in 848ms (next.js: 761ms, application-code: 87ms)
GET / 200 in 19ms (next.js: 2ms, application-code: 17ms)
GET /admin 200 in 125ms (next.js: 108ms, application-code: 18ms)

Строка GET /admin появилась только после захода на /admin — до него страница не собиралась. Первый GET / занял 848 мс, повторный — 19: собирать уже нечего, осталась отрисовка. Приложение крошечное, поэтому смотрите на порядок чисел, а не на сами числа.

Кеш переживает и перезапуск. В Next из этих листингов Turbopack по умолчанию пишет его на диск: для next dev — в .next/dev/cache/turbopack, для next build — в .next/cache/turbopack. Выключают это настройками experimental.turbopackFileSystemCacheForDev и experimental.turbopackFileSystemCacheForBuild. Вторая сборка того же приложения сразу после первой из начала статьи:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5 (Turbopack), то же приложение; лог второй сборки целиком; macOS arm64, Node 26.5.0, 14.09.2026
▲ Next.js 16.3.5 (Turbopack)
✓ Running next.config took 6ms
Creating an optimized production build ...
✓ Compiled successfully in 134ms
Running TypeScript ...
Finished TypeScript in 767ms ...
Collecting page data using 5 workers ...
Generating static pages using 5 workers (0/4) ...
Generating static pages using 5 workers (1/4)
Generating static pages using 5 workers (2/4)
Generating static pages using 5 workers (3/4)
✓ Generating static pages using 5 workers (4/4) in 278ms
Finalizing page optimization ...

Compiled successfully in 134ms против 1502 мс у первой сборки: на порядок быстрее, и снова это порядок, а не замер. Выигрыш есть, только если .next/cache дожил до следующей сборки. Сборка в чистой папке — в CI или в слое сборки образа — кеша не находит и каждый раз идёт холодной, пока папку с кешем не сохранить и не вернуть явно. Тот же принцип — не делать работу, ключ которой уже есть, — встретится и для данных: в статье про свой кеш ключ собирают из того, что вошло в функцию.

Ни ленивость, ни кеш на диске Turbopack не придумал. next dev --webpack тоже собирает маршрут при первом заходе и хранит кеш webpack в .next/dev/cache/webpack. Чем Turbopack отличается на самом деле, разберём в разделе про webpack.

Vite тоже держит dev-сервер и тоже собирает проект, но в разработке отдаёт код иначе. Сравнивать число скриптов на странице бессмысленно: ниже два разных проекта — приложение Next из этой статьи и шаблон Vite с картинками и стилями. Сравнивать стоит другое — сколько модулей проекта приходит одним ответом сервера. Прогноз: у какого инструмента в одном файле dev-сервера окажется больше одного модуля проекта?

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · Chrome 152 без окна, next 16.3.5 (Turbopack), приложение из этой статьи; macOS arm64, Node 26.5.0, 14.09.2026
проверяю http://127.0.0.1:3600/
всего ответов: 16 {"Document":1,"Script":14,"Other":1}
200 Document /
200 Script /_next/static/chunks/%5Bturbopack%5D_browser_dev_hmr-client_hmr-client_ts_1di75ot._.js
200 Script /_next/static/chunks/node_modules_next_dist_compiled_react-server-dom-turbopack_164kp-6._.js
200 Script /_next/static/chunks/node_modules_next_dist_compiled_1amofcm._.js
200 Script /_next/static/chunks/node_modules_next_dist_1e8vcs8._.js
200 Script /_next/static/chunks/_1anvha4._.js
200 Script /_next/static/chunks/node_modules_%40swc_helpers_cjs_1r9vbqw._.js
200 Script /_next/static/chunks/_219uq1s._.js
200 Script /_next/static/chunks/node_modules_next_dist_20wefz_._.js
200 Script /_next/static/chunks/turbopack-_08bm286._.js
200 Script /_next/static/chunks/_0ngubum._.js
200 Script /_next/static/chunks/node_modules_next_dist_compiled_react-dom_096_9a-._.js
200 Script /_next/static/chunks/node_modules_next_dist_compiled_next-devtools_index_090k2jm.js
200 Script /_next/static/chunks/node_modules_next_dist_client_0_90u2t._.js
200 Script /_next/static/chunks/%5Bturbopack%5D_browser_dev_hmr-client_hmr-client_ts_1mojsay._.js
404 Other /favicon.ico
текст страницы: "Корзина\n1 × 350 ₽"
модули в куске /_next/static/chunks/_0ngubum._.js (grep "[project]/…"):
"[project]/lib/money.ts [app-client] (ecmascript)"
"[project]/node_modules/next/dist/build/polyfills/process.js [app-client] (ecmascript)"
"[project]/node_modules/next/dist/compiled/react/cjs/react-jsx-dev-runtime.development.js [app-client] (ecmascript)"
"[project]/node_modules/next/dist/compiled/react/index.js [app-client] (ecmascript)"
"[project]/node_modules/next/dist/compiled/react/jsx-dev-runtime.js [app-client] (ecmascript)"
"[project]/ui/price.tsx [app-client] (ecmascript)"
shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · Chrome 152 без окна, vite 8.3.0, шаблон react-ts из create-vite 9.2.1; macOS arm64, Node 26.5.0, 14.09.2026
проверяю http://127.0.0.1:3600/
всего ответов: 21 {"Document":1,"Script":15,"Other":2,"Image":3}
200 Document /
200 Script /src/main.tsx
200 Script /@react-refresh
200 Script /node_modules/.vite/deps/react_jsx-dev-runtime.js?v=1c4cc720
200 Script /node_modules/.vite/deps/react.js?v=1c4cc720
200 Script /node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js?v=c95f1a92
200 Script /src/index.css
200 Script /src/App.tsx
200 Script /src/assets/hero.png?import
200 Script /src/assets/react.svg?import
200 Script /src/assets/vite.svg?import
200 Script /src/App.css
200 Script /@vite/client
200 Script /node_modules/vite/dist/client/env.mjs
200 Script /node_modules/.vite/deps/react-dom_client.js?v=1c69c093
200 Script /node_modules/.vite/deps/react-dom.js?v=1c4cc720
200 Other /icons.svg
200 Image /src/assets/react.svg
200 Image /src/assets/vite.svg
200 Image /src/assets/hero.png
200 Other /favicon.svg
проверяю http://127.0.0.1:3600/ (vite preview после vite build)
всего ответов: 7 {"Document":1,"Stylesheet":1,"Script":1,"Other":1,"Image":3}
200 Document /
200 Stylesheet /assets/index-D64VDMd1.css
200 Script /assets/index-jOB7hSkO.js
200 Other /icons.svg
200 Image /assets/hero-CLDdwZDr.png
200 Image /assets/react-CHdo91hT.svg
200 Image /assets/vite-BF8QNONU.svg

У Vite в разработке один ответ — один модуль. /src/main.tsx и /src/App.tsx пришли отдельно, и даже index.css с App.css — отдельными ответами. Склеены заранее только зависимости: react.js в /node_modules/.vite/deps/ — один ответ на весь пакет. У Next в том же режиме куски: в одном _0ngubum._.js шесть модулей, среди них lib/money.ts и ui/price.tsx. А после vite build скрипт на странице один: бандл Vite собирает только на сборке.

Отдаваемый модуль Vite переписывает на лету. Начало main.tsx, каким его получает браузер, уже было в статье про компилятор файла: голые имена пакетов заменены адресами в /node_modules/.vite/deps/, импорт App стал адресом /src/App.tsx — за ним браузер придёт следующим запросом, — а в хвосте встроена карта кода:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, vite 8.3.0 в режиме разработки, @vitejs/plugin-react 6.1.1; macOS arm64, Node 26.5.0, 14.09.2026; начало ответа и последняя строка, середина опущена, путь к проекту сокращён
const StrictMode = __vite__cjsImport0_react["StrictMode"];const createRoot = __vite__cjsImport1_reactDom_client["createRoot"];const _jsxDEV = __vite__cjsImport4_react_jsxDevRuntime["jsxDEV"];import __vite__cjsImport0_react from "/node_modules/.vite/deps/react.js?v=c1476f08";
import __vite__cjsImport1_reactDom_client from "/node_modules/.vite/deps/react-dom_client.js?v=c1476f08";
import "/src/index.css";
import App from "/src/App.tsx";
var _jsxFileName = "/…/vite-app/src/main.tsx";
import __vite__cjsImport4_react_jsxDevRuntime from "/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=c1476f08";
…
//# sourceMappingURL=data:application/json;base64,…

Горячая замена, последняя строка таблицы из статьи про фреймворки, видна в обоих списках: у Vite это /@vite/client и /@react-refresh, у Next — куски hmr-client. В списке ответов vite preview этих запросов уже нет, а среди кусков next build из начала статьи нет кусков hmr-client.

Почему Turbopack бандлит и в разработке, объясняет его документация: отдавать нативные модули по одному хорошо на маленьком приложении, а большое замедляет число запросов. По той же причине у самого Vite появился экспериментальный режим разработки с бандлом. А если серверная половина Next вашему приложению не нужна, Vite с роутером — честный выбор.

Шаблон создан и собран так:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, create-vite 9.2.1, vite 8.3.0; macOS arm64, Node 26.5.0, 14.09.2026; вывод npm i опущен, путь к проекту сокращён
npm warn exec The following package was not found and will be installed: create-vite@9.2.1
> npx
> 'create-vite' vite-app --template react-ts --no-interactive
│
◇ Scaffolding project in …/vite-app...
│
└ Done. Now run:
cd vite-app
npm install
npm run dev
exit 0
# scripts и devDependencies из package.json
"dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", "preview": "vite preview"
(devDependencies: @vitejs/plugin-react ^6.1.1, typescript ~6.0.2, vite ^8.3.0)
> vite-app@0.0.0 build
> tsc -b && vite build
vite v8.3.0 building client environment for production...
transforming...
✓ 20 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.45 kB │ gzip: 0.29 kB
dist/assets/react-CHdo91hT.svg 4.12 kB │ gzip: 2.06 kB
dist/assets/vite-BF8QNONU.svg 8.70 kB │ gzip: 1.60 kB
dist/assets/hero-CLDdwZDr.png 13.05 kB
dist/assets/index-D64VDMd1.css 4.10 kB │ gzip: 1.47 kB
dist/assets/index-jOB7hSkO.js 222.53 kB │ gzip: 69.28 kB
✓ built in 380ms

Скрипт build шаблона сначала зовёт tsc -b: Vite, как и Turbopack, типы не проверяет, проверка — отдельная команда. TypeScript шаблон ставит шестой версии.

До Turbopack Next собирался webpack-ом, и с флагом --webpack собирается им до сих пор. Устроен webpack проще, чем выглядят его конфиги. Конфиг называет точки входа, и webpack обходит граф сразу от всех: в глубину, с пометкой пройденных файлов, как свой сборщик, только входов несколько. Каждый файл проходит цепочку лоадеров из правил module.rules. Лоадер — функция над текстом файла, и в конце цепочки должен получиться JavaScript: так webpack делает модуль из чего угодно, от картинки до TypeScript, и так же зовёт компилятор файла — babel-loader отдаёт текст Babel.

Всё остальное делают плагины. Плагин — объект с методом apply(compiler): webpack передаёт ему компилятор, и плагин подписывается на его события — hooks.beforeCompile.tap до компиляции, hooks.emit.tap перед записью файлов — и получает внутренние объекты сборки. По словам README webpack, этим же интерфейсом сделана большая часть возможностей самого webpack. apply здесь — просто имя метода, который зовёт webpack, а не apply функции: plugin.apply(compiler) — обычный вызов метода с одним аргументом.

Модель ниже — проект из модели Turbopack, только вместо компонента картинка ui/logo.svg. hook — событие: плагин подписывается через tap, компилятор зовёт подписчиков через call. webpack() подключает плагины, объявляет beforeCompile, собирает граф от всех точек входа через лоадеры и объявляет emit. Имена — как у настоящего API. Прогноз: что напечатается раньше, beforeCompile или emit, и попадёт ли в сборку app/admin/page.js, если в браузере открыта только /?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
разбор вывода
beforeCompile — первым: плагин подписался до сборки, и компилятор объявил событие раньше, чем прочёл хоть один файл. В emit все пять модулей, app/admin/page.js и lib/db.js тоже: голый webpack не знает, что открыто в браузере, и собирает всё, что достижимо от точек входа. Порядок имён в строке другой, чем у своего сборщика: там файл вставал в очередь выполнения после своих зависимостей, а здесь модуль записывается при первом заходе — это список собранного, а не очередь. Третья строка — картинка, ставшая модулем JavaScript. Сравните с последней строкой модели Turbopack: там было «/admin собирался: false».

И сразу оговорка, без которой сравнение webpack с моделью Turbopack соврёт. Next на webpack в разработке всё подряд не собирает: он сам добавляет страницу в компиляцию при первом заходе на неё и хранит кеш webpack на диске, а webpack в режиме наблюдения пересобирает только изменённый модуль. Голый dev-сервер webpack держит собранное в памяти и умеет слать браузеру горячие обновления, а Next в режиме --webpack пишет сборку на диск, в .next/dev. Разница с Turbopack не «лениво против сразу», а в том, где живут ленивость и кеш.

Где они живут у Next на webpack, видно через функцию webpack() в next.config.ts. Next с самого начала собирался webpack-ом, а вскоре отдал его настройку наружу этой функцией — туда и дописывают свои лоадеры и плагины. Функция ниже ничего не меняет, только печатает, для какого компилятора её позвали и сколько у него плагинов:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5, NEXT_TELEMETRY_DISABLED=1, копия того же приложения; macOS arm64, Node 26.5.0, 14.09.2026; «…» — строки от проверки типов до таблицы маршрутов, те же, что в начале статьи
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
webpack: (config, { isServer, nextRuntime }) => {
console.log("webpack() вызван:", isServer ? "server" : "client", nextRuntime ?? "-", config.plugins.length, "плагинов");
return config;
},
};
export default nextConfig;
$ rm -rf .next && npx next build --webpack
▲ Next.js 16.3.5 (webpack)
✓ Running next.config.ts took 16ms
Creating an optimized production build ...
webpack() вызван: server nodejs 12 плагинов
webpack() вызван: server edge 12 плагинов
webpack() вызван: client - 15 плагинов
✓ Compiled successfully in 1978ms
…
$ ls .next/cache/ .next/cache/webpack
.next/cache/:
swc
webpack
.next/cache/webpack:
client-production
edge-server-production
server-production

Функция вызвана трижды: для сервера на Node, для сервера в среде edge — урезанной, без половины API Node, — и для браузера. Это три компилятора webpack, и у каждого свой список плагинов и свой кеш — три папки в .next/cache/webpack. Большая часть плагинов в этих списках — код самого Next, хотя есть и встроенные плагины webpack вроде DefinePlugin. Манифест клиентских ссылок из раздела про один граф в этом режиме пишет плагин Next ClientReferenceManifestPlugin в компиляторе браузера. Ленивость Next достраивает поверх трёх компиляторов, а каждая пересборка — новый проход компиляции со всеми событиями плагинов.

Turbopack устроен иначе. Граф один, а сервер и клиент — слои в нём, как в листинге слоёв. Ленивость и кеш — свойства самого движка, и кеш, как пишет документация Turbopack, хранит результаты вплоть до отдельных функций. Скорость здесь сравнивать не будем: цифры в этих листингах сняты на крошечном приложении.

Что из мира webpack Turbopack понимает? Лоадеры — да. Он исполняет их библиотекой loader-runner из проекта webpack — так пишет документация turbopack.rules, — а вписывают их в конфиг, как в следующем разделе. Ограничения два: годятся только лоадеры, которые возвращают JavaScript, а их опции — только данные, то есть примитивы, объекты и массивы, но не функции и не модули через require(). Лоадеры, которые команда Next проверила, перечислены в той же документации. Плагины — нет. Плагин написан против событий и объектов компилятора webpack, как reportPlugin в модели, а у Turbopack таких событий нет. Документация говорит прямо: плагины webpack не поддерживаются, функция webpack() из next.config не читается. У части плагинов есть замена настройкой самого Next — например, у DefinePlugin, — но это другой механизм, а не тот же плагин под Turbopack; такие замены разбирает последняя статья главы.

Отсюда ошибка, которую Next печатает, найдя в конфиге функцию webpack() без ключа turbopack:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5, то же приложение; macOS arm64, Node 26.5.0, 14.09.2026
▲ Next.js 16.3.5 (Turbopack)
✓ Running next.config.ts took 16ms
Creating an optimized production build ...
⨯ ERROR: This build is using Turbopack, with a `webpack` config and no `turbopack` config.
This may be a mistake.
As of Next.js 16 Turbopack is enabled by default and
custom webpack configurations may need to be migrated to Turbopack.
NOTE: your `webpack` config may have been added by a configuration plugin.
To configure Turbopack, see https://nextjs.org/docs/app/api-reference/next-config-js/turbopack
TIP: Many applications work fine under Turbopack with no configuration,
if that is the case for you, you can silence this error by passing the
`--turbopack` or `--webpack` flag explicitly or simply setting an
empty turbopack config in your Next config file (e.g. `turbopack: {}`).
> Build error occurred
Error: Call retries were exceeded
at ignore-listed frames {
type: 'WorkerError'
}
(код выхода 1)

Сборка упала, и next dev с таким конфигом тоже не работает: напечатает Ready, следом ту же ошибку и завершится. Выхода три, и выбирают по тому, что лежит в webpack().

ВыходЧто происходитКогда годится
перенести настройки в turbopackлоадеры переезжают в turbopack.rules, алиасы — в resolveAliasв webpack() только то, что переносится
next build --webpackсборка идёт webpack-ом со всеми плагинами из webpack()без плагина не обойтись; платформа без нативного Turbopack
--turbopack или пустой turbopack: {}сборка идёт Turbopack-ом, а webpack() не вызывается вовсеиз webpack() не нужно ничего

Третий выход коварнее, чем выглядит. Руководство по переходу на 16-ю версию предлагает флаг --turbopack, подсказка TIP в самой ошибке — пустой turbopack: {}. Та же функция-счётчик, в конфиг дописан пустой ключ:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5, NEXT_TELEMETRY_DISABLED=1, та же копия; macOS arm64, Node 26.5.0, 14.09.2026; «…» — те же строки, что в начале статьи
▲ Next.js 16.3.5 (Turbopack)
✓ Running next.config.ts took 20ms
Creating an optimized production build ...
✓ Compiled successfully in 3.2s
…
$ grep -c "webpack() вызван" ../build-turbopack-fn.log # лог этой сборки
0
$ ls .next/cache
turbopack

Сборка зелёная, а функция не вызвана ни разу; с флагом --turbopack без ключа — так же. Вместе с плагинами молча пропадают и вписанные в webpack() лоадеры и алиасы, поэтому этот выход годится, только если из функции действительно ничего не нужно. Если ключ webpack добавила чужая обёртка конфига — об этом строка NOTE в ошибке, — это ещё не довод, что он лишний: сначала посмотрите, что обёртка туда кладёт.

И последнее имя из этого мира — Rspack: сборщик с API webpack, написанный на Rust. Когда он уместен и что переедет в него из webpack.config, разбирает та же последняя статья главы.

Лоадер переезжает в turbopack.rules. Конфиг ниже собран из двух примеров документации Next: @svgr/webpack делает из файла .svg компонент React, а алиас в resolveAlias подменяет пакет — импорт underscore загрузит lodash. Страница админки пользуется обоими:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5 (Turbopack), @svgr/webpack 8.1.0, lodash 4.18.1, NEXT_TELEMETRY_DISABLED=1, копия приложения из статьи; типы для .svg и underscore объявлены в assets.d.ts; «…» — строки от проверки типов до таблицы маршрутов опущены; macOS arm64, Node 26.5.0, 14.09.2026
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
rules: {
"*.svg": {
loaders: ["@svgr/webpack"],
as: "*.js",
},
},
resolveAlias: {
underscore: "lodash",
},
},
};
export default nextConfig;
$ cat app/admin/page.tsx
import Logo from "../../ui/logo.svg";
import _ from "underscore";
export default function Admin() {
return <h1><Logo /> Админка, lodash {_.VERSION}</h1>;
}
$ rm -rf .next && npx next build
▲ Next.js 16.3.5 (Turbopack)
✓ Running next.config.ts took 1810ms
Creating an optimized production build ...
✓ Compiled successfully in 1894ms
…
$ grep -o '<h1>.*</h1>' .next/server/app/admin.html
<h1><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="8"></circle></svg> Админка, lodash <!-- -->4.18.1</h1>

Правило в rules выбирает файлы не регуляркой, как test у webpack, а шаблоном имени — "*.svg". loaders — та же цепочка, что в use. А as: "*.js" велит обрабатывать результат лоадера как файл .js: лоадер вернул модуль, а не картинку. Без этой строки Turbopack попробует разобрать вывод лоадера как SVG и остановит сборку ошибкой Processing image failed. В собранной странице картинка стала разметкой svg, а версия пришла из lodash, хотя в импорте написано underscore.

Babel в rules вписывать не нужно: если в проекте найдётся babel-конфиг, Turbopack включит Babel сам — что при этом печатает лог и когда такой файл лучше удалить, разобрано в статье про компилятор файла.

Главное место, где Babel в Next всё ещё нужен, — React Compiler. Флаг reactCompiler включает его как плагин Babel из пакета babel-plugin-react-compiler. У Turbopack есть экспериментальный порт компилятора на Rust, которому этот пакет не нужен. Включается он второй настройкой рядом с первой — так в документации:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5 (Turbopack), то же приложение; «…» — остальной лог сборки опущен; macOS arm64, Node 26.5.0, 14.09.2026
▲ Next.js 16.3.5 (Turbopack)
✓ Running next.config.ts took 56ms
- Experiments (use with caution):
✓ turbopackRustReactCompiler
Creating an optimized production build ...
✓ Compiled successfully in 1564ms
…
.next/static/chunks/0m9b9g22yzobb.js (next.config: reactCompiler: true, experimental.turbopackRustReactCompiler: true; babel-plugin-react-compiler НЕ установлен):
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,79474,(t,e,r)=>{"use strict";var c=t.r(71645).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;r.c=function(t){return c.H.useMemoCache(t)}},932,(t,e,r)=>{"use strict";t.i(47167),e.exports=t.r(79474)},20501,t=>{"use strict";var e=t.i(43476),r=t.i(932),c=t.i(71645);t.s(["Price",0,function(t){let i,s,u,o=(0,r.c)(8),{base:n}=t,[_,T]=(0,c.useState)(1);return(o[0]!==_?(i=()=>T(_+1),o[0]=_,o[1]=i):i=o[1],o[2]!==n)?(s=n+" ₽",o[2]=n,o[3]=s):s=o[3],o[4]!==_||o[5]!==i||o[6]!==s?(u=(0,e.jsxs)("button",{onClick:i,children:[_," × ",s]}),o[4]=_,o[5]=i,o[6]=s,o[7]=u):u=o[7],u}],20501)}]);
$ # тот же конфиг без turbopackRustReactCompiler
▲ Next.js 16.3.5 (Turbopack)
✓ Running next.config.ts took 15ms
Creating an optimized production build ...
> Build error occurred
Error: Turbopack build failed with 1 error:
./next.config.ts
Error: Failed to resolve package babel-plugin-react-compiler while attempting to resolve React Compiler
React compiler is enabled in next.config.ts. We attempted to resolve React Compiler relative to the next package. Is babel-plugin-react-compiler installed in your node_modules directory?
at ignore-listed frames

С обеими настройками сборка прошла без пакета Babel, и лог честно предупреждает об эксперименте. Кусок с Price читается так же, как в статье про React Compiler: o=(0,r.c)(8) заводит восемь ячеек кеша, а проверки o[0]!==_ пересчитывают значение, только когда поменялся вход. Новое здесь — не сам кеш, а то, что его поставил Turbopack, а не Babel. Без второй настройки тот же конфиг ищет Babel-плагин и падает. Порт работает только под Turbopack: с --webpack, по документации, эта настройка — ошибка.

Ещё две вещи в Next пришли из Vite: import.meta.env с полями вроде DEV, PROD и MODE и import.meta.glob работают только под Turbopack, а переменные VITE_ Next не читает.

  • За next dev и next build по умолчанию стоит Turbopack, скомпилированный в нативный модуль Next; webpack включают флагом --webpack. Какой сборщик работал, пишет первая строка лога.
  • У Turbopack один граф на сервер и клиент. Файл с "use client" живёт в нём слоями: ссылкой у серверных компонентов, кодом для HTML на сервере и кодом для браузера. Манифест клиентских ссылок и строка I в потоке — продукт этого разреза.
  • В разработке собирается только открытое, а ключ работы строится из входа, поэтому повторная работа не делается. Кеш лежит на диске и переживает перезапуск, но сборка в чистой папке его не видит.
  • Vite в разработке отдаёт модули проекта по одному и склеивает заранее только зависимости, а бандл собирает на сборке. Turbopack бандлит и в разработке.
  • webpack обходит граф сразу от всех точек входа. Лоадер — функция над текстом файла, плагин — подписка на события компилятора. Next на webpack — три компилятора, а у Turbopack слои одного графа.
  • Лоадеры переезжают в turbopack.rules, алиасы — в resolveAlias, плагины не переезжают. Пустой turbopack: {} глушит ошибку, но выбрасывает функцию webpack() целиком.

1/4В первой строке лога next build в скобках стоит (webpack). Что это значит?

2/4Компонент в ui/price.tsx начинается с "use client", его рисует серверная страница. В каких слоях графа Turbopack окажется этот файл?

3/4В модели ключ работы — файл, сторона и хеш содержимого. Поправили lib/money.ts, из которого Price берёт rub. Сколько работ даст следующий заход на /?

4/4В next.config проекта есть функция webpack() с лоадером для .svg и плагином. Что из этого заработает под Turbopack?

Админка тоже берёт money

В модели из раздела про ленивость серверная страница app/admin/page.tsx теперь сама импортирует lib/money.ts. Предскажите все три строки вывода — число работ на каждом заходе и ключи money — и только потом запускайте. Затем допишите в конец правку money, как в разделе, и заходы на оба маршрута: сколько работ будет теперь?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Первый заход — те же четыре работы, что в разделе. Заход на /admin даёт три: саму страницу, db и money на серверной стороне. Ключ money с клиентской стороны уже лежит в done, но он не подходит: сторона — часть ключа, и серверная версия — отдельная работа. Файл, до которого дотягиваются с обеих сторон границы, собирается дважды.

После правки:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

печатает правка money, снова /: работ 1 и и /admin: работ 1: у каждой стороны по одному новому ключу.

В настоящем Next так же. Если в app/admin/page.tsx импортировать rub из lib/money и зайти на обе страницы, у lib/money.ts к слоям [app-ssr] и [app-client] добавится третий, [app-rsc] (next dev 16.3.5, тот же grep по .next/dev, 14.09.2026):

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Все подходящие правила

В модели webpack лоадеры выбирает rules.find — берётся первое подходящее правило. Настоящий webpack применяет к файлу все подходящие правила: их лоадеры встают в одну цепочку в порядке правил, а выполняется цепочка справа налево. Ниже у ui/logo.svg два правила, и второе модель пропускает. Перепишите выбор лоадеров так, как это делает webpack, и предскажите, что напечатается.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Лоадеры всех подходящих правил, в порядке правил:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

flatMap — тот же map, только массивы use всех правил склеиваются в один. Цепочка — [svgLoader, roleLoader], и reduceRight проходит её с конца: сначала roleLoader правит текст картинки, потом svgLoader делает из него модуль. Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Порядок здесь не формальность. Если пройти ту же цепочку слева направо, roleLoader получит уже готовый модуль и вставит кавычки внутрь строки без экранирования:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Такой файл не разберётся. Поэтому лоадер, который превращает файл в JavaScript, в use пишут первым, а текстовые правки — после него: выполнятся они раньше.

Пустой turbopack и пропавший алиас

В проекте на Next 16 функция webpack() в конфиге добавляет лоадер для .svg и алиас underscore → lodash, и с флагом --webpack страница админки собирается. Чтобы заглушить ошибку про webpack-конфиг, в конфиг дописали пустой turbopack: {} — и сборка упала:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5, @svgr/webpack 8.1.0, lodash 4.18.1, копия приложения из статьи со страницей админки из раздела про next.config; macOS arm64, Node 26.5.0, 14.09.2026; цветовые коды терминала убраны
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {},
webpack: (config) => {
config.module.rules.push({ test: /\.svg$/, use: ["@svgr/webpack"] });
config.resolve.alias.underscore = "lodash";
return config;
},
};
export default nextConfig;
$ rm -rf .next && npx next build
▲ Next.js 16.3.5 (Turbopack)
✓ Running next.config.ts took 16ms
Creating an optimized production build ...
> Build error occurred
Error: Turbopack build failed with 1 error:
./app/admin/page.tsx:2:1
Error: Module not found: Can't resolve 'underscore'
1 | import Logo from "../../ui/logo.svg";
> 2 | import _ from "underscore";
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
3 |
4 | export default function Admin() {
5 | return <h1><Logo /> Админка, lodash {_.VERSION}</h1>;
https://nextjs.org/docs/messages/module-not-found
at <unknown> (./app/admin/page.tsx:2:1)
at <unknown> (https://nextjs.org/docs/messages/module-not-found)

Объясните, почему сборщик не нашёл underscore, хотя алиас в конфиге есть. Какая ошибка встретится следующей, если перенести в turbopack только алиас? И как должен выглядеть конфиг, с которым next build без флагов соберёт страницу?

Ключ turbopack заглушил ошибку, и сборка пошла Turbopack-ом, а он функцию webpack() не вызывает вовсе. Алиас жил внутри неё — значит, для Turbopack его нет, и underscore ищется как настоящий пакет, которого в проекте нет.

Если перенести один алиас, импорт найдётся, но лоадер для картинки тоже остался в webpack(). Turbopack отдаст .svg как файл-ресурс, Logo окажется объектом, а не компонентом, и сборка упадёт позже, на отрисовке страницы (проверено на next 16.3.5, 14.09.2026):

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Переносить нужно обе настройки — это конфиг из раздела про next.config:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Функцию webpack() рядом можно оставить: next build собирает Turbopack-ом по ключу turbopack, а next build --webpack — по функции, и в той же проверке оба варианта собрали страницу с картинкой и версией lodash. Но тогда одни и те же настройки живут в двух местах, и менять их придётся парой.