Vite Configuration & Ecosystem
Vite is two bundlers wearing one config file. In development it serves your source as native ES modules and uses esbuild only to pre-bundle dependencies; in production it hands the graph to Rollup for tree-shaking, chunking, and minification. Almost every confusing Vite behaviour — a plugin hook firing at the wrong moment, an import.meta.env value that exists in dev but not in the build, a dependency that works on the dev server and explodes during vite build — traces back to the fact that these two engines have different module resolution, different transform pipelines, and different caches. This overview maps the whole configuration surface, names the concrete engine responsible for each behaviour, and links out to the deep-dive guides for plugins, environment modes, the dev server, SSR/SSG, and library packaging.
The goal here is to give you a mental model precise enough that you can predict which engine handles a given file, then reach for the right guide to tune it. The sections below follow the order you actually hit problems in: configuration architecture, the plugin pipeline, environment and build modes, the dev server and HMR, SSR/SSG, and library mode. A decision matrix, a performance-and-observability section, and a future-trajectory note (Rolldown, Oxc, the Environment API) round it out, followed by an implementation checklist you can paste into a PR description.
Why does this problem exist at all? The historical bundlers — Webpack, Parcel, Browserify — were built for a browser that could not load ES modules natively, so they had to concatenate the entire graph into one or more bundles before the first byte reached the browser, in both development and production. That upfront concatenation is the cost that grew linearly with codebase size and turned a 400-module app into a fifteen-second dev start. Vite’s founding bet was that modern browsers ship native <script type="module"> support, so in development the browser itself can be the module loader and the tool only has to transform each file the browser actually requests. That bet pays off enormously on cold start, but it means the code you run in development was never bundled the way it will be in production, and any behaviour that only emerges from bundling — tree-shaking, chunk boundaries, scope hoisting, minifier assumptions — is invisible until vite build. The two-engine design is the direct consequence: esbuild is fast enough to convert CommonJS dependencies on the fly for the unbundled dev server, but its output is not production-grade, so Rollup owns the shipped artifact.
What breaks when you ignore the split? The failure mode is always the same shape — something works in vite serve and fails in vite build, or vice versa — because the two engines disagree on module resolution, on how aggressively dead code is eliminated, on how import.meta is rewritten, and on which files even enter the graph. A component that renders in dev can throw undefined is not a function in the built bundle because esbuild kept a side-effectful import that Rollup tree-shook away. A plugin that transforms fine on the dev server can be skipped entirely in the build because it was implicitly ordered by request timing rather than by an explicit enforce. Treat every “works in dev, breaks in build” report as a two-engine mismatch until proven otherwise; the sections below name the concrete engine and cache behind each surface so you can localize the fault instead of guessing.
Core configuration architecture
The vite.config.ts file is the declarative control plane for module resolution, the dependency graph, and environment branching. Wrap it in defineConfig() so TypeScript infers the full schema and gives you autocomplete on nested keys like resolve.alias, optimizeDeps, and build.target. The config can also be a function — defineConfig(({ command, mode }) => ({ … })) — which is how you branch behaviour between vite serve and vite build without duplicating the file. Modern configs (Vite 5 and 6) assume native ESM throughout; authoring the config itself as .ts with top-level ESM imports is the default, and CommonJS interop only surfaces when a dependency ships CJS.
That CJS-to-ESM conversion is the job of dependency pre-bundling. On the first dev start, esbuild scans your entry HTML, finds bare imports into node_modules, converts any CommonJS or UMD packages into a single optimized ESM chunk per dependency, and caches the result in node_modules/.vite/deps. This is what gives Vite its sub-second cold start — esbuild is roughly 10–100x faster than a JavaScript-based transpiler — but the cache is also a frequent source of confusion. When a dependency is added, removed, or its lockfile hash changes, Vite re-runs the scan; if you see a full page reload with “new dependencies optimized” in the log, that is the pre-bundler re-running. Use optimizeDeps.exclude for packages that are already valid ESM and should not be pre-bundled, optimizeDeps.include to force-bundle a deep import that the scanner misses, and delete .vite/deps (or pass --force) when the cache desyncs in a monorepo.
There are two independent reasons pre-bundling exists, and conflating them causes most of the mistakes. The first is correctness: the browser cannot execute module.exports, so any CommonJS or UMD dependency has to be rewritten to ESM before it can be served as a native module — this is non-negotiable and applies to a single package. The second is performance: a package like lodash-es is authored as hundreds of tiny internal ES modules, and serving each one as a separate HTTP request would flood the browser with waterfalled round-trips, so esbuild flattens the whole package into one file to collapse those requests. This is why a valid-ESM but internally-fragmented dependency still benefits from pre-bundling even though it needs no format conversion. The cache key is a hash derived from your lockfile, the relevant optimizeDeps options, and a handful of config fields; when any of them changes Vite invalidates .vite/deps and re-optimizes, which is the mechanism behind the mid-session “new dependencies optimized, reloading” page reload. If that reload fires repeatedly during a normal edit loop, something is mutating an input to the hash — a post-install script rewriting a dependency, a lockfile churning in a container mount, or a glob in optimizeDeps.entries matching files that change on every save.
The scanner itself is worth understanding because it decides what gets pre-bundled and what is missed. Vite crawls from your HTML entry points, follows static import statements, and records every bare specifier. What it cannot see are dynamic imports built from runtime strings, dependencies only reached through a package that is itself excluded, and imports inside files the crawl never visits. Those are exactly the cases where you get a mid-session reload the first time the code path executes, because the dependency was discovered late. The fix is to name the missed specifier explicitly in optimizeDeps.include so it is bundled on the initial pass. A minimal but production-shaped config that exercises all three levers looks like this:
// vite.config.ts — Vite 6
import { defineConfig } from 'vite'
export default defineConfig(({ command, mode }) => ({
optimizeDeps: {
// force-bundle a deep import the static scan can't reach
include: ['some-lib/dist/esm/submodule', 'react-dom/client'],
// leave an already-optimized ESM package alone
exclude: ['@my-scope/design-system'],
// help the scanner find non-HTML entry points (e.g. a worker)
entries: ['index.html', 'src/worker.ts'],
},
// branch real behaviour on the two engines instead of duplicating the file
build: command === 'build' ? { sourcemap: mode !== 'production' } : {},
}))
Getting the cache wrong is not cosmetic. If a dependency is force-excluded but actually ships CommonJS, the browser receives raw require calls and throws require is not defined at load; if a large fragmented package is excluded, the dev server slows to a crawl under hundreds of module requests. The way to confirm which is happening is to open the Network tab and count requests into node_modules/.vite/deps versus raw node_modules paths — pre-bundled dependencies resolve to a single hashed file under .vite/deps, while an excluded package shows up as many individual source files.
Plugin pipeline and hook order
Vite plugins are a superset of Rollup plugins: every standard Rollup hook (resolveId, load, transform, renderChunk) runs in both dev and build, and Vite adds its own (config, configResolved, transformIndexHtml, handleHotUpdate). The two properties that govern execution order are enforce ('pre' runs before core plugins, 'post' runs after) and apply ('serve' or 'build' restricts a plugin to one engine). Getting these wrong is the most common plugin bug — a transform that depends on another plugin’s output but runs first will silently see the original source. The ordering rules, virtual-module conventions (the \0 null-byte prefix on resolved ids), and cross-plugin communication patterns are covered in depth in advanced Vite plugin configuration, and the specific case of pinning enforce and apply for a misbehaving chain is walked through in debugging Vite plugin hook order.
The reason enforce exists rather than plain array order is that Vite interleaves your plugins with its own internal ones. Regardless of where you place a plugin in the plugins array, the resolved order is: all enforce: 'pre' plugins in array order, then Vite’s core plugins, then plugins with no enforce, then all enforce: 'post' plugins. Within a single hook, resolveId and load are first-wins — the first plugin to return a non-null value ends the chain — while transform is sequential, every plugin receiving the previous one’s output. That asymmetry is the root of the classic bug: a transform that needs to run against raw source must be enforce: 'pre', because by default it runs after core plugins have already compiled the file into something it no longer recognizes. Conversely a transform that must see the fully-compiled output — a license-banner injector, a final minification pass — belongs at enforce: 'post'.
Virtual modules are the other mechanism worth internalizing, because they are how plugins inject code that has no file on disk. A plugin returns a synthetic id from resolveId (conventionally prefixed with virtual:), then serves its contents from load. Vite requires that the resolved id be prefixed with a null byte (\0) so that other plugins and the dev server know not to treat it as a real path and try to read it from the filesystem. Forgetting the \0 is a common failure: the module works until some other plugin or the file watcher tries to fs.stat the fake path and crashes. A minimal, correct virtual-module plugin looks like this:
// build-info-plugin.ts — Vite 6 / Rollup 4 plugin interface
import type { Plugin } from 'vite'
export function buildInfo(): Plugin {
const virtualId = 'virtual:build-info'
const resolvedId = '\0' + virtualId // null-byte prefix marks it synthetic
return {
name: 'build-info',
enforce: 'pre', // resolve before core plugins claim the bare id
resolveId(id) {
return id === virtualId ? resolvedId : null
},
load(id) {
if (id !== resolvedId) return null
return `export const builtAt = ${JSON.stringify(new Date().toISOString())}`
},
}
}
Framework plugins are where hook order matters most. @vitejs/plugin-react injects JSX runtime and Fast Refresh transforms; @vitejs/plugin-vue runs SFC compilation; @vitejs/plugin-react-swc swaps Babel for a Rust SWC core and typically cuts HMR transform time by 20–30% on large component trees. These must sit in the right slot in the plugins array relative to any enforce: 'pre' transform you author. Teams arriving from Webpack usually find that most of their loader chain collapses into two or three Vite plugins; the full porting path, including how resolve.alias replaces resolve.modules and how define replaces DefinePlugin, is laid out in migrating from Webpack 5 to Vite.
The consequence of getting hook order wrong is subtle because it usually does not crash — it silently no-ops. A transform that expected to rewrite import.meta.env.MY_FLAG but ran before the define-replacement plugin sees the original text and matches nothing, so the flag ships unreplaced and the feature never toggles. The way to confirm hook order at runtime is to add a console.log(this.getModuleInfo(id)) inside your transform, or run vite build --debug plugin-transform, which prints each plugin’s transform in the order it actually executed. If your plugin is not in the position you expect, set enforce explicitly rather than reordering the array, because array position does not survive Vite’s internal reshuffling.
Environment variables and build modes
Vite resolves env files by mode with a strict precedence: .env.[mode].local beats .env.[mode] beats .env.local beats .env. Only variables prefixed VITE_ are exposed to client code through import.meta.env, and they are statically replaced at build time rather than read at runtime — which is exactly why a value that prints fine on the dev server can come back undefined in the production bundle if the prefix is missing or the variable is read dynamically. Type-safe access means augmenting ImportMetaEnv and adding /// <reference types="vite/client" />. The mode itself (development, production, or a custom --mode staging) drives both env-file selection and the import.meta.env.PROD/DEV booleans, so multi-environment setups should lean on mode rather than ad-hoc conditionals.
The static-replacement mechanism is where the sharp edges are. import.meta.env.VITE_API_URL is not a property read at runtime; it is a textual token that the build replaces with the literal string value, the same way DefinePlugin worked in Webpack. That has three consequences that trip people up. First, you cannot compute the key — import.meta.env['VITE_' + name] defeats the static match and yields undefined in the build, even though the object-style access happens to work on the dev server where import.meta.env is a real populated object. Second, because the value is inlined as a string literal, a VITE_FEATURE_FLAG=false becomes the string "false", which is truthy; boolean env values must be compared explicitly (=== 'true') or coerced. Third, anything you did not prefix with VITE_ is simply absent from the replacement table, so it silently becomes undefined rather than throwing — which is why the missing-prefix bug survives all the way to production before anyone notices.
A type-safe, boolean-correct env access module makes both failure modes impossible to reintroduce:
// src/env.ts — Vite 6, requires vite/client types
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_ENABLE_ANALYTICS: string // env values are always strings
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
// coerce at one boundary so the rest of the app sees real types
export const env = {
apiUrl: import.meta.env.VITE_API_URL,
analytics: import.meta.env.VITE_ENABLE_ANALYTICS === 'true',
} as const
The mechanics of mode-specific overrides, how to keep secrets out of the client bundle, and the layered-file strategy for staging-versus-production are in environment variables and build modes in Vite, with the specific failure of import.meta.env coming back undefined in production builds given its own diagnosis guide. If you run several deployment targets, managing multiple env files across Vite environments covers the precedence edge cases that bite when .env.local leaks into CI.
The security boundary is the prefix, and it is easy to breach by accident. Because the entire VITE_-prefixed set is inlined into client JavaScript, any secret that picks up a VITE_ prefix — a database URL, an admin token, a private API key — is shipped verbatim in a .js file that anyone can view. The rule is mechanical: server-only secrets must never carry the prefix, and they should be read through process.env in server code (SSR entry, build scripts) where they stay in the Node process. To confirm a secret did not leak, grep the built output: grep -r "your-secret-value" dist/ should return nothing. Running that grep in CI as a hard gate is cheaper than rotating a credential after it ships in a source map.
Dev server and HMR
The dev server replaces the watch-and-rebuild loop with on-demand compilation. Source files are served as native ESM over HTTP, pre-bundled dependencies come from .vite/deps, and Hot Module Replacement runs over a persistent WebSocket: a chokidar file event triggers a targeted vite:beforeUpdate payload rather than a rebuild. The cost model is inverted from Webpack — startup is near-instant regardless of app size, but each requested module is transformed on first access, so a cold navigation to a deep route can feel slower than the homepage until the graph warms.
Under the hood, the request path is straightforward once you see it. The browser requests a module by URL; Vite’s dev middleware runs the plugin transform chain for that one file, rewrites its bare imports to resolvable URLs (import x from 'react' becomes import x from '/node_modules/.vite/deps/react.js'), sets long-lived cache headers keyed on the file’s content, and streams it back. On a subsequent edit, chokidar fires a change event, Vite invalidates that module in its graph, computes the set of affected modules by walking importers upward until it reaches an accept boundary, and pushes a small JSON payload over the WebSocket telling the client exactly which module URLs to re-fetch. Nothing rebuilds; the client re-imports the changed module with a new query string to bust the browser cache. This is why HMR latency is a function of transform time for the changed file plus the depth to the nearest boundary, not of total app size.
HMR quality depends on accept boundaries. A module that calls import.meta.hot.accept() becomes a boundary and updates in place; a module without one bubbles the update to its importers, and if nothing accepts it, Vite falls back to a full page reload that destroys component state. Framework plugins install these boundaries for you — @vitejs/plugin-react makes every file exporting only components self-accepting via React Fast Refresh — which is why a file that also exports a non-component value (a constant, a hook factory, a context object) silently loses Fast Refresh and starts triggering full reloads. Two structural patterns force full reloads more than anything else: circular imports and barrel files that re-export an entire directory. A barrel (index.ts re-exporting fifty modules) makes every consumer depend transitively on all fifty, so editing any one of them invalidates the barrel and everything downstream of it, collapsing the accept-boundary calculation into a whole-subtree reload. Tuning WebSocket behaviour, server.warmup for critical entries, and the accept-boundary model is covered in optimizing the Vite dev server and HMR; the monorepo-specific slowdowns are in fixing slow Vite HMR in large monorepos, and the barrel-import reload trap is dissected in fixing Vite HMR full-reloads from circular barrel imports.
To confirm you have a full-reload problem rather than a slow-transform problem, open the browser console: an in-place HMR update logs [vite] hot updated: /src/Foo.tsx, whereas a bubbled failure logs [vite] page reload with the file that could not be accepted. If you see the reload line on a component edit, the fix is almost always to split the offending file so components live in one module and non-component exports in another, or to warm the critical entries so the reload at least re-transforms quickly. For a self-managed accept boundary in a non-framework module, the pattern is explicit:
// store.ts — Vite 6 HMR API, accept your own updates to preserve state
export const store = createStore()
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
// migrate live state into the replacement module instead of reloading
if (newModule) newModule.store.hydrate(store.snapshot())
})
}
SSR and SSG
Server-side rendering forces you to confront the two-engine split directly, because the same module graph is compiled once for the browser and once for Node. The ssr.external and ssr.noExternal options decide which dependencies stay as Node require/import calls versus getting bundled into the server build; a misconfigured external array is the usual cause of double-bundled CSS-in-JS or a missing peer dependency at runtime. The build.ssrManifest flag emits a map from modules to their CSS and async chunks so the server can inline critical styles before client JS hydrates, which is the mechanism behind avoiding a flash of unstyled content.
The mental model for external versus noExternal is worth stating precisely because the defaults invert between client and server. In a normal client build everything is bundled. In an SSR build the default flips: dependencies in node_modules are left external — kept as runtime import/require calls that Node resolves from disk — because Node can load them natively and bundling them wastes time and risks running the same module twice. You add a package to ssr.noExternal when it must be transformed by Vite’s pipeline before Node runs it: anything shipping raw .vue/.jsx/.ts, anything importing CSS or other assets Node cannot parse, and packages published only as ESM that a CommonJS server context cannot require. You add a package to ssr.external in the rarer case where a dependency is being bundled but should not be — usually to break a duplicate-instance bug. Getting noExternal wrong produces the two signature failures: a raw SyntaxError: Cannot use import statement outside a module when an ESM-only package was left external, or ERR_UNKNOWN_FILE_EXTENSION .vue when a component library was left external and Node tried to import a source file it cannot parse.
A complete dual-build setup — separate client and server Rollup outputs, the dev middleware via vite.ssrLoadModule, and a production Express entry — is documented in Vite SSR and SSG integration, with a worked Express and Node.js SSR configuration you can lift directly. The class of bug where server and client markup disagree is its own beast; fixing hydration mismatch errors in Vite SSR covers the date, locale, and conditional-render causes that produce Hydration completed but contains mismatches.
Hydration mismatches deserve a mechanism note because the error message is famously unhelpful. Hydration is React (or Vue) attaching event listeners to server-rendered DOM while assuming the DOM it renders on the client is byte-identical to what the server produced. Any value that differs between the two environments at render time — Date.now(), Math.random(), a locale-formatted number, window-gated conditional rendering, a user’s timezone — makes the trees diverge, and the framework discards the server markup and re-renders from scratch, throwing away the SSR performance benefit and often flashing incorrect content. The root cause is always non-determinism at render time, and the fix is to make the first client render deterministic: render the server-safe value first and move the environment-specific value into an effect that runs only after hydration. Confirm the fix by rendering with JavaScript disabled — the server HTML you see should match the first paint with JavaScript enabled.
Library mode and package bundling
When you are shipping a package rather than an app, build.lib reconfigures Vite into a deterministic library bundler. You set build.lib.entry, choose formats from ['es', 'cjs', 'umd'], and — critically — list your peer dependencies in build.rollupOptions.external so React, Vue, or whatever the consumer already has does not get bundled in and duplicated. Library mode also turns off most app-oriented defaults: no HTML entry, no asset hashing by default, and a single CSS file emitted alongside the JS. Type declarations are not produced by Vite’s core, so you bolt on vite-plugin-dts or run tsc --emitDeclarationOnly as a parallel step.
The externalization mechanism is the part people get wrong, so it is worth spelling out how Rollup decides. Anything listed in rollupOptions.external is left as a bare import in the output instead of being inlined; the consumer’s own bundler resolves it against their node_modules. If you forget to externalize react, Rollup copies React’s source into your bundle, and when the consuming app also imports React you end up with two React instances that do not share the same module-level useState dispatcher — the runtime throws Invalid hook call or hooks silently misbehave. The rule is that every entry in your package’s peerDependencies must appear in external, and for UMD output you additionally map each external to a global variable name so the browser build can find it on window. A regex-based external matcher also catches deep imports like react-dom/client, which a plain string list misses:
// vite.config.ts — Vite 6 library mode
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'
export default defineConfig({
build: {
lib: {
entry: 'src/index.ts',
name: 'MyKit', // UMD global name
formats: ['es', 'cjs', 'umd'],
fileName: (format) => `my-kit.${format}.js`,
},
rollupOptions: {
// match react, react-dom, and any deep import into them
external: [/^react($|\/)/, /^react-dom($|\/)/],
output: {
globals: { react: 'React', 'react-dom': 'ReactDOM' },
},
},
},
plugins: [dts({ rollupTypes: true })], // the one thing Vite core won't emit
})
The full library workflow — entry and format selection, exports map wiring in package.json, and why UMD globals still matter for CDN distribution — is in Vite library mode and package bundling. The single most common library mistake, bundling a peer dependency and causing “two copies of React” errors in the consumer, has a dedicated walkthrough in externalizing peer dependencies in Vite library mode. For exact version pins — which Vite major needs which Node version, which Rollup version ships inside each Vite release, and which plugins break across majors — consult the Vite version compatibility reference before you upgrade.
Decision matrix: Vite vs Rollup vs esbuild vs Turbopack
Vite is not always the answer, and knowing when to drop to a lower-level tool keeps your build honest. The trade-off is roughly: Vite for applications with a dev server, raw Rollup for libraries that need fine-grained output control, raw esbuild for build scripts and CLIs where you want one fast pass, and Turbopack when you are already inside Next.js.
| Tool | Best for | Dev server / HMR | Output control | Speed profile |
|---|---|---|---|---|
| Vite | SPAs, SSR apps, most libraries | Native ESM + HMR, fast | Rollup-level via rollupOptions |
Instant start, on-demand transform |
| Rollup | Libraries, custom build graphs | None (build only) | Highest; full plugin and output API | Slower, fully optimized output |
| esbuild | Build scripts, CLIs, transpile passes | Basic serve/watch only | Limited chunking and code-splitting | Fastest single pass; weaker tree-shaking |
| Turbopack | Next.js apps | Incremental, Next-integrated | Framework-managed | Fast incremental rebuilds, Next-coupled |
For applications, the deciding question is whether you need a dev server with HMR — if yes, Vite wins on developer experience while still giving you Rollup’s output API underneath. For libraries, the question is how much you need to shape output chunks and the exports map; library mode handles the common case, but a complex multi-entry package sometimes justifies driving Rollup directly. esbuild as a standalone tool shines for one-shot transforms and tooling scripts where its weaker tree-shaking does not matter; the lower-level esbuild and Turbopack workflows live in their own esbuild and Turbopack workflows overview. The bundler-agnostic foundations — what tree-shaking and ESM/CJS interop actually do under any of these tools — are in core concepts of modern bundling.
Performance and observability
Vite’s performance story has two halves: dev-server responsiveness and production bundle quality. On the dev side, the metrics that matter are cold-start time (dominated by the first esbuild dependency scan), warm-start time (which should be near-instant once .vite/deps is populated), and HMR update latency (the gap between save and browser repaint). Watch the server log for repeated “optimized dependencies changed, reloading” lines — that is the pre-bundler thrashing, usually from a dependency that should be in optimizeDeps.exclude or from an unstable lockfile in CI.
On the production side, instrument the Rollup build. Pass --profile or wire up rollup-plugin-visualizer to produce a treemap of the output, then set size budgets in CI so a stray import of a heavy library fails the build instead of shipping. Track total bundle size, the largest chunk, and the number of chunks (excessive chunking inflates HTTP requests; too few defeats caching). build.reportCompressedSize adds gzip/brotli numbers to the build output but slows large builds, so disable it in CI once budgets are enforced elsewhere. The two engines have different tree-shaking behaviour — esbuild’s is shallower than Rollup’s — which is why a dependency can look fine in dev and only reveal dead-code bloat in the production analysis. Pin versions deliberately and check the Vite version compatibility reference when a build regresses after an upgrade.
Chunking is the lever with the largest effect on real-world load performance, and it is worth a concrete note. By default Rollup produces one chunk per dynamic import plus a shared vendor chunk, and hashes each filename on its content so unchanged chunks stay cached across deploys. The failure mode is a poorly-placed manualChunks split that lumps a rarely-changing vendor library into the same chunk as your frequently-edited app code — every app edit then changes the vendor chunk’s hash and busts a cache that should have survived for months. The inverse failure is splitting too aggressively, so a single route pulls twenty small chunks and pays twenty round-trips before it can render. A pragmatic manual-chunk boundary isolates the genuinely stable, large dependencies and lets Rollup handle the rest:
// vite.config.ts — Vite 6, cache-stable vendor splitting
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
// keep the big, rarely-updated framework in its own long-lived chunk
if (id.includes('node_modules/react')) return 'react-vendor'
if (id.includes('node_modules/chart.js')) return 'charts'
// everything else falls back to Rollup's automatic splitting
},
},
},
},
}
To confirm a chunking change actually helped, build twice — once before and once after — and diff the emitted filenames: chunks whose hash is unchanged between an app-only edit and a rebuild are the ones staying in the browser cache. A size regression that appears only in production and not in the dev Network tab is the tell-tale of esbuild’s shallower tree-shaking hiding bloat that Rollup would have surfaced, so always benchmark against the built output, never the dev server.
Future trajectory: Rolldown, Oxc, and the Environment API
The most consequential change on Vite’s roadmap is the move to a single bundler for both dev and build. Rolldown — a Rust port of Rollup’s API built on the Oxc toolchain — is being integrated as Vite’s bundler, which will eventually collapse the esbuild-in-dev / Rollup-in-build split that this entire overview is organized around. The transitional package, rolldown-vite, lets teams opt in early; it keeps the Rollup-compatible plugin interface while replacing the engine, so most plugins continue to work, but anything that depended on esbuild-specific pre-bundling quirks should be tested. Oxc (the Oxidation Compiler) also supplies a faster parser, resolver, and transformer that Vite is adopting incrementally, which is why some transform paths already feel faster on recent versions.
The other significant addition is the Environment API, which generalizes the client/SSR split into a first-class concept of named build environments. Instead of the binary ssr boolean, a config can declare multiple environments (client, SSR, edge, worker) each with their own module graph, resolution, and plugins — which is how Vite is positioning itself for edge runtimes and multi-target builds without bolt-on hacks. None of this changes the configuration surface described above in a breaking way today, but it does mean that the “two engines, one config” model is a snapshot of the current major, not a permanent law. Track the Vite version compatibility reference for when rolldown-vite stops being opt-in.
Implementation checklist
- Wrap the config in
defineConfig()and use the function form to branch oncommand/modeinstead of duplicating files. - Audit
optimizeDeps.include/excludeso the dev server stops re-running the dependency scan; delete.vite/depsor pass--forcewhen the cache desyncs. - Set
enforceandapplyexplicitly on every custom plugin; verify hook order against framework plugins. - Prefix all client-exposed env vars with
VITE_, augmentImportMetaEnv, and confirm values survive the production build (not just dev). - Add
import.meta.hot.accept()boundaries at component/module entry points; eliminate circular barrel imports that force full reloads. - For SSR, get
ssr.external/ssr.noExternalright and emitbuild.ssrManifestto inline critical CSS. - For libraries, list every peer dependency in
rollupOptions.externaland generate type declarations withvite-plugin-dtsortsc --emitDeclarationOnly. - Wire
rollup-plugin-visualizerand CI size budgets; disablebuild.reportCompressedSizeonce budgets are enforced. - Pin Vite, Rollup, and plugin versions against the compatibility reference before any major upgrade; test rolldown-vite in a branch.
Related
- Advanced Vite plugin configuration — hook order, virtual modules, and cross-plugin communication.
- Environment variables and build modes in Vite — mode resolution, env precedence, and type-safe
import.meta.env. - Optimizing the Vite dev server and HMR — accept boundaries, WebSocket tuning, and warmup.
- Vite SSR and SSG integration — dual-build setup, manifests, and hydration.
- Vite library mode and package bundling —
build.lib, formats, and externalized peers. - Vite build performance and caching — dependency pre-bundling,
manualChunksmemory control, and CI cache restoration. - Vite version compatibility reference — Vite ↔ Rollup ↔ Node version pins and upgrade notes.