cheat sheet
Vite
Next-generation frontend build tool using native ES modules for near-instant dev server startup and Rollup for production bundles. Covers setup, config, HMR, env vars, assets, CSS, and plugins.
Vite
What it is
Vite is a next-generation frontend build tool. During development it serves source files using native ES modules in the browser (no bundling step), which gives near-instant server startup and fast Hot Module Replacement (HMR) regardless of app size. For production, it bundles via Rollup with automatic code splitting, tree-shaking, and optimised assets.
Create a project
npm create vite@latest
# Or non-interactive with template
npm create vite@latest my-app -- --template react-ts
npm create vite@latest my-app -- --template vue-ts
npm create vite@latest my-app -- --template vanilla-ts
Output:
✔ Project name: my-app
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC
Scaffolding project in /home/user/my-app...
Done. Now run:
cd my-app
npm install
npm run dev
Available templates
| Template | Description |
|---|---|
vanilla | Plain JavaScript |
vanilla-ts | Plain TypeScript |
vue | Vue 3 |
vue-ts | Vue 3 + TypeScript |
react | React with Babel |
react-ts | React + TypeScript (Babel) |
react-swc | React with SWC (faster) |
react-swc-ts | React + TypeScript + SWC |
preact | Preact |
preact-ts | Preact + TypeScript |
lit | Lit web components |
svelte | Svelte |
svelte-ts | Svelte + TypeScript |
solid | Solid.js |
qwik | Qwik |
Dev server, build, preview
npm run dev # start dev server (default http://localhost:5173)
npm run build # production build → dist/
npm run preview # serve production build locally (http://localhost:4173)
Output: (none — exits 0 on success)
Dev server output:
VITE v5.3.1 ready in 312 ms
➜ Local: http://localhost:5173/
➜ Network: http://192.168.1.10:5173/
➜ press h + enter to show help
Build output:
vite v5.3.1 building for production...
✓ 35 modules transformed.
dist/index.html 0.46 kB │ gzip: 0.30 kB
dist/assets/index-BVF5BGBG.css 1.39 kB │ gzip: 0.72 kB
dist/assets/index-Dg8HUZFZ.js 142.50 kB │ gzip: 45.80 kB
✓ built in 1.21s
vite.config.ts
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "node:path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@components": path.resolve(__dirname, "./src/components"),
"@utils": path.resolve(__dirname, "./src/utils"),
},
},
server: {
port: 3000,
open: true, // open browser on start
host: true, // listen on all interfaces (0.0.0.0)
proxy: {
// Forward /api requests to a backend during development
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
build: {
outDir: "dist",
target: "es2020", // JS output syntax target
minify: "esbuild", // "esbuild" (fast) or "terser" (smaller)
sourcemap: true, // generate source maps
chunkSizeWarningLimit: 500, // kB — warn if chunk exceeds this
rollupOptions: {
output: {
// Manual code splitting
manualChunks: {
vendor: ["react", "react-dom"],
router: ["react-router-dom"],
},
},
},
},
preview: {
port: 4173,
},
});
Dev server options
server: {
port: 5173, // default port
strictPort: true, // fail instead of trying next port
open: "/dashboard", // open a specific path on start
host: "0.0.0.0", // expose to local network
https: true, // enable HTTPS (uses self-signed cert)
cors: true, // enable CORS for dev server
hmr: {
overlay: true, // show error overlay on HMR failures
port: 24678, // WebSocket port for HMR
},
watch: {
ignored: ["**/node_modules/**", "**/dist/**"],
},
}
Build options
build: {
outDir: "dist", // output directory
assetsDir: "assets", // subdirectory for assets within outDir
assetsInlineLimit: 4096, // inline assets smaller than 4 kB as base64
cssCodeSplit: true, // extract CSS into separate files per chunk
target: "es2020", // "esnext" for modern browsers
minify: "esbuild", // false = no minification
sourcemap: false, // true | "inline" | "hidden"
emptyOutDir: true, // clear outDir before building
reportCompressedSize: true, // show gzip sizes in output
rollupOptions: {
input: {
// Multi-page app entry points
main: "./index.html",
admin: "./admin.html",
},
external: ["react", "react-dom"], // don't bundle these (library mode)
},
}
Environment variables
Vite exposes env variables starting with VITE_ to client code via import.meta.env.
# .env (loaded always)
VITE_API_URL=https://api.example.com
# .env.local (loaded always, git-ignored)
VITE_SECRET_KEY=dev-only-key
# .env.production (loaded only for production build)
VITE_API_URL=https://api.production.com
# .env.development (loaded only for dev server)
VITE_API_URL=http://localhost:8080
Output: (none — exits 0 on success)
// In your source code
const apiUrl = import.meta.env.VITE_API_URL;
const mode = import.meta.env.MODE; // "development" | "production"
const isDev = import.meta.env.DEV; // true in dev mode
const isProd = import.meta.env.PROD; // true in production build
const ssr = import.meta.env.SSR; // true in SSR context
console.log(import.meta.env);
Output (in development):
{
VITE_API_URL: 'http://localhost:8080',
MODE: 'development',
DEV: true,
PROD: false,
SSR: false,
BASE_URL: '/'
}
Only variables prefixed with
VITE_are exposed to the browser bundle. Server-side secrets (database passwords, API keys) must NOT use theVITE_prefix.
Static assets
Import assets directly in JavaScript to get a build-time-resolved URL that Vite hashes for cache busting. Suffix ?url to force URL import, ?raw to get the file as a string, and ?worker to wrap it as a Web Worker constructor. Files placed in public/ are served as-is with no processing.
// Import an asset URL (handled at build time)
import logoUrl from "./assets/logo.svg";
const img = document.createElement("img");
img.src = logoUrl;
// ?url suffix — always get the URL
import workerUrl from "./worker.js?url";
// ?raw suffix — import as a string
import shaderSource from "./shader.glsl?raw";
// ?worker — import as a Web Worker constructor
import MyWorker from "./worker.ts?worker";
const worker = new MyWorker();
// public/ directory — served as-is, never processed by Vite
// Reference with absolute paths: /logo.png not ./logo.png
<img src="/logo.png" />
// In CSS
background-image: url("/bg.png");
CSS handling
Vite processes CSS out of the box. No extra config needed for PostCSS, CSS Modules, or preprocessors (just install the preprocessor).
# Sass / SCSS
npm install -D sass
# Less
npm install -D less
# Stylus
npm install -D stylus
Output: (none — exits 0 on success)
// vite.config.ts — CSS options
css: {
modules: {
// CSS Modules class name generation
localsConvention: "camelCase",
generateScopedName: "[name]__[local]___[hash:base64:5]",
},
preprocessorOptions: {
scss: {
// Inject a global SCSS partial into every file
additionalData: `@use "@/styles/variables" as *;`,
},
},
devSourcemap: true,
}
// CSS Modules — file must end in .module.css
import styles from "./Button.module.css";
element.className = styles.container; // scoped class name
Tailwind CSS v4 integration
npm install tailwindcss @tailwindcss/vite
Output: (none — exits 0 on success)
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
});
/* src/index.css */
@import "tailwindcss";
Plugins
Vite's plugin API is a superset of Rollup's, so most Rollup plugins work in Vite. Add plugins to the plugins array in vite.config.ts; they apply to both the dev server and the production build. Official plugins live under @vitejs/; the community ecosystem covers frameworks, asset handling, PWA, and more.
# React (Babel)
npm install -D @vitejs/plugin-react
# React (SWC — faster)
npm install -D @vitejs/plugin-react-swc
# Vue
npm install -D @vitejs/plugin-vue
# Progressive Web App
npm install -D vite-plugin-pwa
# SVG as React components
npm install -D vite-plugin-svgr
# Bundle visualizer
npm install -D rollup-plugin-visualizer
Output: (none — exits 0 on success)
// vite.config.ts with multiple plugins
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { VitePWA } from "vite-plugin-pwa";
import svgr from "vite-plugin-svgr";
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [
react(),
svgr(),
VitePWA({
registerType: "autoUpdate",
manifest: {
name: "My App",
short_name: "App",
theme_color: "#000000",
},
}),
visualizer({ open: true }), // opens bundle analysis after build
],
});
Library mode
Use library mode to package a component library or utility package instead of an app.
// vite.config.ts — library mode
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import dts from "vite-plugin-dts";
import path from "node:path";
export default defineConfig({
plugins: [react(), dts()],
build: {
lib: {
entry: path.resolve(__dirname, "src/index.ts"),
name: "MyLib",
fileName: (format) => `my-lib.${format}.js`,
formats: ["es", "cjs"],
},
rollupOptions: {
// Exclude React from the bundle (peer dependency)
external: ["react", "react-dom"],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
},
},
},
},
});
Multi-page app (MPA)
Configure build.rollupOptions.input as an object where each key names a page and each value points to its HTML entry file. Vite will produce a separate HTML file and chunk per entry, sharing common vendor code automatically.
// vite.config.ts — multiple HTML entry points
import { defineConfig } from "vite";
import path from "node:path";
export default defineConfig({
build: {
rollupOptions: {
input: {
main: path.resolve(__dirname, "index.html"),
about: path.resolve(__dirname, "about.html"),
dashboard: path.resolve(__dirname, "dashboard/index.html"),
},
},
},
});
Dep pre-bundling
Vite pre-bundles CommonJS dependencies with esbuild on first start, converting them to ESM. The cache lives in node_modules/.vite.
# Force re-bundle all dependencies
npx vite --force
# Or delete cache manually
rm -rf node_modules/.vite
Output: (none — exits 0 on success)
// vite.config.ts — control pre-bundling
optimizeDeps: {
include: ["lodash-es", "axios"], // force pre-bundle
exclude: ["@my/local-package"], // skip pre-bundling
esbuildOptions: {
target: "es2020",
},
}
Dev server architecture
Vite's dev server is fundamentally different from Webpack-style bundlers. There is no bundle in development — the browser receives raw ES module source over HTTP and resolves imports lazily. This is what makes Vite startup near-instant for projects of any size.
Browser ──(GET /src/main.ts)──▶ Vite dev server
├── transform .ts via esbuild
├── rewrite import specifiers
│ (bare → /node_modules/.vite/deps/...)
└── serve as ESM
Browser ──(GET /node_modules/.vite/deps/react.js)──▶ pre-bundled cache
Three things happen on each module request:
- Resolve — Vite's plugin pipeline maps the request URL to a real file. Bare specifiers like
import React from "react"rewrite to the pre-bundled path innode_modules/.vite/deps/. - Load — Plugins claim the file (e.g. the
.vueplugin parses the SFC and emits TS/CSS chunks). - Transform — TS, JSX, CSS, postcss, etc. run via esbuild or framework plugins.
Hot Module Replacement (HMR) opens a WebSocket on the dev server. When a file changes, Vite sends a JSON message listing the changed modules; the client runtime invalidates them and re-imports. Because no bundle exists, only the changed module (plus its accepting boundary upward) is re-evaluated — an HMR update for a single component file is usually under 50 ms regardless of project size.
Why index.html is the entry point
Vite treats index.html as the entry, not a Webpack-style JS entry. The browser hits / → gets index.html → the <script type="module" src="/src/main.ts"> triggers Vite's transform pipeline. This means HTML can contain build-time references (e.g. <script src="/src/main.ts">) and Vite rewrites them into hashed bundle assets at build time.
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Plugin API hooks
Vite's plugin API is a superset of Rollup's. Every Vite plugin is a Rollup plugin plus optional dev-server-specific hooks. The contract is a function that returns an object whose keys are hooks.
// vite.config.ts
import type { Plugin } from "vite";
function myPlugin(): Plugin {
return {
name: "my-plugin", // required
// Vite-specific (dev server only)
configResolved(config) {
console.log("resolved mode:", config.mode);
},
configureServer(server) {
// Add custom middleware
server.middlewares.use("/health", (_req, res) => {
res.end("OK");
});
},
transformIndexHtml(html) {
return html.replace("<!--inject-->", "<meta name='build-time' content='" + Date.now() + "'>");
},
handleHotUpdate({ file, server }) {
if (file.endsWith(".json")) {
server.ws.send({ type: "full-reload" });
return [];
}
},
// Rollup-shared (apply at build time + dev)
resolveId(source) {
if (source === "virtual:my-module") return "\0" + source;
},
load(id) {
if (id === "\0virtual:my-module") {
return "export const answer = 42;";
}
},
transform(code, id) {
if (id.endsWith(".special")) {
return { code: `export default ${JSON.stringify(code)};`, map: null };
}
},
};
}
export default defineConfig({ plugins: [myPlugin()] });
Plugin ordering — enforce and apply
{
name: "pre-plugin",
enforce: "pre", // runs before Vite core plugins
}
{
name: "post-plugin",
enforce: "post", // runs after Vite core plugins
}
{
name: "dev-only-plugin",
apply: "serve", // only in dev server (alternative: "build")
}
The full order is: aliases → user enforce: "pre" → Vite core → user (no enforce) → Vite build plugins → user enforce: "post" → Vite post-build.
Build modes — development, production, custom
Vite distinguishes command (dev/serve vs build) from mode (dev / production / custom). Mode is what selects which .env.* files load. Override mode with --mode:
# Build for staging — loads .env.staging
npx vite build --mode staging
# Dev server in production-like mode
npx vite --mode production
Output:
vite v5.3.1 building for staging...
✓ 35 modules transformed.
// vite.config.ts — use mode for conditional config
export default defineConfig(({ command, mode }) => {
if (command === "serve") {
return { /* dev-only config */ };
}
if (mode === "staging") {
return { build: { sourcemap: true } };
}
return { build: { sourcemap: false, minify: "terser" } };
});
SSR support
Vite has built-in Server-Side Rendering primitives. You point a Node server at Vite's createServer in middleware mode, use ssrLoadModule(url) to load any source file as ESM on the server, and use transformIndexHtml(url, template) to inject the rendered HTML. Frameworks (Astro, Nuxt, SvelteKit, vite-plugin-ssr) wrap this for you.
// server.js — minimal SSR setup
import express from "express";
import { createServer as createViteServer } from "vite";
const app = express();
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
});
app.use(vite.middlewares);
app.use("*", async (req, res) => {
const { render } = await vite.ssrLoadModule("/src/entry-server.tsx");
const html = await vite.transformIndexHtml(req.originalUrl, baseTemplate);
const appHtml = await render(req.originalUrl);
res.send(html.replace("<!--ssr-outlet-->", appHtml));
});
app.listen(5173);
Output:
Server running at http://localhost:5173
For production SSR builds, run two Vite builds (--ssr src/entry-server.ts for the server bundle, plain vite build for the client bundle). Both share Vite plugins, so transforms apply identically server-side.
Framework integrations
| Framework | Plugin | Notes |
|---|---|---|
| React | @vitejs/plugin-react or @vitejs/plugin-react-swc | SWC version is faster on cold starts; Babel version supports more plugins |
| Vue 3 | @vitejs/plugin-vue | SFC parsing, <script setup>, scoped CSS |
| Vue 2 | @vitejs/plugin-vue2 | Legacy projects |
| Svelte | @sveltejs/vite-plugin-svelte | Used by SvelteKit |
| Solid | vite-plugin-solid | Hot-swap JSX runtime |
| Preact | @preact/preset-vite | Includes Compat for React libs |
| Qwik | @builder.io/qwik/optimizer | Resumability + lazy chunks |
| Lit | None (Lit is plain web components) | Use vanilla-ts template |
| Marko | @marko/vite | Concurrent SSR + streaming |
| Astro | (Astro embeds Vite internally) | See Astro |
Astro is the most interesting case: it embeds Vite as its internal build engine. Every astro dev is a Vite dev server with Astro's content collection and island plugins layered on top.
Comparison — Vite vs Webpack vs Rollup vs esbuild vs Parcel
| Concern | Vite | Webpack | Rollup | esbuild | Parcel |
|---|---|---|---|---|---|
| Dev experience | Native ESM (no bundle) | Bundle + serve | Bundle + serve | Bundle (fast) | Zero-config bundle |
| Dev startup (medium app) | <1 s | 10–60 s | ~5 s | <1 s | 1–3 s |
| HMR update speed | <50 ms | 200 ms–2 s | N/A (no first-class HMR) | N/A | 100–300 ms |
| Production bundler | Rollup (under the hood) | Webpack | Rollup | esbuild | Parcel |
| Tree-shaking | Excellent (Rollup) | Good | Excellent | Good | Good |
| Code splitting | Automatic | Manual + automatic | Automatic | Automatic | Automatic |
| Plugin ecosystem | Rollup-compatible + Vite-specific | Largest | Mature | Smaller | Smaller |
| Config style | Minimal | Verbose | Moderate | Programmatic | Zero-config first |
| Best for | Modern apps (React/Vue/Svelte) | Legacy / complex Webpack-specific deps | Libraries | Build step inside other tools | Static sites / small apps |
The short answer: for any new project use Vite; for libraries use Rollup directly (or Vite's library mode, which is Rollup with sane defaults); for an existing Webpack codebase, migration cost is real but rarely regretted. esbuild and SWC are usually invisible — they live inside Vite for transforms, not as user-facing bundlers.
Performance — what to measure and how
Vite's dev mode is fast by design, but production builds can still be slow on large apps. The pragmatic optimisation ladder:
1. Profile with --debug
DEBUG=vite:* npx vite build 2>&1 | tee build.log
Output (excerpt):
vite:resolve plugin=vite:resolve 12ms
vite:transform 318ms /src/main.tsx
vite:transform 22ms /src/components/Card.tsx
2. Use the bundle visualiser
npm install -D rollup-plugin-visualizer
Output: (none — exits 0 on success)
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [visualizer({ open: true, filename: "stats.html", gzipSize: true })],
});
Then npx vite build opens stats.html showing the size of every module in the bundle. The big wins are usually moment.js (use date-fns or temporal), lodash (use lodash-es with tree-shaking), or accidentally bundling a server-only library into a client chunk.
3. Manual chunks for vendor split
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("node_modules/react") || id.includes("node_modules/react-dom")) {
return "react-vendor";
}
if (id.includes("node_modules/lodash")) {
return "lodash";
}
},
},
},
}
A function-form manualChunks is more flexible than the object form for deciding chunk grouping per module.
4. Disable source maps in production
Source maps double build time and inflate dist size 2-3×. Use sourcemap: "hidden" to emit them but omit the //# sourceMappingURL comment, then upload them to your error tracker (Sentry, Datadog).
5. Pre-warming with optimizeDeps.include
If your app imports a CJS-only library inside a dynamic route, Vite pre-bundles it lazily on first request — causing a visible pause. Pre-bundle it eagerly:
optimizeDeps: {
include: ["some-cjs-lib"],
}
Common pitfalls
process.envis undefined in client code — Vite exposes env viaimport.meta.env, notprocess.env. Usedefine: { "process.env.NODE_ENV": JSON.stringify(...) }only when a library insists.- Imports from
public/— files inpublic/are NEVER processed by Vite. Reference them with absolute paths (/logo.png), and they appear as-is in the output. Don'timport "/public/foo". - CSS @import resolution differs in build vs dev — Vite resolves
@importvia PostCSS in build mode but via the browser in dev. UseadditionalDatafor global preprocessor variables instead of@importeverywhere. - Dynamic imports without bundle hints split into one chunk per route — that's the intended behaviour; if you want lazy but grouped chunks, use
import(/* @vite-ignore */ "...")only as escape hatch. baseconfig breaks asset paths in dev — setbase: "/myapp/"only if your prod app is served from a subpath; then make sure dev URLs also include the base.import.meta.urlreturns the source URL in dev, the bundled URL in build — usually fine, but watch out when constructing relative paths to assets..env.*.localis gitignored automatically — Vite's.gitignoretemplate includes.env.*.local. Make sure you don't accidentally commit secrets in.env.production(no.local).server.host: truedoesn't bind on a remote dev container — sethost: "0.0.0.0"explicitly when in Docker/Codespaces, plus expose the port.- HMR breaks behind a reverse proxy — proxy the WebSocket too:
server.hmr.clientPort = 443if your dev URL is HTTPS-fronted.
Real-world recipes
Proxy /api to a Node backend without CORS
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ""),
},
"/socket.io": {
target: "ws://localhost:8080",
ws: true,
changeOrigin: true,
},
},
}
Inject a build-time constant
import { defineConfig } from "vite";
export default defineConfig({
define: {
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
__VERSION__: JSON.stringify(process.env.npm_package_version),
},
});
// app.ts
console.log(`Build ${__VERSION__} at ${__BUILD_TIME__}`);
Output (after build):
Build 1.2.0 at 2026-05-25T10:00:00.000Z
Conditional alias for dev-only mocks
import { defineConfig } from "vite";
export default defineConfig(({ command }) => ({
resolve: {
alias: command === "serve"
? { "@/api": "/src/api/mock.ts" }
: { "@/api": "/src/api/real.ts" },
},
}));
Multi-environment build matrix
# Build artefacts per environment
npx vite build --mode production --outDir dist/prod
npx vite build --mode staging --outDir dist/staging
npx vite build --mode preview --outDir dist/preview
Output:
✓ built in 1.21s (dist/prod)
✓ built in 1.18s (dist/staging)
✓ built in 1.22s (dist/preview)
Use Vite to build a CLI
Library mode + a bin entry. See package.json for the bin field.
// vite.config.ts
export default defineConfig({
build: {
lib: {
entry: "src/cli.ts",
formats: ["es"],
fileName: () => "cli.js",
},
rollupOptions: { external: ["node:fs", "node:path", "commander"] },
},
});
Add the shebang in the entry source:
// src/cli.ts
#!/usr/bin/env node
import { program } from "commander";
// ...
Detect server-only env access during build
// vite.config.ts
export default defineConfig({
define: {
"process.env.DATABASE_URL": JSON.stringify(undefined),
},
});
Combined with ESLint rule no-restricted-globals: ["process"], this forces any accidental server-side env access to be caught at build time.
Static file copy without public/
npm install -D vite-plugin-static-copy
Output: (none — exits 0 on success)
import { viteStaticCopy } from "vite-plugin-static-copy";
export default defineConfig({
plugins: [
viteStaticCopy({
targets: [
{ src: "vendor/*.wasm", dest: "wasm" },
{ src: "fonts/*.woff2", dest: "fonts" },
],
}),
],
});
See also
- Vitest — Vite's sister test runner
- ESLint, Prettier — typical lint/format companions
- Biome — Rust-based linter+formatter
- Astro — uses Vite internally for the build
- Bun, Deno — alternative runtimes (both can host Vite or replace parts of it)
- package.json —
scripts,bin,exportsintegration with Vite library mode