How Vite Resolves Configuration and Assembles the Plugin Pipeline
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:
bundle(default) — Compilesvite.config.tswith Rolldown into a temporary JS file, then imports it. This is the most robust approach because it handles TypeScript, path aliases, and imports fromnode_modulestransparently.runner(experimental) — Uses Vite's own module runner to process the config on the fly, avoiding the temporary file.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
configDefaultsfrozen 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
sharedDuringBuildflag (line 184) controls whether a plugin instance is shared across environments duringvite build --app. By default, plugins are re-created per environment for backward compatibility. Setting this totrueopts 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.