Vite — The Next-Generation Frontend Build Tool
Prerequisites: Solid JavaScript/TypeScript fundamentals, familiarity with npm/pnpm/yarn package management, basic understanding of ES modules (import/export syntax), and experience with at least one frontend framework (React, Vue, or Svelte). Prior exposure to any bundler (Webpack, Parcel) is helpful for context but not required.
Why Vite Exists: The Problem with Traditional Bundlers
In 2020, frontend development had a dirty secret: the larger your application grew, the more painful your development experience became. Tools like Webpack — the backbone of most build setups including Vue CLI, Create React App, and Angular CLI — were architecturally incapable of staying fast as projects scaled. Cold-starting a dev server for a large application routinely took 30 to 60+ seconds, and every code change triggered multi-second Hot Module Replacement (HMR) updates.
Evan You, the creator of Vue.js, hit this wall firsthand while dogfooding Vue 3 through Vue CLI's Webpack-based toolchain. His frustration wasn't with a configuration problem or a missing plugin — it was with a fundamental architectural bottleneck that no amount of Webpack tuning could solve.
The Bundle-First Bottleneck
Traditional bundlers follow a bundle-first model. Before your browser can display anything, the dev server must:
- Crawl your entire module graph starting from the entry point
- Parse every JavaScript, TypeScript, CSS, and asset file it discovers
- Transform each module (transpile JSX, compile TypeScript, process CSS)
- Bundle everything into one or more output files
- Serve the final bundle to the browser
This happens before a single pixel renders on screen. A project with 1,000 modules means 1,000 files parsed, transformed, and concatenated — even if the page you're working on only imports 20 of them. The cold start cost is proportional to the total size of your application, not the page you're viewing.
// Webpack must resolve ALL of these before serving anything
// entry.js → imports 50 modules
// → each module imports 10 more → 500 modules
// → each of those imports 2 more → 1,000+ modules
//
// Total: every module parsed, transformed, and bundled
// Result: 30-60 second cold start on a large project
HMR suffers from the same problem. When you edit a single file, the bundler must re-traverse parts of the dependency graph, re-transform affected modules, and reconstruct bundle chunks. In a large Webpack project, this frequently takes 2-10 seconds — long enough to break your flow and make you alt-tab to check if the save actually registered.
Vite's Inversion: Serve First, Bundle Never (in Dev)
Vite inverts the entire model. Instead of bundling your code and then serving it, Vite starts an HTTP server immediately and transforms files on demand as the browser requests them. The key insight: modern browsers natively understand ES module import statements. There is no need to bundle modules together during development — the browser can fetch them individually.
When you run vite, the server starts in milliseconds. When the browser loads your page and encounters import App from './App.vue', it sends a request to Vite. Vite transforms just that one file — compiling the <script>, <template>, and <style> blocks — and returns a native ES module. The browser then discovers the next set of imports and requests those. Transformation is lazy, per-file, and incremental.
graph TB
subgraph Traditional["🐢 Traditional Bundler Dev Server"]
direction TB
A["📁 All Source Files
(1,000+ modules)"] --> B["🔍 Crawl entire
dependency graph"]
B --> C["⚙️ Parse & Transform
every module"]
C --> D["📦 Bundle into
output chunks"]
D --> E["⏳ WAIT 30-60s"]
E --> F["🌐 Serve bundle
to browser"]
end
subgraph ViteDev["⚡ Vite Dev Server"]
direction TB
G["🌐 Browser requests
/src/App.vue"] --> H["🔀 Vite intercepts
the request"]
H --> I["⚙️ Transform only
that single file"]
I --> J["✅ ~50ms response"]
J --> K["📤 Serve native
ES module"]
end
The performance difference is structural, not incremental. A Webpack dev server in a 1,000-module project does 1,000 units of work upfront. Vite does zero work upfront — then does exactly as many units of work as the browser requests for the current page, typically a fraction of the total.
The Browser Advances That Made This Possible
Vite's approach would have been impossible just a few years earlier. Two browser-level advances unlocked it:
| Advance | What It Enabled | When It Became Universal |
|---|---|---|
| Native ES Modules (ESM) | Browsers can natively execute <script type="module"> and resolve import / export statements without a bundler |
All major browsers by 2018–2019 (Chrome 61+, Firefox 60+, Safari 11+, Edge 79+) |
| HTTP/2 Multiplexing | Hundreds of small module requests can be served over a single connection without the per-request overhead that made unbundled development impractical under HTTP/1.1 | Widespread server and browser support by 2019–2020 |
Bundlers originally existed because browsers had no module system at all — you had to concatenate scripts into a single file. Once browsers gained native import/export support, the primary reason for bundling during development disappeared. Vite was the first major tool to fully embrace this reality.
Without native ESM, every module would need to be wrapped in a runtime module system (like Webpack's __webpack_require__) and concatenated into bundles. Without HTTP/2, requesting hundreds of individual files would create a waterfall of sequential connections, each with TCP and TLS handshake overhead, making unbundled serving slower than just bundling everything.
From Vue CLI Frustration to a Universal Tool
Before Vite, Vue developers used Vue CLI — a Webpack-based scaffolding tool that shipped a sophisticated, pre-configured Webpack setup. For small projects it worked fine. But as Vue 3 matured and applications grew, the pain became acute. Evan You was building large Vue 3 applications and experiencing the full weight of Webpack's bundle-first architecture: slow cold starts, sluggish HMR, and configuration complexity that fought you at every turn.
Rather than patching Vue CLI's Webpack config, Evan started a clean-slate experiment in April 2020 — initially under the name vite (pronounced /vit/, French for "fast"). The first prototype proved the concept: by leveraging native ESM and performing on-demand transformation with esbuild for pre-bundling dependencies, dev server startup dropped from tens of seconds to under 300 milliseconds, regardless of project size.
Although Vite was born from the Vue ecosystem, Evan You made a deliberate decision to make it framework-agnostic from day one. React, Svelte, Lit, Solid, and vanilla JS all work as first-class citizens through Vite's plugin system. This wasn't an afterthought — it was a core design choice that drove Vite's adoption far beyond the Vue community.
Vite's Design Philosophy
The name captures the ambition, but Vite's success comes from three deliberate design principles:
- Fast by default — Speed isn't a feature you opt into; it's the baseline. Native ESM in dev, esbuild for dependency pre-bundling, and Rollup (now transitioning to Rolldown) for optimized production builds.
- Convention over configuration — Sensible defaults for TypeScript, JSX, CSS Modules, PostCSS, and static assets out of the box. A fresh Vite project has a config file measured in single-digit lines, not hundreds.
- Framework-agnostic core — The core handles module serving, HMR, and build optimization. Framework-specific behavior (Vue SFC compilation, React Fast Refresh) lives in plugins, keeping the core lean and universal.
Vite didn't just iterate on the bundler formula — it changed what a dev server fundamentally does. That architectural shift is why it went from a side project to the default tooling recommendation for Vue, SvelteKit, Astro, Nuxt 3, Remix, and a growing list of frameworks in under three years.
Vite's Architecture: ESM, esbuild, and Rollup Working Together
Vite isn't a single monolithic bundler — it's a layered architecture that delegates work to three specialized engines, each chosen for what it does best. Understanding which engine handles what (and when) is the key to reasoning about Vite's behavior, debugging build issues, and writing effective plugins.
The architecture splits cleanly along one axis: development vs. production. In development, Vite serves native ES modules over a Connect-based dev server, using esbuild for heavy lifting. In production, Vite hands control to Rollup for optimally split, tree-shaken bundles. A unified plugin system bridges both worlds.
The Big Picture
Before diving into each layer, here's how the three engines fit together across the two build modes:
graph TD
subgraph dev["Development Mode"]
Browser["🌐 Browser"]
Browser -->|"ESM import requests
(e.g. import './App.tsx')"| DevServer
DevServer["⚡ Vite Dev Server
(Connect middleware)"]
DevServer -->|"bare imports
(node_modules)"| Esbuild["esbuild
Pre-bundling & Transpilation"]
DevServer -->|"source files
(.ts, .vue, .jsx)"| PluginsDev["Vite Plugin Pipeline
(transform hooks)"]
PluginsDev --> FS["�� File System
(your source code)"]
Esbuild -->|"single ESM bundle
per dependency"| DevServer
FS --> PluginsDev
PluginsDev -->|"transformed ESM"| DevServer
DevServer -->|"HTTP response
(ESM module)"| Browser
end
subgraph prod["Production Build"]
Source["📁 Source Files"] --> PluginsProd["Vite Plugin Pipeline
(build hooks)"]
PluginsProd --> Rollup["Rollup 4
(SWC-based parser)"]
Rollup -->|"code splitting,
tree shaking"| Bundles["📦 Optimized Bundles
(JS, CSS, assets)"]
end
Layer 1: The Dev Server (Connect)
Vite's dev server is built on Connect, a minimal Node.js HTTP middleware framework. When your browser loads a page, it encounters <script type="module"> tags that trigger native ESM import requests. The dev server intercepts each request, transforms the requested module on the fly, and returns valid ESM that the browser can execute.
This is fundamentally different from webpack's approach. Webpack bundles your entire application graph before serving anything. Vite only processes the modules the browser actually requests, which means startup time stays fast regardless of app size.
<!-- Your index.html — Vite serves this directly -->
<script type="module" src="/src/main.tsx"></script>
<!-- The browser then makes HTTP requests for each import:
GET /src/main.tsx
GET /src/App.tsx
GET /node_modules/.vite/deps/react.js (pre-bundled)
-->
When the dev server receives a request like GET /src/App.tsx, it reads the file from disk, runs it through the plugin pipeline (which handles Vue SFCs, JSX, CSS modules, etc.), and rewrites bare import specifiers like import React from 'react' into paths the browser can fetch — something like /node_modules/.vite/deps/react.js.
Layer 2: esbuild — The Speed Engine
esbuild is the workhorse behind Vite's speed in development. It handles two critical jobs: dependency pre-bundling and TypeScript/JSX transpilation. In production, it also handles CSS and (optionally) JS minification.
Why esbuild is so fast
esbuild is 10–100× faster than JavaScript-based tools like Babel or Terser, and this isn't marketing — it's architecture. Four design decisions make this possible:
| Design Choice | Why It Matters |
|---|---|
| Written in Go | Compiles to native machine code — no V8 JIT warmup, no garbage collection pauses during hot paths |
| Parallelism | Parsing, linking, and code generation run across all available CPU cores using Go's goroutines |
| Shared memory | All threads share a single address space — no serialization cost when passing data between compilation phases |
| Single-pass pipeline | Avoids the "parse → serialize AST → deserialize → transform → serialize again" pattern of JS tools. The AST stays in memory throughout |
Dependency pre-bundling
Most npm packages aren't published as ESM — they use CommonJS or UMD. The browser can't execute require() calls. So before your dev server starts, Vite runs esbuild to convert every dependency in node_modules into ESM and flatten deep import chains into single files.
Consider a library like lodash-es: importing debounce triggers imports across dozens of internal modules. Without pre-bundling, the browser would make hundreds of HTTP requests, creating a waterfall that crushes performance. esbuild collapses each dependency into a single ESM file, cached in node_modules/.vite/deps.
// vite.config.ts — you can control pre-bundling behavior
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
// Force-include deps that aren't statically detectable
include: ['lodash-es/debounce'],
// Exclude deps you know are valid ESM
exclude: ['my-esm-only-package'],
},
})
TypeScript and JSX transpilation
esbuild also transpiles TypeScript and JSX on every request in development. It strips type annotations and converts JSX to function calls, but it does not perform type checking — that's left to your IDE or a separate tsc process. This is a deliberate tradeoff: type checking requires whole-program analysis, while transpilation is a per-file operation that esbuild can do in microseconds.
esbuild handles three things: (1) pre-bundling dependencies into ESM, (2) transpiling TS/JSX per file, and (3) minifying CSS and optionally JS in production. It does not handle code splitting, tree shaking, or the full production bundling pipeline — that's Rollup's job.
Layer 3: Rollup — The Production Optimizer
When you run vite build, Vite switches engines entirely. Rollup takes over because production builds demand capabilities esbuild doesn't offer: advanced code splitting with precise chunk boundaries, robust tree shaking with side-effect analysis, and a mature plugin ecosystem that powers CSS extraction, asset hashing, and manifest generation.
Rollup was designed from the ground up for ES modules. Its tree-shaking algorithm operates at the statement level — it can eliminate individual exports from a module, not just entire files. This matters for libraries like lodash-es where you import 3 functions from a package that exports 300.
// vite.config.ts — Rollup options for production
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
// Control chunk splitting strategy
manualChunks: {
vendor: ['react', 'react-dom'],
charts: ['d3', 'recharts'],
},
},
},
// esbuild still handles minification by default
minify: 'esbuild', // or 'terser' for legacy compat
},
})
Why not esbuild for production?
This is the question every developer asks. esbuild can bundle — so why not use it for production too? The answer comes down to three gaps that matter for shipping optimized apps:
| Capability | esbuild | Rollup |
|---|---|---|
| Code splitting (dynamic imports) | Basic support | Advanced — fine-grained control over chunk boundaries, manualChunks |
| Tree shaking | Good for most cases | Statement-level with side-effect analysis and /*#__PURE__*/ annotations |
| Plugin ecosystem | Limited hook surface | Mature, extensive — CSS extraction, asset handling, HTML generation |
| CSS code splitting | Not supported | Automatic — CSS is split per async chunk |
| Build speed | Extremely fast | Slower — but Rollup 4 with SWC parser closes the gap significantly |
The tradeoff is deliberate: esbuild optimizes for speed per transform, while Rollup optimizes for output quality. In development, speed wins because you're iterating. In production, output quality wins because your users pay the cost of every extra kilobyte.
The Plugin System: Bridging Both Engines
Vite's plugin API is a superset of Rollup's plugin interface. If you've written a Rollup plugin, it already works in Vite's production build. Vite extends this interface with dev-server-specific hooks like configureServer, transformIndexHtml, and handleHotUpdate.
This design means most plugins work in both development and production without separate implementations. A plugin that transforms .svg files into React components uses the same transform hook regardless of which engine is running the build.
// A Vite plugin works across both engines
import type { Plugin } from 'vite'
export function myPlugin(): Plugin {
return {
name: 'my-transform-plugin',
// Rollup-compatible hook — runs in dev AND production
transform(code, id) {
if (id.endsWith('.custom')) {
return { code: compileCustomFormat(code), map: null }
}
},
// Vite-only hook — runs only in dev
configureServer(server) {
server.middlewares.use('/health', (req, res) => {
res.end('ok')
})
},
}
}
Use the apply property in a plugin to restrict it to either 'serve' (dev) or 'build' (production). Use the enforce property ('pre' or 'post') to control execution order relative to Vite's core plugins.
Vite 5+ and Rollup 4: Closing the Speed Gap
Vite 5 upgraded to Rollup 4, which replaced Rollup's JavaScript-based parser with a native one built on SWC (a Rust-based compiler). This made parsing — typically the most expensive phase of bundling — significantly faster. The result: production builds in Vite 5+ are measurably quicker without sacrificing any of Rollup's output quality.
This evolution points toward Vite's long-term strategy: keep the dual-engine architecture but push both engines toward native-speed tooling. The dev server gets speed from esbuild (Go), and the production build gets speed from SWC (Rust) inside Rollup, while the plugin API stays in JavaScript for ecosystem compatibility.
Vite does not use esbuild for production bundling. If your production build is behaving differently from dev, it's likely because you're hitting a Rollup-specific behavior (different code splitting, stricter tree shaking, or a plugin that runs differently in build mode). Always test with vite build before deploying.
Mental Model: Which Engine Does What
Here's the cheat sheet to keep in your head:
| Task | Engine | When |
|---|---|---|
| HTTP server & middleware | Connect (Node.js) | Dev only |
| Dependency pre-bundling (CJS → ESM) | esbuild | Dev startup |
| TypeScript / JSX transpilation | esbuild | Dev (per request) |
| CSS minification | esbuild | Production |
| JS minification (default) | esbuild | Production |
| Code splitting & bundling | Rollup | Production |
| Tree shaking | Rollup | Production |
| Asset hashing & manifests | Rollup | Production |
Plugin transform hooks | Both (via Vite's plugin container) | Dev & Production |
Every performance characteristic, every behavioral difference between dev and prod, and every debugging clue traces back to this division of labor. When something works in dev but breaks in production, you now know exactly where to look.
Getting Started: Scaffolding Projects with create-vite
The fastest way to start a Vite project is with create-vite, the official scaffolding tool. It generates a minimal, well-structured project with sensible defaults — no ejecting, no hidden configuration. You get a working dev server in under 30 seconds.
Interactive Scaffolding
Run the following command and create-vite walks you through framework and variant selection via interactive prompts:
npm create vite@latest
You'll be prompted for a project name, then a framework (React, Vue, Svelte, etc.), and finally a variant (JavaScript or TypeScript). The tool creates a directory, writes the template files, and tells you to cd in and install dependencies.
Explicit Template Flag (Skip Prompts)
If you already know what you want — or you're running this in a CI pipeline — pass the --template flag directly. The -- after the project name separates npm's arguments from the ones forwarded to create-vite:
npm create vite@latest my-app -- --template react-ts
This creates a my-app/ directory with a fully configured React + TypeScript project — no interactive prompts, no follow-up questions.
Official Templates
Vite ships with templates for every major frontend framework, each available in plain JavaScript and TypeScript variants:
| Framework | JavaScript | TypeScript |
|---|---|---|
| Vanilla | vanilla | vanilla-ts |
| Vue | vue | vue-ts |
| React | react | react-ts |
| React (SWC) | react-swc | react-swc-ts |
| Preact | preact | preact-ts |
| Lit | lit | lit-ts |
| Svelte | svelte | svelte-ts |
| Solid | solid | solid-ts |
| Qwik | qwik | qwik-ts |
The react-swc and react-swc-ts variants swap Babel for SWC — a Rust-based compiler that's significantly faster for JSX/TSX transforms. If you're starting a new React project, the SWC variants are the better default.
Project Structure: react-ts Template
After scaffolding with --template react-ts and running npm install, here's what you get:
my-app/
├── index.html # The entry point — Vite serves this directly
├── package.json # Scripts: dev, build, preview
├── tsconfig.json # TypeScript base config
├── tsconfig.app.json # App-specific TS config (extends base)
├── tsconfig.node.json # Node-targeted TS config (for vite.config.ts)
├── vite.config.ts # Vite configuration
├── public/ # Static assets (copied as-is to dist/)
│ └── vite.svg
└── src/
├── main.tsx # React DOM mount — renders <App />
├── App.tsx # Root component
├── App.css # Component styles
├── index.css # Global styles
└── vite-env.d.ts # TypeScript ambient types for Vite
Key Files Explained
index.html is the true entry point of your application. Vite's dev server serves it directly, and during builds, Vite processes it as the starting point for the module graph. The file contains a <script type="module"> tag pointing to /src/main.tsx:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
In traditional bundlers like webpack, index.html lives in public/ and gets treated as a static asset — the bundler injects script tags during the build. Vite flips this: index.html is the entry point. Vite's dev server intercepts it, resolves the <script type="module"> imports, and serves transformed modules on the fly. Placing it at the project root makes this relationship explicit — it's source code, not a build artifact.
src/main.tsx is the JavaScript entry point referenced by index.html. It mounts the React app to the DOM:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
vite.config.ts is where you configure plugins, build options, dev server behavior, and more. The React-TS template starts minimal:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
The Three Core Scripts
Every create-vite template includes three scripts in package.json. These are the only commands you need during development:
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
}
}
Here's what each does and when to use it:
-
npm run dev— Start the Dev ServerLaunches Vite's development server on
http://localhost:5173. It serves yourindex.html, transforms modules on demand via native ESM, and enables Hot Module Replacement (HMR). File changes reflect in the browser in milliseconds — no full page reload needed.bash$ npm run dev VITE v6.x.x ready in 150 ms ➜ Local: http://localhost:5173/ ➜ Network: use --host to expose ➜ press h + enter to show help -
npm run build— Production BuildFirst runs
tsc -bto type-check your TypeScript (it does not emit JS — Vite handles that). Thenvite buildbundles your app with Rollup under the hood, producing optimized, minified, code-split output in thedist/directory. This is what you deploy.bash$ npm run build vite v6.x.x building for production... ✓ 34 modules transformed. dist/index.html 0.46 kB │ gzip: 0.30 kB dist/assets/index-BsQm4xGf.css 1.39 kB │ gzip: 0.72 kB dist/assets/index-DiwrgTda.js 143.36 kB │ gzip: 46.09 kB ✓ built in 780ms -
npm run preview— Preview the Production BuildSpins up a local static file server that serves the
dist/directory. Use this to verify your production build works correctly before deploying. It's not a dev server — there's no HMR, no transforms. You must runnpm run buildfirst.bash$ npm run build && npm run preview ➜ Local: http://localhost:4173/ ➜ Network: use --host to expose
Package Manager Variants
The create command works across all major package managers. The syntax is almost identical:
# Interactive
npm create vite@latest
# Explicit template
npm create vite@latest my-app -- --template react-ts
# Then install and run
cd my-app && npm install && npm run dev
# Interactive
pnpm create vite@latest
# Explicit template (no -- needed with pnpm)
pnpm create vite@latest my-app --template react-ts
# Then install and run
cd my-app && pnpm install && pnpm dev
# Interactive
yarn create vite
# Explicit template
yarn create vite my-app --template react-ts
# Then install and run
cd my-app && yarn && yarn dev
# Interactive
bun create vite@latest
# Explicit template
bun create vite@latest my-app --template react-ts
# Then install and run
cd my-app && bun install && bun run dev
Tips for Real-World Usage
Use --template in CI/Automation
When scaffolding projects in CI pipelines, scripts, or project generators, always use the explicit --template flag. Interactive prompts will hang in non-TTY environments. Combine it with a fixed project name to make the command fully deterministic:
# Fully non-interactive — safe for CI
npm create vite@latest my-app -- --template react-ts
cd my-app && npm install && npm run build
Community Templates via degit
The official templates are intentionally minimal. For more opinionated starters (with routing, state management, testing, linting pre-configured), you can pull community templates directly from GitHub using the --template flag with a repository URL:
# Pull a community template from GitHub (uses degit under the hood)
npx degit user/repo-name my-project
# Example: a popular React + TS + Tailwind starter
npx degit bluwy/create-vite-extra/template-react-ts-tailwind my-project
Several frameworks build on Vite and offer their own scaffolding CLIs with richer defaults. SvelteKit (npx sv create), Nuxt (npx nuxi init), Analog (Angular + Vite), and SolidStart all use Vite under the hood but add routing, SSR, and more. If your framework has a dedicated meta-framework, start there instead of bare create-vite.
The Native ESM Dev Server: How Vite Serves Your Code
Vite's dev server fundamentally rethinks how a development environment works. Instead of bundling your entire application before serving it — the way Webpack or Parcel does — Vite leverages the browser's native ES module (ESM) loader to fetch only the files it actually needs. The result is a server that starts in milliseconds, regardless of project size.
Understanding what happens under the hood when you run vite dev is key to debugging issues, configuring the server correctly, and appreciating why Vite feels so fast.
The Browser Drives the Process
When you start the Vite dev server and open your app, the browser first fetches index.html. Inside that file, it finds a <script type="module"> tag — this is the critical entry point. That type="module" attribute tells the browser to treat the script as an ES module, which means the browser itself will parse the file, discover import statements, and issue HTTP requests for each dependency.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My Vite App</title>
</head>
<body>
<div id="root"></div>
<!-- The browser treats this as an ES module -->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
This is a fundamentally different model from traditional bundlers. The browser is the one deciding which modules to load, based on the actual import graph it discovers at runtime. Vite simply sits on the other end, waiting for requests and transforming files on demand. Unused code is never even touched during development — if no import path leads to a file, Vite never processes it.
Request Lifecycle: From URL to Transformed Code
Here's the full sequence of what happens when the browser requests a module like /src/main.tsx:
sequenceDiagram
participant Browser
participant Vite as Vite Dev Server
participant FS as File System
Browser->>Vite: GET /index.html
Vite->>Browser: index.html (with script type=module)
Note over Browser: Parses HTML, finds script src="/src/main.tsx"
Browser->>Vite: GET /src/main.tsx
Vite->>Vite: Check module graph (cached? unchanged?)
Vite->>FS: Read /src/main.tsx
Vite->>Vite: Plugin transforms (TSX → JS)
Vite->>Browser: 200 OK (Content-Type: application/javascript)
Note over Browser: Parses main.tsx, discovers imports
par Parallel module requests
Browser->>Vite: GET /src/App.tsx
Vite->>Vite: Transform TSX → JS
Vite->>Browser: Transformed App.tsx
and
Browser->>Vite: GET /node_modules/.vite/deps/react.js
Vite->>Browser: Pre-bundled React (from cache)
end
Note over Browser: App renders in the browser
Notice how the browser makes parallel requests for /src/App.tsx (your source code, transformed on the fly) and /node_modules/.vite/deps/react.js (a pre-bundled dependency served directly from cache). This parallelism is another reason Vite feels fast — the browser's module loader handles concurrency for you.
The Transformation Pipeline
Every module request passes through a well-defined pipeline. Understanding these steps helps you debug when a plugin isn't working or a file isn't being served correctly.
-
URL received and normalized
The dev server receives an HTTP request (e.g.,
GET /src/App.tsx?t=1700000000). Vite strips query parameters used for cache-busting and normalizes the path to a file system location. -
Module graph lookup
Vite checks its in-memory module graph to see if this module has been served before. If the file hasn't changed since the last request (verified via file system timestamp), Vite returns a
304 Not Modifiedresponse. The browser uses its cached version and skips re-parsing entirely. -
Read the source file
If the module is new or has changed, Vite reads the raw file from disk. For pre-bundled dependencies (anything inside
node_modules/.vite/deps/), Vite serves the already-optimized file directly — no further transformation needed. -
Run through plugin transform hooks
The file passes through Vite's plugin pipeline, which mirrors Rollup's hook system. Three hooks matter most here:
resolveId— Resolves bare specifiers and aliases to absolute file pathsload— Optionally provides the file content (useful for virtual modules)transform— Transforms the source code (e.g., TSX → JS via esbuild, Vue SFC compilation, CSS modules)
-
Return transformed code
Vite sends the transformed JavaScript back to the browser with the correct
Content-Type: application/javascriptheader. Import specifiers within the code have been rewritten to browser-loadable URLs, ready for the browser to fetch next.
Bare Import Rewriting
Browsers don't understand bare import specifiers like import React from 'react'. They need a full URL path. Vite intercepts these during the transform step and rewrites them to point to the pre-bundled dependencies cache.
// What you write in your source code:
import React from 'react';
import { useState } from 'react';
import dayjs from 'dayjs';
// What the browser actually receives from Vite:
import React from '/node_modules/.vite/deps/react.js?v=a1b2c3d4';
import { useState } from '/node_modules/.vite/deps/react.js?v=a1b2c3d4';
import dayjs from '/node_modules/.vite/deps/dayjs.js?v=e5f6g7h8';
The ?v= query parameter is a content hash. It changes when the dependency is updated, busting the browser's cache. This lets Vite serve dependencies with strong Cache-Control: max-age=31536000,immutable headers — the browser caches them indefinitely until the hash changes.
Vite could theoretically use browser-native import maps to resolve bare specifiers. However, rewriting imports gives Vite full control over cache-busting, allows it to inject HMR client code, and avoids compatibility issues with older browsers. The rewrite approach is more reliable and works consistently across all target environments.
The Module Graph
At the heart of Vite's dev server is the module graph — an in-memory data structure that tracks every module the server has processed. Each node in the graph stores the module's URL, its file path, the transformed code, a timestamp, and crucially, two sets of relationships: importers (who imports this module) and importees (what this module imports).
// Simplified view of a module node in Vite's module graph
interface ModuleNode {
url: string; // e.g., "/src/App.tsx"
file: string | null; // Absolute path on disk
importers: Set<ModuleNode>; // Modules that import this one
importedModules: Set<ModuleNode>; // Modules this one imports
transformResult: { // Cached transform output
code: string;
map: SourceMap | null;
} | null;
lastHMRTimestamp: number; // Used for cache invalidation
acceptedHmrDeps: Set<ModuleNode>; // HMR boundary detection
}
This graph is what makes HMR efficient. When you edit Button.tsx, Vite walks the importers chain upward until it finds a module that accepts HMR updates (an HMR boundary). Only the modules within that boundary are invalidated and re-fetched — not the entire page. Without this graph, Vite would have no way of knowing which parts of your app are affected by a single file change.
Dev Server Configuration
Vite's dev server is built on top of Connect (a minimal Node.js HTTP middleware framework) and exposes several practical configuration options you'll commonly use.
API Proxy
When your frontend needs to talk to a backend API during development, you can proxy requests to avoid CORS issues entirely. Vite uses http-proxy under the hood.
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 3000,
host: true, // Listen on all addresses (0.0.0.0)
open: true, // Auto-open browser on start
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/ws': {
target: 'ws://localhost:8080',
ws: true, // Proxy WebSocket connections
},
},
},
});
With this config, a fetch to /api/users from your frontend is transparently forwarded to http://localhost:8080/users. The browser sees a same-origin request, so no CORS headers are needed.
HTTPS and CORS
Some APIs or authentication flows (like OAuth redirects or Secure cookies) require HTTPS even in development. Vite makes this easy with @vitejs/plugin-basic-ssl, which generates a self-signed certificate on the fly.
// vite.config.ts
import { defineConfig } from 'vite';
import basicSsl from '@vitejs/plugin-basic-ssl';
export default defineConfig({
plugins: [basicSsl()],
server: {
cors: true, // Enable CORS for all origins
// Or fine-tune it:
// cors: { origin: 'https://my-app.test', credentials: true },
},
});
Set server.host: true when you need to access the dev server from another device on your network (e.g., testing on a phone). Without it, Vite binds only to localhost and won't be reachable from other machines.
Putting It All Together
The elegance of Vite's dev server comes from inverting the traditional model. Instead of the build tool deciding what to process upfront, the browser decides at runtime by following the import graph. Vite's job is simply to respond — transforming files on demand, caching aggressively, and keeping a module graph to know exactly what to invalidate when something changes.
| Aspect | Traditional Bundler (Webpack) | Vite Dev Server |
|---|---|---|
| Startup | Bundles entire app before serving | Starts server immediately, transforms on demand |
| File processing | All files in the dependency graph | Only files the browser requests |
| Module resolution | Resolved at build time by bundler | Resolved at runtime by browser + Vite rewrites |
| Caching | In-memory bundle, full rebuild on change | Per-module 304s + strong caching for deps |
| Dependency handling | Bundled with app code | Pre-bundled separately, served from cache |
This on-demand architecture is why a Vite project with 10,000 modules starts just as fast as one with 100. The server startup cost is constant — the variable cost shifts to the browser's first page load, which only fetches the modules actually needed for the current route.
Hot Module Replacement: How Vite Updates Without Reloading
Hot Module Replacement (HMR) is the feature that makes Vite feel magical during development. When you save a file, your changes appear in the browser almost instantly — no full page reload, no lost application state. But behind that seamless experience is a precise, multi-step pipeline that coordinates your file system, the Vite dev server, and the browser's HMR runtime.
Understanding how this pipeline works isn't just academic curiosity. It helps you debug situations where HMR doesn't behave as expected, write custom HMR code for non-framework modules, and appreciate why framework plugins like @vitejs/plugin-react exist.
The HMR Cycle: End to End
Every hot update follows the same five-stage cycle. A file changes on disk, the server figures out what's affected, and the browser surgically replaces just the modules that need updating. Here's the full sequence:
sequenceDiagram
participant FS as File System
participant CK as chokidar (Watcher)
participant VS as Vite Dev Server
participant MG as Module Graph
participant WS as WebSocket
participant HC as Browser HMR Client
participant App as Application
FS->>CK: File saved to disk
CK->>VS: change event (file path)
VS->>MG: Invalidate changed module + importers
VS->>MG: Walk up graph to find HMR boundary
MG-->>VS: Boundary module (accepts hot update)
VS->>WS: Send update payload {type, path, timestamp}
WS->>HC: Receive update message
HC->>VS: Fetch updated module(s) via HTTP
VS-->>HC: Return transformed module with ?t=timestamp
HC->>App: Re-execute module within boundary
App->>App: UI updates without page reload
Stage 1: File System Watching with chokidar
Vite uses chokidar, a battle-tested Node.js file watcher, to monitor your project directory for changes. When you hit save, chokidar fires a change event with the absolute file path. Vite filters this against configured watch options (by default, it ignores node_modules and .git).
Stage 2: Module Graph Invalidation
Vite maintains a module graph — an in-memory data structure that tracks every module, its imports, and its importers. When a file changes, Vite finds the corresponding ModuleNode and invalidates it by clearing its cached transform result. It then walks up the importer chain, invalidating every module that depends on the changed file, until it reaches a module that can handle the update.
Stage 3: Determining the HMR Boundary
The critical question Vite answers at this stage is: how far up the module graph does this change propagate? Vite walks upward from the changed module, looking for the nearest module that has called import.meta.hot.accept(). This module is the HMR boundary — the point where the update stops propagating and gets handled. If the changed module itself calls accept(), it is its own boundary.
Stage 4: WebSocket Update Payload
Vite sends a JSON message over the WebSocket connection to the browser. The payload includes the update type, the list of modules to update, and a timestamp used for cache-busting. Here's what a typical payload looks like:
{
"type": "update",
"updates": [
{
"type": "js-update",
"path": "/src/utils/formatDate.js",
"acceptedPath": "/src/utils/formatDate.js",
"timestamp": 1700000000000
}
]
}
Stage 5: Browser-Side Module Replacement
The HMR client runtime (injected by Vite into every page as @vite/client) receives the WebSocket message. It fetches the updated module from the Vite dev server using a cache-busted URL like /src/utils/formatDate.js?t=1700000000000. The server transforms the file on-the-fly (applying plugins, converting JSX, etc.) and returns native ESM. The client then re-executes the module and triggers any registered accept callbacks.
The import.meta.hot API
The import.meta.hot object is only available during development (Vite strips it in production builds). It provides the primitives that control how a module participates in HMR. Every method on this API serves a specific role in the update lifecycle.
accept() — Self-Accepting Modules
When a module calls import.meta.hot.accept() with no arguments (or with a callback), it declares itself as an HMR boundary. When this module changes, Vite re-executes it and calls the callback with the new module. The old module is replaced in place — no propagation up the import chain.
// src/theme.js — a self-accepting module
export const colors = {
primary: '#3b82f6',
secondary: '#10b981',
};
if (import.meta.hot) {
import.meta.hot.accept();
}
This is the simplest form. When theme.js changes, Vite re-executes it and any module that imports colors from it will get the new values on next access. However, modules that already captured the old values in local variables won't see the update — that's an important caveat.
accept(deps, callback) — Accepting Dependency Updates
A module can also watch for changes in its dependencies and react to them. This is useful when you need to re-run side effects (like re-rendering) when a dependency changes, without re-executing the current module entirely.
// src/renderer.js — accepts updates from a dependency
import { colors } from './theme.js';
function render() {
document.getElementById('app').style.backgroundColor = colors.primary;
}
render();
if (import.meta.hot) {
import.meta.hot.accept('./theme.js', (newTheme) => {
// newTheme is the re-executed module's exports
document.getElementById('app').style.backgroundColor = newTheme.colors.primary;
});
}
When theme.js changes, the callback fires with the fresh module exports. The current module (renderer.js) is not re-executed — only the callback runs. This gives you fine-grained control over exactly what happens during an update.
dispose(callback) — Cleanup Before Replacement
The dispose callback runs just before the old version of a module is replaced. Use it to tear down side effects: clear timers, remove event listeners, disconnect WebSocket connections, or clean up DOM mutations. Without proper disposal, you'll accumulate stale listeners and timers with each hot update.
// src/ticker.js — cleans up on disposal
let intervalId = setInterval(() => {
console.log('tick', Date.now());
}, 1000);
if (import.meta.hot) {
import.meta.hot.dispose(() => {
clearInterval(intervalId);
});
import.meta.hot.accept();
}
invalidate() — Propagate Upward
Sometimes a module realizes mid-update that it can't handle the change itself. Calling import.meta.hot.invalidate() inside an accept callback tells Vite to propagate the update further up the module graph, as if this module didn't have an HMR boundary at all. This is a safety valve for conditional acceptance.
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
if (newModule.hasBreakingChange) {
// Can't handle this update — propagate to parent
import.meta.hot.invalidate();
}
});
}
API Summary
| Method | Purpose | When It Runs |
|---|---|---|
accept() | Mark this module as an HMR boundary | After the new module is executed |
accept(deps, cb) | Accept changes from specific dependencies | After the dependency is re-executed |
dispose(cb) | Clean up side effects before replacement | Before the old module is discarded |
invalidate() | Reject the update, propagate to parent | Inside an accept callback |
prune(cb) | Clean up when module is removed from graph | When the module is no longer imported |
data | Persist state across HMR updates | Available on both old and new module |
Practical Example: HMR for a Vanilla Counter Module
Let's put it all together with a realistic example — a counter module that preserves its count across hot updates, cleans up its DOM, and handles dependency changes. This is the kind of code you'd write for a vanilla (non-framework) project.
// src/counter.js
let count = import.meta.hot?.data?.count ?? 0;
const container = document.getElementById('counter');
function render() {
container.innerHTML = `
<p>Count: ${count}</p>
<button id="inc">Increment</button>
`;
document.getElementById('inc').addEventListener('click', () => {
count++;
render();
});
}
render();
if (import.meta.hot) {
// Preserve count across updates
import.meta.hot.dispose((data) => {
data.count = count;
});
import.meta.hot.accept();
}
The key pattern here is the dispose/data handoff. Before the old module is discarded, dispose saves the current count to import.meta.hot.data. When the new module executes, it reads the preserved value from import.meta.hot.data.count. The ?? 0 fallback handles the first load when no prior data exists.
import.meta.hot.data ObjectThe data object is shared between the old and new versions of the same module. You write to it in dispose() and read from it when the new module initializes. It's the official way to pass state across HMR boundaries — don't use global variables or window hacks.
Framework Plugins: Automatic HMR Boundaries
If you've worked with Vite and React or Vue, you've probably never written a single line of import.meta.hot code. That's because framework plugins automatically inject HMR boundaries for you. Understanding what they do behind the scenes explains why HMR "just works" in framework projects — and why it sometimes doesn't.
React: Fast Refresh via @vitejs/plugin-react
The @vitejs/plugin-react plugin uses Babel (or SWC in the SWC variant) to transform your React components at dev time. For every file that exports React components, the plugin injects a Fast Refresh wrapper. Here's a simplified view of what happens to your code:
// src/App.jsx
export default function App() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// Simplified — actual output is more complex
import { createHotContext } from "/@vite/client";
import.meta.hot = createHotContext("/src/App.jsx");
import RefreshRuntime from "/@react-refresh";
export default function App() {
const [count, setCount] = useState(0);
return /* jsx output */;
}
// Injected by the plugin:
RefreshRuntime.register(App, "/src/App.jsx App");
if (import.meta.hot) {
import.meta.hot.accept();
RefreshRuntime.performReactRefresh();
}
React Fast Refresh preserves useState and useRef state by keeping the existing component fiber and only swapping the render function. This is why you can edit JSX and see changes without losing form inputs or scroll position. However, Fast Refresh has rules: if a file exports anything that isn't a React component (a constant, a utility function), Fast Refresh bails out and falls back to a full re-render of that boundary.
Vue: SFC Hot Reload via @vitejs/plugin-vue
Vue's plugin takes a different approach. It splits each .vue Single File Component into its <template>, <script>, and <style> blocks and injects HMR code that can reload each part independently. Editing the template re-renders the component without resetting its reactive state. Editing the <style> block triggers a CSS-only update that doesn't touch the component at all.
If you're using React, Vue, Svelte, or Solid with their official Vite plugins, HMR boundaries are injected automatically. You only need to write manual import.meta.hot code for vanilla JS modules, custom state management, or framework-agnostic libraries.
Edge Cases: When HMR Falls Back to Full Reload
HMR doesn't always work. Understanding the failure modes helps you avoid frustrating "why did my whole page reload?" moments.
No HMR Boundary Found
If Vite walks all the way up the module graph from the changed file and never encounters a module with import.meta.hot.accept(), it has no choice but to trigger a full page reload. This commonly happens when you edit a module that's imported directly by your entry point (main.js) and the entry point doesn't accept hot updates.
Syntax Errors
If the updated module contains a syntax error, the HMR update fails. Vite displays an error overlay in the browser (via the @vite/client runtime) rather than crashing silently. Once you fix the error and save again, HMR resumes normally.
Circular Dependencies in the Module Graph
Circular imports can confuse the boundary-finding walk. Vite handles many circular dependency cases correctly, but complex circular graphs may cause unexpected full reloads. If you notice this, it's often a signal to refactor the circular dependency.
Vite handles CSS HMR separately. Imported .css files are injected as <style> tags, and Vite can replace them without any JavaScript module re-execution. If you're seeing full reloads on CSS changes, check whether the CSS file is being processed by a plugin that outputs JS (like CSS Modules) and ensure the plugin supports HMR.
State Preservation and the prune Event
We've already seen import.meta.hot.data for passing state between module versions. But there's one more lifecycle event worth knowing: prune.
The prune Event
The prune callback fires when a module is completely removed from the module graph — for example, when you delete an import statement so the module is no longer needed. This is different from dispose, which fires when a module is being replaced. Use prune to clean up resources that should only exist as long as the module is imported.
// src/analytics.js
const tracker = startAnalyticsSession();
if (import.meta.hot) {
// Runs when this module is replaced with a new version
import.meta.hot.dispose(() => {
tracker.pause();
});
// Runs when this module is removed from the graph entirely
import.meta.hot.prune(() => {
tracker.shutdown();
});
import.meta.hot.accept();
}
State Preservation Patterns
For complex state that must survive HMR updates, follow this pattern consistently across your vanilla modules:
// Pattern: Initialize from HMR data, save on dispose
const state = import.meta.hot?.data?.state ?? { items: [], selectedId: null };
// ... module logic uses `state` ...
if (import.meta.hot) {
import.meta.hot.dispose((data) => {
data.state = state; // Save state for next version
});
import.meta.hot.accept(); // Mark as HMR boundary
}
This three-line pattern — initialize from data, save in dispose, call accept — covers the vast majority of vanilla HMR needs. For framework projects, the plugin handles equivalent logic automatically, which is why React's useState values and Vue's reactive data survive hot updates without any manual intervention.
Dependency Pre-Bundling with esbuild: What, Why, and How to Configure It
When you run vite for the first time, it doesn't just start a dev server — it first pre-bundles your dependencies. This step uses esbuild (written in Go, 10–100× faster than JavaScript-based bundlers) to transform every dependency in node_modules into a browser-ready ES module cached in node_modules/.vite/deps/.
Pre-bundling solves two specific problems that would otherwise make unbundled ESM development unusable in practice. If you've ever wondered why Vite takes a beat on that first cold start, this is it.
The Two Problems Pre-Bundling Solves
Problem 1: CommonJS and UMD Packages
Browsers only understand ES modules — they can't interpret require() calls or module.exports assignments. But the npm ecosystem is still dominated by CommonJS. React, for example, ships CommonJS by default:
// node_modules/react/index.js — this is CommonJS!
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.min.js');
} else {
module.exports = require('./cjs/react.development.js');
}
Vite's pre-bundler converts this to an ESM wrapper with proper named exports so the browser can consume it directly via native import statements.
Problem 2: The HTTP Request Waterfall
Even packages that do ship as ESM can cause problems. Consider lodash-es: it exports over 600 individual modules. When you write import { debounce } from 'lodash-es', the browser would need to fetch hundreds of files in a cascading waterfall of HTTP requests — each one blocked until the previous finishes resolving its own imports.
Pre-bundling collapses all those internal modules into a single file. Instead of 600+ requests, the browser makes one.
| Package | Without Pre-Bundling | With Pre-Bundling |
|---|---|---|
lodash-es | 600+ HTTP requests (chained) | 1 request |
react | Fails (CommonJS, not ESM) | 1 ESM file with named exports |
react-dom | Fails (CommonJS + internal deps) | 1 ESM file, consolidated |
d3 | ~30 sub-packages, dozens of requests | 1 request |
How the Pre-Bundling Process Works
The pre-bundling pipeline kicks in on the first dev server start and whenever your dependencies change. Here's the full flow:
graph TD
A["First dev server start
(or deps changed)"] --> B["Scan source files for
bare import specifiers"]
B --> C["Resolve dependency
entry points in node_modules"]
C --> D["Feed entry points to esbuild
(one bundle per dependency)"]
D --> E["Output single ESM file per dep
in node_modules/.vite/deps/"]
E --> F["Write _metadata.json
with dependency map + hashes"]
F --> G["Rewrite browser imports
to point to pre-bundled files"]
style A fill:#1a1a2e,stroke:#7c3aed,color:#e2e8f0
style D fill:#1a1a2e,stroke:#f59e0b,color:#e2e8f0
style E fill:#1a1a2e,stroke:#10b981,color:#e2e8f0
style G fill:#1a1a2e,stroke:#7c3aed,color:#e2e8f0
Let's trace what happens when you have import React from 'react' in your code.
Step 1 — Scan: Vite crawls your source files (starting from your HTML entry points) and collects every bare import specifier — that is, imports that don't start with ., /, or @fs/. Bare specifiers like 'react', 'lodash-es', or '@tanstack/react-query' are flagged as dependencies.
Step 2 — Resolve & Bundle: Each discovered dependency is resolved to its entry point in node_modules, then handed to esbuild. Esbuild bundles the dependency (and all its internal imports) into a single flat ESM file.
Step 3 — Rewrite: Vite rewrites the import in your served code so the browser fetches the pre-bundled version:
// What you write:
import React from 'react';
import { debounce } from 'lodash-es';
// What the browser actually receives:
import __vite__cjsImport0_react from "/node_modules/.vite/deps/react.js?v=a1b2c3d4";
const React = __vite__cjsImport0_react.__esModule
? __vite__cjsImport0_react.default
: __vite__cjsImport0_react;
import { debounce } from "/node_modules/.vite/deps/lodash-es.js?v=a1b2c3d4";
The ?v=a1b2c3d4 query parameter is a content hash used for strong caching — the browser caches these files with max-age=31536000,immutable headers, so repeated page loads are instant.
The Dependency Cache
Pre-bundling only runs when it needs to. Vite writes a _metadata.json file into node_modules/.vite/deps/ that records exactly what was bundled and under what conditions. On subsequent starts, Vite compares three things to decide if the cache is still valid:
- Lockfile hash — the content hash of your
package-lock.json,yarn.lock,pnpm-lock.yaml, orbun.lockb - Relevant vite.config fields — specifically
optimizeDepsoptions and any plugin configurations that affect deps - NODE_ENV — since some packages ship different code for development vs production
If any of these change, the cache is invalidated and pre-bundling runs again. You can also force a cache bust manually:
# Delete the cache and force a full re-bundle
vite --force
# Or delete the directory manually
rm -rf node_modules/.vite
Configuring Pre-Bundling
Most of the time, pre-bundling works automatically. When it doesn't, Vite gives you three configuration knobs in vite.config.ts.
optimizeDeps.include — Force Pre-Bundle
If Vite's scanner misses a dependency (because it's dynamically imported, conditionally required, or comes from a linked package), you can force it into the pre-bundling step:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
include: [
'axios', // dynamically imported somewhere
'lodash-es/debounce', // deep import you want consolidated
'my-linked-lib > react', // dep nested inside a linked package
],
},
});
The > syntax is particularly useful for monorepos: 'my-linked-lib > react' tells Vite to pre-bundle the react copy that my-linked-lib depends on.
optimizeDeps.exclude — Skip Pre-Bundling
Some packages should not be pre-bundled — typically because they're already valid ESM and you want Vite to process them as part of your source (for example, to apply Vite plugins to them):
// vite.config.ts
export default defineConfig({
optimizeDeps: {
exclude: [
'my-esm-only-lib', // already ships clean ESM, skip it
],
},
});
If an excluded package has CommonJS dependencies of its own, those CJS dependencies must still be listed in optimizeDeps.include. Otherwise the browser will choke on require() calls it can't resolve.
optimizeDeps.esbuildOptions — Customize esbuild
You can pass options directly to esbuild for the pre-bundling step. This is useful when dependencies need special handling — for example, JSX in .js files or custom define replacements:
// vite.config.ts
export default defineConfig({
optimizeDeps: {
esbuildOptions: {
// Some older packages use JSX in .js files
loader: {
'.js': 'jsx',
},
// Inject defines only for the pre-bundling step
define: {
global: 'globalThis',
},
// Add esbuild plugins (e.g., for Node.js polyfills)
plugins: [
nodePolyfillPlugin(),
],
},
},
});
Common Scenarios and Fixes
Linked Packages in Monorepos
Packages linked via npm link, yarn link, or workspace references aren't inside node_modules, so Vite's scanner treats them as source code rather than dependencies. If a linked package imports heavy dependencies internally (like react), those won't be pre-bundled unless you explicitly include them:
// vite.config.ts — monorepo with a linked UI library
export default defineConfig({
optimizeDeps: {
include: [
'@myorg/ui-lib > react',
'@myorg/ui-lib > react-dom',
'@myorg/ui-lib > styled-components',
],
},
});
Conditional and Dynamic Imports
Vite's static scanner can't detect dependencies that are imported conditionally or constructed dynamically at runtime. These will be missed during the initial pre-bundling pass:
// This dynamic import WON'T be detected by the scanner
const loadChart = async (type) => {
if (type === 'bar') {
return import('chart.js/auto'); // missed!
}
};
Fix this by adding 'chart.js/auto' to optimizeDeps.include.
The Re-optimization Flow
Even after the initial pre-bundling pass, Vite can discover new dependencies at runtime — for example, when you navigate to a lazily-loaded route that imports a package the scanner didn't see. When this happens, you'll see a message in the terminal:
[vite] new dependencies optimized: chart.js/auto
[vite] ✨ optimized dependencies changed. reloading
Here's what happens under the hood: Vite re-runs esbuild with the new dependency added to the list, updates _metadata.json, and triggers a full page reload. The reload is necessary because the module graph has changed — the browser's module cache has stale references that can't be patched in place.
If you see repeated "new dependencies optimized" reloads during development, add the reported packages to optimizeDeps.include in your config. This moves the cost to the initial startup instead of interrupting you mid-workflow.
For large applications with many lazy routes, proactively listing known dynamic dependencies in optimizeDeps.include prevents this re-optimization churn entirely. The trade-off is a slightly longer cold start, but smoother development after that.
Pre-bundling only applies to the dev server. In production builds, Vite uses Rollup (or optionally Rollup-compatible Rust-based bundler starting from Vite 6) which handles dependency bundling as part of the normal build pipeline. The optimizeDeps config has no effect on production output.
vite.config.ts In Depth: Every Major Option Explained
The configuration file is the control center of every Vite project. While Vite works out of the box with zero config, real-world projects almost always need customization — aliases, proxy rules, build targets, CSS preprocessors, and more. This section walks through every major option group with practical examples so you know exactly what each knob does and when to turn it.
defineConfig and Config File Formats
Vite looks for a config file named vite.config.ts (or .js, .mjs, .cjs) at the project root. The defineConfig helper doesn't change runtime behavior at all — it's a pure identity function that exists solely to give you TypeScript type checking and IntelliSense in your editor. Always use it.
import { defineConfig } from 'vite'
export default defineConfig({
// Full IntelliSense and type checking here
root: './src',
build: {
outDir: '../dist',
},
})
If you're stuck in a CommonJS environment (e.g., a legacy Node setup), use the .cjs extension. If you want guaranteed ESM without relying on "type": "module" in package.json, use .mjs. In practice, vite.config.ts is the standard choice — Vite transpiles it automatically via esbuild.
| File | Format | When to use |
|---|---|---|
vite.config.ts | TypeScript ESM | Default choice — type safety + IntelliSense |
vite.config.js | ESM or CJS (depends on package.json) | When you don't want TypeScript in config |
vite.config.mjs | ESM (always) | Force ESM regardless of package.json |
vite.config.cjs | CommonJS (always) | Legacy CJS environments |
Conditional Config via Function Export
Instead of exporting a static object, you can export a function. Vite calls it with { command, mode, isSsrBuild, isPreview } so you can branch your configuration based on whether you're running vite dev (command: 'serve') or vite build (command: 'build'), and which --mode was used.
import { defineConfig } from 'vite'
export default defineConfig(({ command, mode }) => {
const isDev = command === 'serve'
return {
define: {
__APP_VERSION__: JSON.stringify('1.2.0'),
},
build: {
sourcemap: mode === 'staging' ? true : false,
minify: isDev ? false : 'esbuild',
},
}
})
command is always 'serve' or 'build'. mode defaults to 'development' for serve and 'production' for build, but you can override it with --mode staging. They serve different purposes: command tells you what Vite is doing, mode tells you for whom.
Top-Level Options: root, base, mode, publicDir, cacheDir
These five options control the fundamental paths and behavior of your project. Most developers only ever touch base — the rest have sensible defaults.
export default defineConfig({
// Where index.html lives. Defaults to process.cwd()
root: './src',
// Base public path for deployment. '/' for root, '/app/' for subpath
base: '/my-app/',
// Overrides default mode ('development' | 'production')
mode: 'staging',
// Static assets copied as-is to build output. Relative to root.
publicDir: '../public',
// Where Vite stores cached files. Relative to root.
cacheDir: '../node_modules/.vite',
})
| Option | Default | Typical override reason |
|---|---|---|
root | process.cwd() | Monorepo where index.html is in a subdirectory |
base | '/' | Deploying to https://cdn.example.com/app/ or GitHub Pages subdirectory |
mode | 'development' / 'production' | Custom modes like staging or test |
publicDir | 'public' | Moving static assets to a different folder |
cacheDir | 'node_modules/.vite' | CI environments with custom cache locations |
resolve: Aliases, Extensions, and Symlinks
The resolve option controls how Vite (and Rollup during build) finds your modules. The most commonly used sub-option is alias, which lets you create import shortcuts like @/components/Button instead of long relative paths.
import { resolve } from 'path'
import { defineConfig } from 'vite'
export default defineConfig({
resolve: {
// Path aliases — replaces the start of an import specifier
alias: {
'@': resolve(__dirname, 'src'),
'@components': resolve(__dirname, 'src/components'),
'@utils': resolve(__dirname, 'src/utils'),
},
// Package export conditions to match (e.g., 'module', 'import', 'browser')
conditions: ['module', 'browser', 'development'],
// Extensions to try when importing without an extension
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'],
// Set true in monorepos with pnpm/npm linked packages
preserveSymlinks: false,
},
})
conditions maps to Node's conditional exports. When a library's package.json has an "exports" field with multiple conditions, Vite uses this list to decide which entry point to resolve. You rarely need to change this unless a library isn't resolving correctly.
preserveSymlinks mirrors Node's --preserve-symlinks flag. Enable it when you're working with npm link or pnpm workspaces and you need the symlinked package to resolve dependencies from its own location rather than the project root.
css: Modules, PostCSS, and Preprocessors
Vite has first-class CSS support built in. Import a .css file and it's injected into the page. Import a .module.css file and you get a scoped class name object. The css config group controls the finer details of this pipeline.
export default defineConfig({
css: {
// CSS Modules behavior
modules: {
localsConvention: 'camelCaseOnly', // .my-class → styles.myClass
scopeBehaviour: 'local', // default; 'global' disables scoping
generateScopedName: '[name]__[local]__[hash:base64:5]',
},
// PostCSS config — inline or auto-loaded from postcss.config.js
postcss: {
plugins: [
require('autoprefixer'),
require('postcss-nesting'),
],
},
// Options passed directly to CSS preprocessors
preprocessorOptions: {
scss: {
additionalData: '@use "@/styles/variables" as *;',
api: 'modern-compiler', // Use the modern Sass API
},
less: {
math: 'always',
globalVars: { primaryColor: '#1890ff' },
},
},
// Generate source maps for CSS during development
devSourcemap: true,
},
})
preprocessorOptions is especially useful for injecting global SCSS/Less variables or mixins into every file without requiring a manual @use or @import in each component. The additionalData string is prepended to every processed file.
json: Named Exports and Stringify
By default, Vite lets you import JSON files with named exports — import { version } from './package.json'. This enables tree-shaking so only the fields you reference end up in your bundle. The stringify option is a performance optimization for large JSON files.
export default defineConfig({
json: {
// Allow import { name } from './data.json'
namedExports: true,
// Use JSON.parse() at runtime instead of inlining as JS object.
// Faster parsing for large JSON. Disables namedExports.
stringify: false,
},
})
esbuild: JSX, Target, and Transform Options
Vite uses esbuild for TypeScript and JSX transpilation during development (and for minification in production by default). The esbuild config lets you control JSX behavior, the transpilation target, and more. This is where you configure things like jsxFactory for Preact or custom JSX runtimes.
export default defineConfig({
esbuild: {
// JSX transform — 'automatic' uses the new JSX runtime (React 17+)
jsx: 'automatic',
// For non-React frameworks like Preact
// jsxFactory: 'h',
// jsxFragment: 'Fragment',
// jsxImportSource: 'preact',
// Transpile down to a specific target
target: 'es2020',
// Strip console.log and debugger in production builds
drop: ['console', 'debugger'],
// Add a legal comment banner to output files
banner: '/* © 2024 My Company */',
},
})
Setting esbuild: false disables esbuild transforms entirely. You'd do this if you're using SWC via a plugin (like @vitejs/plugin-react-swc) and want to avoid double-processing.
assetsInclude and plugins
The assetsInclude option extends the list of file types that Vite treats as static assets (importable as URLs). By default, Vite handles images, fonts, media, and other common formats. Use this when you have unusual asset types.
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
// Treat .glb and .hdr files as importable assets
assetsInclude: ['**/*.glb', '**/*.hdr'],
// Plugins are applied in order
plugins: [
react(),
// myCustomPlugin(),
],
})
The plugins array is where most of Vite's power comes from. Plugins are applied in declaration order. Vite's plugin API is a superset of Rollup's — any Rollup plugin works, plus Vite adds hooks like configureServer and transformIndexHtml for dev-specific behavior.
server: Dev Server Configuration
The server option group controls everything about the Vite development server — the host, port, HTTPS, proxy rules, HMR behavior, file system access, and more. This is one of the most commonly customized sections.
export default defineConfig({
server: {
host: '0.0.0.0', // Listen on all interfaces (useful in Docker)
port: 3000, // Default is 5173
strictPort: true, // Fail if port is already in use
open: '/dashboard', // Open this path in browser on start
// Proxy API requests to a backend server
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
// WebSocket proxy
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
// CORS — true to allow all origins
cors: true,
// HMR configuration
hmr: {
overlay: true, // Show error overlay in browser
// port: 24678, // Custom HMR WebSocket port
},
// File watching options (passed to chokidar)
watch: {
usePolling: true, // Needed in Docker/VM environments
interval: 1000,
},
// File system access restrictions
fs: {
allow: [
// Allow serving files from one level up (e.g., monorepo root)
'..',
],
},
},
})
The proxy option is critical for local development when your frontend and backend run on different ports. The changeOrigin: true flag rewrites the Host header to match the target — most backend frameworks require this. The rewrite function lets you strip or modify the path prefix before forwarding.
fs.allow controls which directories the dev server can serve files from. By default, Vite restricts access to the workspace root. If you're in a monorepo and need to import from a sibling package, add the monorepo root or the sibling's path here.
build: Production Build Configuration
The build option group is where you control the production output — browser targets, output directories, code splitting, sourcemaps, minification, and Rollup-specific overrides. This section has the most options of any config group.
export default defineConfig({
build: {
// Browser compatibility target — passed to esbuild for transpilation
target: 'es2020',
// Output directory (relative to project root)
outDir: 'dist',
// Directory for generated assets inside outDir
assetsDir: 'assets',
// Split CSS per async chunk. true = better caching.
cssCodeSplit: true,
// Source maps: true | 'inline' | 'hidden' | false
sourcemap: 'hidden', // Maps for error tracking, not exposed to browser
// Direct Rollup configuration overrides
rollupOptions: {
input: {
main: 'index.html',
admin: 'admin.html',
},
output: {
// Manual chunk splitting
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
},
},
},
// Minifier: 'esbuild' (fast) or 'terser' (smaller, slower)
minify: 'esbuild',
// Warn when a chunk exceeds this size (in kB)
chunkSizeWarningLimit: 500,
// Generate a manifest.json for server-side integration
manifest: true,
},
})
build.target Explained
build.target controls two things: which JavaScript syntax features esbuild transpiles away, and which CSS features Vite will transform. The default is 'modules', which targets browsers that support native ES modules (<script type="module">). Set it to a specific ES version like 'es2020' or a browser list like ['chrome89', 'safari15'] when you need fine-grained control.
build.lib for Library Mode
If you're building a library instead of an application, use build.lib to produce UMD, ESM, and CJS bundles without an HTML entry point.
import { resolve } from 'path'
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyLib', // Global variable name for UMD
formats: ['es', 'umd'], // Output formats
fileName: (format) => `my-lib.${format}.js`,
},
rollupOptions: {
// Externalize deps that shouldn't be bundled
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
},
})
preview: Preview Server Configuration
The preview option group configures the server that vite preview starts. This command serves the production build locally so you can verify it before deploying. Its options mirror the server group.
export default defineConfig({
preview: {
host: '0.0.0.0',
port: 4173,
strictPort: true,
proxy: {
'/api': {
target: 'http://staging-api.example.com',
changeOrigin: true,
},
},
},
})
The preview server is a simple static file server — it doesn't perform any bundling or HMR. Use it to catch issues like incorrect base paths, missing assets, or broken proxy rules that only surface with the production build.
Advanced Patterns
Async Config Functions
Your config function can be async. This is useful when you need to fetch data, read files, or dynamically import a module before constructing the config.
import { defineConfig } from 'vite'
export default defineConfig(async ({ command, mode }) => {
// Dynamically import a heavy plugin only during build
const plugins = []
if (command === 'build') {
const { visualizer } = await import('rollup-plugin-visualizer')
plugins.push(visualizer({ open: true }))
}
return { plugins }
})
Merging Configs with mergeConfig
When you have shared base configuration and environment-specific overrides, use Vite's mergeConfig utility. It performs a deep merge that correctly handles arrays (like plugins) and nested objects — unlike a shallow spread.
import { defineConfig, mergeConfig, type UserConfig } from 'vite'
import baseConfig from './vite.config.base'
const prodOverrides: UserConfig = {
build: {
sourcemap: 'hidden',
minify: 'terser',
},
}
export default defineConfig(mergeConfig(baseConfig, prodOverrides))
Using Environment Variables in Config
Environment variables from .env files are not loaded when the config file runs — the config is evaluated before env files are processed. To access .env values inside your config, use Vite's loadEnv helper explicitly.
import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => {
// Load .env files based on mode. The third argument '' means
// load ALL env vars, not just VITE_-prefixed ones.
const env = loadEnv(mode, process.cwd(), '')
return {
server: {
proxy: {
'/api': {
target: env.API_URL, // e.g., from .env.development
changeOrigin: true,
},
},
},
define: {
__API_URL__: JSON.stringify(env.VITE_API_URL),
},
}
})
By default, loadEnv only loads variables prefixed with VITE_. Pass an empty string '' as the third argument to load all variables. Be careful — don't accidentally expose secrets by passing non-VITE_ variables to the define option, which inlines values into client-side code.
Best Practices
Keep Configs Minimal
Vite's defaults are well-chosen. Only override what you actually need. A config file with 200 lines of options is a maintenance burden and usually means you're fighting the tool instead of using it. Start with zero config and add options one at a time as requirements emerge.
Prefer Plugins Over Custom rollupOptions
If you find yourself writing complex rollupOptions with custom plugins or hooks, check if a Vite/Rollup plugin already exists. For example, instead of manually configuring rollupOptions.output.manualChunks with intricate logic, consider vite-plugin-chunk-split. Plugins encapsulate complexity and are easier to update or swap out.
Use TypeScript Config Files
Always use vite.config.ts with defineConfig. The type safety catches typos and invalid option combinations at edit time. There's zero performance penalty — Vite transpiles the config with esbuild before executing it.
Align Aliases with tsconfig.json Paths
When you set up resolve.alias, mirror the same paths in your tsconfig.json's compilerOptions.paths. Otherwise your editor will show import errors even though Vite resolves them fine at runtime.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
}
}
}
Use vite-tsconfig-paths plugin to automatically sync tsconfig.json paths as Vite aliases — no need to maintain them in two places.
Environment Variables and Modes: .env Files and import.meta.env
Vite uses dotenv to load environment variables from .env files in your project root. These variables become available in your application through the import.meta.env object — but only if they follow Vite's security rules. Understanding this system is essential for managing API keys, feature flags, and per-environment configuration.
.env File Precedence
Vite loads .env files in a specific order, where later files override earlier ones. The precedence from lowest to highest priority is:
| File | When Loaded | Git-ignored? | Priority |
|---|---|---|---|
.env | Always | No | Lowest |
.env.local | Always | Yes | ⬆ |
.env.[mode] | Only in specified mode | No | ⬆ |
.env.[mode].local | Only in specified mode | Yes | Highest |
For example, during vite dev (which uses development mode by default), Vite loads: .env, then .env.local, then .env.development, then .env.development.local. A variable defined in .env.development.local overrides the same variable in .env.
Mode-specific .env files (e.g., .env.production) take priority over generic ones (e.g., .env). If a conflict exists, the mode-specific value wins. Also, environment variables that already exist when Vite starts (e.g., from your shell) have the highest priority and will not be overwritten by .env files.
A typical project structure looks like this:
.env # Shared defaults for all environments
.env.local # Local overrides (git-ignored) — secrets go here
.env.development # Dev-specific vars (committed)
.env.development.local # Dev-specific local overrides (git-ignored)
.env.production # Production vars (committed)
.env.production.local # Production local overrides (git-ignored)
.env.staging # Custom mode: staging
The VITE_ Prefix and Security
Not all variables in your .env files end up in the browser. Vite only exposes variables prefixed with VITE_ to your client-side code via import.meta.env. This is a deliberate security boundary.
# ✅ Exposed to client code — has VITE_ prefix
VITE_API_URL=https://api.example.com
VITE_APP_TITLE=My App
# ❌ NOT exposed to client code — no VITE_ prefix
DATABASE_URL=postgres://user:password@localhost:5432/mydb
SECRET_API_KEY=sk-super-secret-key-12345
In your application code, only the VITE_-prefixed variables are accessible:
console.log(import.meta.env.VITE_API_URL); // "https://api.example.com"
console.log(import.meta.env.VITE_APP_TITLE); // "My App"
console.log(import.meta.env.DATABASE_URL); // undefined
console.log(import.meta.env.SECRET_API_KEY); // undefined
Vite statically replaces import.meta.env.VITE_* references with their string values during the build. This means the values are literally embedded in your JavaScript bundle and visible to anyone who inspects the output. Without the prefix gate, a database password in your .env file could end up shipped to every user's browser. The VITE_ prefix forces you to make a conscious choice about what gets exposed.
Built-in Environment Variables
Vite provides several built-in variables on import.meta.env that are always available without any prefix:
| Variable | Type | Description |
|---|---|---|
import.meta.env.MODE | string | The current mode: "development", "production", or a custom value |
import.meta.env.BASE_URL | string | The base URL the app is served from, set by the base config option |
import.meta.env.PROD | boolean | true when MODE === "production" |
import.meta.env.DEV | boolean | true when MODE === "development" (always opposite of PROD) |
import.meta.env.SSR | boolean | true when code is running during server-side rendering |
These are useful for conditional logic without needing any .env file at all:
if (import.meta.env.DEV) {
// This block is tree-shaken out in production builds
console.log("Running in development mode");
enableDevTools();
}
// Use MODE for more granular checks
if (import.meta.env.MODE === "staging") {
connectToStagingAnalytics();
}
Modes: Default and Custom
A "mode" in Vite determines which .env.[mode] files are loaded and what import.meta.env.MODE resolves to. By default, vite dev uses development mode and vite build uses production mode. You can override this with the --mode flag.
# Default modes
vite dev # mode = "development"
vite build # mode = "production"
# Custom modes
vite build --mode staging # mode = "staging", loads .env.staging
vite dev --mode testing # mode = "testing", loads .env.testing
Create a .env.staging file and add a corresponding npm script to make it easy to use:
# .env.staging
VITE_API_URL=https://staging-api.example.com
VITE_ENABLE_DEBUG=true
{
"scripts": {
"dev": "vite",
"build": "vite build",
"build:staging": "vite build --mode staging",
"preview:staging": "vite preview --mode staging"
}
}
TypeScript: Typing Custom Env Variables
By default, TypeScript doesn't know about your custom VITE_* variables, so import.meta.env.VITE_API_URL has type any. You can fix this by augmenting the ImportMetaEnv interface in an env.d.ts file at your project root (or in src/).
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_APP_TITLE: string;
readonly VITE_ENABLE_DEBUG: string; // all env vars are strings
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Make sure this file is included in your tsconfig.json — either via include or by placing it within the included source directories. The /// <reference types="vite/client" /> directive brings in the built-in variable types (MODE, DEV, PROD, etc.), and your interface declaration merges with it.
HTML Env Replacement
Vite also supports replacing environment variables directly in your index.html file using the %ENV_VARIABLE% syntax. This works with any variable from the loaded .env files — including the built-in ones.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>%VITE_APP_TITLE%</title>
<link rel="icon" href="%BASE_URL%favicon.ico" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
If a referenced variable is not defined, it is left as-is (the literal %VITE_APP_TITLE% string appears in the output) rather than being replaced with an empty string. This makes missing variables easy to spot.
Advanced: Variable Interpolation with dotenv-expand
Vite uses dotenv-expand out of the box, so you can reference other variables inside your .env files using $VARIABLE or ${VARIABLE} syntax:
VITE_HOST=https://api.example.com
VITE_API_VERSION=v2
VITE_API_BASE=${VITE_HOST}/${VITE_API_VERSION}
# Result: VITE_API_BASE = "https://api.example.com/v2"
# Escape the $ sign if you need a literal dollar sign
VITE_PRICE=\$100
Advanced: Accessing Env in vite.config.ts with loadEnv()
Your vite.config.ts runs in Node.js, before the .env files are processed for your app code. This means import.meta.env is not available inside the config file. Instead, Vite provides the loadEnv() utility to manually load env variables for the current mode.
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
// Load env files based on `mode` in the current working directory.
// The third argument ('') loads ALL env vars, not just VITE_-prefixed.
const env = loadEnv(mode, process.cwd(), '');
return {
define: {
// Make a non-VITE_ variable available to client code (use carefully!)
'__APP_VERSION__': JSON.stringify(env.APP_VERSION),
},
server: {
// Use env vars to configure the dev server
proxy: {
'/api': {
target: env.API_PROXY_TARGET,
changeOrigin: true,
},
},
},
};
});
The loadEnv() signature is loadEnv(mode, envDir, prefixes?). By default, it only loads variables with the VITE_ prefix. Pass an empty string '' as the third argument to load all variables. Pass a specific prefix string or array of prefixes to filter differently.
Advanced: Customizing the envPrefix
If you don't want to use the VITE_ prefix — perhaps you're migrating from Create React App (REACT_APP_) or have organizational naming conventions — you can configure envPrefix in your Vite config:
import { defineConfig } from 'vite';
export default defineConfig({
// Accept multiple prefixes — useful during CRA migrations
envPrefix: ['VITE_', 'REACT_APP_'],
// Or use a completely custom prefix
// envPrefix: 'MYAPP_',
});
Never set envPrefix to an empty string "". This would expose every variable from your .env files to client code, completely defeating the security boundary. Vite will warn you if you try to do this.
TypeScript Support: Transpilation, Type Checking, and tsconfig
Vite supports TypeScript out of the box — you can import .ts and .tsx files and they just work. But there's a critical distinction you need to understand: Vite only transpiles TypeScript. It does not type-check it. This is a deliberate design choice, and it affects how you structure your build pipeline.
Transpilation ≠ Type Checking
Under the hood, Vite uses esbuild to strip type annotations from your TypeScript files. esbuild doesn't analyze types at all — it simply removes them, converting .ts into plain .js at extraordinary speed. esbuild transpiles TypeScript roughly 20–30x faster than tsc because it skips the entire type-checking phase and is written in Go rather than JavaScript.
This means your Vite dev server starts instantly regardless of project size. Type errors won't block your HMR updates or slow down page loads. But it also means if you have a type error, Vite won't tell you about it — your code will still compile and run (or fail at runtime).
Many developers assume that because Vite handles .ts files, it also validates types. It does not. A file with egregious type errors will still be served by the dev server as long as the JavaScript is syntactically valid after type stripping.
Adding Type Checking to Your Workflow
Since Vite won't catch type errors, you need to run tsc yourself. The most common pattern is gating your production build behind a type check using tsc --noEmit (which checks types without outputting files). This way, broken types can't silently ship to production.
{
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
}
}
With this setup, npm run build will first run the TypeScript compiler to verify types. If any errors exist, the build fails before Vite even starts bundling. During development, rely on your editor's TypeScript language service for real-time feedback.
For developers who want type errors displayed directly in the browser overlay (similar to how ESLint errors appear in Create React App), the vite-plugin-checker package runs tsc in a separate worker thread alongside Vite's dev server.
// vite.config.ts
import { defineConfig } from 'vite';
import checker from 'vite-plugin-checker';
export default defineConfig({
plugins: [
checker({
typescript: true, // runs tsc --noEmit in a worker thread
}),
],
});
This gives you the best of both worlds: Vite's fast transpilation for HMR, plus type error feedback in your browser without blocking the dev server.
tsconfig Settings That Matter for Vite
Not every tsconfig.json option is relevant when Vite handles transpilation. Some settings are critical because esbuild respects them, and others matter because they must be compatible with esbuild's per-file transpilation model. Here are the key ones:
| Option | Recommended Value | Why It Matters |
|---|---|---|
target | "ESNext" | Let Vite/esbuild handle downleveling. Setting this to ESNext tells tsc to leave modern syntax alone. |
useDefineForClassFields | true | Aligns with ECMAScript spec behavior. esbuild defaults to true, so your tsconfig should match to avoid subtle class field bugs. |
isolatedModules | true | Essential. esbuild transpiles each file independently — it can't resolve cross-file type information. This flag makes tsc warn you about patterns that break under per-file transpilation (e.g., const enum, re-exporting types without type keyword). |
moduleResolution | "bundler" | Matches how Vite actually resolves modules — supports package.json exports, optional file extensions, and other bundler conventions. |
types | ["vite/client"] | Provides type definitions for Vite-specific features (covered below). |
A complete tsconfig for a Vite project typically looks like this:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"isolatedModules": true,
"useDefineForClassFields": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src"]
}
isolatedModules is non-negotiableWithout isolatedModules: true, TypeScript allows patterns like const enum that require the compiler to read multiple files to produce correct output. esbuild processes one file at a time and has no way to inline values from a const enum defined in another module. Setting isolatedModules makes tsc flag these patterns as errors so you catch them early.
Vite Client Type Definitions
Adding "vite/client" to your types array (or using a /// <reference types="vite/client" /> directive) unlocks TypeScript support for several Vite-specific APIs. Without it, your editor will show red squiggles on perfectly valid Vite code.
Here's what vite/client provides types for:
import.meta.env— Typed access to environment variables (VITE_*prefixed variables,MODE,BASE_URL,DEV,PROD,SSR)- Asset imports — Importing
.svg,.png,.jpg,.woff2, and other static assets resolves to astring(the URL) - CSS imports — Importing
.cssworks without errors;.module.cssimports resolve to a record of class names import.meta.hot— The HMR API is properly typed foraccept(),dispose(), and other hot module methods
To add your own custom environment variable types, extend the ImportMetaEnv interface:
// src/env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_APP_TITLE: string;
readonly VITE_ENABLE_ANALYTICS: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Path Aliases: Syncing Vite and TypeScript
Path aliases like @/components/Button need to be configured in two places: Vite's resolve.alias (so the bundler can resolve them) and TypeScript's paths (so the type checker and your editor understand them). If you only configure one side, either the build breaks or your editor shows errors.
// vite.config.ts
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@utils': path.resolve(__dirname, './src/utils'),
},
},
});
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
}
}
}
Both configurations must stay in sync manually. If you add a new alias in vite.config.ts, you must add the corresponding entry in tsconfig.json — there's no automatic synchronization between them. The vite-tsconfig-paths plugin can read your tsconfig paths and automatically register them as Vite aliases, letting you maintain a single source of truth.
JSX Configuration for Non-React Frameworks
By default, Vite transpiles JSX using React's runtime. If you're using Preact, SolidJS, or another JSX-based framework, you need to tell esbuild which factory functions to use. You configure this in vite.config.ts under the esbuild option:
// vite.config.ts — Preact example
import { defineConfig } from 'vite';
export default defineConfig({
esbuild: {
jsxFactory: 'h',
jsxFragment: 'Fragment',
jsxInject: `import { h, Fragment } from 'preact'`,
},
});
jsxFactory sets the function that replaces <div> calls (React uses React.createElement, Preact uses h). jsxFragment sets the fragment wrapper. jsxInject automatically prepends an import statement to every JSX file so you don't need manual imports.
esbuild vs. SWC: Choosing a Transpiler
While esbuild is Vite's default transpiler, the @vitejs/plugin-react-swc plugin swaps it out for SWC during development. Both strip types without checking them, but they have different strengths:
| Aspect | esbuild (default) | SWC (plugin-react-swc) |
|---|---|---|
| Language | Go | Rust |
| Speed | Extremely fast | Comparable — within the same order of magnitude |
| React Fast Refresh | Via @vitejs/plugin-react (uses Babel for Fast Refresh transform) | Built-in — no Babel needed |
| Decorator support | Limited (TC39 stage 3 only) | Full legacy & stage 3 decorator support |
| Custom transforms | Not supported | SWC plugin ecosystem available |
| Use case | Great default for most projects | Best for React projects that want to drop Babel entirely |
The practical difference for most React projects: @vitejs/plugin-react (esbuild) still uses Babel under the hood for the React Fast Refresh transform and any custom Babel plugins you configure. @vitejs/plugin-react-swc eliminates Babel entirely, which can noticeably improve dev server startup and HMR speed in large projects. If you don't need Babel plugins, SWC is the faster choice for React.
For new React projects, start with @vitejs/plugin-react-swc. You get faster HMR, no Babel dependency, and decorator support if you need it. Switch to the standard @vitejs/plugin-react only if you depend on specific Babel plugins (e.g., babel-plugin-styled-components, relay compiler).
Static Asset Handling: Images, Fonts, JSON, WASM, and the Public Directory
Every web application ships static files — images, fonts, JSON data, WebAssembly modules, and more. Vite gives you a unified, import-based system for handling all of them, with automatic content hashing, inlining of small files, and zero-config defaults that work out of the box.
Understanding how Vite resolves and transforms these assets is essential. The wrong approach can break cache busting, bloat your bundle, or cause assets to go missing in production.
Importing Assets as URLs
When you import a static file (image, video, font, etc.), Vite returns the resolved public URL as a string. During development, this is a simple path to the file. In production, Vite adds a content hash to the filename for cache busting.
import logo from './assets/logo.png';
import heroVideo from './assets/hero.mp4';
import resume from './assets/resume.pdf';
// In dev: /src/assets/logo.png
// In build: /assets/logo-a1b2c3d4.png
document.getElementById('logo').src = logo;
The content hash (a1b2c3d4) is derived from the file's contents, not its name or timestamp. This means if the file doesn't change between builds, the hash stays identical and browsers can keep using their cached copy. Change even one pixel, and the hash changes, busting the cache automatically.
The public Directory
Not every file belongs in the import pipeline. Some files need to keep their exact filename — favicon.ico, robots.txt, _redirects (for Netlify), or sitemap.xml. These go in the public/ directory at your project root.
my-project/
├── public/
│ ├── favicon.ico # → /favicon.ico
│ ├── robots.txt # → /robots.txt
│ └── og-image.png # → /og-image.png
├── src/
│ └── assets/
│ └── logo.png # → imported, hashed
└── vite.config.ts
Files in public/ are served at the root path during development and copied as-is to the build output. They are never processed, hashed, or transformed by Vite. Reference them with absolute paths in your code:
<!-- Correct: absolute path, no import needed -->
<link rel="icon" href="/favicon.ico" />
<img src="/og-image.png" alt="Open Graph preview" />
<!-- Wrong: don't try to import public assets -->
<!-- import favicon from '../public/favicon.ico' -->
Imported Assets vs. Public Assets
| Feature | Imported Asset (src/assets/) | Public Asset (public/) |
|---|---|---|
| Content hashing | ✅ Automatic hash in filename | ❌ No hashing — exact filename preserved |
| Tree-shaking | ✅ Unused imports are excluded | ❌ All files copied regardless |
| Small file inlining | ✅ Files under 4kb inlined as base64 | ❌ Never inlined |
| Referencing | import img from './img.png' | /img.png (absolute path) |
| Best for | Images, fonts, icons used in components | favicon.ico, robots.txt, _redirects, CNAME |
Asset Inlining with assetsInlineLimit
When an imported asset is smaller than assetsInlineLimit (default: 4096 bytes / 4kb), Vite converts it to a base64 data URL and inlines it directly into the JavaScript bundle. This eliminates an HTTP request for tiny files like small icons or SVGs.
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
// Default: 4096 (4kb). Set to 0 to disable inlining entirely.
assetsInlineLimit: 8192, // inline files up to 8kb
},
});
A 2kb icon imported as import icon from './icon.svg' won't produce a separate file in the build output — instead, icon will be a data:image/svg+xml;base64,... string. Files above the limit get a hashed filename and a normal URL.
Inlining increases your JS bundle size. For SVGs, consider using a component-based approach (e.g., vite-plugin-svgr for React) instead of base64 inlining — you get better rendering, styling, and accessibility.
Special Import Suffixes
Vite uses URL query suffixes to change how an import is handled. These suffixes transform the same file into completely different outputs depending on what you need.
Raw Imports with ?raw
Importing a file with ?raw gives you its content as a plain string. This is useful for loading shader code, SQL files, markdown, or any text you need at runtime.
import vertexShader from './shaders/vertex.glsl?raw';
import migrationSQL from './db/migration-001.sql?raw';
// vertexShader is the full file content as a string
console.log(typeof vertexShader); // "string"
JSON Imports
JSON files support both full default imports and named exports. Named exports allow tree-shaking — only the fields you actually use end up in the bundle.
// Full import — entire JSON object in the bundle
import packageJson from './package.json';
console.log(packageJson.version);
// Named export — only 'version' and 'name' are bundled (tree-shakeable!)
import { version, name } from './package.json';
console.log(version, name);
Named exports for JSON are enabled by default (json.namedExports: true in Vite config). You can disable this if it causes issues with non-object JSON files (arrays, primitives):
// vite.config.ts
export default defineConfig({
json: {
namedExports: true, // default — enables import { field } from './data.json'
stringify: false, // set true to use JSON.parse() at runtime (faster for large JSON)
},
});
Web Workers with ?worker
Vite can import Web Worker scripts directly. The imported value is a constructor that creates a new worker instance.
import MyWorker from './heavy-computation.js?worker';
const worker = new MyWorker();
worker.postMessage({ data: largeDataset });
worker.onmessage = (e) => {
console.log('Result from worker:', e.data);
};
WASM with ?init
WebAssembly modules can be imported with the ?init suffix. The import gives you an initialization function that returns the WASM instance's exports.
import init from './image-processor.wasm?init';
const instance = await init();
const result = instance.exports.processImage(pixelData);
// You can also pass importObject for WASM imports
const instanceWithImports = await init({
env: { memory: new WebAssembly.Memory({ initial: 256 }) },
});
Dynamic Asset URLs with new URL()
Static imports work great when you know the asset at write time. But what if the asset path is dynamic — selected from a list, loaded from a config, or computed at runtime? The new URL(path, import.meta.url) pattern solves this.
// Dynamic asset URL — works in both dev and production
function getImageUrl(name) {
return new URL(`./assets/icons/${name}.png`, import.meta.url).href;
}
const iconUrl = getImageUrl('arrow-right');
// Dev: http://localhost:5173/src/assets/icons/arrow-right.png
// Build: https://example.com/assets/arrow-right-x7f3a2.png
Vite statically analyzes the template literal at build time. It finds all files matching the pattern (./assets/icons/*.png) and includes them in the output. The key requirement is that the path must contain a static portion that Vite can resolve — a fully dynamic string like new URL(someVariable, import.meta.url) where someVariable could be anything won't work.
The new URL() pattern does not work in SSR because import.meta.url resolves differently in Node.js vs the browser. For SSR, use the public/ directory or a server-side file reading approach instead.
Custom Asset Types with assetsInclude
Vite recognizes a wide range of file extensions as static assets by default (images, media, fonts, etc.). If you work with a file type Vite doesn't recognize — like .glb for 3D models, .hdr for HDR images, or .lottie for animations — you can add it via the assetsInclude config option.
// vite.config.ts
export default defineConfig({
assetsInclude: ['**/*.glb', '**/*.hdr', '**/*.lottie'],
});
Once registered, these files behave exactly like built-in asset types — they get content-hashed filenames, respect assetsInlineLimit, and can be imported as URL strings.
import helmetModel from './models/helmet.glb';
// helmetModel is now '/assets/helmet-d4e5f6.glb' in production
scene.load(helmetModel);
Font Handling and Self-Hosted Fonts
Fonts imported from your source code follow the same asset pipeline: content hashing, optional inlining (though you almost never want to inline fonts), and resolved URLs. Here's the recommended pattern for self-hosted fonts.
First, place your font files in src/assets/fonts/ and create a CSS file that references them:
/* src/styles/fonts.css */
@font-face {
font-family: 'Inter';
font-weight: 400;
font-style: normal;
font-display: swap;
src: url('../assets/fonts/inter-regular.woff2') format('woff2'),
url('../assets/fonts/inter-regular.woff') format('woff');
}
@font-face {
font-family: 'Inter';
font-weight: 700;
font-style: normal;
font-display: swap;
src: url('../assets/fonts/inter-bold.woff2') format('woff2'),
url('../assets/fonts/inter-bold.woff') format('woff');
}
Then import this CSS file in your entry point. Vite processes the url() references, hashes the font files, and rewrites the paths in the built CSS automatically.
// src/main.js
import './styles/fonts.css';
import './styles/global.css';
Always use font-display: swap to avoid invisible text during font loading. Prefer .woff2 — it has the best compression and 97%+ browser support. Only include the weights and styles you actually use. Preload your primary font in index.html with <link rel="preload" href="/assets/fonts/inter-regular.woff2" as="font" type="font/woff2" crossorigin> for faster first paint.
Font Preloading in Vite Builds
Vite automatically generates <link rel="preload"> tags for font files that are directly imported in CSS chunks that are loaded on the initial page. If you need explicit control, you can manually add preload hints in your index.html — just remember that in production, font filenames will be hashed, so manual preloads for imported fonts may need to reference the public/ directory version instead.
<!-- index.html — only for fonts in public/ that need early loading -->
<head>
<link
rel="preload"
href="/fonts/inter-regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
</head>
Putting It All Together
Here's a quick reference for every asset import pattern covered in this section:
// Standard asset → resolved URL string (with content hash in prod)
import logo from './logo.png';
// Raw file content → string
import shader from './effect.glsl?raw';
// JSON → full object (default) or named exports (tree-shakeable)
import data from './config.json';
import { apiUrl } from './config.json';
// Web Worker → constructor
import MyWorker from './worker.js?worker';
// WASM → async init function
import init from './module.wasm?init';
// Dynamic asset URL → works with runtime values
const url = new URL(`./icons/${name}.svg`, import.meta.url).href;
// Asset as explicit URL (skips inlining, always emits file)
import logoUrl from './logo.png?url';
CSS Handling: PostCSS, Modules, Preprocessors, and CSS-in-JS
Vite treats CSS as a first-class citizen. Any .css file you import is automatically processed, and the behavior changes depending on whether you're in dev or production. During development, imported CSS is injected into the page via <style> tags with HMR support. During build, Vite extracts all CSS into separate .css files, minifies them, and handles cache-busting hashes.
Plain CSS Imports
Importing a CSS file in any JavaScript or TypeScript module is all it takes. Vite resolves @import statements within CSS files using postcss-import, so relative paths, absolute paths, and even ~ aliased paths (from node_modules) work as expected.
// main.js — plain CSS import
import './styles/global.css';
import './styles/reset.css';
/* global.css — @import is resolved by Vite */
@import './variables.css';
@import 'normalize.css'; /* from node_modules */
body {
font-family: var(--font-sans);
color: var(--color-text);
}
PostCSS
If Vite detects a PostCSS config file (postcss.config.js, .postcssrc, or any format supported by postcss-load-config), it automatically applies PostCSS to all imported CSS — including CSS from preprocessors and CSS Modules. You don't need to install any Vite plugins.
// postcss.config.js
export default {
plugins: {
'postcss-preset-env': {
stage: 2,
features: {
'nesting-rules': true,
},
},
autoprefixer: {},
},
};
PostCSS runs after preprocessors like Sass or Less, so your PostCSS plugins see standard CSS. This means Autoprefixer and postcss-preset-env work seamlessly with any preprocessor output.
CSS Modules
Any file ending in .module.css is treated as a CSS Module. When you import it, Vite returns a JavaScript object that maps original class names to unique scoped names. This eliminates global namespace collisions without any framework-specific tooling.
/* App.module.css */
.container {
max-width: 1200px;
margin: 0 auto;
}
.title {
font-size: 2rem;
color: #1a1a2e;
}
.title-highlighted {
color: #e94560;
}
// App.jsx
import styles from './App.module.css';
export default function App() {
// styles.container → "_container_1a2b3_1"
// styles.titleHighlighted → "_title-highlighted_1a2b3_14" (with camelCase)
return (
<div className={styles.container}>
<h1 className={styles.title}>Hello Vite</h1>
<p className={styles.titleHighlighted}>Fast by default</p>
</div>
);
}
CSS Modules Configuration
Vite exposes the full css.modules configuration object in vite.config.ts. The most commonly tweaked options are localsConvention, scopeBehaviour, and generateScopedName.
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
css: {
modules: {
// 'camelCase' — exports both original and camelCase keys
// 'camelCaseOnly' — exports only camelCase keys
// 'dashes' / 'dashesOnly' — converts only dashed names
localsConvention: 'camelCaseOnly',
// 'local' (default) — scopes all classes
// 'global' — makes all classes global by default
scopeBehaviour: 'local',
// Custom class naming pattern
// [name] = file name, [local] = original class, [hash:base64:5] = short hash
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
});
localsConvention | CSS Class | JS Key(s) |
|---|---|---|
'camelCase' | .my-title | styles['my-title'] and styles.myTitle |
'camelCaseOnly' | .my-title | styles.myTitle only |
'dashes' | .my-title | styles['my-title'] and styles.myTitle |
'dashesOnly' | .my-title | styles.myTitle only |
Preprocessors: Sass, Less, and Stylus
Vite has built-in support for .scss, .sass, .less, and .styl files — no plugins needed. The only requirement is that you install the corresponding preprocessor package. Vite delegates compilation to these tools, then pipes the output through PostCSS (if configured).
# Install only the one you need
npm install -D sass # for .scss / .sass files
npm install -D less # for .less files
npm install -D stylus # for .styl files
Passing Global Variables and Mixins
A common requirement is making design tokens (colors, breakpoints, mixins) available in every component file without explicit imports. The css.preprocessorOptions config lets you inject code at the top of every processed file.
// vite.config.ts
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
// Prepended to every .scss file before compilation
additionalData: `@use "@/styles/variables" as *;\n`,
// You can also pass Sass API options directly
api: 'modern-compiler', // use the modern Sass API (Vite 5.4+)
},
less: {
math: 'always',
globalVars: {
primaryColor: '#e94560',
},
},
},
},
});
Only use additionalData for @use, @forward, or variable declarations — never actual style rules. Since the code is prepended to every file, any CSS rules there get duplicated in every compiled chunk, bloating your output.
Lightning CSS (Experimental)
Starting with Vite 5, you can opt into Lightning CSS — a Rust-based CSS parser, transformer, and minifier — as an alternative to PostCSS for transforms and cssnano for minification. When enabled, Lightning CSS handles vendor prefixing, syntax lowering, and minification in a single pass, dramatically faster than the PostCSS pipeline.
npm install -D lightningcss
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
css: {
transformer: 'lightningcss', // use Lightning CSS instead of PostCSS
lightningcss: {
// Browser targets — Lightning CSS handles prefixing automatically
targets: {
chrome: 110,
firefox: 115,
safari: 16,
},
// Enable draft CSS features
drafts: {
customMedia: true,
},
},
},
build: {
cssMinify: 'lightningcss', // also use it for minification in production
},
});
When css.transformer is set to 'lightningcss', your postcss.config.js is ignored for transforms. You can still set css.transformer: 'postcss' (the default) and only use build.cssMinify: 'lightningcss' if you want Lightning CSS for minification only while keeping PostCSS for transforms.
CSS Code Splitting
Vite automatically splits CSS alongside your JavaScript chunks. When you use dynamic import() to lazy-load a component, any CSS that component imports gets extracted into a separate .css file. That CSS file loads on demand — only when the async chunk is fetched — keeping the initial page load lean.
// CSS for Dashboard is extracted into its own file
// and loaded only when this route is visited
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
// Build output:
// dist/assets/Dashboard-[hash].js ← JS chunk
// dist/assets/Dashboard-[hash].css ← CSS chunk (loaded automatically)
Vite also preloads the CSS file with a <link rel="stylesheet"> tag inserted before the JS chunk executes, preventing a flash of unstyled content (FOUC). If you need all CSS in a single file instead, set build.cssCodeSplit: false in your config.
Source Maps and Debugging
During development, CSS source maps are not enabled by default. You can turn them on with css.devSourcemap to get accurate file and line references in the browser dev tools — particularly useful when working with preprocessors or CSS Modules where the authored file differs from the injected output.
// vite.config.ts
export default defineConfig({
css: {
devSourcemap: true, // enables CSS source maps during dev
},
});
For production builds, source maps follow the top-level build.sourcemap option. Setting it to true or 'hidden' generates CSS source maps alongside JS source maps in the build output.
The Plugin System: Vite's Pipeline and Rollup Compatibility
Vite's plugin system is one of its most powerful architectural decisions. Rather than inventing a plugin interface from scratch, Vite extends a subset of Rollup's well-designed plugin API with additional hooks specific to development-time concerns. This means you get access to Rollup's battle-tested ecosystem while also being able to tap into Vite's dev server, HMR, and HTML transformation capabilities.
A Vite plugin is simply an object with a name property and one or more hook functions. When Vite processes your project — whether in dev or during a production build — it runs each plugin's hooks in a specific, predictable order.
The Plugin Hook Execution Pipeline
Every Vite build (dev or production) follows a well-defined pipeline. Some hooks run once at startup, others run once per module as Vite encounters them, and a final set runs at teardown. Understanding this order is the key to writing plugins that behave correctly.
flowchart TD
A["config
Modify raw user config"] --> B["configResolved
Read final merged config"]
B --> C{"Mode?"}
C -->|Dev| D["configureServer
Add custom middleware"]
C -->|Build| E["buildStart
Rollup build begins"]
D --> E
E --> F["Per Module Pipeline"]
subgraph PER_MODULE ["🔁 Runs for each module"]
G["resolveId
Resolve import specifier → file path"] --> H["load
Read file contents"]
H --> I["transform
Transform source code"]
end
F --> G
I --> J{"More modules?"}
J -->|Yes| G
J -->|No| K["buildEnd
Build finished"]
K --> L["closeBundle
Final cleanup"]
style PER_MODULE fill:#1a1a2e,stroke:#4fc3f7,stroke-width:2px
style A fill:#2d2d44,stroke:#4fc3f7
style B fill:#2d2d44,stroke:#4fc3f7
style D fill:#2d2d44,stroke:#66bb6a
style E fill:#2d2d44,stroke:#ffa726
style G fill:#2d2d44,stroke:#ffa726
style H fill:#2d2d44,stroke:#ffa726
style I fill:#2d2d44,stroke:#ffa726
style K fill:#2d2d44,stroke:#ffa726
style L fill:#2d2d44,stroke:#ffa726
Within each hook category, plugins run in their configured order. The enforce property determines where a plugin sits relative to Vite's core plugins: pre plugins fire first, then Vite's core, then normal (default) plugins, and finally post plugins.
Rollup Hook Compatibility
Vite doesn't support every Rollup hook — it only implements the ones that make sense for an unbundled dev server. During production builds, Vite hands off to Rollup directly, so all Rollup hooks work as expected. During development, however, only a subset of Rollup hooks are called because Vite serves modules individually without bundling.
| Rollup Hook | Dev Server | Production Build | Notes |
|---|---|---|---|
resolveId | ✅ | ✅ | Resolves import paths to file paths or virtual module IDs |
load | ✅ | ✅ | Loads the content for a resolved module |
transform | ✅ | ✅ | Transforms individual module source code |
buildStart | ✅ | ✅ | Called once when the server/build starts |
buildEnd | ✅ | ✅ | Called when the server/build ends |
closeBundle | ✅ | ✅ | Final cleanup after bundle generation |
options | ✅ | ✅ | Modifies Rollup input options |
renderChunk | ❌ | ✅ | Build-only — no chunks exist in dev |
generateBundle | ❌ | ✅ | Build-only — no bundle output in dev |
writeBundle | ❌ | ✅ | Build-only — nothing written to disk in dev |
augmentChunkHash | ❌ | ✅ | Build-only — chunk hashing not applicable in dev |
The core trio — resolveId, load, and transform — works identically in both dev and build. This is what makes Vite plugins portable: if your plugin only uses these three hooks, it automatically works in both modes without any conditional logic.
Vite-Specific Hooks
Beyond Rollup compatibility, Vite introduces its own hooks that address dev server concerns, HTML processing, and hot module replacement. These hooks have no Rollup equivalent and are ignored during the Rollup build phase (except where noted).
config — Modify the Raw Config
The config hook receives the raw user config before Vite merges it with defaults. You can return a partial config object that will be deep-merged, or mutate the config directly. This is the right place to set defaults your plugin needs.
function myPlugin() {
return {
name: 'my-plugin',
config(userConfig, { command, mode }) {
// Return a partial config to merge
if (command === 'serve') {
return { server: { port: 4000 } }
}
}
}
}
configResolved — Read the Final Config
After Vite merges all config sources (file, CLI flags, plugin config hooks), it calls configResolved with the frozen result. Store a reference here if your other hooks need access to the resolved config.
function myPlugin() {
let resolvedConfig
return {
name: 'my-plugin',
configResolved(config) {
resolvedConfig = config
},
transform(code, id) {
// Now you can use resolvedConfig.root, resolvedConfig.command, etc.
if (resolvedConfig.command === 'build') {
// production-specific transform
}
}
}
}
configureServer — Add Dev Server Middleware
This hook gives you direct access to the Vite dev server instance (a Connect app under the hood). You can add custom middleware for API mocking, request logging, or custom route handling. It only runs in dev mode.
function apiMockPlugin() {
return {
name: 'api-mock',
configureServer(server) {
server.middlewares.use('/api/user', (req, res) => {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ id: 1, name: 'Alice' }))
})
}
}
}
transformIndexHtml — Modify HTML
This hook lets you transform the index.html file. You can inject scripts, meta tags, or environment-specific markup. It receives the HTML string and returns either a modified string or an array of tag descriptors to inject.
function injectAnalytics() {
return {
name: 'inject-analytics',
transformIndexHtml(html) {
return html.replace(
'</head>',
`<script defer src="/analytics.js"></script>\n</head>`
)
}
}
}
handleHotUpdate — Custom HMR Logic
When a file changes, Vite determines which modules need to be invalidated and sends an HMR update to the browser. The handleHotUpdate hook lets you intercept this process — you can filter the affected modules, trigger custom events, or perform a full page reload for certain file types.
function dbSchemaPlugin() {
return {
name: 'db-schema-hmr',
handleHotUpdate({ file, server }) {
if (file.endsWith('.schema.json')) {
// Notify the client via a custom event
server.ws.send({ type: 'custom', event: 'schema-update' })
return [] // Don't trigger the default HMR update
}
}
}
}
Plugin Ordering with enforce
Vite processes plugins in a specific order, and the enforce property controls where your plugin sits in that sequence. This matters because a transform plugin that runs before Vite's core JSX handling will see raw JSX, while one that runs after will see the compiled output.
The full plugin ordering is:
- Alias resolution — Vite's built-in path alias handling
enforce: 'pre'plugins — Run before Vite's core transforms- Vite core plugins — Built-in handling for CSS, assets, JSX, etc.
- Normal plugins (no
enforce) — The default position - Vite build plugins — Internal plugins for production bundling
enforce: 'post'plugins — Run after everything else
// Runs BEFORE Vite core — sees raw source before any transforms
function rawSourceLinter() {
return {
name: 'raw-source-linter',
enforce: 'pre',
transform(code, id) {
if (id.endsWith('.ts') && code.includes('eval(')) {
this.warn('Avoid using eval() in ' + id)
}
}
}
}
// Runs AFTER everything — sees fully transformed output
function bundleSizeReporter() {
return {
name: 'bundle-size-reporter',
enforce: 'post',
generateBundle(_, bundle) {
for (const [name, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk') {
console.log(`${name}: ${(chunk.code.length / 1024).toFixed(1)} KB`)
}
}
}
}
}
The apply Property
Some plugins only make sense in one mode. A dev server mock API is useless during production builds, and a bundle-size analyzer has nothing to measure in dev. The apply property lets you restrict when a plugin is active.
// Only active during `vite dev`
function devOnlyPlugin() {
return {
name: 'dev-only',
apply: 'serve',
configureServer(server) { /* ... */ }
}
}
// Only active during `vite build`
function buildOnlyPlugin() {
return {
name: 'build-only',
apply: 'build',
generateBundle() { /* ... */ }
}
}
// Custom logic — only apply for non-SSR builds
function customApplyPlugin() {
return {
name: 'custom-apply',
apply(config, { command }) {
return command === 'build' && !config.build?.ssr
}
}
}
Virtual Modules
Virtual modules are modules that don't exist on disk. They're resolved by a plugin and their content is generated on the fly. This is a powerful pattern for injecting build-time data, environment variables, or generated code into your application.
The convention is to prefix virtual module IDs with \0 in the resolved ID. This prefix tells Vite and Rollup that the module is virtual and shouldn't be passed to other plugins or the file system. Users import the module with a virtual: prefix (or any convention you define).
function virtualBuildInfoPlugin() {
const virtualModuleId = 'virtual:build-info'
const resolvedVirtualModuleId = '\0' + virtualModuleId
return {
name: 'build-info',
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `export const buildTime = "${new Date().toISOString()}";
export const nodeVersion = "${process.version}";`
}
}
}
}
In your application code, you'd import it like any other module:
import { buildTime, nodeVersion } from 'virtual:build-info'
console.log(`Built at ${buildTime} on Node ${nodeVersion}`)
The \0 prefix in the resolved ID is not optional. Without it, other plugins may try to resolve the module as a file path, the file system may be scanned unnecessarily, and source maps can break. Always prefix resolved virtual module IDs with \0.
Essential Community Plugins
Vite's ecosystem includes official and community plugins for every major framework. These plugins handle framework-specific compilation, HMR, and optimizations so that you don't have to wire up those transforms yourself.
| Plugin | Purpose | Key Features |
|---|---|---|
@vitejs/plugin-react |
React support via Babel or SWC | Fast Refresh HMR, automatic JSX runtime, configurable Babel plugins |
@vitejs/plugin-vue |
Vue 3 Single File Component support | SFC compilation, <script setup>, CSS scoping, template HMR |
@sveltejs/vite-plugin-svelte |
Svelte component compilation | Svelte compiler integration, HMR, preprocessor support |
Using these plugins is straightforward — import and add them to the plugins array in your Vite config:
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react({
// Optional: use SWC instead of Babel for faster transforms
// jsxRuntime: 'automatic'
})
]
})
If your plugin only uses the resolveId / load / transform hooks, it's automatically a valid Rollup plugin too. This is a good design target — your plugin works in Vite, Rollup, and any tool that supports the Rollup plugin interface (like WMR or esbuild adapters).
Writing Custom Vite Plugins: A Hands-On Guide
The best way to understand Vite's plugin system is to build real plugins. In this section, you'll create four practical plugins from scratch — each exercising a different part of the plugin API. By the end, you'll have the muscle memory to reach for the right hook whenever you need to extend Vite.
Every Vite plugin is an object with a name property and one or more hook functions. The convention is to wrap this object in a factory function so consumers can pass options. Let's start simple and build up.
Plugin 1 — Transform: Inject Build Timestamps
The transform hook runs on every module as Vite processes it. It receives the source code and the module's file path, and you return modified source code. This is the workhorse hook for any "find and replace" style transformation.
Our first plugin replaces every occurrence of __BUILD_TIME__ in your source files with the actual build timestamp. This is useful for embedding version info into your app's footer or debug panel.
// vite-plugin-build-time.js
export default function buildTimePlugin(options = {}) {
const placeholder = options.placeholder ?? '__BUILD_TIME__';
const format = options.format ?? 'iso'; // 'iso' | 'locale' | 'unix'
return {
name: 'vite-plugin-build-time',
// Only run during production builds
apply: 'build',
transform(code, id) {
// Skip files that don't contain our placeholder
if (!code.includes(placeholder)) return null;
// Skip node_modules
if (id.includes('node_modules')) return null;
const now = new Date();
let timestamp;
switch (format) {
case 'locale': timestamp = now.toLocaleString(); break;
case 'unix': timestamp = String(now.getTime()); break;
default: timestamp = now.toISOString();
}
return {
code: code.replaceAll(placeholder, JSON.stringify(timestamp)),
map: null, // Return null if you don't have a source map
};
},
};
}
What's Happening Here
apply: 'build'— This plugin only runs duringvite build, not during dev server. The timestamp would be misleading in dev mode since HMR would re-stamp files at random times.- Early return with
null— Whentransformreturnsnull, Vite leaves the module unchanged. This is cheaper than returning the original code because Vite skips source map merging. - Returning
{ code, map }— The transform hook should return an object with the new code and an optional source map. We passmap: nullbecause a simple string replacement doesn't shift line numbers.
Using the Plugin
// vite.config.js
import { defineConfig } from 'vite';
import buildTimePlugin from './vite-plugin-build-time';
export default defineConfig({
plugins: [
buildTimePlugin({ format: 'iso' }),
],
});
Then in your application code:
// src/footer.js
const buildInfo = `Built at: ${__BUILD_TIME__}`;
// After build → const buildInfo = `Built at: ${"2025-01-15T09:30:00.000Z"}`;
Plugin 2 — Virtual Module: Build-Time App Config
A virtual module is a module that doesn't exist on disk. Your plugin fabricates its content on the fly. This is powerful for injecting build-time configuration, environment data, or generated code into your application through a clean import statement.
The pattern requires two hooks working together: resolveId tells Vite "I own this module ID" and load returns the module's source code when Vite asks for it.
// vite-plugin-app-config.js
export default function appConfigPlugin(appConfig) {
const virtualModuleId = 'virtual:app-config';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
return {
name: 'vite-plugin-app-config',
resolveId(id) {
if (id === virtualModuleId) {
// The '\0' prefix is a Rollup convention that tells other plugins
// "this is a virtual module — don't try to resolve it as a file"
return resolvedVirtualModuleId;
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
// Generate the module source code as a string
return `export default ${JSON.stringify(appConfig, null, 2)};`;
}
},
};
}
\0 prefix?The null-byte prefix (\0) is a Rollup convention adopted by Vite. It signals to other plugins and Vite internals that this ID is virtual — don't try to read it from the file system, don't pass it through other resolveId hooks, and don't try to watch it for changes. Always use this prefix for virtual modules.
Hook-by-Hook Breakdown
| Hook | When It Fires | What You Return |
|---|---|---|
resolveId(id, importer) | Any time Vite encounters an import and needs to resolve the specifier to a file path or ID | A string (the resolved ID) to claim ownership; null or undefined to pass |
load(id) | When Vite needs the source code for a resolved module ID | A string of source code, or null to let Vite read from disk |
Using the Virtual Module
// vite.config.js
import appConfigPlugin from './vite-plugin-app-config';
export default defineConfig({
plugins: [
appConfigPlugin({
apiBase: 'https://api.example.com',
featureFlags: { darkMode: true, betaSearch: false },
version: '2.4.1',
}),
],
});
// src/api-client.js
import config from 'virtual:app-config';
console.log(config.apiBase); // "https://api.example.com"
console.log(config.featureFlags); // { darkMode: true, betaSearch: false }
If you're using TypeScript, add a type declaration so the import doesn't produce an error:
// src/vite-env.d.ts
declare module 'virtual:app-config' {
const config: {
apiBase: string;
featureFlags: Record<string, boolean>;
version: string;
};
export default config;
}
Plugin 3 — configureServer: Debug Endpoint for the Module Graph
The configureServer hook gives you direct access to Vite's dev server (a Connect-based HTTP server). You can add custom middleware, intercept requests, or tap into the WebSocket connection used for HMR. This hook only runs during development — it doesn't exist in the build pipeline.
Let's build a plugin that exposes a /__debug endpoint. When you hit this URL in your browser, it returns the entire module graph as JSON — incredibly useful for debugging dependency issues or understanding what Vite has loaded.
// vite-plugin-debug-endpoint.js
export default function debugEndpointPlugin(options = {}) {
const path = options.path ?? '/__debug';
return {
name: 'vite-plugin-debug-endpoint',
apply: 'serve', // Dev server only
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url !== path) return next();
const modules = [];
// server.moduleGraph contains all loaded modules
for (const mod of server.moduleGraph.idToModuleMap.values()) {
modules.push({
id: mod.id,
url: mod.url,
file: mod.file,
type: mod.type,
importers: [...mod.importers].map((m) => m.url),
importedModules: [...mod.importedModules].map((m) => m.url),
lastHMRTimestamp: mod.lastHMRTimestamp,
transformResult: mod.transformResult ? 'loaded' : 'not loaded',
});
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ totalModules: modules.length, modules }, null, 2));
});
},
};
}
How configureServer Works
The server object you receive is Vite's ViteDevServer. Its key properties include:
server.middlewares— The Connect app instance. Use.use()to add middleware that runs before Vite's internal middleware (static file serving, HMR, etc.).server.moduleGraph— A live graph of all modules Vite has processed. You can query it by URL, file path, or module ID.server.ws— The WebSocket server used for HMR. You can send custom events to the client withserver.ws.send().
If you need your middleware to run after Vite's internal middleware (for example, as a fallback), return a function from configureServer:
configureServer(server) {
// Return a function → runs AFTER internal middleware
return () => {
server.middlewares.use((req, res, next) => {
// This runs after Vite's static file serving
// Good for SPA fallback routes
});
};
}
Plugin 4 — transformIndexHtml: Auto-Inject Tags into HTML
The transformIndexHtml hook lets you modify index.html before Vite serves or bundles it. This is the right place to inject analytics scripts, add preload hints, or insert environment-specific meta tags — tasks you'd otherwise handle with a templating engine or a shell script.
This plugin demonstrates all three: injecting a meta tag, adding a preload hint, and conditionally inserting an analytics script.
// vite-plugin-html-inject.js
export default function htmlInjectPlugin(options = {}) {
const {
analyticsId = null,
preloadFonts = [],
environment = 'production',
} = options;
return {
name: 'vite-plugin-html-inject',
// enforce: 'pre' ensures this runs before Vite's built-in HTML processing
enforce: 'pre',
transformIndexHtml(html) {
const tags = [];
// 1. Inject environment meta tag
tags.push({
tag: 'meta',
attrs: { name: 'app-environment', content: environment },
injectTo: 'head',
});
// 2. Add preload hints for critical fonts
for (const font of preloadFonts) {
tags.push({
tag: 'link',
attrs: {
rel: 'preload',
href: font,
as: 'font',
type: 'font/woff2',
crossorigin: 'anonymous',
},
injectTo: 'head',
});
}
// 3. Conditionally inject analytics script
if (analyticsId && environment === 'production') {
tags.push({
tag: 'script',
attrs: { async: true, src: `https://analytics.example.com/a.js?id=${analyticsId}` },
injectTo: 'head',
});
}
return tags;
},
};
}
Return Value Options
The transformIndexHtml hook is flexible in what you can return:
| Return Type | Behavior |
|---|---|
string | Replaces the entire HTML content |
HtmlTagDescriptor[] | Array of tag objects to inject (as shown above) |
{ html: string, tags: HtmlTagDescriptor[] } | Replace HTML and inject tags |
Each tag descriptor uses injectTo to control placement: 'head', 'body', 'head-prepend', or 'body-prepend'. This gives precise control without string parsing.
Configuring the Plugin
// vite.config.js
import htmlInjectPlugin from './vite-plugin-html-inject';
export default defineConfig({
plugins: [
htmlInjectPlugin({
analyticsId: 'UA-123456',
preloadFonts: ['/fonts/inter-var.woff2', '/fonts/fira-code.woff2'],
environment: process.env.NODE_ENV ?? 'development',
}),
],
});
Testing Your Plugins
Vite plugins are just functions that return objects — you can unit test them without spinning up a full Vite dev server. For integration testing, Vite exposes a programmatic build API that makes it straightforward to verify end-to-end behavior.
Unit Testing a Transform Plugin
// __tests__/build-time-plugin.test.js
import { describe, it, expect } from 'vitest';
import buildTimePlugin from '../vite-plugin-build-time';
describe('buildTimePlugin', () => {
it('replaces __BUILD_TIME__ with an ISO timestamp', () => {
const plugin = buildTimePlugin({ format: 'iso' });
const input = 'console.log(__BUILD_TIME__)';
const result = plugin.transform(input, '/src/app.js');
expect(result).not.toBeNull();
expect(result.code).toMatch(/"\d{4}-\d{2}-\d{2}T/);
expect(result.code).not.toContain('__BUILD_TIME__');
});
it('returns null when placeholder is absent', () => {
const plugin = buildTimePlugin();
const result = plugin.transform('console.log("hello")', '/src/app.js');
expect(result).toBeNull();
});
it('skips node_modules', () => {
const plugin = buildTimePlugin();
const result = plugin.transform(
'const t = __BUILD_TIME__',
'/node_modules/lib/index.js'
);
expect(result).toBeNull();
});
});
Integration Testing with Vite's Build API
// __tests__/app-config-plugin.integration.test.js
import { describe, it, expect } from 'vitest';
import { build } from 'vite';
import appConfigPlugin from '../vite-plugin-app-config';
describe('appConfigPlugin (integration)', () => {
it('makes virtual:app-config importable in a build', async () => {
const result = await build({
root: __dirname + '/fixtures/app-config-test',
plugins: [
appConfigPlugin({ apiBase: 'https://test.api.com', version: '1.0' }),
],
build: { write: false }, // Don't write files — return bundles in memory
});
const output = result.output[0].code;
expect(output).toContain('https://test.api.com');
expect(output).toContain('1.0');
});
});
Best Practices for Plugin Authors
Following these conventions makes your plugins predictable, composable, and easy for others to adopt.
The Factory Function Pattern
Always export a function that returns the plugin object, even if your plugin takes no options. This gives consumers a consistent API and lets you add options later without a breaking change.
// ✅ Good — factory function
export default function myPlugin(options = {}) {
return { name: 'vite-plugin-mine', /* hooks */ };
}
// ❌ Bad — bare object export
export default { name: 'vite-plugin-mine', /* hooks */ };
Use enforce to Control Hook Ordering
Vite runs plugins in three phases: pre, normal (default), and post. Set enforce when your plugin must run before or after other plugins:
| Value | Execution Order | Use When |
|---|---|---|
enforce: 'pre' | Before Vite core plugins | You need to transform code before other plugins see it (e.g., preprocessing) |
| (default) | After Vite core plugins | Most plugins — you operate on already-resolved, standard code |
enforce: 'post' | After all other plugins | You need the final transformed output (e.g., minification, analysis) |
Use apply for Environment-Specific Plugins
If your plugin only makes sense in one mode, declare it explicitly. This prevents confusing behavior and avoids unnecessary work:
// Only during dev server
{ name: 'my-plugin', apply: 'serve', /* ... */ }
// Only during production build
{ name: 'my-plugin', apply: 'build', /* ... */ }
// Conditional logic via a function
{
name: 'my-plugin',
apply(config, { command }) {
// Only apply during build AND when SSR is enabled
return command === 'build' && !!config.build?.ssr;
},
}
Error Reporting with this.warn() and this.error()
Inside Rollup-compatible hooks (transform, resolveId, load, etc.), you have access to this.warn() and this.error(). These produce nicely formatted messages with file location context, rather than raw console.log output that gets lost in the noise.
transform(code, id) {
if (code.includes('DEPRECATED_API')) {
// Warning: build continues, message shown in terminal
this.warn('DEPRECATED_API usage detected — migrate to the new API');
}
if (code.includes('FORBIDDEN_IMPORT')) {
// Error: build fails immediately with a clear message
this.error('FORBIDDEN_IMPORT is not allowed in this project');
}
}
If you publish your plugin to npm, name the package vite-plugin-* (e.g., vite-plugin-build-time). For framework-specific plugins, use vite-plugin-vue-* or vite-plugin-react-*. This convention helps users discover plugins and makes the ecosystem searchable. Inside the plugin object, use the same name in the name property — Vite uses it in warning and error messages.
Quick Reference: Common Hooks
| Hook | Phase | Primary Use Case |
|---|---|---|
config | Config | Modify Vite config before it's resolved |
configResolved | Config | Read the final resolved config |
configureServer | Dev only | Add middleware or tap into the dev server |
resolveId | Build | Claim virtual modules or custom resolution logic |
load | Build | Provide source code for resolved module IDs |
transform | Build | Modify module source code after loading |
transformIndexHtml | HTML | Inject tags or modify HTML content |
handleHotUpdate | Dev only | Custom HMR logic for file changes |
The transform hook is called for every single module. If your transform involves expensive operations (parsing ASTs, network calls), always add a fast bail-out check at the top — like checking the file extension or doing a quick code.includes() test — before doing any heavy work. A slow transform plugin can make your entire dev server feel sluggish.
Production Builds: Rollup Under the Hood
When you run vite build, Vite switches gears entirely. Instead of the lightning-fast esbuild-powered dev server, it hands your application over to Rollup — a bundler purpose-built for producing highly optimized, tree-shaken output. Vite wraps Rollup with a pre-configured plugin pipeline that handles HTML entry points, CSS extraction, asset hashing, and code splitting out of the box.
The result is a dist/ directory containing production-ready files: minified JavaScript chunks, extracted CSS, hashed static assets, and a transformed index.html with all the correct <script> and <link> tags injected automatically.
The Build Pipeline at a Glance
Here's how source files flow through Vite's production build pipeline from start to finish:
graph TD
A["Source Files + index.html"] --> B["Vite Plugin Pipeline\n(resolve, transform)"]
B --> C["Rollup Bundle\n(module graph + tree shaking)"]
C --> D["JS Chunks\n(code split)"]
C --> E["CSS Files\n(extracted)"]
C --> F["Static Assets\n(images, fonts)"]
D --> G["Terser / esbuild\nMinify"]
E --> H["CSS Minify\n(Lightning CSS / esbuild)"]
F --> I["Hash + Copy"]
G --> J["dist/ output directory"]
H --> J
I --> J
J --> K["index.html\n(updated script/link/preload tags)"]
Build Pipeline Step by Step
Understanding each stage helps you debug build issues and fine-tune output. Here's exactly what happens when Rollup takes over:
-
Resolve entry HTML
Vite's internal HTML plugin reads your
index.html(or multiple HTML files in a multi-page app) as the entry point. Unlike traditional bundlers that start from a.jsfile, Vite treats HTML as a first-class entry — the same file you use in development. -
Find script entries
The HTML plugin scans each HTML file for
<script type="module" src="...">tags. Thesesrcattributes become the actual entry points fed into Rollup. CSS<link>tags and inline scripts are also tracked for later processing. -
Build the module graph with Rollup
Rollup resolves every
importstatement recursively, building a complete dependency graph. Vite's plugins hook into Rollup'sresolveIdandloadphases to handle aliases, bare module imports (fromnode_modules), and virtual modules. -
Apply tree shaking
Rollup performs static analysis on the module graph, eliminating unused exports (dead code). This is one of the key reasons Vite uses Rollup for production — its tree shaking is significantly more thorough than esbuild's, especially for complex re-export chains.
-
Code splitting across dynamic imports and shared chunks
Every
import()expression creates a separate chunk. Rollup also identifies modules shared between multiple entry points or dynamic imports and extracts them into common chunks, preventing duplication. You can influence this behavior withoutput.manualChunks. -
CSS extraction and minification
CSS imported in JavaScript (via
import './style.css') is extracted into separate.cssfiles rather than left as injected<style>tags. Each code-split chunk gets its own CSS file that loads in parallel. The CSS is minified using Lightning CSS (Vite 5+) or esbuild. -
Asset processing
Static assets referenced via
import logo from './logo.png'or through CSSurl()are copied todist/assets/with content hashes appended to filenames (e.g.,logo-a1b2c3d4.png). This enables aggressive long-term caching — when content changes, the hash changes, busting the cache automatically. -
HTML transformation
Finally, Vite rewrites your
index.html: the original<script type="module">tags are replaced with references to the hashed output chunks,<link rel="stylesheet">tags are injected for extracted CSS, and<link rel="modulepreload">hints are added for critical chunks to speed up loading.
Dev vs. Build: Two Different Engines
This is one of the most important architectural decisions in Vite. Development and production use entirely different tools for transforming your code, and understanding why prevents a whole class of "works in dev, breaks in build" bugs.
| Aspect | Dev Server (esbuild) | Production Build (Rollup) |
|---|---|---|
| Primary tool | esbuild (Go-based, ~100x faster) | Rollup (JS-based, highly optimizable) |
| Bundling | No bundling — serves native ESM | Full bundle with code splitting |
| Tree shaking | None (not needed per-module) | Deep static analysis |
| CSS handling | Injected via <style> + HMR | Extracted to separate .css files |
| Minification | None | esbuild (default) or Terser |
| Asset URLs | Served directly from source | Content-hashed filenames in dist/ |
| Speed priority | Startup and HMR speed | Output size and runtime performance |
esbuild is fast but lacks some features critical for production: advanced code splitting heuristics, fine-grained chunk control via manualChunks, and the mature plugin ecosystem that Rollup offers. The Vite team has explored using Rolldown (a Rust-based Rollup-compatible bundler) to unify dev and build in the future.
Build Target: build.target
By default, Vite targets browsers that support native ES Modules, which it expresses as 'modules'. This means the output uses import/export syntax, optional chaining, nullish coalescing, and other modern features — no unnecessary transpilation for browsers that already understand them.
You can adjust this in vite.config.ts by setting build.target to any esbuild target string:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
// Default: 'modules' (browsers with native ESM support)
target: 'es2020',
// Or target specific browser versions
// target: ['chrome87', 'firefox78', 'safari14', 'edge88'],
},
})
For apps that must support legacy browsers (IE11 or older mobile browsers without ESM), use the official @vitejs/plugin-legacy. It generates two bundles: a modern one for capable browsers and a legacy fallback with SystemJS polyfills.
// vite.config.ts
import legacy from '@vitejs/plugin-legacy'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
legacy({
targets: ['defaults', 'not IE 11'],
// Adds polyfills automatically based on usage
additionalLegacyPolyfills: ['regenerator-runtime/runtime'],
}),
],
})
Advanced Rollup Customization: build.rollupOptions
Vite exposes the full power of Rollup's configuration through build.rollupOptions. This is your escape hatch for controlling exactly how the bundle is structured. The three most common use cases are: marking dependencies as external, defining manual chunk splits, and customizing output filenames.
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
// Exclude dependencies loaded from a CDN
external: ['vue', 'react', 'react-dom'],
output: {
// Custom chunk splitting logic
manualChunks(id) {
if (id.includes('node_modules')) {
// Group all vendor code into a single chunk
return 'vendor'
}
},
// Custom naming patterns — [hash] ensures cache busting
entryFileNames: 'js/[name]-[hash].js',
chunkFileNames: 'js/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
},
},
},
})
The manualChunks function receives each module's resolved ID and returns a chunk name. Returning undefined lets Rollup decide. A common pattern splits vendor libraries by package name for better caching:
manualChunks(id) {
if (id.includes('node_modules')) {
const packageName = id.split('node_modules/')[1].split('/')[0]
// Isolate large libraries into their own chunks
if (['lodash', 'moment', 'three'].includes(packageName)) {
return `vendor-${packageName}`
}
return 'vendor' // Everything else → shared vendor chunk
}
}
Aggressive manualChunks splitting can create circular dependencies between chunks, causing runtime errors. If you see __vite_ssr_import_0__ is not defined or blank pages after a build, try simplifying your chunk strategy. Rollup will warn you about circular dependencies in the build output — don't ignore those warnings.
Backend Integration: build.manifest
If you're using Vite with a backend framework (Laravel, Rails, Django, Spring Boot), you need a way to map your original source files to the hashed output filenames. Setting build.manifest to true generates a .vite/manifest.json file in the output directory.
// vite.config.ts
export default defineConfig({
build: {
manifest: true,
rollupOptions: {
// In backend mode, specify JS entry directly (no HTML)
input: 'src/main.ts',
},
},
})
The generated manifest maps each source path to its build output, including CSS and asset dependencies:
{
"src/main.ts": {
"file": "js/main-a1b2c3d4.js",
"src": "src/main.ts",
"isEntry": true,
"css": ["assets/main-x9y8z7w6.css"],
"imports": ["_vendor-e5f6g7h8.js"]
},
"src/views/Dashboard.vue": {
"file": "js/Dashboard-i1j2k3l4.js",
"src": "src/views/Dashboard.vue",
"isDynamicEntry": true,
"css": ["assets/Dashboard-m5n6o7p8.css"]
}
}
Your backend template reads this manifest at runtime and injects the correct <script> and <link> tags. Most backend frameworks have official Vite integrations that do this automatically (e.g., laravel-vite-plugin, django-vite).
Source Maps: build.sourcemap
Source maps are essential for debugging production errors. Vite gives you four options for controlling source map generation:
| Value | Behavior | Use Case |
|---|---|---|
false | No source maps (default) | Maximum security, smallest build |
true | Separate .map files | Upload to error tracking (Sentry, Datadog) |
'inline' | Inlined as data URIs in JS | Quick debugging (large files — avoid in production) |
'hidden' | Separate .map files, no //# sourceMappingURL comment | Upload maps to monitoring service without exposing to browsers |
// vite.config.ts — recommended for production monitoring
export default defineConfig({
build: {
sourcemap: 'hidden',
// The .map files are generated but browsers won't
// auto-fetch them. Upload them to Sentry/Datadog
// as part of your CI/CD pipeline.
},
})
Use build.sourcemap: 'hidden' combined with a CI step that uploads .map files to your error monitoring service, then deletes them before deployment. This gives you full stack traces in Sentry without exposing your source code to end users.
Performance Optimization: Code Splitting, Tree Shaking, and Chunk Strategies
A fast dev server means nothing if your production bundle ships a 2 MB monolith to users. Vite delegates production builds to Rollup, which gives you a mature, battle-tested optimization pipeline — but you need to know which levers to pull. This section walks through every major strategy for shrinking bundles, splitting code intelligently, and eliminating dead weight.
mindmap
root((Vite Performance))
Code Splitting
dynamic import()
React.lazy
Vue defineAsyncComponent
Route-based splitting
Manual chunks
Tree Shaking
ESM static analysis
sideEffects flag
Dead code elimination
CSS
Per-chunk extraction
build.cssCodeSplit
Analysis
rollup-plugin-visualizer
vite-bundle-analyzer
chunkSizeWarningLimit
Loading Optimization
modulepreload directives
Async chunk parallelization
Code Splitting with Dynamic Imports
Every time you write a dynamic import() expression, Rollup treats it as a split point. The imported module and its dependency subtree get extracted into a separate chunk that loads on demand. This is the foundation of all code splitting in Vite — every other pattern (lazy routes, async components) builds on top of this single mechanism.
The key insight: static import statements get bundled together; dynamic import() calls create new chunks. You control the boundary.
// Static import — bundled into the main chunk
import { formatDate } from './utils/date';
// Dynamic import — creates a separate chunk loaded on demand
const handleExport = async () => {
const { generatePDF } = await import('./utils/pdf-export');
generatePDF(reportData);
};
In the example above, pdf-export and all of its unique dependencies become a separate chunk. The browser only fetches it when the user actually clicks "Export." This is especially valuable for heavy libraries like PDF generators, chart renderers, or rich text editors that most users never touch in a given session.
React: React.lazy() with Suspense
React provides React.lazy() as a first-class wrapper around dynamic imports for component-level code splitting. You pair it with a <Suspense> boundary that renders a fallback while the chunk is loading.
import { lazy, Suspense } from 'react';
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const AnalyticsPanel = lazy(() => import('./pages/AnalyticsPanel'));
function App() {
return (
<Suspense fallback={<div className="skeleton-loader" />}>
<Routes>
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/analytics" element={<AnalyticsPanel />} />
</Routes>
</Suspense>
);
}
Vue: defineAsyncComponent() with Suspense
Vue's equivalent is defineAsyncComponent(), which accepts a loader function (a dynamic import) and optionally configures loading/error states. Vue's <Suspense> provides the boundary for async dependencies.
<script setup>
import { defineAsyncComponent } from 'vue';
import LoadingSpinner from './components/LoadingSpinner.vue';
const AdminDashboard = defineAsyncComponent({
loader: () => import('./pages/AdminDashboard.vue'),
loadingComponent: LoadingSpinner,
delay: 200, // show spinner after 200ms
timeout: 10000, // fail after 10s
});
</script>
<template>
<Suspense>
<AdminDashboard />
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
Route-Based Code Splitting
Route boundaries are the most natural split points in a single-page application. Each route maps to a page the user navigates to intentionally — so there's no reason to bundle pages the user hasn't visited. Both React Router and Vue Router integrate seamlessly with dynamic imports.
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Each route = separate chunk
const Home = lazy(() => import('./pages/Home'));
const Products = lazy(() => import('./pages/Products'));
const Checkout = lazy(() => import('./pages/Checkout'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
export default function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/profile" element={<UserProfile />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/',
component: () => import('./pages/Home.vue'),
},
{
path: '/products',
component: () => import('./pages/Products.vue'),
},
{
path: '/checkout',
component: () => import('./pages/Checkout.vue'),
},
{
path: '/profile',
component: () => import('./pages/UserProfile.vue'),
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
In Vue Router, any route component defined as an arrow function returning import() is automatically code-split — no extra wrapper like defineAsyncComponent is needed. Vue Router handles the async resolution internally.
Manual Chunks: Taking Control of Bundle Boundaries
Automatic code splitting works well for route-level boundaries, but sometimes you need finer control. Maybe you want all vendor code in a single long-lived cacheable chunk, or you want to isolate a heavy library like chart.js so it doesn't pollute your main bundle. Vite exposes Rollup's manualChunks option for exactly this.
There are two syntaxes: object syntax for simple package-to-chunk mapping, and function syntax for dynamic, logic-driven decisions.
Object Syntax
The object syntax maps chunk names to arrays of module IDs. Any module whose resolved path contains a matching string gets placed into that chunk.
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// Group React ecosystem into one chunk
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
// Isolate heavy charting library
'vendor-charts': ['chart.js', 'react-chartjs-2'],
// Shared UI component library
'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
},
},
},
},
});
Function Syntax
The function syntax gives you full control. You receive the module ID and can return a chunk name string (or undefined to let Rollup decide). This is powerful for patterns like "all of node_modules in one vendor chunk" or "split by top-level package scope."
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
// Isolate very large packages into their own chunks
if (id.includes('chart.js')) return 'vendor-charts';
if (id.includes('lodash')) return 'vendor-lodash';
if (id.includes('firebase')) return 'vendor-firebase';
// Everything else from node_modules → single vendor chunk
return 'vendor';
}
// Group shared utility modules
if (id.includes('src/utils/')) {
return 'shared-utils';
}
},
},
},
},
});
If module A in chunk X imports module B in chunk Y, and module B imports module C which you've forced into chunk X, you'll create a circular dependency between chunks. Rollup will handle it, but it may duplicate code or produce unexpected loading order. Test with the bundle visualizer after configuring manual chunks.
Tree Shaking: Eliminating Dead Code
Tree shaking is Rollup's ability to analyze your ES module import/export graph and exclude any exported code that nobody actually imports. It works because ES modules have static structure — the imports and exports are determined at parse time, not runtime. Rollup can trace exactly which exports are consumed and drop the rest.
This happens automatically in Vite production builds, but there are two things you need to get right for it to work aggressively: your code must use ES module syntax (import/export, not require/module.exports), and packages must declare their side-effect status.
The sideEffects Field in package.json
When a module is imported but none of its exports are used, Rollup still can't safely remove it — the module might have side effects (e.g., polyfills, CSS imports, global registrations). The sideEffects field in package.json tells the bundler which files are safe to eliminate entirely when unused.
{
"name": "my-component-library",
"sideEffects": false
}
Setting "sideEffects": false tells Rollup: "every file in this package is side-effect free — if you don't use an export, drop the entire module." For packages that have some side-effectful files (like CSS), you can specify them explicitly:
{
"name": "my-component-library",
"sideEffects": [
"*.css",
"./src/polyfills.js",
"./src/global-setup.js"
]
}
With this configuration, Rollup will preserve CSS imports and the two listed files even if their exports aren't referenced, but aggressively eliminate everything else.
Prefer named imports over namespace imports. import { debounce } from 'lodash-es' allows Rollup to drop everything except debounce. The CommonJS version require('lodash') pulls in the entire 70 KB library because CommonJS isn't statically analyzable. Always use the -es variant of libraries when available.
CSS Code Splitting
Vite automatically extracts CSS into separate files during production builds. More importantly, it splits CSS per async chunk. When you code-split a route or component, any CSS that route imports travels with its chunk — not in a single monolithic stylesheet. The CSS file loads in parallel with the JS chunk and is inserted before the component renders, preventing a flash of unstyled content.
This behavior is controlled by the build.cssCodeSplit option, which defaults to true:
// vite.config.js
export default defineConfig({
build: {
// Default: true — CSS is split per async chunk
cssCodeSplit: true,
// Set to false to force all CSS into a single file
// cssCodeSplit: false,
},
});
When would you set cssCodeSplit: false? Rarely. One case is when you're building a library and want consumers to import a single CSS file. For applications, leave it enabled — splitting CSS alongside JS keeps each route's payload minimal.
Preload Directives and Async Chunk Optimization
Vite doesn't just split your code — it also optimizes how split chunks load. Two mechanisms work together to eliminate network waterfalls.
modulepreload for Entry Chunks
Vite automatically injects <link rel="modulepreload"> tags into your HTML for entry chunks and their direct imports. This tells the browser to fetch, parse, and compile these modules before they're actually needed by the execution flow. Without preloading, the browser discovers each dependency only after parsing its parent — creating a sequential chain of requests.
<!-- Vite generates these automatically in the built HTML -->
<link rel="modulepreload" crossorigin href="/assets/index-DkR2c1Bg.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-BxK4HnLf.js">
<link rel="stylesheet" crossorigin href="/assets/index-CdF1oWnK.css">
<script type="module" crossorigin src="/assets/index-DkR2c1Bg.js"></script>
Async Chunk Loading Optimization
Consider a common scenario: a dynamically-imported route chunk (Checkout.js) imports a shared utility chunk (shared-utils.js). Without optimization, the browser first downloads Checkout.js, parses it, discovers the import of shared-utils.js, then starts a second network request — a waterfall.
Vite solves this by transforming dynamic imports to fetch the chunk and its direct dependencies in parallel. Under the hood, it rewrites the dynamic import to simultaneously load all required chunks using Promise.all. The result: one round trip instead of two (or more).
// What you write:
const Checkout = () => import('./pages/Checkout');
// What Vite produces (simplified):
const Checkout = () => Promise.all([
import('./assets/Checkout-a1b2c3.js'),
import('./assets/shared-utils-d4e5f6.js'), // direct dependency
]).then(([m]) => m);
Analyzing Your Bundle
You can't optimize what you can't measure. Before tweaking manual chunks or hunting for tree-shaking failures, you need a clear picture of what's actually in your bundle. Two tools stand out.
Using rollup-plugin-visualizer
rollup-plugin-visualizer generates an interactive treemap of your production bundle. Every module appears as a rectangle sized proportional to its contribution. You can immediately spot oversized dependencies and modules that shouldn't be there.
npm install -D rollup-plugin-visualizer
// vite.config.js
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
filename: 'stats.html', // output file
open: true, // auto-open in browser after build
gzipSize: true, // show gzip-compressed sizes
brotliSize: true, // show brotli-compressed sizes
template: 'treemap', // 'treemap', 'sunburst', or 'network'
}),
],
});
Run vite build and the treemap opens in your browser. Look for: vendor packages that dominate the chart, duplicate copies of the same library, and modules you expected to be tree-shaken but weren't.
Using vite-bundle-analyzer
An alternative is vite-bundle-analyzer, which is purpose-built for Vite and provides a similar visualization with Vite-specific metadata.
// vite.config.js
import { defineConfig } from 'vite';
import { analyzer } from 'vite-bundle-analyzer';
export default defineConfig({
plugins: [
analyzer(), // launches interactive report after build
],
});
Managing Chunk Sizes
Vite warns you when any chunk exceeds 500 kB (the default threshold). This warning exists because large chunks defeat the purpose of code splitting — they block rendering and waste bandwidth on code the user may not need.
// vite.config.js
export default defineConfig({
build: {
// Adjust the warning threshold (in kB)
chunkSizeWarningLimit: 600,
},
});
Raising the limit silences the warning, but it doesn't fix the problem. Here are concrete strategies to actually reduce chunk sizes:
| Strategy | How It Works | When to Use |
|---|---|---|
| Dynamic imports | Move heavy features behind import() | Features not needed on initial page load |
| Manual chunks | Extract large vendor libs into dedicated chunks | Libraries like chart.js, monaco-editor, firebase |
| Replace heavy dependencies | Swap moment.js → date-fns, lodash → lodash-es | Legacy packages that don't tree-shake |
| Lazy-load below-the-fold | Split components not visible on initial viewport | Modals, drawers, tabbed content, charts |
| Audit with visualizer | Identify unexpectedly large modules | Always — run before and after optimizing |
The goal is not to achieve the smallest possible number of chunks — too many tiny chunks create their own overhead (per-request latency, HTTP/2 multiplexing limits). Aim for a balance: an initial entry chunk under 200 kB, vendor chunks that change infrequently (for long-term caching), and route chunks sized 50–150 kB each.
Server-Side Rendering (SSR) with Vite
Vite provides first-class SSR support that makes server-side rendering feel like a natural extension of your dev workflow — not a bolted-on afterthought. Instead of requiring a separate build pipeline or a completely different mental model, Vite lets your Node.js server import the same application code your browser uses, render it to an HTML string, and send it down the wire.
The core idea is simple: your server imports your app's entry point, calls a framework-specific render function (like React's renderToString), injects the resulting HTML into a template, and sends the complete page to the browser. The browser then hydrates the server-rendered markup by attaching event listeners and making it interactive.
SSR Request Lifecycle
The following diagram shows how an SSR request flows through the system, from the initial browser request to the final hydrated page. Note how the dev and production paths diverge at the module loading step.
sequenceDiagram
participant Browser
participant Server as Node.js Server
participant Vite as Vite Dev Server / Built Bundle
participant App as App Entry Module
Browser->>Server: HTTP GET /page
alt Development
Server->>Vite: vite.ssrLoadModule('/src/entry-server.tsx')
Vite->>App: Transform & load (TS, JSX, HMR)
else Production
Server->>App: require('./dist/server/entry-server.js')
end
App-->>Server: render(url) → HTML string
Server->>Server: Read index.html template
Server->>Server: Inject HTML string into template
Server-->>Browser: Full HTML response
Browser->>Browser: Load client bundle
Browser->>Browser: Hydrate (attach event listeners)
Development Setup — Middleware Mode
In development, you create a Vite dev server in middleware mode and attach it to your own HTTP server (like Express). This gives you full control over the request handling while Vite handles module transformation, HMR, and asset serving behind the scenes.
The key method is server.ssrLoadModule(). Unlike a regular require() or import(), this function transforms modules on the fly through Vite's full pipeline — supporting TypeScript, JSX, CSS modules, and more — without bundling. Each module is transformed individually and cached. When you edit a file, only that module is invalidated, so you get HMR-like instant feedback without ever restarting your server.
import express from 'express';
import { createServer as createViteServer } from 'vite';
async function startServer() {
const app = express();
// Create Vite in middleware mode (no built-in HTML serving)
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
// Use Vite's middleware for asset serving & HMR
app.use(vite.middlewares);
app.use('*', async (req, res) => {
const url = req.originalUrl;
// 1. Read and transform the HTML template
let template = fs.readFileSync('index.html', 'utf-8');
template = await vite.transformIndexHtml(url, template);
// 2. Load the server entry — fully transformed, with HMR
const { render } = await vite.ssrLoadModule('/src/entry-server.tsx');
// 3. Render the app to HTML
const appHtml = await render(url);
// 4. Inject into template and send
const html = template.replace('<!--ssr-outlet-->', appHtml);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
});
app.listen(3000);
}
startServer();
ssrLoadModule() processes each module through Vite's transform pipeline individually — it does not bundle them. This means TypeScript, JSX, Vue SFCs, and CSS modules all work out of the box with zero config. A regular require() would choke on any of these non-standard syntaxes. In production, you use the pre-built bundle instead, so ssrLoadModule is dev-only.
Complete Example: Express + React SSR
Let's wire up a full SSR example with React, including data fetching, head management, and hydration. You'll need three key files: the HTML template, the server entry, and the client entry.
HTML Template
Your index.html serves as the shell. Vite's transformIndexHtml processes it in dev to inject the HMR client, and the production build uses it as the client entry point.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!--head-tags-->
</head>
<body>
<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>
Server Entry (entry-server.tsx)
This module exports a render function that the server calls on each request. It receives the URL, fetches any needed data, renders the app to a string, and returns both the HTML and any head tags that should be injected.
// src/entry-server.tsx
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { App } from './App';
import { fetchDataForRoute } from './data';
import { HeadProvider, extractHeadTags } from './head';
export async function render(url: string) {
// Fetch route-specific data before rendering
const data = await fetchDataForRoute(url);
const headContext: string[] = [];
const html = renderToString(
<HeadProvider context={headContext}>
<StaticRouter location={url}>
<App initialData={data} />
</StaticRouter>
</HeadProvider>
);
const headTags = extractHeadTags(headContext);
return { html, headTags, data };
}
Client Entry (entry-client.tsx)
The client entry hydrates the server-rendered HTML. It reads the serialized data from the page and passes it to the app so the initial render matches the server output exactly.
// src/entry-client.tsx
import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
// Grab the data the server serialized into the page
const data = (window as any).__SSR_DATA__;
hydrateRoot(
document.getElementById('app')!,
<BrowserRouter>
<App initialData={data} />
</BrowserRouter>
);
Wiring It Together in the Server
The server handler calls render(), injects the HTML and head tags into the template, and serializes the fetched data so the client can pick it up during hydration.
app.use('*', async (req, res) => {
const url = req.originalUrl;
let template = fs.readFileSync('index.html', 'utf-8');
template = await vite.transformIndexHtml(url, template);
const { render } = await vite.ssrLoadModule('/src/entry-server.tsx');
const { html: appHtml, headTags, data } = await render(url);
const finalHtml = template
.replace('<!--head-tags-->', headTags)
.replace('<!--ssr-outlet-->', appHtml)
.replace(
'</body>',
`<script>window.__SSR_DATA__=${JSON.stringify(data)}</script></body>`
);
res.status(200).set({ 'Content-Type': 'text/html' }).end(finalHtml);
});
Production Build
In development, ssrLoadModule transforms modules on the fly. In production, you need a pre-built bundle. Vite's --ssr flag produces a Node.js-compatible bundle from your server entry point — no additional tooling needed.
# Build the client bundle (assets, JS, CSS for the browser)
vite build --outDir dist/client
# Build the server bundle (Node.js-compatible, no client assets)
vite build --ssr src/entry-server.tsx --outDir dist/server
The production server drops Vite entirely and loads the built bundle with a standard import(). It also serves the client assets from the dist/client directory.
// server-prod.ts
import express from 'express';
import fs from 'fs';
import { render } from './dist/server/entry-server.js';
const app = express();
// Serve built client assets with caching
app.use('/assets', express.static('dist/client/assets', {
maxAge: '1y',
immutable: true,
}));
app.use(express.static('dist/client', { index: false }));
const template = fs.readFileSync('dist/client/index.html', 'utf-8');
app.use('*', async (req, res) => {
const { html: appHtml, headTags, data } = await render(req.originalUrl);
const finalHtml = template
.replace('<!--head-tags-->', headTags)
.replace('<!--ssr-outlet-->', appHtml)
.replace('</body>',
`<script>window.__SSR_DATA__=${JSON.stringify(data)}</script></body>`
);
res.status(200).set({ 'Content-Type': 'text/html' }).end(finalHtml);
});
app.listen(3000);
SSR Configuration Options
Vite gives you fine-grained control over how dependencies are handled during SSR through the ssr config option. The two most important settings are ssr.external and ssr.noExternal, which control whether a dependency is left as a bare require()/import() (externalized) or pulled through Vite's transform pipeline (bundled).
| Option | What It Does | When to Use |
|---|---|---|
ssr.external |
Forces dependencies to be externalized — loaded via Node.js require() at runtime, not transformed by Vite. |
Large Node-native packages (e.g., sharp, prisma) that shouldn't be bundled. |
ssr.noExternal |
Forces dependencies to be processed by Vite's pipeline — bundled into the SSR output. | Packages that ship ESM-only code, use import.meta, or include CSS imports that Vite needs to handle. |
ssr.target |
Sets the SSR target environment. Defaults to 'node'. |
Use 'webworker' for edge runtimes like Cloudflare Workers, Deno Deploy, or Vercel Edge. |
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
ssr: {
// Don't bundle these — let Node.js resolve them at runtime
external: ['sharp', '@prisma/client'],
// Force these through Vite's pipeline (e.g., ESM-only packages)
noExternal: ['my-esm-ui-library', /^@my-scope\/.*/],
// 'node' (default) or 'webworker' for edge runtimes
target: 'node',
},
});
When ssr.target is set to 'webworker', Vite adjusts the output to avoid Node.js-specific APIs like fs, path, and process. It also changes the module resolution conditions to match worker environments. This is how frameworks like SvelteKit and Astro deploy to Cloudflare Workers — they set ssr.target: 'webworker' and let Vite produce a compatible bundle.
Streaming SSR with renderToPipeableStream
The renderToString approach works, but it's blocking — the server must finish rendering the entire tree before sending anything. React 18's renderToPipeableStream enables streaming SSR, where the server starts sending HTML as soon as the shell is ready. Content inside <Suspense> boundaries streams in as each data fetch resolves.
With streaming, time-to-first-byte (TTFB) drops dramatically because the browser starts parsing HTML, loading CSS, and downloading scripts while the server is still rendering deeper parts of the component tree.
// src/entry-server-streaming.tsx
import { renderToPipeableStream } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { App } from './App';
export function render(url: string, res: express.Response) {
const { pipe } = renderToPipeableStream(
<StaticRouter location={url}>
<App />
</StaticRouter>,
{
bootstrapModules: ['/src/entry-client.tsx'],
onShellReady() {
// The shell (everything outside Suspense) is ready — start streaming
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(error) {
res.status(500).send('<h1>Server Error</h1>');
},
onError(error) {
console.error('SSR streaming error:', error);
},
}
);
}
Streaming SSR requires that your HTML template be split into a "before app" and "after app" chunk, since you can't do a simple string replacement on a stream. Most meta-frameworks handle this splitting automatically — if you're building a custom SSR setup, write the template head first, then pipe the React stream, then write the closing tags.
Meta-Frameworks Built on Vite's SSR
You can build your own SSR setup with Vite, but you probably shouldn't — at least not for production applications. The code above handles the happy path, but real-world SSR involves dozens of edge cases: route-level code splitting, CSS extraction and ordering, preload link generation, error boundaries, redirect handling, and cache management.
These meta-frameworks use Vite's SSR primitives under the hood and handle all the complexity for you:
| Framework | UI Library | Key SSR Features |
|---|---|---|
| Nuxt 3 | Vue | Hybrid rendering (per-route SSR/SSG/ISR), Nitro server engine, edge deployment |
| SvelteKit | Svelte | Adapter-based deployment (Node, Vercel, Cloudflare, etc.), streaming, prerendering |
| Astro | Any (React, Vue, Svelte, etc.) | Islands architecture, zero JS by default, partial hydration, hybrid rendering |
| Remix | React | Vite plugin (since v2.8), nested routes, streaming loaders, progressive enhancement |
Vite's SSR API is designed as a low-level primitive for framework authors, not as a complete SSR solution for application developers. If you're building a product, start with one of the meta-frameworks above. If you're building a framework or have very specific requirements, Vite's API gives you the building blocks to create a custom SSR pipeline.
Library Mode: Building Reusable Packages with Vite
Vite isn't just an app bundler — it's also an excellent library bundler. When you set build.lib in your Vite config, Vite switches into library mode: it skips HTML generation, externalizes dependencies you specify, and produces clean distributable bundles ready for npm. This is how you ship component libraries, utility packages, and SDKs.
Under the hood, library mode still uses Rollup for the production build, so you get tree-shaking, code-splitting, and all of Rollup's plugin ecosystem. The difference is that Vite provides a simpler, more opinionated configuration surface.
The build.lib Configuration
The core of library mode is three properties: entry, name, and fileName. Together they tell Vite what to bundle, what global name to expose (for UMD/IIFE), and how to name the output files.
// vite.config.ts
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
build: {
lib: {
// The entry point for your library
entry: resolve(__dirname, 'src/index.ts'),
// Global variable name for UMD/IIFE builds
name: 'MyLib',
// Output filename pattern (extension added automatically per format)
fileName: (format) => `my-lib.${format}.js`,
},
},
})
| Property | Type | Purpose |
|---|---|---|
entry | string | string[] | Record<string, string> | Your library's entry point(s). Use an absolute path via resolve(). |
name | string | The global variable name exposed in UMD and IIFE builds. Not needed for ESM/CJS-only libraries. |
fileName | string | ((format, entryName) => string) | Controls output filenames. Use a function for full control over naming per format. |
formats | string[] | Which formats to produce. Defaults to ['es', 'umd']. Options: es, cjs, umd, iife. |
Output Formats Explained
Vite can produce four output formats. Each targets a different consumption environment. Choosing the right combination depends on who your consumers are.
| Format | Module System | Use Case | Loaded Via |
|---|---|---|---|
es | ES Modules | Modern bundlers (Vite, webpack 5, Rollup), Node 14+ | import statements |
cjs | CommonJS | Node.js, older bundlers, Jest | require() calls |
umd | Universal Module Definition | Works everywhere — browsers via <script>, AMD, CommonJS | <script> tag or require() |
iife | Immediately Invoked Function Expression | Direct browser use, no module system assumed | <script> tag (exposes global) |
For most modern npm packages, shipping es and cjs is sufficient. Add umd only if you need CDN/script-tag support (e.g., for unpkg or jsDelivr). Skip iife unless you're building a standalone widget or analytics snippet.
Complete Library Setup: A React Component Library
Let's build a complete, publishable component library. This example uses React, but the same pattern applies to Vue, Svelte, or framework-agnostic libraries. You need three files working together: the entry point, the Vite config, and package.json.
Library Entry Point
// src/index.ts — re-export everything consumers should access
export { Button } from './components/Button'
export { Modal } from './components/Modal'
export { useToast } from './hooks/useToast'
export type { ButtonProps, ModalProps, ToastOptions } from './types'
Vite Configuration
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'
export default defineConfig({
plugins: [
react(),
dts({
insertTypesEntry: true, // auto-generates types entry in package.json
rollupTypes: true, // bundles .d.ts files into a single file
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyComponentLib',
fileName: (format) => `my-component-lib.${format}.js`,
formats: ['es', 'cjs'],
},
rollupOptions: {
// CRITICAL: externalize deps you don't want bundled into your library
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
sourcemap: true,
// Reduce bloat by setting a low inline limit
assetsInlineLimit: 0,
},
})
rollupOptions.external is the most critical setting in library mode. If you forget to externalize React (or Vue, Angular, etc.), your library will bundle its own copy of the framework. This causes duplicate React instances, broken hooks, inflated bundle sizes, and cryptic runtime errors. Externalize every peer dependency without exception.
Package Configuration
Your package.json must tell bundlers and Node.js which file to load in each environment. The exports field is the modern standard — use it alongside legacy fields for backward compatibility.
{
"name": "my-component-lib",
"version": "1.0.0",
"type": "module",
"files": ["dist"],
"main": "./dist/my-component-lib.cjs.js",
"module": "./dist/my-component-lib.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/my-component-lib.es.js",
"require": "./dist/my-component-lib.cjs.js"
},
"./styles.css": "./dist/style.css"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"typescript": "^5.5.0",
"vite": "^6.0.0",
"vite-plugin-dts": "^4.0.0"
},
"scripts": {
"build": "tsc --noEmit && vite build",
"dev": "vite build --watch"
}
}
Multi-Entry Libraries
For large libraries where consumers only use a subset of features, you can create multiple entry points. This enables deep imports like import { Button } from 'my-lib/components' and guarantees that unused modules are never loaded — even by bundlers with imperfect tree-shaking.
// vite.config.ts — multi-entry library
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
build: {
lib: {
entry: {
index: resolve(__dirname, 'src/index.ts'),
components: resolve(__dirname, 'src/components/index.ts'),
hooks: resolve(__dirname, 'src/hooks/index.ts'),
utils: resolve(__dirname, 'src/utils/index.ts'),
},
formats: ['es', 'cjs'],
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
// Preserve the module structure in the output
preserveModules: false,
},
},
},
})
Then map each entry in your package.json exports:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js",
"require": "./dist/index.cjs.js"
},
"./components": {
"types": "./dist/components.d.ts",
"import": "./dist/components.es.js",
"require": "./dist/components.cjs.js"
},
"./hooks": {
"types": "./dist/hooks.d.ts",
"import": "./dist/hooks.es.js",
"require": "./dist/hooks.cjs.js"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.es.js",
"require": "./dist/utils.cjs.js"
}
}
}
CSS Handling in Libraries
CSS in libraries is tricky. You have two strategies: extract the CSS into a separate file that consumers import explicitly, or inject it at runtime so consumers don't have to think about it. Each has tradeoffs.
Strategy 1: Extract CSS (Default Behavior)
By default, Vite extracts all CSS into a single dist/style.css file. Consumers must import it manually. This is the recommended approach — it gives consumers full control over load order and lets them purge unused styles if they wish.
// Consumer's code
import { Button } from 'my-component-lib'
import 'my-component-lib/styles.css' // must import explicitly
Strategy 2: Inject CSS at Runtime
If you want zero-config CSS for consumers, set cssCodeSplit: false and use a plugin like vite-plugin-css-injected-by-js to inline styles into the JavaScript bundle. The JS injects a <style> tag when the module loads.
// vite.config.ts — CSS injection approach
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
export default defineConfig({
plugins: [react(), cssInjectedByJsPlugin()],
build: {
lib: { /* ... */ },
cssCodeSplit: false, // merge all CSS into one chunk for injection
},
})
The tradeoff: injected CSS increases JS bundle size, can cause a flash of unstyled content, and makes SSR more complicated since there's no DOM to inject into during server rendering. For most libraries, extracted CSS is the safer default.
Generating TypeScript Declaration Files
Vite doesn't generate .d.ts files on its own — it only transpiles TypeScript, it doesn't type-check or emit declarations. You have two approaches to fill this gap.
Option A: vite-plugin-dts (Recommended)
This plugin runs the TypeScript compiler during the Vite build and emits declaration files directly into dist/. It can optionally roll up all declarations into a single .d.ts file.
npm install -D vite-plugin-dts
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [
dts({
rollupTypes: true, // bundle all .d.ts into one file
tsconfigPath: './tsconfig.json',
}),
],
// ...
})
Option B: Separate tsc Step
If you prefer keeping type generation decoupled from the build, run tsc in declaration-only mode as a separate step. This is simpler to debug but produces individual .d.ts files mirroring your source tree.
{
"scripts": {
"build": "vite build && tsc --emitDeclarationOnly --outDir dist/types",
"build:types": "tsc --emitDeclarationOnly --outDir dist/types"
}
}
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "dist/types",
"rootDir": "src"
},
"include": ["src"]
}
Testing Locally with npm link
Before publishing, test your library in a real consuming project. npm link creates a symlink from your library into another project's node_modules, so changes rebuild instantly without publishing to a registry.
-
Build and link the librarybash
# In your library directory npm run build npm link -
Link from the consuming projectbash
# In the consuming app directory npm link my-component-lib -
Use watch mode for live development
Run
vite build --watchin the library to rebuild on every change. The consuming project picks up changes through the symlink automatically.bash# In the library directory (keep running) npx vite build --watch -
Clean up when donebash
# In the consuming app directory npm unlink my-component-lib # In the library directory npm unlink
Best Practices Checklist
Library authoring has more constraints than app development because your code runs in environments you don't control. These practices prevent the most common publishing mistakes.
1. Always externalize frameworks and peer dependencies
If a dependency appears in peerDependencies, it must appear in rollupOptions.external. Automate this to avoid human error:
// vite.config.ts — auto-externalize peer deps
import pkg from './package.json'
const external = [
...Object.keys(pkg.peerDependencies || {}),
// Also externalize sub-paths like 'react/jsx-runtime'
/^react\//,
/^react-dom\//,
]
export default defineConfig({
build: {
rollupOptions: { external },
},
})
2. Ship both ESM and CJS with a proper exports map
The exports field in package.json is the modern resolution mechanism. Always put types first — TypeScript requires it to be the first condition. Provide import for ESM consumers and require for CJS consumers:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/my-lib.es.js",
"require": "./dist/my-lib.cjs.js"
}
},
"main": "./dist/my-lib.cjs.js",
"module": "./dist/my-lib.es.js",
"types": "./dist/index.d.ts"
}
3. Include sourcemaps
Set build.sourcemap: true in your Vite config. Sourcemaps let consumers debug issues that originate in your library without reading minified code. The extra file size only affects development — bundlers strip sourcemaps from production app builds.
4. Validate your package before publishing
Use npm pack --dry-run to see exactly which files will end up in the tarball. Use the publint tool to catch common package.json configuration mistakes. Run arethetypeswrong to verify your type exports resolve correctly in every module resolution mode.
# Preview what gets published
npm pack --dry-run
# Check for package.json issues
npx publint
# Verify TypeScript resolution works in all module modes
npx @arethetypeswrong/cli --pack .
Add publint and @arethetypeswrong/cli to your CI pipeline's prepublish step. These tools catch issues like missing exports entries, incorrect types paths, and CJS/ESM interop problems before your users discover them.
Multi-Page Apps and Backend Integration
Not every project is a single-page application. Many real-world codebases need multiple HTML entry points, and many teams use Vite purely as an asset pipeline for server-rendered frameworks like Django, Rails, or Laravel. Vite handles both patterns elegantly through Rollup's multi-input builds and a manifest-based asset resolution system.
Multi-Page Apps (MPA)
Vite supports multiple HTML entry points through Rollup's input option. Instead of a single index.html at the project root, you specify an object where each key is a named entry and each value is the path to an HTML file. Vite processes each entry independently — every page gets its own JS and CSS bundles, while shared dependencies are automatically extracted into common chunks.
Project Structure
A typical multi-page project looks like this:
my-mpa-project/
├── index.html # Main site entry
├── admin/
│ └── index.html # Admin panel entry
├── login/
│ └── index.html # Login page entry
├── src/
│ ├── main.ts # JS for main site
│ ├── admin.ts # JS for admin panel
│ ├── login.ts # JS for login page
│ └── shared/
│ └── analytics.ts # Shared module (auto-chunked)
├── vite.config.ts
└── package.json
Each HTML file includes a <script type="module"> tag pointing to its corresponding source file. Vite treats each HTML file as a separate Rollup entry point.
Configuration
// vite.config.ts
import { resolve } from 'path'
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
admin: resolve(__dirname, 'admin/index.html'),
login: resolve(__dirname, 'login/index.html'),
},
},
},
})
The keys (main, admin, login) become the chunk names in the output. After building, you get a dist/ folder with separate bundles for each entry. If main.ts and admin.ts both import shared/analytics.ts, Rollup automatically extracts it into a common chunk like analytics-BxkF3a9q.js, loaded by both pages without duplication.
Always use resolve(__dirname, '...') for input paths. Relative paths can break depending on where you run the build command. The keys in the input object must be unique — they determine the output filenames.
Backend Integration
Many production applications use a server-rendered backend (Django, Rails, Laravel, Express) that generates HTML on the server, and only need Vite for processing JavaScript, CSS, and other frontend assets. In this pattern, Vite doesn't serve HTML at all — it acts purely as a build tool and dev server for assets.
The integration works differently in development and production, but the core idea is the same: the backend controls the HTML, and Vite controls the assets.
How It Works
flowchart LR
subgraph DEV["Development"]
direction LR
B1["Backend Server\n(Django / Rails / Laravel)"] -- "serves HTML" --> HTML1["HTML Response"]
HTML1 -- 'script src=\nhttp://localhost:5173/src/main.tsx' --> V1["Vite Dev Server\n:5173"]
V1 -- "serves JS/CSS\nwith HMR" --> Browser1["Browser"]
end
subgraph PROD["Production"]
direction LR
VB["vite build"] -- "produces" --> Dist["dist/ +\nmanifest.json"]
Dist -- "read by" --> B2["Backend Server"]
B2 -- "serves HTML with\nhashed asset URLs" --> Browser2["Browser"]
end
In development, the backend serves HTML pages that point to the Vite dev server (typically http://localhost:5173) for all JS and CSS assets. Vite provides HMR, so changes appear instantly without a full page reload. In production, you run vite build to produce hashed, optimized assets plus a manifest.json file that the backend reads to resolve the correct filenames.
The Manifest File
The manifest is the bridge between your source file paths and the hashed production filenames. Enable it with build.manifest:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
manifest: true,
rollupOptions: {
input: 'src/main.ts',
},
},
})
After building, Vite writes a .vite/manifest.json in your output directory. It maps each source entry to its output file, CSS dependencies, and imported chunks:
{
"src/main.ts": {
"file": "assets/main-BxkF3a9q.js",
"src": "src/main.ts",
"isEntry": true,
"css": ["assets/main-Dkw2lqaB.css"],
"imports": ["_shared-CxR0k1p8.js"]
},
"_shared-CxR0k1p8.js": {
"file": "assets/shared-CxR0k1p8.js"
}
}
Your backend reads this JSON at runtime, looks up "src/main.ts", and generates the correct <script> and <link> tags with the hashed filenames. It also picks up CSS files and shared chunks listed in the imports array, preloading them with <link rel="modulepreload"> for optimal loading.
Dev Server Setup
In development, your backend templates need to point directly at the Vite dev server instead of reading the manifest. A conditional template helper switches between the two modes. Here's the basic pattern:
<!-- Development: point to Vite dev server -->
<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/src/main.ts"></script>
<!-- Production: use hashed filenames from manifest -->
<link rel="stylesheet" href="/assets/main-Dkw2lqaB.css">
<script type="module" src="/assets/main-BxkF3a9q.js"></script>
The @vite/client script is essential in development — it establishes the WebSocket connection that powers HMR. Without it, you get no hot module replacement.
The server.origin Config Option
When your backend serves HTML that references Vite dev server assets, relative asset URLs (images, fonts loaded from CSS) can break because the browser resolves them relative to the backend's origin, not Vite's. The server.origin option fixes this by prepending the Vite dev server origin to all asset URLs:
// vite.config.ts
export default defineConfig({
server: {
origin: 'http://localhost:5173',
},
})
With this set, a CSS rule like url('./logo.png') resolves to http://localhost:5173/src/logo.png during development, instead of looking for it on the backend server's port.
Framework-Specific Integration
While you can wire up the manifest pattern manually for any backend, several frameworks have first-class Vite plugins that handle dev/prod switching, manifest reading, and tag generation automatically. Here's how the major ecosystems compare:
| Framework | Integration | Template Helper | Maturity |
|---|---|---|---|
| Laravel | laravel-vite-plugin (official) | @vite('resources/js/app.js') | Gold standard — built-in since Laravel 9 |
| Rails | vite_ruby gem | vite_javascript_tag 'application' | Mature, widely adopted |
| Django | django-vite | {% vite_asset 'src/main.ts' %} | Stable, community-maintained |
| Express / Node | Manual or vite-express | Custom middleware reads manifest | DIY — flexible but more setup |
Laravel: The Gold Standard
Laravel's Vite integration is the most polished of any backend framework. The laravel-vite-plugin ships as a first-party package and the Blade @vite directive handles everything — manifest resolution in production, pointing to the dev server in development, and injecting @vite/client for HMR automatically.
// vite.config.ts
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true, // full reload on Blade template changes
}),
],
})
In your Blade templates, a single directive is all you need:
<!-- resources/views/app.blade.php -->
<head>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
In development, @vite emits script tags pointing to http://localhost:5173 plus the HMR client. In production, it reads public/build/.vite/manifest.json and emits hashed <link> and <script> tags. The refresh: true option watches .blade.php files and triggers a full page reload when they change — bridging the gap between server-rendered templates and Vite's HMR.
Rails with vite_ruby
The vite_ruby gem follows the same manifest-based pattern. After installation, it provides helper methods for your ERB templates:
<!-- app/views/layouts/application.html.erb -->
<head>
<%= vite_client_tag %>
<%= vite_javascript_tag 'application' %>
<%= vite_stylesheet_tag 'application' %>
</head>
The vite_client_tag helper injects the HMR client only in development and is safely omitted in production. The gem also configures a proxy so the Rails dev server forwards asset requests to Vite automatically.
Django with django-vite
For Django projects, django-vite provides template tags that handle the dev/prod switching:
<!-- templates/base.html -->
{% load django_vite %}
<head>
{% vite_hmr_client %}
{% vite_asset 'src/main.ts' %}
</head>
You configure the manifest path and dev server URL in settings.py, and django-vite reads the appropriate manifest in production while proxying to the dev server locally.
Express / Node.js (Manual Integration)
For Express or other Node.js backends, there's no dominant plugin — you read the manifest yourself. This gives you full control at the cost of a bit more boilerplate:
import fs from 'fs'
import path from 'path'
import express from 'express'
const app = express()
const isDev = process.env.NODE_ENV !== 'production'
function getAssetTags(entrypoint: string): string {
if (isDev) {
return `
<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/${entrypoint}"></script>
`
}
const manifest = JSON.parse(
fs.readFileSync(path.resolve('dist/.vite/manifest.json'), 'utf-8')
)
const entry = manifest[entrypoint]
const css = (entry.css || [])
.map((f: string) => `<link rel="stylesheet" href="/dist/${f}">`)
.join('\n')
return `${css}\n<script type="module" src="/dist/${entry.file}"></script>`
}
app.get('/', (req, res) => {
res.send(`<html><head>${getAssetTags('src/main.ts')}</head><body>...</body></html>`)
})
In production, cache the parsed manifest in memory instead of reading it from disk on every request. The manifest never changes between deployments, so reading it once at server startup is both safe and significantly faster.
Combining MPA + Backend Integration
These two patterns compose naturally. If your backend serves multiple routes, each with its own JavaScript entry point, you combine multi-input builds with the manifest:
// vite.config.ts — MPA with manifest for backend integration
export default defineConfig({
build: {
manifest: true,
rollupOptions: {
input: {
main: 'src/main.ts',
admin: 'src/admin.ts',
checkout: 'src/checkout.ts',
},
},
},
server: {
origin: 'http://localhost:5173',
},
})
The resulting manifest contains entries for all three entry points. Each backend route looks up its own entry — the homepage template reads manifest["src/main.ts"], the admin dashboard reads manifest["src/admin.ts"], and the checkout page reads manifest["src/checkout.ts"]. Shared chunks are listed in each entry's imports array, so your backend can emit <link rel="modulepreload"> tags for them.
When using backend integration, don't include HTML files as Rollup inputs. Your backend generates the HTML — Vite only needs the JS/CSS entry points. Passing HTML files to input is for MPA mode where Vite also serves the HTML.
Advanced Features: Glob Imports, WebAssembly, and Web Workers
Vite ships with first-class support for three powerful features that go well beyond basic module bundling: glob imports for bulk file loading, WebAssembly for near-native performance, and Web Workers for off-main-thread computation. Each feature integrates seamlessly with Vite's dev server and production build pipeline.
Glob Imports with import.meta.glob
import.meta.glob is a Vite-specific API that lets you import multiple modules matching a file pattern. It returns an object where each key is a matched file path and each value is a function that returns a dynamic import() promise. Vite statically analyzes these patterns at build time and expands them into individual import statements — no runtime filesystem access is involved.
Lazy Loading (Default)
By default, each matched module is lazy-loaded. The import function is only called when you invoke it, which keeps your initial bundle small.
// Each value is a () => Promise<Module>
const modules = import.meta.glob('./modules/*.ts');
// Result shape at build time:
// {
// './modules/auth.ts': () => import('./modules/auth.ts'),
// './modules/dashboard.ts': () => import('./modules/dashboard.ts'),
// './modules/settings.ts': () => import('./modules/settings.ts'),
// }
// Load a specific module on demand
const auth = await modules['./modules/auth.ts']();
auth.initialize();
Eager Loading
When you need all modules available immediately — for example, during app initialization — pass the eager: true option. The values become the resolved module objects directly instead of lazy-loading functions.
// Modules are imported eagerly — no dynamic import wrapper
const modules = import.meta.glob('./modules/*.ts', { eager: true });
// Each value is already the resolved module
for (const [path, mod] of Object.entries(modules)) {
console.log(path, mod);
}
The import Option for Named Exports
You can narrow the import to specific named exports using the import option. This enables tree-shaking — Vite only includes the exports you request in the final bundle.
// Only import the 'setup' named export from each module
const setups = import.meta.glob('./plugins/*.ts', {
import: 'setup',
eager: true,
});
// Use 'default' to grab the default export
const defaults = import.meta.glob('./components/*.vue', {
import: 'default',
eager: true,
});
The as Option for Import Types
The query option (replacing the older as syntax) lets you control how files are imported. You can load files as raw strings, URLs, or workers.
// Load as raw text strings
const markdownFiles = import.meta.glob('./docs/*.md', {
query: '?raw',
import: 'default',
});
// Load as resolved URLs (useful for assets)
const imageUrls = import.meta.glob('./assets/icons/*.svg', {
query: '?url',
import: 'default',
eager: true,
});
Practical Pattern: Auto-Registering Route Modules
One of the most common real-world uses of glob imports is building file-based routing. Each file in a directory automatically becomes a route — no manual registration needed.
// src/router.ts
const pageModules = import.meta.glob('./pages/**/*.tsx');
const routes = Object.entries(pageModules).map(([path, loader]) => {
// './pages/users/profile.tsx' → '/users/profile'
const routePath = path
.replace('./pages', '')
.replace(/\.tsx$/, '')
.replace(/\/index$/, '/');
return {
path: routePath,
lazy: async () => {
const mod = await loader();
return { Component: (mod as any).default };
},
};
});
Practical Pattern: Building a Plugin System
// Auto-discover and register all plugins
interface Plugin {
name: string;
setup: (app: App) => void;
}
const pluginModules = import.meta.glob<{ setup: Plugin['setup']; name: string }>(
'./plugins/*/index.ts',
{ eager: true }
);
function registerAllPlugins(app: App) {
for (const [path, plugin] of Object.entries(pluginModules)) {
console.log(`Registering plugin: ${plugin.name}`);
plugin.setup(app);
}
}
The glob string passed to import.meta.glob must be a literal — you cannot use variables or template strings. Vite resolves all matching files at build time and transforms them into explicit import statements. If you add a new file matching the pattern, the dev server picks it up automatically via HMR.
WebAssembly Support
Vite provides built-in support for .wasm files. You can import a WebAssembly module, initialize it, and call its exported functions — all with standard ESM syntax and proper TypeScript support.
The ?init Import Pattern
Appending ?init to a .wasm import gives you an initialization function. Call it to compile and instantiate the module. You can optionally pass an imports object to provide functions the WASM module depends on.
import init from './hashing.wasm?init';
// Initialize the WASM module
const instance = await init();
// Call exported functions
const hash = instance.exports.sha256 as (ptr: number, len: number) => number;
// You can also pass an imports object for host functions
const instanceWithImports = await init({
env: {
log_message: (ptr: number) => console.log('WASM says:', ptr),
},
});
Practical Example: Rust-Compiled WASM for Image Processing
A common use case is offloading CPU-intensive work to WASM compiled from Rust, C, or Go. Here's a realistic example using a Rust-compiled module for image blur.
// src/image-processing.ts
import initImageWasm from './wasm/image_ops.wasm?init';
let wasmInstance: WebAssembly.Instance | null = null;
// Initialize once, reuse everywhere
async function getWasm(): Promise<WebAssembly.Instance> {
if (!wasmInstance) {
wasmInstance = await initImageWasm();
}
return wasmInstance;
}
export async function blurImage(
imageData: Uint8Array,
width: number,
height: number,
radius: number
): Promise<Uint8Array> {
const wasm = await getWasm();
const { memory, alloc, dealloc, gaussian_blur } = wasm.exports as any;
// Allocate memory in WASM linear memory
const inputPtr = alloc(imageData.length);
const wasmMemory = new Uint8Array(memory.buffer);
wasmMemory.set(imageData, inputPtr);
// Run the blur — this is 10-50x faster than pure JS
const outputPtr = gaussian_blur(inputPtr, width, height, radius);
// Copy result and free WASM memory
const result = new Uint8Array(memory.buffer, outputPtr, imageData.length).slice();
dealloc(inputPtr, imageData.length);
dealloc(outputPtr, imageData.length);
return result;
}
Top-Level Await and Module Sharing
If your project targets modern browsers, you can use top-level await to initialize WASM at module scope. This guarantees the module is ready before any other code in the module runs, and the instance is shared across all importers.
// src/crypto.ts — top-level await pattern
import init from './wasm/crypto.wasm?init';
// Module blocks here until WASM is ready
const instance = await init();
const exports = instance.exports as {
sha256: (ptr: number, len: number) => number;
argon2: (ptr: number, len: number, cost: number) => number;
};
// All importers of this module get the same initialized instance
export const sha256 = exports.sha256;
export const argon2 = exports.argon2;
Vite supports the WebAssembly ESM Integration proposal, which allows direct import of .wasm files as ES modules. However, browser support is not yet universal. The ?init pattern shown above works reliably today across all build targets. Set build.target to 'esnext' in your Vite config if you want to opt in to the latest WASM integration features.
Web Workers
Web Workers let you run JavaScript off the main thread, preventing UI jank during expensive operations. Vite supports workers as first-class citizens with two import patterns: the native new Worker() constructor and the ?worker import suffix.
Native Pattern: new Worker() with import.meta.url
This pattern follows the web standard and works without any Vite-specific syntax. Vite detects the new URL(..., import.meta.url) pattern and bundles the worker file separately during production builds.
// Native pattern — standards-compliant
const worker = new Worker(
new URL('./heavy-computation.worker.ts', import.meta.url),
{ type: 'module' } // Required for ES module workers
);
worker.postMessage({ data: largeDataSet, iterations: 1000 });
worker.addEventListener('message', (event) => {
console.log('Result from worker:', event.data);
});
// heavy-computation.worker.ts
// This file runs in a separate thread — no DOM access
self.addEventListener('message', (event) => {
const { data, iterations } = event.data;
let result = data;
for (let i = 0; i < iterations; i++) {
result = processChunk(result);
}
self.postMessage(result);
});
function processChunk(data: ArrayBuffer): ArrayBuffer {
// CPU-intensive work happens here
const view = new Float64Array(data);
for (let i = 0; i < view.length; i++) {
view[i] = Math.sqrt(view[i] * view[i] + 1);
}
return data;
}
The ?worker Import Suffix
Vite's ?worker suffix provides a more ergonomic API. Instead of manually constructing new Worker(), you import a constructor wrapper directly. This is especially convenient when you want to instantiate the same worker type multiple times.
// The ?worker suffix wraps the file as an instantiable worker
import CompressionWorker from './compression.worker?worker';
// Each call to the constructor creates a new Worker instance
const worker1 = new CompressionWorker();
const worker2 = new CompressionWorker();
worker1.postMessage({ file: rawFile1, format: 'gzip' });
worker2.postMessage({ file: rawFile2, format: 'brotli' });
Inline Workers with ?worker&inline
The ?worker&inline variant inlines the worker's compiled code as a base64 data URL. This eliminates the extra network request for the worker script, which is useful for small workers in environments where separate file loading is problematic (e.g., browser extensions, sandboxed iframes).
// Worker code is inlined as base64 — no separate file request
import InlineWorker from './hash.worker?worker&inline';
const worker = new InlineWorker();
worker.postMessage(fileBuffer);
SharedWorker Support
Vite also supports SharedWorker, which allows multiple browser tabs or iframes to share a single worker instance. This is ideal for scenarios like shared caches, synchronized state, or connection pooling across tabs.
// Use ?sharedworker suffix for SharedWorker
import SharedCacheWorker from './cache.worker?sharedworker';
const sharedWorker = new SharedCacheWorker();
// SharedWorkers communicate through a port
sharedWorker.port.start();
sharedWorker.port.postMessage({ action: 'get', key: 'user-session' });
sharedWorker.port.addEventListener('message', (event) => {
console.log('Cached value:', event.data);
});
Comparison: Worker Import Patterns
| Pattern | Syntax | Best For |
|---|---|---|
new Worker(new URL(...)) | Standards-compliant | Maximum portability, framework-agnostic code |
?worker | Vite-specific import suffix | Ergonomic multi-instance workers, simple setup |
?worker&inline | Vite-specific, base64 inlined | Small workers, browser extensions, no extra requests |
?sharedworker | Vite-specific import suffix | Cross-tab communication, shared state/cache |
If you're building a library that might be consumed outside Vite, use the new Worker(new URL('./worker.ts', import.meta.url)) pattern. It's recognized by Webpack 5, Rollup, and other bundlers. The ?worker suffix is Vite-specific and won't work in other build tools.
The Vite Ecosystem: Vitest, VitePress, and the Plugin Galaxy
Vite is more than a build tool — it's the foundation for an entire ecosystem. Testing frameworks, documentation generators, full-stack meta-frameworks, and hundreds of community plugins all build on Vite's fast dev server and plugin architecture. Understanding this ecosystem helps you pick the right tools and avoid reinventing what already exists.
mindmap
root((Vite Ecosystem))
Testing
Vitest
Playwright Vite config
Documentation
VitePress
Meta-Frameworks
Nuxt 3
SvelteKit
Astro
Remix
SolidStart
Analog
Plugins
Framework
DX
Build
unplugin
Community
awesome-vite
Discord
RFCs
Vitest: Unit Testing That Shares Your Vite Config
Vitest is a unit testing framework designed to reuse Vite's transform pipeline. It reads your vite.config.ts, applies the same plugins and resolve aliases, and runs your tests with the same TypeScript, JSX, and CSS handling your app already uses. There's no separate Babel config, no Jest transformer boilerplate, and no config drift between your app and your test suite.
This matters more than it sounds. With Jest, you often spend hours configuring ts-jest or @swc/jest, setting up module name mappers to mirror your Vite aliases, and debugging transform mismatches. Vitest eliminates that entire category of problems. It also executes tests with native ESM, uses smart module caching to avoid re-transforming unchanged files, and runs test files in parallel workers for speed.
// vite.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
coverage: {
provider: 'v8', // or 'istanbul'
},
},
});
// src/utils/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './format';
describe('formatCurrency', () => {
it('formats USD with two decimal places', () => {
expect(formatCurrency(1234.5, 'USD')).toBe('$1,234.50');
});
it('handles zero correctly', () => {
expect(formatCurrency(0, 'USD')).toBe('$0.00');
});
it('rounds to nearest cent', () => {
expect(formatCurrency(19.999, 'EUR')).toBe('€20.00');
});
});
Key Vitest Features
- In-source testing — Write tests directly in your source files inside an
if (import.meta.vitest)block. Vitest strips them from production builds automatically. Great for testing private utility functions. - UI mode — Run
vitest --uito get a browser-based dashboard that shows test status, module graphs, and reruns tests as you edit. Think of it as a visual test runner baked in. - Coverage — Built-in support via
v8(fast, native) oristanbul(more mature, supports more reporters). No extra wiring needed. - Snapshot testing — Works like Jest snapshots. Call
expect(result).toMatchSnapshot()and Vitest creates/compares snapshot files. - Mocking — Full
vi.mock(),vi.spyOn(), andvi.fn()API. Mocks are hoisted automatically, matching Jest's behavior.
If your vite.config.ts defines path aliases like @/components, Vitest resolves them identically — no need for a separate moduleNameMapper. If you add a Vite plugin that transforms .graphql files, Vitest picks it up automatically. One config, zero drift.
VitePress: Documentation Sites Powered by Vite
VitePress is a static site generator built on top of Vite and Vue. It's purpose-built for documentation: you write Markdown files, optionally embed Vue components inside them, and VitePress compiles everything into a fast static site with SPA navigation. Page transitions are instant because VitePress prefetches linked pages and renders them client-side without full reloads.
Out of the box, VitePress gives you a polished documentation theme with dark mode, sidebar navigation, full-text search (powered by MiniSearch locally or Algolia DocSearch), and responsive layout. Since it's built on Vite, your dev server starts instantly and hot-reloads as you edit Markdown.
# Getting Started
Install the package:
```bash
npm install my-library
```
## Interactive Demo
You can embed Vue components directly in Markdown:
<script setup>
import { DemoCounter } from '../components/DemoCounter.vue'
</script>
<DemoCounter :initial-count="5" />
This counter is a **live Vue component** rendered alongside your docs.
VitePress powers the official documentation sites for Vite itself, Vue, Vitest, Pinia, VueUse, Rollup, and many other projects. If you need to write technical docs and want fast iteration, it's the go-to choice in the Vite ecosystem.
Meta-Frameworks: Vite as the Industry-Standard Bundler
Vite has become the default build tool for nearly every modern meta-framework. This isn't a coincidence — Vite's plugin API is flexible enough to support arbitrary framework transforms, and its dev server speed makes full-stack DX viable. Here's the current landscape:
| Framework | Meta-Framework | Vite Role |
|---|---|---|
| Vue | Nuxt 3 | Dev server + production bundler (via Nitro) |
| Svelte | SvelteKit | Core bundler, handles SSR and client builds |
| Multi-framework | Astro | Compiles .astro, React, Vue, Svelte components |
| React | Remix (v2+) | Optional Vite plugin replaces the legacy compiler |
| Solid | SolidStart | Full build pipeline with Vinxi (Vite-based) |
| Angular | Analog | File-based routing + SSR via Vite + Nitro |
When a meta-framework adopts Vite, it inherits the entire plugin ecosystem. A Vite plugin for image optimization works in Nuxt, SvelteKit, and Astro without framework-specific forks. This shared foundation is what makes Vite the de facto standard — plugins are written once and work everywhere.
The Plugin Ecosystem
Vite's Rollup-compatible plugin interface has sparked a massive ecosystem. Plugins fall into a few clear categories, each solving a different class of problem.
Framework Plugins
These are maintained by the Vite team and provide first-class support for UI frameworks. They handle framework-specific transforms like JSX compilation, SFC parsing, and HMR integration.
@vitejs/plugin-react— Uses Babel (or SWC via@vitejs/plugin-react-swc) for React Fast Refresh and JSX transforms@vitejs/plugin-vue— Compiles Vue Single File Components, supports<script setup>, CSS modules@vitejs/plugin-vue-jsx— Adds JSX/TSX support for Vuevite-plugin-svelte— Svelte component compilation with HMR (maintained by the Svelte team)
DX Plugins (Developer Experience)
These plugins make development faster and more ergonomic. They don't affect production output — they improve the feedback loop.
vite-plugin-inspect— Opens a local UI showing how each plugin transforms your modules. Invaluable for debugging plugin ordering issues or unexpected transform results.unplugin-auto-import— Automatically imports commonly used APIs (ref,computed,useState) so you don't need manual import statements. Generates a TypeScript declaration file to keep type-checking intact.unplugin-vue-components— Auto-registers Vue components on use, eliminating repetitive import boilerplate.
Build Plugins
These plugins augment the production build with features like PWA support, compression, or legacy browser compatibility.
vite-plugin-pwa— Generates service workers and web app manifests for Progressive Web App support. Integrates Workbox under the hood.vite-plugin-compression— Pre-compresses assets with gzip or Brotli at build time, so your server can skip on-the-fly compression.@vitejs/plugin-legacy— Generates legacy chunks with syntax transpilation and polyfills for older browsers via SystemJS.
The awesome-vite repository is the community-curated catalog of plugins, templates, and integrations. It's the best starting point when you need a plugin for a specific task.
The unplugin Ecosystem: Write Once, Run Everywhere
The unplugin project provides a unified plugin API that compiles down to Vite, Rollup, Webpack, esbuild, and rspack plugins. If you write a plugin using unplugin, it works in all five bundlers from a single codebase. This is how libraries like unplugin-auto-import and unplugin-vue-components support multiple build tools without maintaining separate implementations.
import { createUnplugin } from 'unplugin';
const unpluginExample = createUnplugin((options) => ({
name: 'unplugin-example',
transformInclude(id) {
return id.endsWith('.custom');
},
transform(code) {
return code.replace(/__TOKEN__/g, options.replacement);
},
}));
// Consumers import the right version for their bundler:
// import Example from 'unplugin-example/vite'
// import Example from 'unplugin-example/webpack'
// import Example from 'unplugin-example/rollup'
The unplugin pattern is increasingly popular because it decouples plugin logic from bundler specifics. If you're building a plugin that isn't tied to Vite-only features, writing it as an unplugin ensures the broadest possible adoption.
Vite's ecosystem strength is self-reinforcing. More meta-frameworks adopt Vite → more plugins are written for Vite → more developers learn Vite → more meta-frameworks adopt Vite. This network effect is why Vite has become the gravitational center of modern frontend tooling.
Vite vs the Competition: Webpack, Parcel, Turbopack, and esbuild
Choosing a build tool isn't just about benchmarks — it's about the trade-offs that matter for your project. Vite is an excellent default choice for new projects, but it doesn't make every alternative obsolete. Each tool in this space occupies a different niche shaped by its architecture, ecosystem, and design philosophy.
This section gives you an honest, nuanced comparison so you can make an informed decision — or defend the one you've already made.
Webpack: The Incumbent
Webpack dominated frontend tooling for nearly a decade, and its influence is everywhere. It pioneered the concept of treating every asset — JavaScript, CSS, images, fonts — as a module in a dependency graph. Its loader and plugin system is the most extensible in the ecosystem, with thousands of community packages covering nearly every conceivable use case.
The cost of that power is complexity. A production-ready Webpack config for a modern React or Vue app can easily exceed 200 lines, requiring loaders for Babel/SWC, CSS modules, asset handling, and plugins for HTML generation, bundle analysis, and code splitting. Dev server startup on a large codebase (50k+ modules) can take 30–60 seconds because Webpack bundles everything before serving a single page.
// webpack.config.js — a typical production setup (abbreviated)
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
entry: './src/index.tsx',
module: {
rules: [
{ test: /\.tsx?$/, use: 'babel-loader', exclude: /node_modules/ },
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'] },
{ test: /\.(png|svg|jpg)$/, type: 'asset/resource' },
],
},
plugins: [
new HtmlWebpackPlugin({ template: './index.html' }),
new MiniCssExtractPlugin(),
],
optimization: { splitChunks: { chunks: 'all' } },
};
Webpack 5 brought meaningful improvements: persistent disk caching cut rebuild times significantly, and Module Federation enabled a first-class micro-frontend architecture where independently deployed apps share dependencies at runtime. No other tool has a comparable story for micro-frontends at scale.
When to still choose Webpack
- Existing large codebases with deep Webpack integration (custom loaders, complex plugin chains) where migration cost is high.
- Module Federation is a hard requirement for your micro-frontend architecture.
- You depend on Webpack-specific plugins with no Vite equivalent (e.g., certain i18n or WASM toolchains).
Parcel: Zero-Config Done Right
Parcel shares Vite's philosophy of minimal configuration, but takes it even further — there's genuinely no config file needed for most projects. Point Parcel at an HTML file and it auto-detects TypeScript, JSX, CSS modules, image optimization, and more. Under the hood, Parcel v2 uses an SWC-based transformer written in Rust, giving it compilation speeds competitive with Vite.
Where Parcel falls short is ecosystem and community. Its plugin API exists but has far fewer third-party plugins than Vite or Webpack. Framework-specific integrations (SSR, custom server middleware) are less mature. If you hit an edge case, you'll find fewer Stack Overflow answers and GitHub discussions to help you out.
// package.json — Parcel needs nothing else
{
"source": "src/index.html",
"scripts": {
"dev": "parcel",
"build": "parcel build"
},
"devDependencies": {
"parcel": "^2.12.0"
}
}
When to choose Parcel
- Simple projects — prototypes, landing pages, or small apps — where zero-config is paramount and you don't want to think about build tooling at all.
- You value automatic code splitting and asset optimization without any setup.
Turbopack: The Next.js Native
Turbopack is Vercel's Rust-based bundler, announced as the spiritual successor to Webpack (its creator, Tobias Koppers, leads the project). It's designed from the ground up for incremental computation — only recomputing what changed — and achieves remarkable HMR speeds as a result. In benchmarks on large Next.js apps, Turbopack's HMR updates can be 10× faster than Webpack's.
The trade-off is maturity and scope. As of 2024, Turbopack is tightly coupled to Next.js. Its plugin API is still evolving, and using it outside the Next.js ecosystem requires significant effort. If you're building a standalone Vue, Svelte, or vanilla app, Turbopack isn't a practical option today.
# Turbopack is activated via the Next.js CLI flag
npx next dev --turbopack
When to consider Turbopack
- You're already committed to Next.js and want the fastest possible dev experience.
- You're willing to be on the bleeding edge and accept that some Webpack plugins you relied on may not be supported yet.
esbuild: Vite's Engine Room
esbuild is the Go-based bundler that Vite uses internally for dependency pre-bundling and TypeScript/JSX transformation. On its own, esbuild is staggeringly fast — it can bundle an entire project 10–100× faster than Webpack. It achieves this through parallelized parsing, aggressive memory reuse, and the raw speed of compiled Go code.
But esbuild is intentionally minimal. It doesn't support HTML entry points, has limited CSS handling (no CSS modules, no PostCSS integration), and its code-splitting output is less optimized than Rollup's. This is exactly why Vite exists: it layers a full developer experience on top of esbuild's raw speed, using Rollup for production builds where output quality matters most.
# esbuild as a standalone bundler — great for libraries
npx esbuild src/index.ts --bundle --outfile=dist/out.js --format=esm --minify
When to use esbuild directly
- Library bundling — outputting ESM and CJS bundles from a TypeScript package.
- CLI tools and build scripts where you need fast compilation without a dev server.
- Preprocessing steps inside a larger build pipeline (e.g., transforming TypeScript before another tool handles bundling).
The Comparison at a Glance
The table below compares five dimensions that matter most when choosing a build tool. Ratings are relative to each other, not absolute — "Moderate" for Webpack's build speed doesn't mean it's slow in isolation, it means others are measurably faster.
| Dimension | Vite | Webpack | Parcel | Turbopack | esbuild |
|---|---|---|---|---|---|
| Dev Start (large app) | ~300ms (no-bundle ESM) | 30–60s (full bundle) | 5–15s (cached: ~1s) | ~500ms (incremental) | N/A (no dev server) |
| HMR Speed | Instant (~50ms) | 1–5s (re-bundles subgraph) | Fast (~100ms) | Instant (~20ms) | N/A |
| Production Build | Fast (Rollup-based) | Moderate | Fast (SWC-based) | Fast (Rust-based) | Extremely fast |
| Config Complexity | Low (~10–30 lines) | High (100–300+ lines) | Near-zero | Near-zero (Next.js) | Low (CLI flags / API) |
| Plugin Ecosystem | Large & growing (Rollup-compatible) | Massive (largest) | Small | Minimal (maturing) | Small (Go-based) |
| Framework Support | React, Vue, Svelte, Solid, Lit, Qwik | All (via loaders) | React, Vue, Svelte | Next.js (React only) | Framework-agnostic (raw) |
The numbers above are representative, not absolute. Dev start and HMR times vary wildly based on project size, number of dependencies, disk speed, and OS. Always benchmark on your own codebase before making migration decisions.
Architecture: Why Vite Feels Different
The fundamental architectural difference is that Vite doesn't bundle during development. Webpack, Parcel, and (historically) Turbopack all create an in-memory bundle before serving your app. Vite instead serves native ES modules directly to the browser, transforming files on-demand as the browser requests them. This is why Vite's dev start is nearly instant regardless of project size — it only processes the modules the current page actually imports.
For production builds, Vite delegates to Rollup (or the new Rolldown engine in Vite 6+), which produces highly optimized, tree-shaken output with excellent code-splitting heuristics. This dual-engine approach — esbuild for dev speed, Rollup for production quality — is Vite's defining design decision.
// vite.config.ts — a complete production-ready config
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: { manualChunks: { vendor: ['react', 'react-dom'] } },
},
},
});
Compare that 10-line Vite config to the Webpack example above. They achieve equivalent results — JSX transformation, production builds with code splitting, and a dev server — but with dramatically different levels of ceremony.
When to Pick What: A Decision Framework
| Your Situation | Best Choice | Why |
|---|---|---|
| New project with React, Vue, Svelte, or Solid | Vite | Fastest DX, great defaults, large plugin ecosystem |
| Existing Webpack app with deep custom config | Webpack (stay) | Migration cost is real; evaluate incrementally |
| Micro-frontends with shared runtime dependencies | Webpack (Module Federation) | No mature alternative for runtime module sharing |
| Quick prototype, zero config tolerance | Parcel | Truly zero-config; just point at an HTML file |
| Next.js app, want cutting-edge performance | Turbopack | Native integration, Vercel-backed, fastest HMR for Next.js |
| Bundling a TypeScript library for npm | esbuild (or tsup) | Maximum speed, simple ESM/CJS dual output |
| Build script or CLI tool | esbuild | Fastest compilation, minimal API surface |
You don't have to migrate all at once. Start by using Vite for a new sub-project or internal tool. Get comfortable with its plugin model and config patterns before attempting a full migration of your main app. The webpack-to-vite compatibility layer can ease the transition for projects with custom Webpack loaders.
Migration Guide: Moving from Webpack or Create React App to Vite
Migrating to Vite from Webpack or Create React App (CRA) is one of the highest-impact improvements you can make to your developer experience. Most CRA projects can be migrated in under an hour. Webpack projects take longer depending on loader complexity, but the core concepts map cleanly.
This guide walks through both migration paths step by step, then covers the gotchas that catch nearly everyone on their first attempt.
Migrating from Create React App
CRA is the most common starting point, and its migration path is well-worn. The key insight is that CRA is just Webpack with opinions — and most of those opinions have Vite equivalents built in (JSX, CSS modules, static assets, environment variables).
-
Remove react-scripts, install Vite and the React plugin
Uninstall CRA's all-in-one dependency and replace it with Vite's leaner toolchain. The
@vitejs/plugin-reactpackage handles JSX transforms and Fast Refresh.bashnpm uninstall react-scripts npm install --save-dev vite @vitejs/plugin-react -
Move index.html to the project root and add the module script entry
Vite uses
index.htmlas the entry point — not a hidden Webpack config. Move it frompublic/index.htmlto the project root, then add a<script>tag pointing to your React entry file.bashmv public/index.html index.htmlThen add the script tag inside the
<body>, just before the closing tag:html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>My App</title> </head> <body> <div id="root"></div> <script type="module" src="/src/index.tsx"></script> </body> </html> -
Remove %PUBLIC_URL% references
CRA injects
%PUBLIC_URL%as a placeholder for the asset base path. Vite doesn't use this convention — assets in thepublic/directory are served from the root. Replace all occurrences with plain relative paths.html<!-- Before (CRA) --> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <!-- After (Vite) --> <link rel="icon" href="/favicon.ico" /> <link rel="manifest" href="/manifest.json" /> -
Create vite.config.ts with the React plugin
This is the minimal config that replaces the entire hidden CRA Webpack configuration. For most projects, these five lines are all you need.
typescriptimport { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], server: { port: 3000, // match CRA's default port open: true, }, }); -
Update scripts in package.json
Replace the CRA scripts with Vite equivalents. The commands are shorter and don't need the
react-scriptswrapper.json{ "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", "lint": "eslint src --ext ts,tsx" } }Note that
vite previewserves the production build locally — it replacesserve -s build. There's no separateejectscript because Vite's config is already fully exposed. -
Rename environment variables from REACT_APP_ to VITE_
CRA exposes environment variables prefixed with
REACT_APP_viaprocess.env. Vite uses theVITE_prefix and exposes them throughimport.meta.envinstead.bash# .env — Before (CRA) REACT_APP_API_URL=https://api.example.com REACT_APP_FEATURE_FLAG=true # .env — After (Vite) VITE_API_URL=https://api.example.com VITE_FEATURE_FLAG=trueThen update every reference in your source code:
typescript// Before (CRA) const apiUrl = process.env.REACT_APP_API_URL; // After (Vite) const apiUrl = import.meta.env.VITE_API_URL;A quick find-and-replace across your
src/directory handles most of this. Search forprocess.env.REACT_APP_and replace withimport.meta.env.VITE_. -
Convert require() calls to import statements
Vite serves native ES modules in development, so CommonJS
require()calls in your application code won't work. Convert them to static or dynamicimportstatements.typescript// Before — CommonJS require for images const logo = require('./logo.png'); // After — ESM static import (processed by Vite's asset pipeline) import logo from './logo.png'; // Before — dynamic require const icon = require(`./icons/${name}.svg`); // After — dynamic import with import.meta.glob const icons = import.meta.glob('./icons/*.svg', { eager: true }); const icon = icons[`./icons/${name}.svg`];
Migrating from Webpack (General)
Webpack and Vite share many of the same concepts, but they're expressed differently. The biggest mental shift is that Vite uses index.html as the entry point (not a JavaScript file), and most of what Webpack loaders do is either built in or handled by a Rollup/Vite plugin.
Concept Mapping: Webpack → Vite
Use this table as a quick reference when translating your webpack.config.js into vite.config.ts:
| Webpack Concept | Vite Equivalent | Notes |
|---|---|---|
entry | index.html with <script type="module"> | Vite discovers the entry from the HTML file |
output.publicPath | base in config | Set to / by default |
Loaders (babel-loader, etc.) | Vite/Rollup plugins | Most transforms are built in (JSX, TS, CSS) |
resolve.alias | resolve.alias | Same concept, nearly same syntax |
DefinePlugin | define in config | String replacement at build time |
devServer.proxy | server.proxy | Same underlying http-proxy library |
file-loader / url-loader | Built-in asset handling | Import any asset — Vite inlines or hashes automatically |
require.context | import.meta.glob | More powerful glob-based module import |
process.env | import.meta.env | Only VITE_ prefixed vars are exposed |
Converting resolve.alias and DefinePlugin
These two Webpack features have almost direct equivalents in Vite. Here's a side-by-side conversion:
const path = require('path');
const webpack = require('webpack');
module.exports = {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@components': path.resolve(__dirname, 'src/components'),
},
},
plugins: [
new webpack.DefinePlugin({
__APP_VERSION__: JSON.stringify('1.2.0'),
'process.env.API_URL': JSON.stringify('https://api.example.com'),
}),
],
};
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@components': path.resolve(__dirname, 'src/components'),
},
},
define: {
__APP_VERSION__: JSON.stringify('1.2.0'),
// Note: Vite uses import.meta.env, so avoid defining process.env here.
// Use VITE_ prefixed .env files instead.
},
});
Converting require.context to import.meta.glob
Webpack's require.context is commonly used for dynamic imports like loading all icons in a folder or auto-registering route modules. Vite's import.meta.glob is the replacement — and it's actually more flexible.
// Before — Webpack require.context
const ctx = require.context('./modules', true, /\.ts$/);
const modules = {};
ctx.keys().forEach((key) => {
modules[key] = ctx(key);
});
// After — Vite import.meta.glob (lazy, returns promises)
const modules = import.meta.glob('./modules/**/*.ts');
// { './modules/auth.ts': () => import('./modules/auth.ts'), ... }
// After — Vite import.meta.glob (eager, loaded immediately)
const modules = import.meta.glob('./modules/**/*.ts', { eager: true });
// { './modules/auth.ts': { default: ..., namedExport: ... }, ... }
By default, import.meta.glob returns lazy imports (functions that return promises). Use { eager: true } only when you need all modules loaded upfront — otherwise you lose the code-splitting benefit.
Common Gotchas
You'll finish the steps above and run npm run dev expecting everything to work. It usually doesn't on the first try. These are the issues that come up in nearly every migration.
CJS Dependencies That Need Pre-Bundling Configuration
Vite pre-bundles dependencies using esbuild to convert CommonJS packages into ESM. Most of the time this works automatically, but some packages with unusual exports or conditional require paths need explicit help.
// vite.config.ts
export default defineConfig({
optimizeDeps: {
include: [
// Force pre-bundling for CJS packages that Vite doesn't detect
'some-cjs-library',
// Also include deeply nested CJS dependencies
'some-library > nested-cjs-dep',
],
exclude: [
// Exclude packages that must NOT be pre-bundled (e.g., linked packages)
'my-local-linked-package',
],
},
});
The symptom is usually a runtime error like exports is not defined or module is not defined in the browser console. Adding the offending package to optimizeDeps.include forces Vite to pre-bundle it into ESM.
require() in Third-Party Library Code
If a dependency uses require() internally and Vite's pre-bundling doesn't catch it, you'll see errors in the browser. This is different from require() in your own code — you can't just rewrite a library.
// vite.config.ts — force the package through the CJS-to-ESM pipeline
export default defineConfig({
optimizeDeps: {
include: ['problematic-library'],
},
build: {
commonjsOptions: {
// Transform CJS modules used in the production build
include: [/problematic-library/, /node_modules/],
},
},
});
Global Polyfills: Buffer, process, and Node.js Built-ins
Webpack v4 and CRA auto-polyfilled Node.js globals like Buffer, process, and global. Vite does not. If a browser library depends on these (many crypto and legacy libraries do), you'll see Buffer is not defined or process is not defined errors.
// Install polyfills
// npm install --save-dev vite-plugin-node-polyfills
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
export default defineConfig({
plugins: [
react(),
nodePolyfills({
include: ['buffer', 'process'],
globals: { Buffer: true, process: true },
}),
],
});
Before reaching for polyfills, check if a browser-native alternative exists. Libraries like buffer can often be replaced with Uint8Array, and crypto with the Web Crypto API. Polyfills add bundle size and mask the real problem — a dependency that shouldn't be used in the browser.
CSS Modules Filename Convention
Both CRA and Vite use the .module.css convention to enable CSS Modules, so this usually works out of the box. The gotcha is if you relied on Webpack's css-loader configuration to treat all CSS files as modules, or used a custom naming pattern.
// vite.config.ts — customize CSS modules behavior
export default defineConfig({
css: {
modules: {
// Match CRA's default class name pattern
generateScopedName: '[name]__[local]___[hash:base64:5]',
// Use camelCase for class name imports
localsConvention: 'camelCaseOnly',
},
},
});
If your Webpack config treated all .css files as modules (not just .module.css), you'll need to rename files or adjust the pattern. Vite strictly uses the .module.css suffix convention by default.
Post-Migration Troubleshooting Checklist
Run through this checklist after migrating. Each item addresses a specific failure mode with the exact fix.
| Symptom | Likely Cause | Fix |
|---|---|---|
| Blank page, no errors | Missing <script type="module"> in index.html |
Add <script type="module" src="/src/index.tsx"> |
process is not defined |
Code references process.env instead of import.meta.env |
Find-replace all process.env usages; add polyfill for dependencies |
exports is not defined |
CJS dependency not pre-bundled | Add the package to optimizeDeps.include |
Buffer is not defined |
Library depends on Node.js globals | Install vite-plugin-node-polyfills or replace the library |
| Styles missing or unstyled components | CSS file not named .module.css |
Rename files to Component.module.css or adjust css.modules config |
Env variable is undefined |
Variable not prefixed with VITE_ |
Rename var in .env file and update code to use import.meta.env.VITE_* |
Image/asset import returns undefined |
require('./img.png') not converted |
Replace with import img from './img.png' |
| Build works, but dev server doesn't | Dependency only provides CJS, not detected by pre-bundler | Add to optimizeDeps.include and check build.commonjsOptions |
| 404 on refresh with client-side routing | Dev server not configured for SPA fallback | Vite handles this by default — check your base config matches your deploy path |
TypeScript errors on import.meta.env |
Missing Vite client types | Add "types": ["vite/client"] to tsconfig.json compilerOptions |
Run npx vite --debug to see detailed logs about dependency pre-bundling, module resolution, and plugin execution. This is the fastest way to diagnose why a specific import is failing.
Deploying Vite Applications: Static Hosts, Docker, and Edge
Running vite build produces a dist/ folder containing fully static assets — HTML, CSS, JavaScript, and any media files. This is the output you deploy. Because it's just static files, you can host them practically anywhere: a CDN, an S3 bucket, a Docker container running Nginx, or even the edge network of a platform like Cloudflare.
This section walks through every major deployment target, from the simplest zero-config push to production-grade Docker images and edge runtimes.
The base Config Option
Before deploying, you need to understand the base option in vite.config.ts. It controls the base public path for all asset URLs in your built output. If your app lives at the root of a domain (https://myapp.com/), you can leave it as the default '/'. But if it's served from a subdirectory — like a GitHub Pages project site — you must set it explicitly.
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
// Deploying to https://username.github.io/my-repo/
base: '/my-repo/',
// Or use an environment variable for flexibility
// base: process.env.VITE_BASE_PATH || '/',
})
When base is set, every generated <script> tag, CSS url() reference, and asset import path will be prefixed with that value. Getting this wrong is the most common reason assets return 404 after deployment.
Previewing Your Build Locally
Before pushing to any hosting platform, you should verify the production build locally. Vite ships a built-in preview server for exactly this purpose.
# Build, then preview the output
npm run build
npx vite preview --port 4173
vite preview serves the dist/ directory on a local HTTP server. It simulates a production static file server, so you can test routing, asset loading, and environment variables before committing to a deploy. This is not a production server — it's a verification tool.
Static Hosting Deployments
Static hosting is the most common deployment target for Vite apps. The output is a bundle of files — no server-side runtime required. Here's how to deploy to the four most popular platforms.
Vercel
Vercel auto-detects Vite projects. Push your repository, and Vercel runs vite build and deploys dist/ with zero configuration. If you need to customize, add a vercel.json for SPA fallback routing:
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
Netlify
Add a netlify.toml at your project root. The publish directory tells Netlify where your built files are, and the redirect rule handles client-side routing so deep-linked URLs don't 404.
[build]
command = "npm run build"
publish = "dist"
# SPA fallback — redirect all paths to index.html
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
GitHub Pages
GitHub Pages serves from a subdirectory by default (https://username.github.io/repo-name/), so you must set the base option. You can deploy using the gh-pages npm package for a quick manual push:
# Install the deployment tool
npm install -D gh-pages
# Add to package.json scripts:
# "deploy": "gh-pages -d dist"
npm run build
npm run deploy
The automated GitHub Actions approach is covered in the full workflow below.
AWS S3 + CloudFront
For AWS, you sync the dist/ folder to an S3 bucket and use CloudFront as the CDN. The critical detail is configuring a custom error response so that CloudFront returns index.html for any 403/404, enabling client-side routing.
# Sync built assets to S3
aws s3 sync dist/ s3://my-vite-app-bucket --delete
# Create a CloudFront invalidation to bust the cache
aws cloudfront create-invalidation \
--distribution-id E1A2B3C4D5E6F7 \
--paths "/*"
In the CloudFront distribution settings, add a custom error response: when the origin returns a 403 or 404, respond with /index.html and HTTP status 200. This is the AWS equivalent of the SPA redirect rules on Netlify and Vercel.
Dockerized Deployments
When you need full control over the serving infrastructure — or you're deploying into a Kubernetes cluster — Docker is the standard approach. The pattern is a multi-stage build: one stage compiles your app, a second stage serves it with a lightweight web server.
Multi-Stage Dockerfile
# Stage 1: Build the application
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Stage 1 uses node:20-alpine to install dependencies and run the build. Stage 2 copies only the dist/ output into an nginx:alpine image — no Node.js, no node_modules, no source code. The final image is typically under 30 MB.
Nginx Config for SPA Routing
The default Nginx config will return 404 for any route that doesn't match a physical file. You need a try_files directive that falls back to index.html:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Enable gzip for static assets
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 256;
location / {
try_files $uri $uri/ /index.html;
}
# Cache hashed assets aggressively (Vite adds content hashes)
location ~* \.(?:js|css|woff2?|png|jpg|jpeg|gif|svg|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Vite includes content hashes in filenames (e.g., index-a1b2c3d4.js), so hashed assets are safe to cache forever with immutable. Only index.html itself should be served with short or no cache.
Environment-Specific Builds
Vite loads .env files based on the current mode. By default, vite build runs in production mode and reads from .env.production. You can create additional modes for staging, QA, or any other environment.
# Build with staging environment variables from .env.staging
npx vite build --mode staging
Your .env files follow a clear loading order. Only variables prefixed with VITE_ are exposed to client-side code — everything else stays server-side only.
VITE_API_URL=https://api.myapp.com
VITE_APP_TITLE=My App
VITE_SENTRY_DSN=https://abc123@sentry.io/456
VITE_API_URL=https://staging-api.myapp.com
VITE_APP_TITLE=My App (Staging)
VITE_SENTRY_DSN=https://def456@sentry.io/789
CI/CD: Build-Time vs Runtime Environment Variables
Vite replaces import.meta.env.VITE_* references with literal values at build time through static string replacement. This means the values are baked into the JavaScript bundle. There is no runtime environment — it's just a static file.
In CI/CD pipelines, you inject values as environment variables before the build step. The CI system's environment takes precedence over .env files:
# CI pipeline: set env vars, then build
export VITE_API_URL="https://api.myapp.com"
export VITE_FEATURE_FLAGS="new-dashboard,beta-search"
npm run build
Because import.meta.env.VITE_API_URL becomes a literal string like "https://api.myapp.com" in the output bundle, you need a separate build per environment. If you need true runtime configuration (one build, many environments), inject config via a <script> tag in index.html that sets a window.__CONFIG__ object, served dynamically by your hosting layer.
Complete GitHub Actions Workflow
This workflow builds a Vite app and deploys it to GitHub Pages on every push to main. It uses the official GitHub Pages actions for a clean, token-free deployment.
# .github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
env:
VITE_BASE_PATH: /${{ github.event.repository.name }}/
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
This workflow has two jobs: build compiles the project and uploads the dist/ folder as a GitHub Pages artifact, and deploy publishes it. The concurrency block ensures only one deployment runs at a time, cancelling any in-progress deploy if a new push lands.
Remember to set base in your vite.config.ts to match the repository name. The workflow above passes it as an environment variable — wire it up in your config like this:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
base: process.env.VITE_BASE_PATH || '/',
})
Edge Deployment with Meta-Frameworks
Vite itself builds static client-side bundles. It doesn't produce server-side code for edge runtimes on its own. However, Vite is the build engine behind several meta-frameworks that do target edge runtimes — and this is where Vite-powered edge deployment becomes practical.
| Framework | Edge Target | How It Works |
|---|---|---|
| Astro | Cloudflare Workers, Vercel Edge, Deno Deploy | Adapter-based: install @astrojs/cloudflare and Astro outputs a Worker-compatible bundle |
| SvelteKit | Cloudflare Workers, Vercel Edge, Netlify Edge | Adapter-based: adapter-cloudflare produces a Worker, adapter-vercel targets Edge Functions |
| Nuxt 3 | Cloudflare Workers, Vercel Edge, Deno Deploy | Nitro server engine auto-detects the platform and outputs a compatible server bundle |
| Remix | Cloudflare Workers, Deno Deploy | Uses Vite as its compiler (v2+) with platform-specific server entry points |
The pattern is consistent: Vite handles the client-side build (bundling, code splitting, asset optimization), while the meta-framework's adapter compiles your server routes into code compatible with the target edge runtime. You write standard framework code and choose an adapter at deploy time.
// astro.config.mjs — deploy to Cloudflare Workers
import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'
export default defineConfig({
output: 'server',
adapter: cloudflare(),
vite: {
// Vite config still applies — it handles the client build
build: { sourcemap: true },
},
})
Edge runtimes are not full Node.js environments. They lack filesystem access, native addons, and many Node built-in modules. Any npm package that depends on fs, child_process, or native C++ bindings will fail at runtime. Audit your server-side dependencies before targeting the edge.
Troubleshooting Common Issues and Debugging Techniques
Even a fast tool like Vite can produce confusing errors, especially at the boundary between ESM and CJS modules. This section catalogs the most frequently encountered Vite issues, explains why they happen, and gives you copy-paste fixes. Bookmark it — you'll be back.
Dependency Resolution Errors
Dependency errors are the single most common class of Vite issues. They almost always stem from a mismatch between what Vite's dev server expects (native ESM) and what a third-party package actually ships (often CommonJS, or a non-standard export map).
Failed to resolve import "X"
This error appears when Vite cannot find a module during dev. Two root causes dominate: the dependency wasn't picked up by Vite's automatic pre-bundling scan, or the package's package.json doesn't expose an ESM entry that Vite can consume.
The fix is to explicitly tell Vite to pre-bundle the package by adding it to optimizeDeps.include:
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
include: ['problematic-package', 'another-lib/nested/entry'],
},
});
If the package still fails, inspect its package.json for an exports or module field. Some older packages only expose a main field pointing to a CJS file. In that case you may need to alias the import to a specific file path or find an ESM-compatible alternative.
Cannot use import statement outside a module
This cryptic error means a CommonJS dependency is being loaded as if it were ESM. The Node.js runtime (or Vite's SSR pipeline) encounters an import statement in a file it believes is CJS. This typically happens during SSR or when a dependency's entry point isn't correctly identified.
In dev mode, adding the package to optimizeDeps.include forces Vite to pre-bundle and convert it to ESM. For SSR scenarios, you also need ssr.noExternal to tell Vite to bundle the dependency instead of leaving it as an external Node.js require:
// vite.config.js
export default defineConfig({
optimizeDeps: {
include: ['legacy-cjs-lib'],
},
ssr: {
noExternal: ['legacy-cjs-lib'],
},
});
Vite's dev server serves native ESM to the browser. When a dependency only ships CJS, Vite must transform it during pre-bundling (via esbuild). If that automatic detection fails, you need to explicitly opt the package in. This is the root cause behind the majority of "it works in Create React App but not Vite" migration issues.
HMR Issues
Hot Module Replacement is one of Vite's flagship features, but when it breaks, the symptoms range from subtle (stale state) to obvious (full page reloads). Understanding why HMR fails helps you fix it quickly.
Changes Not Reflecting
If you save a file and nothing updates in the browser, the most common culprit is circular dependencies. When module A imports module B which imports module A, the HMR propagation can get stuck in a loop and silently bail out. Refactor shared logic into a third module to break the cycle.
Another cause is missing HMR boundaries. HMR works by propagating updates upward through the module graph until it hits a module that "accepts" the update. If no module in the chain accepts it, HMR gives up. Framework plugins (React, Vue, Svelte) automatically create these boundaries for component files, but plain .js or .ts utility files don't have them by default.
Full Page Reloads Instead of HMR
When you see the browser doing a full reload on every save, check the Vite terminal output — it usually prints [vite] page reload along with the file that triggered it. This happens when the edited module (or any module up the import chain) doesn't accept hot updates.
Common fixes:
- Verify your framework plugin is installed and configured. A missing
@vitejs/plugin-reactor@vitejs/plugin-vuein your config means zero HMR boundaries for components. - Check that files use the right extension. React Fast Refresh only works on files that export React components. A file that exports both a component and a constant will lose HMR.
- Avoid editing entry-level files. Changes to
main.tsor root layout files always trigger a full reload because they sit at the top of the module graph.
Build Errors
ReferenceError: require is not defined
This error surfaces during production builds when CommonJS code leaks into the ESM output. Vite's production build (powered by Rollup) targets ESM by default, so any require() call that survives the bundling process becomes invalid at runtime.
The fix depends on the source. If a dependency is the culprit, use @rollup/plugin-commonjs (included by default) and make sure the dependency is being bundled rather than externalized. If it's your own code, replace require() with import. For dynamic require() patterns, use Vite's import.meta.glob instead:
// ❌ CJS pattern — breaks in production build
const config = require(`./configs/${name}.json`);
// ✅ ESM equivalent using Vite's glob import
const modules = import.meta.glob('./configs/*.json', { eager: true });
const config = modules[`./configs/${name}.json`];
CORS Errors in Dev
If API requests from your Vite dev app fail with CORS errors, the browser is blocking cross-origin requests from localhost:5173 to your API server. The cleanest fix is to proxy API requests through Vite's built-in dev server proxy, which avoids CORS entirely:
// vite.config.js
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});
If you instead need Vite itself to allow cross-origin requests (e.g., you're loading the dev app in an iframe), set server.cors: true in your config.
Performance Problems
Large Bundle Size
If your production build is unexpectedly large, you need visibility into what's actually in the bundle before you can fix it. Install rollup-plugin-visualizer to generate a treemap of your output chunks:
npm install -D rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
open: true, // auto-open in browser after build
gzipSize: true, // show gzipped sizes
filename: 'stats.html',
}),
],
});
Run vite build and the visualizer opens an interactive treemap. Look for oversized dependencies, duplicated packages, and modules you didn't expect to be included. Common culprits include moment.js (switch to date-fns or dayjs), full lodash (use lodash-es with tree-shaking), and icon libraries importing every icon.
Out-of-Memory Crashes
Very large projects can exhaust Node.js's default heap limit (around 1.5–2 GB) during builds. The symptom is a crash with FATAL ERROR: Reached heap limit Allocation failed. Increase the heap size with the NODE_OPTIONS environment variable:
# Increase Node.js heap to 8 GB
NODE_OPTIONS='--max-old-space-size=8192' vite build
# Or set it permanently in your npm script
# package.json: "build": "NODE_OPTIONS='--max-old-space-size=8192' vite build"
Path Issues on Windows
Windows path handling is a frequent source of "works on Mac, breaks in CI" bugs. Vite normalizes most paths internally, but issues still arise in your config files and glob patterns. Two rules will save you hours:
- Always use forward slashes (
/) invite.config.js— even on Windows. Backslashes break glob patterns and Rollup's internal path matching. - Respect case sensitivity. Windows filesystems are case-insensitive, but Linux CI environments are not. If you import
./Components/Button.vuebut the file is./components/Button.vue, it works locally and fails in CI.
// ❌ Backslashes break on non-Windows and in glob patterns
resolve: { alias: { '@': path.resolve(__dirname, 'src\\components') } }
// ✅ Forward slashes work everywhere
resolve: { alias: { '@': path.resolve(__dirname, 'src/components') } }
Debugging Techniques
When the error message alone isn't enough, Vite provides several tools that let you look under the hood. Knowing these techniques turns a 2-hour mystery into a 5-minute investigation.
The --debug Flag
Running Vite with --debug enables verbose logging for every internal subsystem — dependency resolution, module transforms, HMR events, and more. You can also narrow the output to specific namespaces:
# Full debug output (very verbose)
vite --debug
# Filter to specific areas
vite --debug resolve # Only dependency resolution logs
vite --debug hmr # Only HMR-related logs
vite --debug transform # Only plugin transform logs
# Combine multiple filters
DEBUG="vite:resolve,vite:hmr" vite
vite-plugin-inspect
This plugin gives you a visual UI showing exactly how each plugin transforms your modules, step by step. It's invaluable when a plugin is producing unexpected output or when you're writing your own plugin and need to verify the transformation pipeline.
npm install -D vite-plugin-inspect
// vite.config.js
import Inspect from 'vite-plugin-inspect';
export default defineConfig({
plugins: [
Inspect(), // visit localhost:5173/__inspect/ during dev
],
});
After starting the dev server, navigate to localhost:5173/__inspect/. You'll see every module listed with a breakdown of which plugins ran, what each plugin's input and output looked like, and the total transform time. Click any module to see the before/after diff for each plugin stage.
Browser DevTools Network Tab
Since Vite's dev server serves unbundled ESM, every module is a separate HTTP request. Open the Network tab in your browser's DevTools, filter by JS, and you can see every module Vite is serving. This reveals the actual module graph as the browser experiences it — useful for catching unexpected dependencies, slow-loading modules, or 404 errors for missing files.
The --force Flag — Clearing the Dependency Cache
Vite caches pre-bundled dependencies in node_modules/.vite. When you update a dependency, change your Vite config, or switch branches, this cache can become stale and cause baffling errors that "shouldn't happen." The --force flag wipes the cache and re-runs pre-bundling from scratch:
# Force re-optimization of dependencies
vite --force
# Or manually delete the cache directory
rm -rf node_modules/.vite
When something inexplicable happens, run rm -rf node_modules/.vite && vite --force before deep-diving. A stale dependency cache causes a surprising number of "impossible" bugs. Make it your first instinct, not your last resort.
Quick Reference Table
| Error / Symptom | Root Cause | Fix |
|---|---|---|
Failed to resolve import "X" | Dependency not pre-bundled or missing ESM entry | Add to optimizeDeps.include |
Cannot use import statement outside a module | CJS dependency loaded as ESM | Add to optimizeDeps.include + ssr.noExternal |
require is not defined | CJS code in ESM production build | Replace require() with import or import.meta.glob |
| CORS errors in dev | Cross-origin API requests from dev server | Configure server.proxy |
| HMR not updating | Circular deps or missing HMR boundaries | Break cycles; verify framework plugin |
| Full page reloads | Module doesn't accept hot updates | Check plugin setup and file exports |
| Large bundle size | Unbundled heavy dependencies | Use rollup-plugin-visualizer to audit |
| Out-of-memory crash | Exceeded Node.js heap limit | NODE_OPTIONS='--max-old-space-size=8192' |
| Works on Mac, fails in CI | Path case sensitivity or backslashes | Use forward slashes; match exact casing |
| Inexplicable stale behavior | Stale dependency cache | vite --force or rm -rf node_modules/.vite |