Problem
In a Pages Router production build, stylesheets are linked in chunk-emission order, not module-graph order. Dev links every CSS module separately in import order, so the two disagree and equal-specificity rules resolve the opposite way in production.
Concretely, given a base component and a second CSS module that overrides it:
/* base.module.css — imported by a shared component */
.panel { position: absolute; visibility: hidden; }
/* reset.module.css — imported from _app's graph */
.reset { display: none; }
Both selectors are one class (specificity 0,1,0), so source order decides. Vite emits each chunk's CSS with that chunk, so:
reset.module.css is folded into the entry stylesheet (_app.<hash>.css), linked first
base.module.css lands in a shared chunk stylesheet, loaded later
The override therefore comes before what it overrides and loses, while in dev it comes after and wins. Measured on a real page (sheet index in document.styleSheets):
|
dev |
production build |
| base rule |
sheet 23 |
sheet 21 |
| overriding rule |
sheet 24 ✅ wins |
sheet 0 ❌ loses |
The visible result was a mobile flyout panel rendering open and in-flow at desktop width, shoving the page sideways — one inverted rule, whole-page damage.
Reproduction shape: the base CSS module must end up in a shared chunk and the overriding CSS module in the entry chunk. A trivial two-file app will inline everything into one chunk and not show it.
Second symptom: most CSS is not in the SSR HTML
collectAssetTags only looks up the page module id and the _app module id in the SSR manifest, so the served HTML carried 2 <link rel="stylesheet"> tags while the hydrated document ended up with ~60 — the rest injected as JS chunks loaded. Final order is therefore chunk-load order, and the first paint is unstyled until hydration.
Cause
collectAssetTags (dist/server/pages-asset-tags.js) resolves assets per module id from the SSR manifest, which records a chunk plus its direct imports. Nothing walks the chunk graph, so nothing establishes dependency-before-importer ordering.
Fix
The client build already emits .vite/manifest.json with imports, dynamicImports and css per chunk — enough to reconstruct graph order. The patch below:
- embeds a trimmed
{ file, imports, dynamicImports, css } graph into the client-assets module at build time, and
- walks it depth-first at request time, emitting imported chunks' CSS before the importer's, ahead of the existing manifest-ordered tags (the existing
seen set then suppresses duplicates).
Following dynamicImports matters: a component behind next/dynamic still renders during SSR, so leaving its stylesheet out means it arrives after first paint and restyles the page.
Result on the same page: the overriding rule moves from sheet 0 to sheet 28 (now after the base rule at 17), the flyout renders correctly, SSR HTML goes from 2 to 61 stylesheet links, and no CSS finishes loading after first-contentful-paint.
Caveats
This is a diff against built dist output — a description of the fix rather than a source patch. It has no tests, does not touch the App Router path (which uses its own emitter and React's data-precedence hoisting, so it likely needs a different treatment — cf. #2879), and does not address build.cssCodeSplit: false, which separately emits a single stylesheet that the SSR HTML then never links.
Environment
- vinext
1.0.0-beta.6
- Vite
8.1.0
- Pages Router,
vinext build + Cloudflare Workers
Patch
diff --color -ruN --text a/dist/index.js b/dist/index.js
--- a/dist/index.js 2026-08-19 01:35:12
+++ b/dist/index.js 2026-08-19 01:35:12
@@ -3248,7 +3248,34 @@
if (fs.existsSync(ssrManifestPath)) try {
ssrManifest = JSON.parse(fs.readFileSync(ssrManifestPath, "utf-8"));
} catch {}
+ // Chunk import graph, used at request time to emit stylesheets in
+ // module-graph order. Vite emits a chunk's own CSS with the chunk,
+ // so an entry's CSS (e.g. `_app`) is linked before the CSS of the
+ // shared chunks it imports. Equal-specificity rules then resolve in
+ // the opposite order from dev, where every CSS module is its own
+ // link in graph order — a CSS-module "reset" meant to override a
+ // base component silently loses in production.
+ let cssGraph;
+ const clientManifestPath = path.join(clientDir, ".vite", "manifest.json");
+ if (fs.existsSync(clientManifestPath)) try {
+ const clientManifest = JSON.parse(fs.readFileSync(clientManifestPath, "utf-8"));
+ cssGraph = {};
+ for (const [key, entry] of Object.entries(clientManifest)) {
+ const imports = entry?.imports ?? [];
+ const css = entry?.css ?? [];
+ const dynamicImports = entry?.dynamicImports ?? [];
+ if (imports.length === 0 && dynamicImports.length === 0 && css.length === 0) continue;
+ cssGraph[key] = {
+ file: entry?.file,
+ imports,
+ dynamicImports: entry?.dynamicImports ?? [],
+ css
+ };
+ }
+ if (Object.keys(cssGraph).length === 0) cssGraph = void 0;
+ } catch {}
pagesClientAssetsModule = buildPagesClientAssetsModule({
+ cssGraph,
clientEntry: runtimeMetadata.clientEntryFile ?? void 0,
appBootstrapPreinitModules: runtimeMetadata.appBootstrapPreinitModules,
ssrManifest,
diff --color -ruN --text a/dist/server/pages-asset-tags.js b/dist/server/pages-asset-tags.js
--- a/dist/server/pages-asset-tags.js 2026-08-19 01:35:12
+++ b/dist/server/pages-asset-tags.js 2026-08-19 01:35:12
@@ -64,6 +64,53 @@
*
* Extracted from `entries/pages-server-entry.ts`.
*/
+/**
+* Stylesheets for a set of module ids, in module-graph order.
+*
+* Vite emits each chunk's CSS alongside that chunk, so an entry's own stylesheet
+* is linked before the stylesheets of the shared chunks it imports. Dev does the
+* opposite: every CSS module is its own link in import order. Where two CSS
+* modules style the same element with equal specificity — a "reset" class meant
+* to override a base component is the common case — the winner flips between dev
+* and production and the built page renders wrong.
+*
+* Walking the chunk graph depth-first and emitting imported chunks' CSS before
+* the importer's restores the dev cascade: dependencies first, importer last.
+*/
+function collectGraphOrderedCss(cssGraph, moduleIds) {
+ if (!cssGraph || !moduleIds || moduleIds.length === 0) return [];
+ const keyFor = (moduleId) => {
+ if (cssGraph[moduleId]) return moduleId;
+ for (const key in cssGraph) if (moduleId === key || moduleId.endsWith("/" + key)) return key;
+ return null;
+ };
+ const ordered = [];
+ const emitted = /* @__PURE__ */ new Set();
+ const visited = /* @__PURE__ */ new Set();
+ const walk = (key) => {
+ if (!key || visited.has(key)) return;
+ visited.add(key);
+ const entry = cssGraph[key];
+ if (!entry) return;
+ const imports = entry.imports ?? [];
+ for (let i = 0; i < imports.length; i++) walk(imports[i]);
+ // Dynamic entries are followed too: a component behind `next/dynamic` or
+ // `React.lazy` still renders during SSR, so its stylesheet has to be in
+ // the document. Left out, the chunk's CSS only arrives once the lazy
+ // chunk loads and restyles the page after first paint.
+ const dynamicImports = entry.dynamicImports ?? [];
+ for (let i = 0; i < dynamicImports.length; i++) walk(dynamicImports[i]);
+ const css = entry.css ?? [];
+ for (let i = 0; i < css.length; i++) {
+ const file = css[i];
+ if (!file || emitted.has(file)) continue;
+ emitted.add(file);
+ ordered.push(file);
+ }
+ };
+ for (let i = 0; i < moduleIds.length; i++) walk(keyFor(moduleIds[i]));
+ return ordered;
+}
function collectAssetTags(options) {
const m = resolveSsrManifest(options.manifest);
const tags = [];
@@ -86,6 +133,17 @@
seen.add(clientEntry);
tags.push("<link rel=\"modulepreload\"" + nonceAttr + " href=\"" + href(clientEntry) + "\"" + preloadCrossOriginAttr + " />");
tags.push("<script type=\"module\"" + deferAttr + nonceAttr + " src=\"" + href(clientEntry) + "\"" + scriptCrossOriginAttr + "><\/script>");
+ }
+ // Emit stylesheets in module-graph order first; `seen` then suppresses the
+ // manifest-ordered duplicates below, so the graph order is what the browser
+ // applies.
+ const graphCss = collectGraphOrderedCss(runtimeAssets.cssGraph, options.moduleIds);
+ for (let gi = 0; gi < graphCss.length; gi++) {
+ let gf = graphCss[gi];
+ if (gf.charAt(0) === "/") gf = gf.slice(1);
+ if (seen.has(gf)) continue;
+ seen.add(gf);
+ tags.push("<link rel=\"stylesheet\"" + nonceAttr + " href=\"" + href(gf) + "\" />");
}
if (m) {
const allFiles = [];
Problem
In a Pages Router production build, stylesheets are linked in chunk-emission order, not module-graph order. Dev links every CSS module separately in import order, so the two disagree and equal-specificity rules resolve the opposite way in production.
Concretely, given a base component and a second CSS module that overrides it:
Both selectors are one class (specificity 0,1,0), so source order decides. Vite emits each chunk's CSS with that chunk, so:
reset.module.cssis folded into the entry stylesheet (_app.<hash>.css), linked firstbase.module.csslands in a shared chunk stylesheet, loaded laterThe override therefore comes before what it overrides and loses, while in dev it comes after and wins. Measured on a real page (sheet index in
document.styleSheets):The visible result was a mobile flyout panel rendering open and in-flow at desktop width, shoving the page sideways — one inverted rule, whole-page damage.
Reproduction shape: the base CSS module must end up in a shared chunk and the overriding CSS module in the entry chunk. A trivial two-file app will inline everything into one chunk and not show it.
Second symptom: most CSS is not in the SSR HTML
collectAssetTagsonly looks up the page module id and the_appmodule id in the SSR manifest, so the served HTML carried 2<link rel="stylesheet">tags while the hydrated document ended up with ~60 — the rest injected as JS chunks loaded. Final order is therefore chunk-load order, and the first paint is unstyled until hydration.Cause
collectAssetTags(dist/server/pages-asset-tags.js) resolves assets per module id from the SSR manifest, which records a chunk plus its direct imports. Nothing walks the chunk graph, so nothing establishes dependency-before-importer ordering.Fix
The client build already emits
.vite/manifest.jsonwithimports,dynamicImportsandcssper chunk — enough to reconstruct graph order. The patch below:{ file, imports, dynamicImports, css }graph into the client-assets module at build time, andseenset then suppresses duplicates).Following
dynamicImportsmatters: a component behindnext/dynamicstill renders during SSR, so leaving its stylesheet out means it arrives after first paint and restyles the page.Result on the same page: the overriding rule moves from sheet 0 to sheet 28 (now after the base rule at 17), the flyout renders correctly, SSR HTML goes from 2 to 61 stylesheet links, and no CSS finishes loading after first-contentful-paint.
Caveats
This is a diff against built
distoutput — a description of the fix rather than a source patch. It has no tests, does not touch the App Router path (which uses its own emitter and React'sdata-precedencehoisting, so it likely needs a different treatment — cf. #2879), and does not addressbuild.cssCodeSplit: false, which separately emits a single stylesheet that the SSR HTML then never links.Environment
1.0.0-beta.68.1.0vinext build+ Cloudflare WorkersPatch