Read OSS

How Vite Resolves Configuration and Assembles the Plugin Pipeline

Advanced

Prerequisites

  • Article 1: Architecture and codebase navigation
  • Understanding of Rollup/Rolldown plugin hook model (resolveId, load, transform)
  • TypeScript generics and utility types

How Vite Resolves Configuration and Assembles the Plugin Pipeline

Vite's configuration system is the most complex part of the codebase — and for good reason. It must reconcile a user's vite.config.ts (which might be a function, a promise, or a plain object), inline config from programmatic callers, per-environment overrides, backward-compatible SSR options, and plugin-provided config modifications — then freeze the result into a ResolvedConfig that the rest of the system treats as immutable. All of this happens in src/node/config.ts, a file that spans over 2,700 lines.

Config File Loading Strategies

Before configuration can be resolved, Vite must find and load the user's config file. The loadConfigFromFile() function supports three loading strategies, controlled by the configLoader option:

  1. bundle (default) — Compiles vite.config.ts with Rolldown into a temporary JS file, then imports it. This is the most robust approach because it handles TypeScript, path aliases, and imports from node_modules transparently.
  2. runner (experimental) — Uses Vite's own module runner to process the config on the fly, avoiding the temporary file.
  3. native (experimental) — Relies on Node.js's native ESM loader, useful when the config file is already plain JavaScript or uses Node.js's built-in TypeScript support.

The CLI exposes this via --configLoader:

// From cli.ts, line 182-183
.option('--configLoader <loader>',
  `[string] use 'bundle' to bundle the config with Rolldown, ...`)

The config file discovery uses DEFAULT_CONFIG_FILES — a list that includes vite.config.js, vite.config.mjs, vite.config.ts, vite.config.cjs, vite.config.mts, and vite.config.cts. The first match wins.

flowchart TD
    A["loadConfigFromFile()"] --> B{"configFile provided?"}
    B -->|yes| C["Use specified file"]
    B -->|no| D["Search DEFAULT_CONFIG_FILES"]
    C --> E{"configLoader?"}
    D --> E
    E -->|bundle| F["Rolldown-compile to temp .js"]
    E -->|runner| G["Vite module runner eval"]
    E -->|native| H["Node.js native import()"]
    F --> I["import(tempFile)"]
    G --> I
    H --> I
    I --> J{"Export is function?"}
    J -->|yes| K["Call with ConfigEnv"]
    J -->|no| L["Use object directly"]
    K --> M["Return { path, config, dependencies }"]
    L --> M

The defineConfig() helper is a type-only identity function — it returns its argument unchanged but provides TypeScript autocomplete. It supports all export shapes: plain object, promise, or function:

export function defineConfig(config: UserConfigExport): UserConfigExport {
  return config
}

The resolveConfig() Pipeline

The heart of the configuration system is resolveConfig(), which orchestrates a multi-step pipeline to transform user input into a fully resolved, frozen config object.

flowchart TD
    A["resolveConfig(inlineConfig, command)"] --> B["Setup Rollup compat shims"]
    B --> C["loadConfigFromFile()"]
    C --> D["mergeConfig(fileConfig, inlineConfig)"]
    D --> E["Filter plugins by 'apply' field"]
    E --> F["Sort into pre/normal/post"]
    F --> G["Run plugin 'config' hooks"]
    G --> H["Ensure client & ssr environments"]
    H --> I["Resolve sub-options:<br/>server, build, CSS, SSR, preview"]
    I --> J["Resolve per-environment options"]
    J --> K["Run 'configEnvironment' hooks"]
    K --> L["Resolve plugins via resolvePlugins()"]
    L --> M["Run 'configResolved' hooks"]
    M --> N["Return frozen ResolvedConfig"]

Let's trace the key steps. The function starts on line 1356:

