Vite Environment API

How Vite’s Environment API works, verified against the source code in _repos/vitejs-vite/.

The Environment API formalizes support for multiple runtime environments within a single Vite instance. Introduced in Vite 6 and matured through Vite 7-8, it replaces the hardcoded client/SSR split with a flexible, extensible system.

The Problem: Hardcoded Client/SSR Split

Before environments, Vite had exactly two modes: client and SSR. The dev server maintained two module graphs, two transform pipelines, and two sets of assumptions baked into the code.

This worked for simple cases, but frameworks hit walls:

  • Nuxt needs edge runtime targets alongside Node.js SSR
  • Remix deploys to Cloudflare Workers, Deno, and Node.js from the same project
  • Astro runs components in multiple runtimes depending on the island

Each framework hacked around the limitation differently. The Environment API provides a first-class solution.

What an Environment Is

An environment is a named runtime target with its own module graph, dev pipeline, and build config. Vite creates two by default:

EnvironmentPurpose
clientBrowser-targeted code (the traditional Vite dev experience)
ssrServer-side rendering (Node.js by default)

You can add custom environments for any runtime: edge workers, service workers, Deno, Bun, or anything else.

Each environment gets:

  • Its own EnvironmentModuleGraph (import tracking, HMR boundaries)
  • Its own dependency optimizer configuration
  • Its own resolved config (merged from the base config + environment overrides)
  • Its own plugin filtering (plugins can opt in/out per environment)

Config Structure

Environments are declared in the environments key (Record<string, EnvironmentOptions>, defined in config.ts line 507). Each environment inherits from the base Vite config, then applies its own overrides:

import { defineConfig } from "vite";

export default defineConfig({
  environments: {
    client: {
      optimizeDeps: { include: ["react", "react-dom"] },
    },
    ssr: {
      resolve: { conditions: ["node"] },
    },
    // Custom environment for edge runtime
    edge: {
      resolve: { conditions: ["edge-light", "worker"], noExternal: true },
      dev: {
        createEnvironment(name, config) {
          return new CloudflareDevEnvironment(name, config);
        },
      },
      build: {
        outDir: "dist/edge",
        rolldownOptions: { platform: "neutral" },
      },
    },
  },
});

Core Classes

The class hierarchy: PartialEnvironment -> BaseEnvironment -> DevEnvironment / BuildEnvironment.

DevEnvironment

DevEnvironment (server/environment.ts line 55) owns the dev-time state for one environment:

class DevEnvironment extends BaseEnvironment {
  mode = "dev" as const;
  moduleGraph: EnvironmentModuleGraph;
  depsOptimizer?: DepsOptimizer;
}

Each instance owns its module graph and dependency optimizer. When the dev server starts, it creates one DevEnvironment per configured environment. The createEnvironment factory in DevEnvironmentOptions lets you provide a custom subclass (as shown in the edge environment config above).

BuildEnvironment

BuildEnvironment (build.ts line 1687) is the production counterpart:

class BuildEnvironment extends BaseEnvironment {
  mode = "build" as const;
  isBuilt = false;
}

The createBuilder API manages multi-environment builds, running each environment’s build in sequence or parallel.

Both extend BaseEnvironment (baseEnvironment.ts line 104), which takes a name, the full ResolvedConfig, and the environment-specific ResolvedEnvironmentOptions.

Per-Environment Plugins

Not every plugin makes sense in every environment. The perEnvironmentPlugin helper (plugin.ts line 417) creates a plugin with an applyToEnvironment callback that checks the environment name and returns whether to run. Vite uses this internally for build plugins that only apply to specific environments.

A plugin that only runs in the client environment:

function clientOnlyPlugin(): Plugin {
  return {
    name: "client-only",
    applyToEnvironment(env) {
      return env.name === "client";
    },
    transform(code, id) {
      // Only runs for client environment modules
    },
  };
}

How Environments Interact with HMR

HMR propagation runs in parallel across all environments by default, controlled by server.hotUpdateEnvironments. When a file changes:

  1. Vite finds all environments that have the file in their module graph
  2. Each environment’s hotUpdate plugin hook runs independently
  3. Each environment determines its own HMR boundaries and update payloads
  4. Updates are sent to their respective clients (browser for client, module runner for SSR/custom)

Practical Example: Browser + Cloudflare Workers

If you’re building an app that targets both the browser and Cloudflare Workers, environments let both run in the same Vite instance with correct module resolution for each target:

  • The client environment resolves browser exports from packages
  • A worker environment uses workerd export conditions and bundles everything (noExternal: true, since there are no node_modules on the edge)
  • Both share the same dev server, plugin pipeline (with per-environment filtering), and HMR infrastructure
  • The worker environment can provide a custom DevEnvironment subclass via createEnvironment that runs code in a workerd-compatible runtime during dev

The config example above demonstrates this pattern with the edge environment. Frameworks like Remix and Nuxt use this approach to serve the same project to multiple targets without running separate Vite instances.

Connection to Vitest

Vitest relies heavily on the SSR environment. Test files run through the SSR DevEnvironment, using moduleRunnerTransform: true to transform code for Node.js execution. The environment API is what allows Vitest to reuse Vite’s infrastructure while targeting a completely different runtime than the browser.

Next Steps