Build/deps/config:
- next, eslint-config-next, @next/bundle-analyzer → ^16; Turbopack is the
default build bundler. Added turbopack:{} config, dropped removed config keys.
- Removed all 151 typed-scss-modules *.module.scss.d.ts in favor of ambient
declarations in src/types/css-modules.d.ts (Turbopack panics on the .d.ts).
- CSS modules: rewrote bare :global to function form for Turbopack.
- trpc/[trpc] handler typing; bundler-agnostic applySourceMaps.
Runtime fixes (Turbopack dev/prod):
- CAConsentManager: static-import instead of next/dynamic. A dynamic component
wrapping the whole app async-loads its chunk under Turbopack dev, causing a
whole-tree hydration mismatch that re-mounts the app and orphans the server
DOM (the "double layout"; US:CA-gated). General rule: wrapper dynamics remount,
leaf dynamics are fine.
- ReactQueryDevtools: load client-only (ssr:false) — not SSR-safe under Turbopack.
- SharedWorkers (signals + civitai-link): Turbopack doesn't compile .ts
SharedWorker entries (vercel/next.js#74842), breaking dev AND the prod build.
Pre-bundle each with esbuild to public/workers/*.js (scripts/build-workers.mjs,
wired into predev/prebuild) and instantiate via static path.
See docs/next-16-migration.md for full details and remaining open items.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 KiB
Next.js 16 + Turbopack Migration — Working Notes / Handoff
Branch: next-16-migration (worktree: C:/Work/model-share-next-16-migration, branched off next-15-migration)
Status: ✅ Build-verified in the prod Docker pipeline · ⚠️ Not yet runtime-verified · 🔧 A couple of decisions open
React: stays on 18.3.1 (Next 16 peer-deps accept React 18, so the React 19 migration is intentionally decoupled)
Nothing is committed — all changes are in the working tree for review.
Why we did this
The Next.js 15 production build OOMs in the Docker pipeline unless the Node heap is raised to 16 GB (the Dockerfile default is 8192; it OOMs at 8 GB and 12 GB). Root cause is the webpack build (the "Creating an optimized production build" phase) plus ~1 GB of source maps.
Next.js 16 makes Turbopack the default build bundler, which is much lighter. The goal of this branch was to find out whether Next 16 + Turbopack builds within the prod-default memory budget. It does.
Headline result (measured)
| Next 15 (webpack) | Next 16 (Turbopack) | |
|---|---|---|
| Docker build heap needed | 16384 (OOMs at 8192 & 12288) | 8192 ✅ (prod default) |
| Compile time (in Docker) | ~18 min | ~2.6 min |
| TypeScript check | passes | passes (~3.4–5.4 min) |
| Final image size | 1.97 GB | 1.15 GB¹ |
output: 'standalone' |
✅ | ✅ |
Both produced runnable images: civitai:next15-migration-test, civitai:next16-migration-test (on the local Docker daemon).
¹ The 1.15 GB figure was measured with browser source maps OFF. They are currently ON in this branch (for the error-reporting fix below), which re-inflates the image — see the source-maps decision below.
Environment setup for a fresh worktree (do this first!)
git worktree add does not bring along gitignored or submodule content. After checking out this worktree you must:
# 1. Initialize the event-engine-common git submodule (imported as ../../../event-engine-common/*).
# Without it the build fails type-check: "Cannot find module '.../event-engine-common/feeds'".
git submodule update --init --recursive
# 2. Provide the gitignored .env (needed for NEXT_PUBLIC_* build-time inlining / page-data collection).
cp ../model-share-next-15-migration/.env .env
# 3. Install deps (Next 16 etc.).
pnpm install
How to build / verify
# Local Turbopack build (fast iteration). Fits 8 GB heap.
SKIP_ENV_VALIDATION=1 IS_BUILD=true NODE_OPTIONS="--max_old_space_size=8192" pnpm build
# Typecheck
pnpm typecheck # passes clean
# Full prod-parity build (the real pipeline test). Note: prod default heap.
DOCKER_BUILDKIT=1 docker build --build-arg NODE_BUILD_MEM=8192 -t civitai:next16-migration-test .
next build in v16 = Turbopack by default (no flag). next build --webpack is the opt-out.
Changes made (manifest)
Dependency / config:
package.json—next,eslint-config-next,@next/bundle-analyzer→^16.2.7;lintscriptnext lint→eslint ...(Next 16 removednext lint).next.config.mjs—- Added
turbopack: {}(Next 16 errors on awebpackconfig with noturbopackconfig). The OTelwebpack.ignoreWarningsblock is kept for the--webpackfallback; Turbopack ignores it, and those warnings don't appear because the OTel packages are inserverExternalPackages. - Removed the
eslint: { ignoreDuringBuilds }key (unsupported in 16). productionBrowserSourceMaps: true(currently on — see decision below).
- Added
tsconfig.json,next-env.d.ts— auto-reconfigured by Next 16 (jsx: react-jsx).pnpm-lock.yaml— updated.
Code fixes for Turbopack compatibility:
src/pages/api/trpc/[trpc].ts— typed thewithAxiomhandler params explicitly (async (req: NextApiRequest, res: NextApiResponse)) so Next 16's stricter route types resolve the API-handler overload.src/components/CivitaiLink/CivitaiLinkProvider.tsx—SharedWorkerURL'/src/workers/...'→ relative'../../workers/...'(Turbopack doesn't support server-relative worker imports).- CSS modules — Turbopack rejects bare
:global .x/:global { ... }. Converted switch-form to function form:global(.x)(wrapping nested global classes like tiptap's.is-editor-empty) and hoisted global-keyframesanimationdeclarations out of& :global { }blocks. Files:CommentForm.module.scss,EditResourceReview.module.scss,RichTextEditorComponent.module.scss,Training/Form/TrainingImagesCaptionViewer.module.scss,Cards/Cards.module.css,Games/LevelProgress/LevelProgress.module.scss,HomeContentToggle/HomeContentToggle.module.css,pages/comics/project/[id]/ProjectWorkspace.module.scss. src/styles/globals.css— added@keyframes pulse-outline(moved out of ProjectWorkspace's:global { @keyframes }block; it's referenced by an inline JS style inPanelCard.tsx, so it must be a global keyframes).
typed-scss-modules removal (the biggest change):
- Turbopack's TS-aware resolver bundles the committed adjacent
*.module.scss.d.tsfiles as runtime modules and panics parsing theirdeclare const(Expected ';', '}' or <eof>). Verified: 0 of 3528 chunks had a same-name.mapsibling pattern; this affected every CSS-module import with a.d.tssibling. - Fix:
git rm'd all 151*.module.scss.d.tsand added ambientdeclare module '*.module.scss' / '*.module.css'insrc/types/css-modules.d.ts. Safe because every CSS-module import in the repo is a default import (0 named imports). - Do NOT run
pnpm generate-types(typed-scss-modules) on this branch — it would regenerate the.d.tsfiles and reintroduce the breakage. It is not wired into husky/CI, so it won't happen automatically.
Client error reporting (applySourceMaps):
src/server/utils/errorHandling.ts— the client-error → Axiom source-mapping utility (/api/application-error→applySourceMaps) assumed the webpack convention that a chunkX.js's map isX.js.map. Turbopack names the map with a different hash, linked only via the in-file//# sourceMappingURL=comment, so the utility silently produced un-resolved stacks.- Fix: added
loadSourceMapContent()(reads the chunk, follows itssourceMappingURL, with a<chunk>.mapwebpack fallback) andnormalizeSourcePath()(stripswebpack://_N_E/andturbopack:///[project]/prefixes → cleansrc/...). Bundler-agnostic. - Verified end-to-end against real Turbopack output: a simulated browser frame
…/_next/static/chunks/0--qyq2uw0ftr.js:1:561resolves tosrc/components/InfoPopover/InfoPopover.tsx:2:0. RequiresproductionBrowserSourceMaps: trueso the maps exist.
Dev-only hydration mismatch (ReactQueryDevtools):
- Symptom: every page (dev only) threw
Did not expect server HTML to contain a <div> in <div>→ "the entire root will switch to client rendering". Component stack namedReactQueryDevtoolsat _app.tsx ({isDev && <ReactQueryDevtools />}). - Root cause:
@tanstack/react-query-devtools@5.101.0'sReactQueryDevtoolsunconditionally returns<div class="tsqd-parent-container">and mounts its UI imperatively in auseEffect— it is not SSR-safe. The server emits that container div; under Turbopack the client doesn't reproduce it during hydration, so React flags it as an unhydrated tail node and bails the whole root to client rendering. Production is unaffected (gated byisDev); it was a webpack-vs-Turbopack dev-SSR difference. - Fix: load it via
next/dynamic(..., { ssr: false })so it never participates in SSR/hydration. Verified/and/modelshydrate with 0 errors after the change (Playwright console capture against the dev server).
Double-mounted layout (CAConsentManager) — the big one:
- Symptom: on every page the entire app layout rendered twice, stacked — one frozen copy (orphaned server DOM, no content) above a live copy that actually loaded data. Every tRPC query fired twice. Region-dependent: only reproduced when the visitor's region requires a consent prompt (e.g. localhost resolving to US:CA).
- Root cause:
ThirdPartyConsentProviderloadedCAConsentManagervianext/dynamic(ssr left at defaulttrue) and that component wraps the whole app aschildren. Server rendersCAConsentManager(children)into the SSR HTML; on the client theCAConsentManagerchunk is fetched async (Turbopack dev) and isn't ready at hydration, so its children are momentarily absent → whole-subtree hydration mismatch → React re-mounts the app fresh and orphans the server DOM (the duplicate). Non-CA visitors hitreturn <>{children}</>(no dynamic) → clean. Worked on next-15/webpack because webpack had the chunk available synchronously at hydration; Turbopack dev loads it truly async. - Fix: static-import
CAConsentManager(it's a context provider;ConsentBanneris currently commented out). Keepsssr: truecorrectness while guaranteeing the code is present synchronously at hydration. Verified:/modelswent fromheaders=2→headers=1, queries no longer doubled. - General rule (Turbopack dev): a
next/dynamiccomponent that wraps the whole app aschildrenwill async-load its chunk and re-mount everything below it. Leaf dynamics are fine; wrapper dynamics are the trap. Two instances existed —ReactQueryDevtoolsandCAConsentManager— both now fixed. If adding a region-/flag-gated wrapper provider, import it statically (lazy-load only leaf UI inside it).
SharedWorker TypeScript scripts not compiled (Turbopack) — FIXED via prebuilt bundles:
- Symptom: console shows
Failed to fetch a worker script(×3). Both SharedWorkers are affected: signals (useSignalsWorker.tsnew SharedWorker(new URL('./worker.ts', import.meta.url), { type: 'module' })) and Civitai Link (CivitaiLinkProvider.tsxcivitai-link.worker.ts). Both use the@okikio/sharedworkerwrapper. - Root cause: known Turbopack bug — vercel/next.js#74842 "[Turbopack] SharedWorker TypeScript scripts not compiled". Turbopack does not compile
.tsSharedWorker entry scripts (.jsworks); the URL resolves to an uncompiled script, so the browser can't fetch it. webpack compiled them fine, so this is a Turbopack regression for live signals + Civitai Link. - Impact: CONFIRMED broken in the prod build too (verified by inspecting
next buildoutput, 2026-06-05). The prod client chunks containnew SharedWorker("/_next/static/media/worker.<hash>.ts")/civitai-link.worker.<hash>.ts— a.tsURL — but.next/static/media/is not emitted at all (the files exist only under.next/dev/as raw.ts). So in production those URLs 404 →Failed to fetch a worker script. Breaks live signals (metrics, generation updates, notifications) and Civitai Link. The build exits 0 — success does NOT mean the workers run. This is a release blocker, not a dev-only issue. - Fix (implemented): pre-bundle each worker with esbuild to
public/workers/*.jsand instantiate via a static path instead ofnew URL(import.meta.url), bypassing Turbopack's worker handling entirely.scripts/build-workers.mjs— esbuild bundlessrc/utils/signals/worker.ts→public/workers/signals.worker.jsandsrc/workers/civitai-link.worker.ts→public/workers/civitai-link.worker.js. Classic/IIFE format; bundles all deps (signalr/socket.io/uuid/idb-keyval); resolves the~/alias via tsconfig; injectsNEXT_PUBLIC_*env +SKIP_ENV_VALIDATIONviadefine: { 'process.env': … }so the standalone bundle doesn't choke on~/env/client(which validates all public env on import).package.json—build:workersscript +predev/prebuildhooks (anddev-debug/build:dev) so bundles regenerate beforepnpm dev/pnpm build. Caveat: these are npm lifecycle hooks — if the server is launched outsidepnpm dev(e.g. the dev-server skill spawningnext devdirectly), runpnpm build:workersonce first.- Instantiation:
useSignalsWorker.ts→new SharedWorker('/workers/signals.worker.js');CivitaiLinkProvider.tsx→new SharedWorker('/workers/civitai-link.worker.js').type: 'module'dropped (classic worker). /public/workers/is gitignored (build output).- Verified: after a dev-server restart, both worker scripts serve and the
Failed to fetch a worker scripterrors are gone (signals + Civitai Link working).prebuildmeans the prod Turbopack build emits them too.
Status scorecard
✅ Verified
- Builds in the prod Docker pipeline at the 8 GB default;
pnpm typecheckclean; standalone output valid. - Turbopack config, the trpc/withAxiom type fix, the
.d.ts/ambient-types fix, and theapplySourceMapsfix.
⚠️ Partially runtime-verified (dev)
- Dev smoke-test done for
/and/models: after theReactQueryDevtoolsandCAConsentManagerfixes, both render a single layout and hydrate with 0 errors (incl. the US:CA consent path). Other routes / full click-through still pending. - The 8 CSS files are not visually verified —
:globalrewrites + keyframes moves can have subtle styling regressions. Check: Buzz purchase, comment/rich-text editors, training captions, level progress, cards, home toggle, comics workspace. - Playwright tests not run.
✅ Fixed & dev-verified
- SharedWorkers (signals + civitai-link) — were broken under Turbopack (next.js#74842); now pre-bundled to
public/workers/*.jsand instantiated by static path. Worker-fetch errors gone after restart. See the SharedWorker note above. Still TODO: confirm on the prod/standalone image (prebuild emits the bundles, but runtime on the built image not yet checked).
🔧 Open decisions (team's call)
- typed-scss-modules → loose ambient types — lose per-class CSS typing across the codebase. Ratify or find an alternative (e.g. a Turbopack-compatible typing approach).
- Source maps vs. image size — the small 1.15 GB image was with browser source maps OFF; they're ON now for error reporting (re-inflates the image via
.next/staticmaps). Pick one: small image or source-map-backed error logging. middleware→proxyrename (Next 16 deprecation; still works, just warns).- React 18 → 19 (deferred; known follow-up).
Suggested next steps (in order)
- Run the built app (dev server or the standalone build) and click through it — especially the components with CSS changes and Civitai Link.
- Visually verify the 8 CSS files (e.g. via the
component-previewskill, dark + light). - Run the Playwright suite.
- Decide the source-maps-vs-image-size and typed-scss-modules questions.
- Do the
middleware→proxyrename to clear the deprecation. - Commit + open PR.
Reference
serverSourceMaps note: experimental.serverSourceMaps is webpack-only — Turbopack ignores it (no napi ProjectOptions field; zero refs in next/dist/build/turbopack-build/). Under Turbopack, productionBrowserSourceMaps is the single lever and it drives both client and server map emission (valid v3 with sourcesContent, ~563 MB client + ~758 MB server, at no measurable build-time/memory cost).