export async function resolveConfig(
  inlineConfig: InlineConfig,
  command: 'build' | 'serve',
  defaultMode = 'development',
  defaultNodeEnv = 'development',
  isPreview = false,
  patchConfig, patchPlugins,
): Promise<ResolvedConfig> {

The patchConfig and patchPlugins parameters are internal hooks — used by createBuilder() during multi-environment builds to override config.build per environment.

After loading the file config, plugins are filtered by their apply property (lines 1418-1428):

const filterPlugin = (p: Plugin | FalsyPlugin): p is Plugin => {
  if (!p) return false
  if (!p.apply) return true
  if (typeof p.apply === 'function') {
    return p.apply({ ...config, mode }, configEnv)
  }
  return p.apply === command
}

This is why you can write apply: 'build' on a plugin to exclude it during dev, or use a function for more complex conditions.

A critical step follows on lines 1443-1460: Vite ensures that client and ssr environments always exist in the config, and enforces their ordering:

config.environments ??= {}
if (!config.environments.ssr && (!isBuild || config.ssr || config.build?.ssr)) {
  config.environments = { ssr: {}, ...config.environments }
}
if (!config.environments.client) {
  config.environments = { client: {}, ...config.environments }
}

The spread-with-reorder pattern ensures client comes first, then ssr, then any custom environments — a consistent ordering that the rest of the system relies on.

UserConfig → ResolvedConfig Type Transformation

Understanding the type system reveals the config resolution contract. UserConfig is what users write — nearly everything is optional:

export interface UserConfig extends DefaultEnvironmentOptions {
  root?: string
  base?: string
  publicDir?: string | false
  cacheDir?: string
  mode?: string
  plugins?: PluginOption[]
  css?: CSSOptions
  server?: ServerOptions
  environments?: Record<string, EnvironmentOptions>
  // ... 30+ more optional fields
}

ResolvedConfig is what the system consumes — fields are required, readonly, and fully resolved:

export interface ResolvedConfig extends Readonly<
  Omit<UserConfig, 'plugins' | 'css' | 'json' | ...> & {
    root: string                              // was optional, now required
    base: string                              // resolved to absolute
    plugins: readonly Plugin[]                // flattened and sorted
    css: ResolvedCSSOptions                   // all defaults filled
    environments: Record<string, ResolvedEnvironmentOptions>
    // ...
  } & PluginHookUtils
> {}
classDiagram
    class UserConfig {
        root?: string
        base?: string
        plugins?: PluginOption[]
        environments?: Record~string, EnvironmentOptions~
        server?: ServerOptions
        build?: BuildEnvironmentOptions
    }
    class ResolvedConfig {
        root: string
        base: string
        plugins: readonly Plugin[]
        environments: Record~string, ResolvedEnvironmentOptions~
        server: ResolvedServerOptions
        build: ResolvedBuildOptions
        +getSortedPlugins()
        +getSortedPluginHooks()
    }
    UserConfig ..> ResolvedConfig : resolveConfig()

The PluginHookUtils mixin adds getSortedPlugins() and getSortedPluginHooks() — utility methods that cache hook-sorted plugin lists for efficient access during request handling.

Environment Options Resolution

Per-environment options are resolved through resolveEnvironmentOptions(), which merges global defaults with environment-specific overrides. The function determines the consumer type (client vs. server) based on the environment name:

const consumer = options.consumer ?? (isClientEnvironment ? 'client' : 'server')

Dev-specific options are resolved by resolveDevEnvironmentOptions(). It selects different defaults based on the consumer: the client environment gets preTransformRequests: true and recoverable: true, while server environments get moduleRunnerTransform: true.

As we saw in Part 1, the PartialEnvironment constructor's Proxy pattern then makes this resolution transparent at runtime. When a plugin reads this.environment.config.build.outDir, the Proxy checks if build exists in the environment options (it does), so it returns the environment-specific value. When it reads this.environment.config.root, that's not in ResolvedEnvironmentOptions, so the Proxy falls through to the top-level config.

Tip: The configDefaults frozen object documents every default value Vite uses. It's the single source of truth for "what happens when a user doesn't specify X."

Internal Plugin Ordering

After config is resolved, resolvePlugins() assembles the complete plugin array. The ordering is precise and intentional:

flowchart TD
    subgraph "Pre-user plugins"
        A1[optimizedDeps]
        A2[watchPackageData]
        A3[preAlias]
        A4[alias - native or JS]
    end
    subgraph "User pre plugins"
        B[enforce: 'pre' plugins]
    end
    subgraph "Core plugins"
        C1[modulePreloadPolyfill]
        C2[oxcResolve]
        C3[htmlInlineProxy]
        C4[css]
        C5[oxc - TS/JSX transform]
        C6[json - native]
        C7[wasm / webWorker / asset]
    end
    subgraph "User normal plugins"
        D[normal plugins]
    end
    subgraph "Post-core plugins"
        E1[wasmFallback]
        E2[define]
        E3[cssPost]
        E4[buildHtml]
        E5[workerImportMetaUrl]
        E6[dynamicImportVars / importGlob]
    end
    subgraph "User post plugins"
        F[enforce: 'post' plugins]
    end
    subgraph "Build post plugins"
        G1[importAnalysisBuild / terser]
        G2[license / manifest / reporter]
    end
    subgraph "Dev-only tail"
        H1[clientInjections]
        H2[cssAnalysis]
        H3[importAnalysis]
    end
    A1 --> B --> C1 --> D --> E1 --> F --> G1 --> H1

Notice the three dev-only plugins appended at the very end (lines 126-132): clientInjectionsPlugin, cssAnalysisPlugin, and importAnalysisPlugin. These are the most important dev-mode plugins — importAnalysisPlugin rewrites every import in your source code to work with unbundled ESM — and they must run last to see the output of all other transforms.

Beyond the global ordering, individual hooks within each plugin can specify order: 'pre' | 'post'. The getSortedPluginsByHook() function handles this per-hook sorting using an efficient in-place insertion approach.

Vite-Specific Plugin Hooks

Vite's Plugin interface extends Rolldown's RolldownPlugin with several hooks that have no equivalent in Rollup/Rolldown:

Hook When Purpose
config Before resolution Mutate or extend the raw user config
configEnvironment Per-environment Modify environment-specific config
configResolved After resolution Read the final frozen config
configureServer Server creation Add middlewares, store server reference
configurePreviewServer Preview creation Same for preview server
transformIndexHtml HTML serving Inject tags, transform HTML content
hotUpdate File change Control HMR update propagation
buildApp Build start Orchestrate multi-environment builds

The enforce property (lines 202-214) controls where a user plugin is inserted relative to Vite's internal plugins:

enforce?: 'pre' | 'post'
// Plugin invocation order:
// - alias resolution
// - `enforce: 'pre'` plugins
// - vite core plugins
// - normal plugins
// - vite build plugins
// - `enforce: 'post'` plugins
// - vite build post plugins

The applyToEnvironment hook (lines 227-229) is new in the Environment API — it lets a plugin conditionally activate per-environment or even return completely different plugin instances for different environments:

applyToEnvironment?: (
  environment: PartialEnvironment,
) => boolean | Promise<boolean> | PluginOption

Tip: The sharedDuringBuild flag (line 184) controls whether a plugin instance is shared across environments during vite build --app. By default, plugins are re-created per environment for backward compatibility. Setting this to true opts into the more efficient shared mode.

What's Next

With the config resolution pipeline and plugin ordering understood, we're ready to explore what happens when a browser requests a module from the dev server. In the next article, we'll trace a request through the connect middleware stack, into the transform pipeline, through the plugin container, and into the module graph that tracks all module relationships for HMR.