Run this yourself. The full three-environment vite.config.ts from this guide:
vite-6-environment-api/in the Coding Dunia code-examples repo.
The Vite Environment API is a Vite 6 feature that lets a single config file define multiple build targets, client, server-side rendering, and edge, as named environments, each with its own module resolution, plugins, and build output. A common pre-6 setup was 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 nothing got missed. The Environment API collapses that into a single config with three named environments.
Quick take: 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.
What Problem Do Multiple Runtimes Create in One App?
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). Every extra target also costs you shipped bytes, which is why it pays to watch bundle size budgets in CI once more than one build comes out of the same config.
Most of these exports condition mismatches trace back to how your compiler is configured, not to Vite itself. Rather than hand-tune moduleResolution and lib by trial and error, our tsconfig generator builds a known-good config from a few toggles, so you can start from a baseline that already matches your target runtime and adjust from there.
How Do You Define 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.
Per the Vite 6 release notes, the config above defines three named environments in roughly 30 lines, replacing what used to take three separate config files of comparable length each. The client environment needs no explicit resolve.conditions override because Vite's default already targets the browser. The ssr environment sets conditions: ['node'] so dependencies resolve their Node build. The edge environment sets conditions: ['worker', 'browser'] plus noExternal: true, because most edge runtimes, Cloudflare Workers included, cannot resolve packages from node_modules at request time and need everything bundled into the output file ahead of deploy.
I set up exactly this config with three trivial entry files and ran vite build --app (Vite 6.4.3, Node 22.23.2) to confirm it actually builds all three targets from the one config, not just that the types check:
$ npx vite build --app
vite v6.4.3 building for production...
✓ 1 modules transformed.
dist/client/assets/entry-client-l0sNRNKZ.js 0.00 kB │ gzip: 0.02 kB
✓ built in 16ms
vite v6.4.3 building SSR bundle for production...
✓ 1 modules transformed.
dist/server/entry-server.mjs 0.08 kB
✓ built in 5ms
vite v6.4.3 building SSR bundle for production...
✓ 1 modules transformed.
dist/edge/entry-edge.mjs 0.07 kB
✓ built in 3ms
One command, three separate dist/{client,server,edge} output directories, each respecting its own resolve.conditions and outDir. That --app flag is what tells Vite to build every named environment in the config instead of just the default client target, easy to miss in the docs since it's not needed for a plain single-target build.
Why Does This Matter 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.
How Do You Run 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.
According to the Vite Environment API guide, server.environments exposes one entry per named environment defined in the config, each with its own transformRequest method that runs a module through that environment's specific plugin pipeline and module resolution. That matters for tooling authors because it means a test runner can execute a file exactly as the SSR build would, including any SSR-only transforms a plugin applies, instead of falling back to a generic Node require call that skips those transforms entirely. Vitest's browser mode and its SSR test environment both lean on this API rather than reimplementing Vite's transform pipeline from scratch. In practice, this is the single biggest reason Vitest test results match production SSR behavior more closely in Vite 6 than they did in Vite 5, where the test runner and the SSR build could diverge on edge-case module resolution.
How Does the Old Way Compare to the Modern Way?
A resolve condition is an entry in Vite's resolve.conditions array that determines which package.json exports field a dependency resolves to for a given environment, for example node, worker, or browser. Getting this wrong per target used to be the single most common source of "works locally, breaks in production" bugs in multi-runtime apps, according to the Vite 6 release notes.
| 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 |
To check whether your project would benefit from migrating to named environments, walk through this quick sequence:
- Count how many separate Vite config files or
if (isSSR)branches your build currently has. - Check whether any dependency resolves differently between your client and server bundles, a common sign of an
exportscondition mismatch. - If both are true, define one
environmentsblock with per-targetresolve.conditionsinstead of maintaining separate configs.
When Should You 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. The same reasoning applies one layer up, in the TypeScript 7 migration: once the compiler resolves your modules the way the runtime does, the number of places a config can disagree with itself drops.
Have you debugged a dependency that resolved differently in your SSR bundle than your client bundle, and spent an hour before realizing it was an exports condition mismatch? That's exactly the class of bug per-environment resolve.conditions is built to surface at build time instead of in production.