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

bash
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:

text
✔ 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

TemplateDescription
vanillaPlain JavaScript
vanilla-tsPlain TypeScript
vueVue 3
vue-tsVue 3 + TypeScript
reactReact with Babel
react-tsReact + TypeScript (Babel)
react-swcReact with SWC (faster)
react-swc-tsReact + TypeScript + SWC
preactPreact
preact-tsPreact + TypeScript
litLit web components
svelteSvelte
svelte-tsSvelte + TypeScript
solidSolid.js
qwikQwik

Dev server, build, preview

bash
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:

text
  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:

text
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

typescript
// 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

typescript
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

typescript
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.

bash
# .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)

typescript
// 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):

text
{
  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 the VITE_ 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.

typescript
// 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).

bash
# Sass / SCSS
npm install -D sass

# Less
npm install -D less

# Stylus
npm install -D stylus

Output: (none — exits 0 on success)

typescript
// 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,
}
typescript
// 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

bash
npm install tailwindcss @tailwindcss/vite

Output: (none — exits 0 on success)

typescript
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [tailwindcss()],
});
css
/* 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.

bash
# 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)

typescript
// 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.

typescript
// 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.

typescript
// 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.

bash
# Force re-bundle all dependencies
npx vite --force

# Or delete cache manually
rm -rf node_modules/.vite

Output: (none — exits 0 on success)

typescript
// 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.

text
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:

  1. 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 in node_modules/.vite/deps/.
  2. Load — Plugins claim the file (e.g. the .vue plugin parses the SFC and emits TS/CSS chunks).
  3. 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.

html
<!-- 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.

typescript
// 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

typescript
{
  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:

bash
# Build for staging — loads .env.staging
npx vite build --mode staging

# Dev server in production-like mode
npx vite --mode production

Output:

text
vite v5.3.1 building for staging...
✓ 35 modules transformed.
typescript
// 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.

typescript
// 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:

text
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

FrameworkPluginNotes
React@vitejs/plugin-react or @vitejs/plugin-react-swcSWC version is faster on cold starts; Babel version supports more plugins
Vue 3@vitejs/plugin-vueSFC parsing, <script setup>, scoped CSS
Vue 2@vitejs/plugin-vue2Legacy projects
Svelte@sveltejs/vite-plugin-svelteUsed by SvelteKit
Solidvite-plugin-solidHot-swap JSX runtime
Preact@preact/preset-viteIncludes Compat for React libs
Qwik@builder.io/qwik/optimizerResumability + lazy chunks
LitNone (Lit is plain web components)Use vanilla-ts template
Marko@marko/viteConcurrent 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

ConcernViteWebpackRollupesbuildParcel
Dev experienceNative ESM (no bundle)Bundle + serveBundle + serveBundle (fast)Zero-config bundle
Dev startup (medium app)<1 s10–60 s~5 s<1 s1–3 s
HMR update speed<50 ms200 ms–2 sN/A (no first-class HMR)N/A100–300 ms
Production bundlerRollup (under the hood)WebpackRollupesbuildParcel
Tree-shakingExcellent (Rollup)GoodExcellentGoodGood
Code splittingAutomaticManual + automaticAutomaticAutomaticAutomatic
Plugin ecosystemRollup-compatible + Vite-specificLargestMatureSmallerSmaller
Config styleMinimalVerboseModerateProgrammaticZero-config first
Best forModern apps (React/Vue/Svelte)Legacy / complex Webpack-specific depsLibrariesBuild step inside other toolsStatic 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

bash
DEBUG=vite:* npx vite build 2>&1 | tee build.log

Output (excerpt):

text
vite:resolve plugin=vite:resolve 12ms
vite:transform 318ms /src/main.tsx
vite:transform 22ms /src/components/Card.tsx

2. Use the bundle visualiser

bash
npm install -D rollup-plugin-visualizer

Output: (none — exits 0 on success)

typescript
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

typescript
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:

typescript
optimizeDeps: {
  include: ["some-cjs-lib"],
}

Common pitfalls

  • process.env is undefined in client code — Vite exposes env via import.meta.env, not process.env. Use define: { "process.env.NODE_ENV": JSON.stringify(...) } only when a library insists.
  • Imports from public/ — files in public/ are NEVER processed by Vite. Reference them with absolute paths (/logo.png), and they appear as-is in the output. Don't import "/public/foo".
  • CSS @import resolution differs in build vs dev — Vite resolves @import via PostCSS in build mode but via the browser in dev. Use additionalData for global preprocessor variables instead of @import everywhere.
  • 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.
  • base config breaks asset paths in dev — set base: "/myapp/" only if your prod app is served from a subpath; then make sure dev URLs also include the base.
  • import.meta.url returns the source URL in dev, the bundled URL in build — usually fine, but watch out when constructing relative paths to assets.
  • .env.*.local is gitignored automatically — Vite's .gitignore template includes .env.*.local. Make sure you don't accidentally commit secrets in .env.production (no .local).
  • server.host: true doesn't bind on a remote dev container — set host: "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 = 443 if your dev URL is HTTPS-fronted.

Real-world recipes

Proxy /api to a Node backend without CORS

typescript
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

typescript
import { defineConfig } from "vite";

export default defineConfig({
  define: {
    __BUILD_TIME__: JSON.stringify(new Date().toISOString()),
    __VERSION__: JSON.stringify(process.env.npm_package_version),
  },
});
typescript
// app.ts
console.log(`Build ${__VERSION__} at ${__BUILD_TIME__}`);

Output (after build):

text
Build 1.2.0 at 2026-05-25T10:00:00.000Z

Conditional alias for dev-only mocks

typescript
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

bash
# 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:

text
✓ 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.

typescript
// 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:

typescript
// src/cli.ts
#!/usr/bin/env node
import { program } from "commander";
// ...

Detect server-only env access during build

typescript
// 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/

bash
npm install -D vite-plugin-static-copy

Output: (none — exits 0 on success)

typescript
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.jsonscripts, bin, exports integration with Vite library mode