Building Separate Module Graphs for Browser, Worker, and Edge Environments with Vite 6 Environment API and Creating Custom Environment Plugins
Written based on official documentation as of September 2026, targeting Vite 6.x.
If you've written plugins up through Vite 5, you eventually hit a point where distinguishing two worlds with a single ssr: boolean parameter feels clumsy. I initially thought, "Well, client and ssr — isn't two enough?" But that changed when I built a full-stack app deployed to Cloudflare Workers. SSR code ran on Node.js in the dev server, while production ran on workerd (Cloudflare's Workers runtime), so "works in dev, breaks in production" kept happening.
Vite 6's Environment API was introduced specifically to solve this problem structurally. Within a single Vite dev server, multiple runtime environments — browser, service worker, SSR server, edge functions — can run simultaneously, each with its own independent module graph. Plugins can access this.environment to know which environment they're currently executing in, enabling far clearer code than branching on a ssr boolean.
This article covers why the Environment API was designed the way it is, how to define custom environments and use them in plugins, and what to watch out for in practice. Worth noting upfront: Vite's official docs still mark this API as Experimental, and the Move to Per-environment APIs guide advises waiting for stabilization before using it in production, as the API may change in future releases.
How Module Graphs Got Mixed in Vite 5
The Limits of a Single ModuleGraph
Up through Vite 5, a single server.moduleGraph handled everything. Internally, each ModuleNode maintained two lists side by side — clientImportedModules and ssrImportedModules — and this structure caused more problems than expected.
For example, if utils.ts is imported by both the client and SSR, both states coexist within a single node. When an HMR update fires, computing the invalidation scope, managing the cache, and storing transformed source all suffer from blurry boundaries between the two environments. I've personally spent a long time debugging cases where code transformed for SSR leaked into the client module graph.
Vite 6 flips this structure entirely. Each environment owns its own ModuleGraph instance, and transformed source code along with dependency relationships are recorded only in that environment's graph. With module state isolated between environments, bugs like "code transformed in environment A affecting environment B's cache" are eliminated at the root.
DevEnvironment and BuildEnvironment
During development, each environment is represented as an instance of the DevEnvironment class. During build, BuildEnvironment is used, and each environment's build proceeds sequentially within a single process. Two environments — client and ssr — are provided by default, and additional environments are registered via the environments configuration.
Defining Custom Environments
Declaring Environments in vite.config.ts
// vite.config.ts
import { defineConfig } from 'vite'
import { cloudflare } from '@cloudflare/vite-plugin'
export default defineConfig({
plugins: [cloudflare()],
environments: {
client: {
// Browser defaults — no additional config needed
},
serviceWorker: {
consumer: 'client',
build: {
outDir: 'dist/sw',
},
},
edge: {
resolve: {
conditions: ['workerd', 'worker'],
},
},
},
})The serviceWorker environment is configured with consumer: 'client'. This means the environment's output is loaded in the browser, so Vite activates the browser export condition in default resolve conditions and processes the output similarly to the optimizeDeps client pipeline. Conversely, environments with consumer: 'server' (the default) prioritize Node/worker-style conditions. The edge environment explicitly adds workerd and worker resolve conditions to enable module resolution tailored for Cloudflare Workers.
Understanding Relationships Between Environments
Each environment has an independent module graph and transformation pipeline, yet they coexist within a single Vite dev server process. The consumer value is a property assigned to an environment definition, not a data flow between runtimes, so it's shown only as a node label in the diagram above. This structure is precisely what makes it possible for plugins like the Cloudflare plugin to run the actual Workers runtime (workerd) directly inside the dev server.
Writing Environment-Aware Plugins
Branching with this.environment
The difference between the old ssr: boolean pattern and the new one is clear when compared side by side.
// Vite 5 style — branching only on ssr boolean
const oldPlugin = () => ({
name: 'old-style-plugin',
transform(code, id, options) {
if (options?.ssr) {
// SSR handling
} else {
// Browser handling
}
},
})
// Vite 6 style — branching with this.environment
const newPlugin = () => ({
name: 'env-aware-plugin',
transform(code, id) {
const envName = this.environment.name
if (envName === 'client') {
// Browser-only handling
} else if (envName === 'ssr') {
// Node.js SSR-only handling
} else if (envName === 'edge') {
// Edge runtime-only handling
}
},
})ssr: boolean can only distinguish two cases, but this.environment.name can distinguish as many cases as there are registered environments. When a third or fourth environment is added, you don't need to change the plugin signature.
Controlling Per-Environment Config with the configEnvironment Hook
const edgePlugin = () => ({
name: 'edge-config-plugin',
configEnvironment(name, _options) {
if (name === 'edge') {
return {
resolve: {
conditions: ['workerd', 'worker', 'browser'],
},
build: {
target: 'es2022',
},
}
}
},
transform(code, _id) {
if (this.environment.name === 'edge') {
// Replace process.env references with constants for edge runtime compatibility
return code.replace(/process\.env\.NODE_ENV/g, '"production"')
}
},
})The configEnvironment(name, options) hook receives the environment name and the currently merged environment options, and returns additional configuration. Throughout the other examples in this article, the second parameter is consistently named _options.
Customizing HMR with the hotUpdate Hook
The options object of the hotUpdate hook includes file, timestamp, modules, read, server, and type. The current environment is accessed via this.environment on the hook context, not as an option. The example below follows this convention.
const customHmrPlugin = () => ({
name: 'custom-hmr-plugin',
hotUpdate({ modules, timestamp }) {
const environment = this.environment
if (environment.name !== 'client') return
const affectedModules = modules.filter(
(m) => m.id?.includes('/components/')
)
if (affectedModules.length > 0) {
environment.hot.send({
type: 'custom',
event: 'component-updated',
data: {
timestamp,
modules: affectedModules.map((m) => m.id),
},
})
}
},
})Using this.environment.name to identify which environment's HMR is firing lets you cleanly isolate HMR logic that should only run in specific environments.
Changes to Plugin State Management Patterns
This is the part most often missed during migration. If an existing plugin manages state with module-level variables, and you need state that's independent per environment, you need to switch to a WeakMap<Environment, State> pattern.
import { createHash } from 'node:crypto'
// Old approach — all environments end up sharing state
const cache = new Map<string, string>()
const oldCachePlugin = () => ({
name: 'old-cache-plugin',
transform(code, id) {
if (cache.has(id)) return cache.get(id)
const result = heavyTransform(code)
cache.set(id, result)
return result
},
})
// Vite 6 — cache separated per environment
const envCachePlugin = () => {
const cacheByEnv = new WeakMap<object, Map<string, string>>()
return {
name: 'env-cache-plugin',
transform(code, id) {
const env = this.environment
if (!cacheByEnv.has(env)) {
cacheByEnv.set(env, new Map())
}
const cache = cacheByEnv.get(env)!
if (cache.has(id)) return cache.get(id)
const result = heavyTransform(code)
cache.set(id, result)
return result
},
}
}
function heavyTransform(code: string): string {
// Conceptual example: inject a build hash banner at the top of the file
const hash = createHash('sha256').update(code).digest('hex').slice(0, 8)
return `/* build:${hash} */\n${code.replace(/__DEV__/g, 'false')}`
}Using environment instances as WeakMap keys means they're automatically garbage collected when an environment is torn down, preventing memory leaks as well.
Connecting a Cloudflare Workers Environment for Real
@cloudflare/vite-plugin is a prime real-world example of Environment API in action. Per the official Changelog, it went GA in April 2025, running your code on the actual workerd runtime during development.
// vite.config.ts
import { defineConfig } from 'vite'
import { cloudflare } from '@cloudflare/vite-plugin'
export default defineConfig({
plugins: [cloudflare()],
})This single line integrates the Workers environment into the Vite dev server, with HMR support included. It's a structural mitigation for the problem of code running on Node.js in development but behaving differently on workerd in production.
If you want to detect the Workers environment inside a plugin, you must match the environment key you register and look up exactly to what's in the config. If you defined the environment name as edge in the earlier examples, the plugin side must use edge as well.
const edgeAwarePlugin = () => ({
name: 'edge-aware-plugin',
configEnvironment(name, _options) {
if (name === 'edge') {
return {
resolve: {
conditions: ['workerd', 'worker'],
},
}
}
},
transform(_code, _id) {
if (this.environment.name === 'edge') {
// Code transformation tailored for the Workers runtime
}
},
})If you want to use the environment name registered by a separate plugin (e.g., @cloudflare/vite-plugin), you need to look up the actual key that plugin registers (e.g., worker). Assuming a name arbitrarily and writing code around it is an easy way to create a bug where a condition is never true.
Criteria for Deciding Whether to Migrate a Plugin
The Vite team's migration docs recommend that if you maintain a publicly distributed plugin library, don't switch immediately — wait for the API to stabilize. For internal project plugins, you can start transitioning incrementally.
Tradeoffs Summary
| Item | Details |
|---|---|
| Production parity | Running actual runtimes (workerd, Deno, Bun, etc.) in the dev server reduces behavioral differences between dev and prod |
| Module graph isolation | Module state doesn't bleed between environments, making debugging easier and preventing dependency leaks |
| Single-process builds | Plugin reuse and centralized config are easier, but environment builds proceed sequentially — parallel builds require separate orchestration |
| Backward compatibility | SPAs/MPAs can migrate to Vite 6 without major code changes. The legacy ssr boolean API is retained for now |
| API stability | Marked experimental per official docs. Signatures may change before stabilization (see: Move to Per-environment APIs) |
| Migration complexity | Plugins using server.moduleGraph, the ssr boolean, or server.transformRequest require modification |
| Conceptual complexity | As environments multiply, config management, inter-environment dependencies, and build ordering become more involved |
| Plugin state management | Module-level shared state must be converted to the WeakMap<Environment, State> pattern |
Honestly, if you're maintaining a plugin distributed as a public npm package, switching to the Environment API right now carries real risk. It's better to wait until the API stabilizes through the experimental period downstream projects go through. On the other hand, if you're building internal tools or a framework yourself, now is a great time to get familiar with the API.
Common Pitfalls
Simply swapping ssr boolean for this.environment.name === 'ssr': When new environments (edge, serviceWorker, etc.) are added, you need to explicitly think about how the plugin should behave in those environments. Be careful that existing else branches don't silently handle unexpected environments.
Sharing plugin state at the module level: If client and edge share the same cache, transformed code can flow into the wrong environment. Designing per-environment state separation from the start is far easier than retrofitting it.
Mismatched environment name between config and plugin: If you define an environment as edge but check for worker in the plugin, the condition will never be true. Pull the name into a constant and reuse it, or at minimum develop the habit of keeping config and plugin code side by side for verification.
Assuming how configEnvironment return values are merged: Check Vite's internal implementation and official docs for the actual merge rules. Certain options may get overwritten in ways you don't expect.
Wrapping Up — Things to Experiment with Now
The Environment API marks a turning point in Vite's evolution from a simple bundler into a development platform that orchestrates multiple runtimes. Running browser, service worker, SSR, and edge functions in a single dev server with separate module graphs per environment narrows the gap between development and production. The Cloudflare plugin going GA, combined with growing framework-level Vite 6 adoption, makes the direction clear. (Astro, for instance, explicitly noted Vite 6 adoption in the Astro 5 release notes; for the extent of Environment API usage in subsequent Astro releases, checking each release note directly is the most accurate approach.)
Here are three experimental ideas to build intuition quickly:
- Pick one of your internal Vite plugins and log
this.environment.nameinstead ofoptions.ssr— observe which environments call it and when. - Add
environments.edgeto your config withworkerdinresolve.conditionsand see how the existing import graph gets reinterpreted. - Apply the
WeakMap<Environment, Map<string, string>>cache pattern, then verify that the cache is separated when the same file is requested from two environments — without restarting the dev server.
Just running through these three gives you a hands-on feel for the difference from the ssr boolean era. If you maintain a public library, wait for stabilization — but use that time to get comfortable with the concepts and actual hook behavior, so migration goes smoothly once the API solidifies.
References
- Environment API | Vite Official Docs
- Environment API for Plugins | Vite Official Docs
- Environment API for Runtimes | Vite Official Docs
- Using Environment Instances | Vite Official Docs
- Move to Per-environment APIs | Vite Migration Guide
- Vite 6.0 is out! | Vite Official Blog
- Migration from v5 | Vite
- Vite Environments · Cloudflare Workers Official Docs
- Just use Vite… with the Workers runtime | Cloudflare Blog
- The Cloudflare Vite plugin is now Generally Available | Cloudflare Changelog
- Astro 5 Release Notes
- Why Vite 6 is a groundbreaking release | Vike Blog
- Environment API · vitejs/vite GitHub Discussion #16358