Production Builds, the Builder API, and Vite's Module Runner for SSR
Prerequisites
- ›Articles 1-4: Full understanding of architecture, config, dev server, HMR, and module graph
- ›Rollup/Rolldown bundling concepts (input options, output options, chunks, code splitting)
- ›SSR concepts (server-side rendering, module evaluation)
Production Builds, the Builder API, and Vite's Module Runner for SSR
Vite's dev server serves unbundled ESM, but production demands bundled, minified, code-split output. Vite 8 delegates this to Rolldown — and with the Environment API, a single vite build invocation can now produce output for multiple targets (client, SSR, edge workers) in a coordinated pipeline. This article covers the build pipeline, the Module Runner for SSR, and the experimental full-bundle dev mode that blurs the line between dev and production.
The Builder API and Multi-Environment Orchestration
When you run vite build, the CLI handler on lines 343-382 creates a builder and calls buildApp():
const { createBuilder } = await import('./build')
const builder = await createBuilder(inlineConfig, null)
await builder.buildApp()
The createBuilder() function resolves the config, then sets up a BuildEnvironment for each entry in config.environments:
flowchart TD
A["createBuilder(inlineConfig)"] --> B["resolveConfigToBuild()"]
B --> C{"builder config provided?"}
C -->|"yes (--app)"| D["Create BuildEnvironment per environment"]
C -->|"no (legacy)"| E["Create single BuildEnvironment<br/>(client or ssr)"]
D --> F["ViteBuilder { environments, build, buildApp }"]
E --> F
F --> G["builder.buildApp()"]
G --> H["Run 'buildApp' plugin hooks (pre + normal)"]
H --> I["config.builder.buildApp(builder)"]
I --> J["Run 'buildApp' plugin hooks (post)"]
J --> K{"Any environment built?"}
K -->|yes| L["Done"]
K -->|no| M["Fallback: build all environments sequentially"]
The buildApp() method has an interesting orchestration pattern. Plugin buildApp hooks run in order, with the user's config.builder.buildApp callback inserted between normal and post hooks. If no environment gets built by any hook, the fallback kicks in and builds them all sequentially.
A key design consideration is plugin instance management during multi-environment builds. By default, each environment gets its own re-resolved config and fresh plugin instances (lines 1860-1914):
if (!configBuilder.sharedConfigBuild) {
environmentConfig = await resolveConfigToBuild(
inlineConfig, patchConfig, patchPlugins,
)
}
The sharedPlugins option and per-plugin sharedDuringBuild flag opt into sharing plugin instances across environments. The patchPlugins function handles this by replacing newly created plugin instances with the shared ones from the initial config resolution.
BuildEnvironment and Rolldown Integration
BuildEnvironment is intentionally lightweight compared to DevEnvironment — no module graph, no plugin container, no deps optimizer:
export class BuildEnvironment extends BaseEnvironment {
mode = 'build' as const
isBuilt = false
constructor(name, config, setup?) {
// merge environment options, call super
}
async init(): Promise<void> {
if (this._initiated) return
this._initiated = true
}
}
The actual bundling happens in buildEnvironment() which calls resolveRolldownOptions() to map Vite's config to Rolldown's input/output format:
sequenceDiagram
participant B as builder.build(env)
participant BE as buildEnvironment()
participant RO as resolveRolldownOptions()
participant RD as Rolldown
participant P as Plugins
B->>BE: buildEnvironment(environment)
BE->>RO: resolveRolldownOptions(environment)
Note over RO: Map config.build → Rolldown input<br/>Resolve entry points<br/>Configure output format<br/>Inject environment into plugins
RO-->>BE: RolldownOptions
BE->>RD: rolldown(options)
RD-->>BE: bundle
BE->>RD: bundle.write() or bundle.generate()
RD->>P: generateBundle / writeBundle hooks
RD-->>BE: RolldownOutput
The resolveRolldownOptions() function determines entry points based on config — using index.html for client builds, an explicit entry for library mode, and rollupOptions.input as an override. It wraps each plugin with injectEnvironmentToHooks() to ensure this.environment is available in every plugin hook.
Tip: The
ViteBuilderinterface exposesbuild(environment)for individual environment builds. Framework integrations can usebuildApphooks to control build ordering — for example, building the client first to collect an asset manifest, then passing it to the SSR build.
Build-Specific Plugins
The resolveBuildPlugins() function assembles plugins that only run during production builds:
flowchart LR
subgraph "pre plugins"
A1[prepareOutDir]
A2[rollup-options-plugins]
A3[webWorkerPost]
end
subgraph "post plugins"
B1[importAnalysisBuild]
B2[esbuild minifier]
B3[terser minifier]
B4[license extraction]
B5[manifest generation]
B6[ssrManifest]
B7[buildReporter]
B8[loadFallback - native]
end
The buildImportAnalysisPlugin is the build-time counterpart to the dev-only importAnalysisPlugin we saw in Part 3. Where the dev plugin rewrites imports to serve unbundled ESM, the build plugin handles module preloading — injecting <link rel="modulepreload"> hints and __vitePreload() wrappers so that dynamic imports don't waterfall.
Note the native Rolldown plugin nativeLoadFallbackPlugin() at the end of the post-plugins — it provides filesystem loading for any module that no plugin handled, a safety net for the build process.
CSS Processing in Build Mode
CSS processing uses a three-plugin pipeline that behaves differently in dev vs. build:
flowchart TD
subgraph "cssPlugin (transform)"
A1["Preprocessor: Sass/Less/Stylus"] --> A2["PostCSS transforms"]
A2 --> A3["CSS Modules scoping"]
A3 --> A4["URL rewriting"]
end
subgraph "cssPostPlugin (renderChunk)"
B1["Extract CSS from JS chunks"]
B1 --> B2["Generate .css asset files"]
B2 --> B3["Minify with LightningCSS"]
end
subgraph "cssAnalysisPlugin (dev only)"
C1["Track CSS dependencies"]
C1 --> C2["HMR support for CSS"]
end
A4 --> B1
The cssPlugin handles the transform phase — running preprocessors, PostCSS, CSS Modules scoping, and URL rewriting. During build, the cssPostPlugin handles chunk extraction: when build.cssCodeSplit is true (the default), each JS chunk gets a corresponding CSS file. When false, all CSS is concatenated into a single file.
CSS minification in Vite 8 defaults to LightningCSS for server environments and follows the build.minify setting for client environments. The build.cssMinify option allows explicit control.
The Module Runner: SSR Module Execution
The ModuleRunner is Vite's solution for executing server-side modules during development. It fetches transformed code from a DevEnvironment, evaluates it, and maintains a module cache — all with HMR support.
sequenceDiagram
participant App as SSR Application
participant MR as ModuleRunner
participant T as Transport
participant DE as DevEnvironment
participant PC as pluginContainer
App->>MR: runner.import('/src/server.ts')
MR->>T: fetchModule('/src/server.ts')
T->>DE: environment.fetchModule(id)
DE->>PC: transformRequest(url)
PC-->>DE: TransformResult { code, map }
DE-->>T: FetchResult { code }
T-->>MR: FetchResult
MR->>MR: ESModulesEvaluator.runExternalModule(code)
Note over MR: new AsyncFunction(ssrImport, ssrExport, ...)(code)
MR-->>App: module.exports
The constructor on lines 53-80 sets up the transport and optionally creates an HMRClient:
constructor(
public options: ModuleRunnerOptions,
public evaluator: ModuleEvaluator = new ESModulesEvaluator(),
) {
this.transport = normalizeModuleRunnerTransport(options.transport)
if (options.hmr !== false) {
this.hmrClient = new HMRClient(
resolvedHmrLogger,
this.transport,
({ acceptedPath }) => this.import(acceptedPath),
)
this.transport.connect(createHMRHandlerForRunner(this))
}
}
The ESModulesEvaluator converts the transformed code into a function that uses Vite's SSR module format — with __vite_ssr_import__, __vite_ssr_export__, and __vite_ssr_import_meta__ as the interop mechanism.
The fetchModule() function on the server side bridges runner requests to the environment's transform pipeline. It first checks if the module is a Node.js builtin or external URL, in which case it returns an externalize result. Otherwise, it calls environment.transformRequest(url) and returns the code.
Runnable and Fetchable Environments
Vite provides two pre-built SSR environment factories, representing two different execution models:
RunnableDevEnvironment runs modules in the same Node.js process as the Vite server. It uses createServerHotChannel() for in-process HMR communication (no WebSocket needed):
export function createRunnableDevEnvironment(
name, config, context = {},
): RunnableDevEnvironment {
if (context.transport == null) {
context.transport = createServerHotChannel()
}
return new RunnableDevEnvironment(name, config, context)
}
FetchableDevEnvironment targets remote runtimes (edge workers, Cloudflare Workers) where modules can't run in-process. It requires a handleRequest function and communicates via the Fetch API:
export function createFetchableDevEnvironment(
name, config, context,
): FetchableDevEnvironment {
if (!context.handleRequest) {
throw new TypeError(
'FetchableDevEnvironment requires a `handleRequest` method'
)
}
return new FetchableDevEnvironment(name, config, context)
}
flowchart TD
subgraph "In-process SSR"
A[RunnableDevEnvironment] --> B[createServerHotChannel]
B --> C[ModuleRunner<br/>same Node.js process]
end
subgraph "Remote SSR"
D[FetchableDevEnvironment] --> E[Custom Transport]
E --> F[ModuleRunner<br/>edge/worker runtime]
end
subgraph "Shared"
G[DevEnvironment base class]
end
G --> A
G --> D
Tip: Framework authors should use
isRunnableDevEnvironment()andisFetchableDevEnvironment()type guards to determine the environment type at runtime, rather than checking class names.
Experimental Full-Bundle Dev Mode
The FullBundleDevEnvironment is an experimental alternative to unbundled dev serving. Enabled via --experimentalBundle or experimental.bundledDev: true, it uses Rolldown's dev() API to bundle your application during development and serve from memory:
import { dev } from 'rolldown/experimental'
export class FullBundleDevEnvironment extends DevEnvironment {
private devEngine!: DevEngine
memoryFiles: MemoryFiles = new MemoryFiles()
// ...
}
The MemoryFiles class stores bundled output in a Map<string, MemoryFile>, and the memoryFilesMiddleware serves these files to the browser. The DevEngine provides HMR through Rolldown's own change detection, bypassing Vite's module graph entirely.
This mode represents a potential future direction for Vite — the dev/production gap narrows when both modes use the same bundler. The tradeoffs are clear:
| Unbundled Dev | Bundled Dev | Production Build | |
|---|---|---|---|
| Startup | Fast (no bundling) | Slower (initial bundle) | Slowest (full optimization) |
| HMR | Module-level granularity | Bundle-level (via Rolldown) | N/A |
| Dev/Prod Parity | Some differences | High parity | — |
| Status | Stable | Experimental | Stable |
The bundled dev mode currently has limitations — the handleHotUpdate/hotUpdate plugin hooks aren't fully supported yet, and the module graph integration is incomplete. But it demonstrates where the architecture is heading: with Rolldown fast enough for dev, the separate dev/build codepaths may eventually converge.
Series Conclusion
Over these six articles, we've traced Vite 8's architecture from the CLI entry point through configuration resolution, the dev server's middleware stack and transform pipeline, HMR and dependency pre-bundling, and finally production builds and SSR module execution. The key architectural insights are:
- The Environment API is the foundational abstraction — every subsystem (module graph, plugin container, deps optimizer, hot channel) is per-environment
- The Proxy-based config merging in
PartialEnvironmentmakes environment-specific config transparent to plugins - The plugin container emulates Rollup's execution model during dev, letting the same plugins work across dev and build
- Rolldown unification eliminates the esbuild/Rollup split, providing consistent behavior across dependency optimization and production bundling
- The shared HMR protocol in
src/shared/ensures identical HMR semantics in browsers and SSR runners
The codebase is well-structured despite its complexity — understanding these six layers gives you the vocabulary to navigate any corner of it.