I maintained a project with three Vite config files, one for the client bundle, one for SSR, one for a Cloudflare Workers edge deploy, each duplicating most of the same plugin list with small variations. Every plugin update meant editing three files and hoping I didn't miss one. The Environment API collapsed that into a single config with three named environments.
TL;DR: Vite 6's Environment API lets one config define multiple build targets, client, SSR, edge, as named "environments," each with its own module resolution, plugins, and output, all within a single Vite instance instead of separate config files stitched together. Most app developers won't touch it directly, their framework (Astro, Remix, and others) already uses it internally, but understanding it explains why multi-target builds got noticeably faster and more consistent in 2026.
The Problem: One App, Multiple Runtimes
A typical SSR app runs in at least two different JavaScript environments: the browser, where React hydrates and DOM APIs exist, and the server (Node.js, or an edge runtime like Cloudflare Workers), where there's no window and module resolution rules can differ. Before Vite 6, handling both meant either:
- Running two separate Vite dev servers and gluing their output together, or
- One config file full of
if (isSSR) { ... } else { ... }branches for plugins, aliases, and build targets.
Neither scales cleanly once you add a third target, an edge runtime with its own module resolution quirks (no Node built-ins, different exports conditions in package.json).
Defining Named Environments
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
environments: {
client: {
build: {
outDir: 'dist/client',
rollupOptions: { input: './src/entry-client.tsx' },
},
},
ssr: {
build: {
outDir: 'dist/server',
rollupOptions: { input: './src/entry-server.tsx' },
ssr: true,
},
resolve: {
conditions: ['node'],
},
},
edge: {
build: {
outDir: 'dist/edge',
rollupOptions: { input: './src/entry-edge.tsx' },
},
resolve: {
conditions: ['worker', 'browser'],
noExternal: true, // bundle everything, edge runtimes often can't resolve node_modules at runtime
},
},
},
plugins: [react()],
});
Each environment gets its own resolve.conditions, which determines which package.json exports field wins when a dependency ships different code for Node versus the browser versus edge runtimes. That's the detail that used to cause the most subtle bugs, a package resolving to its Node build inside an edge bundle, then crashing at runtime on a missing fs import.
Why This Matters Even If You Don't Write the Config
If you use Astro, the framework already defines environments like this internally for its SSR adapters (Node, Cloudflare, Vercel edge). The practical result: switching your Astro output mode or adapter no longer means Vite re-resolving your entire dependency graph from scratch with a different config, it reuses shared module graph analysis across environments where possible. That's part of why cold starts and rebuild times on multi-target Astro projects got faster through 2026 without any change to your own astro.config.mjs.
Running an Environment-Specific Dev Server Programmatically
For tooling authors (framework builders, test runners), the Environment API exposes a programmatic way to run a specific environment's module graph without spinning up a full dev server per target:
import { createServer } from 'vite';
const server = await createServer({
configFile: './vite.config.ts',
});
// Run a module through the 'ssr' environment specifically
const ssrEnv = server.environments.ssr;
const mod = await ssrEnv.transformRequest('/src/entry-server.tsx');
Most app developers never call this directly, it's the layer test runners like Vitest use to execute your code the same way Vite would at runtime, including SSR-specific transforms, instead of running your source through a generic Node require.
Old Way vs Modern Way
| Task | Vite 5 and earlier | Vite 6 Environment API |
|---|---|---|
| Client + SSR build | Two config files or heavy conditionals | Two named environments in one config |
| Edge runtime module resolution | Manual resolve.conditions overrides per build script | Per-environment resolve.conditions |
Dependency exports mismatches | Discovered at runtime, often in production | Caught at build time per environment |
| Plugin list maintenance | Duplicated across config files | Shared plugin list, per-environment overrides only where needed |
When to Reach for This Directly
Most teams shipping a single-target SPA or a standard SSR app through a mainstream meta-framework never need to write environment configs by hand, the framework owns that layer. Reach for it directly when you're building a custom deploy target the framework doesn't support yet, or when you're authoring a Vite plugin or framework yourself and need consistent module resolution across more than one runtime.
Conclusion
The Environment API doesn't change what Vite builds, it changes how cleanly you can describe multiple targets from one place. If you've ever debugged why a dependency resolved differently in your SSR bundle than your client bundle, that's exactly the class of bug per-environment resolve.conditions is built to catch at build time instead of in production.