Vite: The Lightning-Fast Build Tool
You run npm start on a big Webpack or Create React App project and go make coffee while it bundles. Then you fix one typo in a component, wait a few seconds for HMR, and the form you'd half filled in resets anyway. That loop gets old fast, and it's the exact problem Vite was built for.
Vite (French for "quick") was created by Evan You, who also wrote Vue.js. This post covers why its dev server is fast, how to set up and configure a project, how production builds work, and the spots where it can still bite you.
Vite doesn't bundle your app during development. Dependencies get pre-bundled once with esbuild (written in Go and much faster than JS-based bundlers), and your own source files are served to the browser as native ES modules. Startup usually takes well under a second, and it doesn't grow much as the project gets bigger.
Why Traditional Bundlers Are Slow
Webpack and similar tools have to process your entire application before they can serve a single page. On a large app with thousands of modules, that shows up in three ways:
- Cold start penalty: Every time you start the dev server, the bundler crawls your entire dependency graph, transforms every file, and bundles everything into a few output chunks before the browser gets anything.
- Slow HMR: When you change a file, the bundler must re-process all affected modules in that bundle chunk, and that work grows with your app size.
- Memory pressure: Keeping the whole dependency graph in memory can push Webpack past a gigabyte of RAM on large projects.
The root cause is architectural. Bundlers were designed when browsers couldn't load ES modules on their own. That stopped being true years ago: every major browser has supported native ESM since around 2018.
Webpack bundles everything upfront; Vite serves files as native ES modules on demand
Getting Started with Vite
Vite has an official scaffolding CLI with templates for React, Vue, Svelte, Lit, Preact, and vanilla JS, each with a TypeScript variant.
Scaffold a new project
Run the create command and follow the interactive prompts to choose your framework and variant.
# npm 7+
npm create vite@latest my-app
# yarn
yarn create vite my-app
# pnpm
pnpm create vite my-app
# Skip prompts: pass framework and variant directly
npm create vite@latest my-react-app -- --template react-ts
npm create vite@latest my-vue-app -- --template vue
npm create vite@latest my-svelte-app -- --template svelte-ts
Install dependencies and start the dev server
Go into the project folder, install, and start. The dev server comes up almost immediately.
cd my-app
npm install
npm run dev
# Output:
# VITE v5.x.x ready in 287 ms
#
# ➜ Local: http://localhost:5173/
# ➜ Network: use --host to expose
Explore the project structure
The scaffolded project is small. There isn't much boilerplate to delete.
my-app/
├── public/ # Static assets copied verbatim to dist/
│ └── vite.svg
├── src/
│ ├── assets/ # Assets processed by Vite (hashed, optimized)
│ ├── App.tsx
│ ├── main.tsx # Entry point, imported as native ESM
│ └── vite-env.d.ts
├── index.html # Root HTML and Vite entry (not in public/)
├── vite.config.ts
├── tsconfig.json
└── package.json
Unlike Webpack where you specify an entry JS file in the config, Vite treats index.html as the entry point. It parses the HTML for <script type="module"> tags and follows the import graph from there, which is the same way a browser loads an ESM app.
How the Vite Dev Server Works
Vite splits your modules into two groups and handles each one differently. That split is why it stays fast as the project grows.
Pre-bundled Dependencies (node_modules)
Packages from node_modules are pre-bundled with esbuild the first time you start the server, and again whenever your dependencies change. The output is cached in node_modules/.vite/deps/ and served as single ESM files. Without this step, a package like lodash-es would make the browser fire off hundreds of separate requests.
One thing that trips people up: if you see new dependencies optimized: ... reloading in the terminal and the page does a full reload, Vite found an import it didn't pre-bundle at startup. It's harmless but annoying. Add the package to optimizeDeps.include so it's bundled up front. And if the cache ever gets stuck after switching branches or patching a package, run vite --force (or delete node_modules/.vite) to rebuild it.
Source Files (your code)
Your own source files are never pre-bundled. When the browser requests a file, Vite transforms it on the fly (TypeScript, JSX, CSS modules, etc.) and serves it as an ES module. Only files the browser actually asks for get processed, so a big module that the current page never imports costs nothing at startup.
Hot Module Replacement (HMR)
When you change a file, Vite invalidates that module and walks up to the nearest HMR boundary. The update the browser receives stays small no matter how many other files the project has. That's why HMR still feels instant in large apps where Webpack can take several seconds.
// Frameworks handle HMR automatically.
// Use the low-level API only for custom module state cleanup:
if (import.meta.hot) {
// Preserve state when module updates
import.meta.hot.accept('./store.ts', (newModule) => {
// newModule is the updated module
replaceStore(newModule.store)
})
// Clean up side effects (intervals, subscriptions, etc.)
import.meta.hot.dispose((data) => {
clearInterval(pollingId)
data.pollingId = pollingId // carry state to next version
})
}
Configuring Vite
Configuration lives in vite.config.ts (or vite.config.js). Wrap it in defineConfig and your editor will autocomplete every option.
A typical vite.config.ts for a React project:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
// Base public path when served in production
base: '/',
// Environment variables prefix (default: VITE_)
envPrefix: 'VITE_',
})
Configure module resolution aliases to avoid long relative imports:
import { defineConfig } from 'vite'
import path from 'path'
export default defineConfig({
resolve: {
alias: {
// "@/components/Button" instead of "../../components/Button"
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@utils': path.resolve(__dirname, './src/utils'),
'@hooks': path.resolve(__dirname, './src/hooks'),
},
// Try these extensions in order when no extension is given
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'],
},
})
Dev server settings: port, proxy, CORS, HTTPS.
import { defineConfig } from 'vite'
export default defineConfig({
server: {
port: 3000, // Default is 5173
open: true, // Open browser on start
host: '0.0.0.0', // Expose to network (for Docker, etc.)
strictPort: true, // Error instead of trying next port
// Proxy API requests to your backend during development
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/graphql': 'http://localhost:4000',
},
// CORS headers
cors: true,
// HTTPS (generate a local cert first)
// https: true,
},
})
Control production build output, code splitting, and minification:
import { defineConfig } from 'vite'
export default defineConfig({
build: {
outDir: 'dist', // Output directory
target: 'es2020', // Browser compatibility target
minify: 'esbuild', // 'esbuild' (fast) or 'terser' (smaller)
sourcemap: true, // Generate source maps
rollupOptions: {
output: {
// Split vendor code into a separate chunk
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
},
// Asset file naming
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
},
},
// Warn when a chunk exceeds this size (in kB)
chunkSizeWarningLimit: 500,
},
})
The Plugin Ecosystem
Vite's plugin API extends Rollup's, so most Rollup plugins work in Vite without changes. These are the ones I'd look at first.
Official Framework Plugins
# React (JSX transform + Fast Refresh)
npm install -D @vitejs/plugin-react
# React with SWC (Rust-based transpiler, even faster)
npm install -D @vitejs/plugin-react-swc
# Vue
npm install -D @vitejs/plugin-vue
# Svelte
npm install -D @sveltejs/vite-plugin-svelte
Useful Community Plugins
| Plugin | What It Does | Install |
|---|---|---|
vite-plugin-pwa |
Zero-config PWA with service worker generation | npm i -D vite-plugin-pwa |
unplugin-vue-components |
Auto-import Vue components on demand | npm i -D unplugin-vue-components |
vite-plugin-compression |
Gzip/Brotli compress build output | npm i -D vite-plugin-compression |
rollup-plugin-visualizer |
Bundle size analysis with treemap visualization | npm i -D rollup-plugin-visualizer |
vite-plugin-svgr |
Import SVGs as React components | npm i -D vite-plugin-svgr |
vite-plugin-mock |
Mock API endpoints during development | npm i -D vite-plugin-mock |
Writing a Custom Vite Plugin
A Vite plugin is just an object with hook functions. This one injects a build timestamp into your app:
import type { Plugin } from 'vite'
export function buildInfoPlugin(): Plugin {
return {
name: 'vite-build-info',
// Runs once at the start of the build
buildStart() {
console.log(`Build started at ${new Date().toISOString()}`)
},
// Transform a specific file's source code
transform(code, id) {
if (id.endsWith('src/main.tsx')) {
// Inject build time as a global constant
return code.replace(
'__BUILD_TIME__',
JSON.stringify(new Date().toISOString())
)
}
},
// Runs after the build completes
closeBundle() {
console.log('Build complete!')
},
}
}
// Usage in vite.config.ts:
// import { buildInfoPlugin } from './plugins/vite-build-info'
// plugins: [react(), buildInfoPlugin()]
Building for Production
For production, Vite bundles with Rollup, not esbuild. That's on purpose. esbuild is very fast, but its code splitting and chunk control aren't as flexible as Rollup's. You get tree-shaken bundles with CSS split per chunk automatically.
Run the build command
Vite compiles and bundles your entire application into the dist/ directory.
npm run build
# Output:
# vite v5.x.x building for production...
# ✓ 42 modules transformed.
# dist/index.html 0.46 kB │ gzip: 0.30 kB
# dist/assets/index-Bx7QLdoA.css 1.39 kB │ gzip: 0.72 kB
# dist/assets/vendor-DcY7Z0lL.js 141.74 kB │ gzip: 45.64 kB
# dist/assets/index-BjnUr2H5.js 3.21 kB │ gzip: 1.49 kB
# ✓ built in 1.38s
# Preview the production build locally
npm run preview
# ➜ Local: http://localhost:4173/
Analyze your bundle
Use rollup-plugin-visualizer to understand what's in your bundle before shipping.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
react(),
// Only run during build: generates stats.html
visualizer({
open: true, // Auto-open in browser after build
gzipSize: true,
brotliSize: true,
filename: 'dist/stats.html',
}),
],
})
Deploy the dist/ folder
A Vite build is just static files. Deploy it to Vercel, Netlify, Cloudflare Pages, S3, or any static host. Configure your server to redirect all 404s to index.html for client-side routing to work.
If you use React Router or Vue Router in history mode, configure your web server to serve index.html for all routes. On Nginx: try_files $uri $uri/ /index.html;. On Apache: use a .htaccess with RewriteRule ^ index.html. Without this, direct URL navigation causes 404 errors.
Environment Variables
Client code reads environment variables through import.meta.env. Only variables prefixed with VITE_ reach the browser, so secrets without the prefix stay out of the bundle. The flip side: anything you do prefix with VITE_ is baked into public JavaScript, so never put an API secret there.
# .env (committed: shared defaults)
VITE_APP_TITLE=My App
VITE_API_BASE_URL=https://api.example.com
# .env.local (gitignored: local overrides)
VITE_API_BASE_URL=http://localhost:8000
# .env.production (used when building for production)
VITE_APP_TITLE=My App (Production)
# Server-only, NOT exposed to browser
DATABASE_URL=postgres://localhost/mydb
SECRET_KEY=super-secret-value
// Built-in Vite env vars
console.log(import.meta.env.MODE) // "development" or "production"
console.log(import.meta.env.BASE_URL) // "/" by default
console.log(import.meta.env.DEV) // true in development
console.log(import.meta.env.PROD) // true in production
// Your custom variables
const apiBase = import.meta.env.VITE_API_BASE_URL
const appTitle = import.meta.env.VITE_APP_TITLE
// TypeScript: add type declarations to vite-env.d.ts
// to get autocomplete for your custom vars:
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
Vite uses Rollup for bundling and esbuild for minification in production
Vite vs Webpack: When to Use Which
For most new projects I'd pick Vite. Webpack still makes sense in large codebases with custom build pipelines that grew over years, where rewriting that config would cost more than it saves.
| Feature | Vite 5 | Webpack 5 |
|---|---|---|
| Dev server cold start | Usually under a second (no bundling) | Seconds to a minute on big apps (full bundle) |
| HMR speed | Instant (single module) | Slow on large apps |
| Production bundler | Rollup (excellent tree-shaking) | Webpack (highly configurable) |
| Configuration complexity | Minimal, sensible defaults | Complex, verbose |
| Plugin ecosystem | Growing, Rollup-compatible | Mature, vast |
| CSS/asset handling | Built-in (CSS Modules, PostCSS) | Requires loaders config |
| Module Federation | Via vite-plugin-federation | Native support (v5) |
| TypeScript | Zero-config (esbuild transpile) | Requires ts-loader/babel |
| Legacy browser support | Via @vitejs/plugin-legacy | Native (target option) |
| Best for | New projects, SPAs, SSR | Complex legacy migrations, MF |
Migrating an Existing Webpack Project to Vite
- Install Vite:
npm install -D vite @vitejs/plugin-react(or your framework plugin) - Move index.html: Move from
public/index.htmlto the project root and replace%PUBLIC_URL%placeholders with plain paths - Add script module tag: In
index.html, add<script type="module" src="/src/main.tsx"></script> - Update env vars: Replace
process.env.REACT_APP_*withimport.meta.env.VITE_*. If you miss one, you'll getUncaught ReferenceError: process is not definedin the browser console, because Vite doesn't polyfillprocesslike CRA did - Create vite.config.ts: Add your framework plugin and configure any aliases or proxies you had in Webpack
- Update package.json scripts: Replace
react-scripts start/buildwithvite/vite build - Handle CommonJS modules: Vite expects ESM. If dependencies use CJS, the pre-bundler converts them automatically, but some edge cases need manual handling via
optimizeDeps - Remove Webpack config and loaders: Webpack-specific files (
webpack.config.js, babel configs, etc.) can be deleted once the migration is confirmed working
When I'd Use Vite (and When I Wouldn't)
For a new SPA, a Laravel front end, or a component library, I'd reach for Vite without thinking about it much. Laravel ships with it by default now, and the config you write is short enough that a new teammate can read all of it.
Cases where I'd slow down
- You rely on Module Federation: Webpack 5 supports it natively. The Vite plugins work, but they're less mature, so test the remote loading carefully before committing.
- Your Webpack config is years of custom loaders: If the build works and nobody complains about dev speed, a migration is a cost with little payoff. Fix the real bottleneck first.
- You depend on old CommonJS-only packages: Most get converted during pre-bundling, but packages that do dynamic
require()calls or touch Node globals can break in odd ways. - You need to support very old browsers: It works through
@vitejs/plugin-legacy, but you're shipping two sets of bundles and the build gets slower.
The other thing to keep in mind is that dev and production go through different pipelines (native ESM plus esbuild in dev, Rollup in prod). Now and then something works in npm run dev and breaks in the build, usually around CommonJS interop or dynamic imports. So don't treat the dev server as proof. Run npm run build && npm run preview locally, or in CI, before you deploy.