Skip to content

fix(build): emit SSR CSS assets referenced by SSR entry - #1304

Merged
james-elicx merged 1 commit into
mainfrom
fix/deploy-suite-ssr-css-asset
May 18, 2026
Merged

fix(build): emit SSR CSS assets referenced by SSR entry#1304
james-elicx merged 1 commit into
mainfrom
fix/deploy-suite-ssr-css-asset

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Problem

Production deployments fail at vinext start time when a server component (or any module SSR'd through the SSR environment) imports a CSS file:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  '/tmp/next-test-.../dist/server/ssr/style.css'
  imported from '/tmp/next-test-.../dist/server/ssr/index.js'

Root cause

Vite defaults environments.ssr.build.emitAssets to false for environments with consumer: "server" (the SSR env's default). With code-split CSS (Vite's default), Vite's CSS plugin still:

  1. Rewrites server-component CSS imports into import "<hash>.css" statements in the SSR JS chunk
  2. Emits the CSS asset via emitFile

But Vite's vite:asset generateBundle hook then strips all asset files from the bundle because emitAssets is false. The dangling import then crashes Node's ESM loader at vinext start time.

@vitejs/plugin-rsc already sets emitAssets: true on the rsc environment for the same reason — but does not extend that fix to the ssr environment.

Fix

Set build.ssrEmitAssets: true at the top level of vinext's vite config. This propagates into environments.ssr.build.emitAssets for both the App Router SSR environment and the Pages Router SSR environment.

Why the top-level form

build.ssrEmitAssets is the documented Vite API for this exact case. Setting environments.ssr.build.emitAssets directly does not work because Vite re-applies the top-level value during resolveConfig and overwrites any per-environment value we set in the config hook.

// Vite resolveConfig, near the end:
if (!resolved.builder?.sharedConfigBuild && resolved.environments.ssr) {
  resolved.environments.ssr.build.emitAssets =
    resolved.build.ssrEmitAssets || resolved.build.emitAssets;
}

The App Router path happens to escape this override because @vitejs/plugin-rsc sets builder.sharedConfigBuild: true. The Pages Router path does not, so a per-env-only fix would silently regress the Pages Router case.

Affected deploy fixtures

test/e2e/app-dir/scss/*
test/e2e/app-dir/next-dynamic-css/
test/e2e/app-dir/css-data-url-global-pages/

…and the broader scss/** family that imports global CSS from server components or root layouts.

Tests

tests/ssr-css-assets.test.ts:

  1. Asserts the App Router SSR environment resolves to emitAssets: true.
  2. Asserts the Pages Router SSR environment resolves to emitAssets: true.
  3. Builds a minimal App Router fixture with global CSS imported from both a root layout and a server-component page, then verifies every import "<spec>.css" statement in dist/server/ssr/*.js resolves to a file that exists on disk.

All three tests fail on main and pass with this change.

Related

Category A4 in the deploy-suite e2e review.

Generated as part of the deploy-suite fix sweep.

Set `build.ssrEmitAssets: true` so the SSR environment writes asset
files (CSS, etc.) that JS chunks reference. Vite defaults
`emitAssets` to `false` for environments with `consumer: "server"`,
so when the CSS plugin rewrites a server-component CSS import into
`import "<hash>.css"` and emits the CSS asset, the asset file is
subsequently stripped from the bundle by Vite's asset-cleanup hook.
The dangling import then crashes `vinext start` with
ERR_MODULE_NOT_FOUND on `dist/server/ssr/style.css`.

We set the top-level `build.ssrEmitAssets` rather than
`environments.ssr.build.emitAssets` because Vite re-applies the
top-level value during `resolveConfig` and would otherwise overwrite
any per-environment value. Mirrors `@vitejs/plugin-rsc`'s existing
`emitAssets: true` on the `rsc` environment.

Adds tests asserting both the App Router and Pages Router SSR
environments resolve to `emitAssets: true` and that every CSS import
in the SSR build output resolves to a real file on disk.
@pkg-pr-new

pkg-pr-new Bot commented May 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1304

commit: 30b5758

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The fix is correct, well-scoped, and thoroughly tested.

Verification

Root cause confirmed in Vite source. I traced the exact code path in the installed @voidzero-dev/vite-plus-core:

  1. node.js:38430emitAssets defaults to consumer === "client", so false for the SSR env
  2. node.js:39884-39887config.build.ssrEmitAssets propagates into configEnvironmentsSsr.build.emitAssets during early env config merging
  3. node.js:40108 — A late override applies resolved.build.ssrEmitAssets || resolved.build.emitAssets to environments.ssr, but only when !resolved.builder?.sharedConfigBuild

Why build.ssrEmitAssets is the right form:

  • @vitejs/plugin-rsc sets builder.sharedConfigBuild: true (plugin-rsc plugin-BhzHKRFo.js:737), which disables the late override at step 3
  • But step 2 still runs regardless of sharedConfigBuild, so ssrEmitAssets: true correctly propagates in both the App Router path (sharedConfigBuild=true) and the Pages Router path (sharedConfigBuild=false/undefined)
  • A per-environment environments.ssr.build.emitAssets would work for App Router (because sharedConfigBuild skips the late override), but would be clobbered by the late override in the Pages Router path — exactly as the PR description explains

@vitejs/plugin-rsc only covers rsc, not ssr: Confirmed at plugin-BhzHKRFo.js:715 — the RSC plugin sets emitAssets: true on the rsc environment config but not on ssr. This PR fills that gap.

Tests

All three tests pass locally (1.15s). The test structure is sound:

  1. Config-level assertions for both App Router and Pages Router SSR envs confirm the setting propagates
  2. The integration test (full createBuilder + buildApp) verifies the end-to-end behavior — every import "*.css" in SSR chunks resolves to a file on disk

The fixture construction is clean (tmpdir + symlinked node_modules + cleanup in finally blocks).

No concerns.

@ask-bonk

ask-bonk Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Reviewed and approved PR #1304. The fix is correct — build.ssrEmitAssets: true is the right API to use here. I verified the full Vite resolveConfig code path in the installed runtime to confirm:

  1. The default emitAssets: false for server consumer environments is the root cause
  2. ssrEmitAssets propagates correctly in both the App Router path (where sharedConfigBuild: true skips the late override but the early merge at line 39884 still applies) and the Pages Router path (where the late override at line 40108 also applies it)
  3. @vitejs/plugin-rsc only sets emitAssets: true on the rsc environment, not ssr — this PR fills that gap
  4. All three tests pass locally

github run

@james-elicx
james-elicx marked this pull request as ready for review May 18, 2026 11:41
@james-elicx
james-elicx merged commit 69c72b0 into main May 18, 2026
36 checks passed
@james-elicx
james-elicx deleted the fix/deploy-suite-ssr-css-asset branch May 18, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant