Turbopack: nested next/dynamic gets no SSR stylesheet link, causing FOUC and layout shift (App Router)
Link to the code that reproduces this issue
https://github.com/vlad-demchuk/next16-nested-dynamic-css-repro
To Reproduce
npm install
npm run build # Turbopack, the Next 16 default
npm run check
The app is six files:
app/page.js server component
└─ components/Resolver.js "use client", imported statically
└─ dynamic(WidgetX) level 1
└─ dynamic(VariantY) level 2
WidgetX and VariantY each import their own CSS module and both keep the default ssr: true.
check.mjs reads the prerendered HTML, lists its <link rel="stylesheet"> tags, then locates the
CSS chunk holding each module's class name and reports whether that chunk is linked.
To confirm it by hand rather than through the script:
npx next start -p 3200
Open http://localhost:3200 and use view-source — not the Elements panel, since by then hydration
has already inserted the missing link. The server HTML contains exactly one <link rel="stylesheet">,
the level-1 one. Throttling the network (DevTools -> Network -> Slow 3G) and hard-reloading makes the
shift visible: the two columns stack full width first, then snap to 62% / 38% when the level-2
stylesheet arrives. On an unthrottled localhost the chunk is a few hundred bytes, so the flash is too
quick to catch.
Current vs. Expected behavior
Current — the level-2 stylesheet is not in the server HTML at all. It is emitted as a chunk, but
nothing links it, so the client chunk loader fetches it after hydration (plain <link>, no
data-precedence, renderBlockingStatus: "non-blocking"). VariantY therefore paints without
display: flex and without its 62% / 38% column widths, then reflows when the stylesheet lands.
Expected — the same <link rel="stylesheet" precedence="dynamic"> that the level-1 dynamic import
receives in the very same build.
Results from the repro:
| build | level 1 CSS linked in SSR HTML | level 2 CSS linked in SSR HTML |
|---|---|---|
| 16.3.4, Turbopack | yes (data-precedence="dynamic") |
no |
16.3.4, next build --webpack |
yes | yes |
| 16.4.0-canary.34, Turbopack | yes | no |
| 16.0.11, Turbopack | yes | yes |
On one and the same version the webpack build links both stylesheets while the Turbopack build links only the first level, and the Turbopack build did link both on 16.0.x.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.5.0
Available memory (MB): 32768
Available CPU cores: 12
Binaries:
Node: 24.19.0
npm: 11.17.0
pnpm: 10.20.0
Relevant Packages:
next: 16.3.4 (also reproduced on 16.4.0-canary.34)
react: 19.3.0
react-dom: 19.3.0
Next.js Config:
output: N/A
Which area(s) are affected? (Select all that apply)
Turbopack, CSS, Module Resolution, Lazy Loading (next/dynamic)
Which stage(s) are affected? (Select all that apply)
next build (local), next start (local), Vercel (Deployed)
Additional context
Regression range, measured on a real app (same application code, only the Next version changed). Metric = number of CSS modules on one route delivered by the client loader instead of an SSR link:
| next | late CSS modules |
|---|---|
| 16.0.0 | 1 |
| 16.0.11 | 1 |
| 16.1.0 | 24 |
| 16.1.7 | 24 |
| 16.2.12 | 29 |
| 16.3.4 | 29 |
| 16.3.5 | 29 |
16.2.x also raised total emitted CSS for that route from 978 KB to 1.16 MB, which may or may not be the same change.
Impact in production on 16.3.4, on a landing page whose above-the-fold section is a level-2 dynamic: CLS 0.4157, from a single shift of 0.3825 at 1543 ms — 83 ms after first contentful paint, with the two columns and the widget wrapper as the shift sources. The statically imported equivalent component on the same page measures CLS 0.
The behaviour looks at odds with the intent stated in Next's own source, though we may be misreading how the pieces fit together:
packages/next/src/shared/lib/lazy-dynamic/loadable.tsx:/* During SSR, we need to preload the CSS from the dynamic component to avoid flash of unstyled content */packages/next/src/shared/lib/lazy-dynamic/preload-chunks.tsx:// For stylesheets we actually need to render the CSS because nothing else is going to do it
Where we suspect this comes from — a pointer, not a diagnosis, as we don't know the Turbopack
internals: PreloadChunks renders its link from opts.modules looked up in
reactLoadableManifest, and the affected chunks are exactly the ones missing from that manifest.
Looking for why, we noticed the DFS in crates/next-api/src/module_graph.rs (tag v16.3.4, around
lines 204-210) records a dynamic entry and then returns GraphTraversalAction::Skip for it. If that
means the traversal does not descend into a dynamically imported module, a dynamic() declared
deeper would never be collected, which matches what we see. The skip itself looks like a sensible
optimisation — we mention it only because the symptom lines up.
experimental.cssChunking is not an option here: Turbopack rejects it with
"experimental.cssChunking is only supported with webpack". experimental.inlineCss: true does not
change the behaviour.
Workaround that keeps the lazy loading: move the level-2 dynamic() declarations into a module
reached by a plain import from a statically imported module. In our app this dropped late CSS
modules from 29 to 10 (the remaining 10 are ssr: false components, where late CSS is expected)
with byte-identical CSS output, the same chunk count, and code splitting intact — the variant still
lands in its own 1.8 KB lazy chunk, and the route keeps 73 lazy chunks versus 58 when the same
components are imported statically.
Related: #98287 (Turbopack omits __NEXT_DATA__.dynamicIds for next/dynamic in pages/_app;
same class of missing registration), #98292 (PR filling another hole in the Turbopack loadable
manifest), #83941 (Turbopack CSS prioritisation differs between dev and build).
Note: I first filed this as #98803 without a reproduction link and the bot closed it, as documented. Re-filing with the repro, per its instructions.
Source: vercel/next.js