Vite for Frontend Projects: Config, Env Variables, Assets, HMR, Plugins and Library Mode

Key takeaways

Vite starts a dev server in under 300ms and uses native ESM to avoid bundling during development — resulting in near-instant Hot Module Replacement regardless of project size. This guide covers everything from basic setup to plugin authoring and library mode.

Why Vite?

webpack dev server startup (large project):  15-60 seconds
Vite dev server startup:                    ~300ms

webpack HMR (large project):               2-10 seconds
Vite HMR:                                 <100ms (often <50ms)

Vite achieves this by eliminating the bundling step during development:

webpack:  source → bundle all modules → serve bundle → browser runs bundle
Vite:     source → serve files directly → browser imports via native ESM
          (browser only loads what it actually needs)

This isn’t just a faster bundler — it’s a fundamentally different architecture. webpack has to resolve and bundle your entire dependency graph before it can serve a single file, because it’s producing one (or a few) combined JavaScript files up front; that cost scales with total project size regardless of which file you’re actually working on, which is why large webpack projects have slow, size-dependent startup and HMR times. Vite instead serves your source files essentially as-is and lets the browser’s native import resolve modules on demand — startup cost stops scaling with total project size and instead scales with how much of the app the browser has actually requested, which for a typical dev session (working on one page, one component at a time) is a small fraction of the whole codebase. This only works during development because modern browsers support ES modules natively; production still needs real bundling (fewer network requests, tree-shaking, minification), which is why Vite switches to Rollup for the production build — a detail covered more in the FAQ above and worth remembering, since it means dev and prod genuinely run through different code paths.


Quick Start

# React + TypeScript
npm create vite@latest my-app -- --template react-ts
cd my-app && npm install && npm run dev

# Vue + TypeScript
npm create vite@latest my-app -- --template vue-ts

# Vanilla TypeScript
npm create vite@latest my-app -- --template vanilla-ts

Available templates: vanilla, vanilla-ts, vue, vue-ts, react, react-ts, react-swc, react-swc-ts, preact, preact-ts, lit, lit-ts, svelte, svelte-ts, solid, solid-ts

The react-swc variants swap the default Babel-based transform for SWC (a Rust-based compiler), which compiles JSX and TypeScript noticeably faster on large codebases — the tradeoff is that SWC’s Babel-plugin ecosystem is smaller, so if a project depends on a specific Babel plugin without an SWC equivalent, the default react-ts template is the safer starting point. create vite scaffolds a minimal, unopinionated project (no routing, no state management, no testing setup pre-wired) deliberately — it’s meant as a bare starting point you build on, in contrast to framework-specific CLIs (like Create React App used to be) that bundle in more opinionated defaults.


Project Structure

my-app/
├── public/            # Static assets (served as-is, no processing)
│   └── favicon.ico
├── src/
│   ├── main.tsx       # Entry point
│   ├── App.tsx
│   └── assets/        # Imported assets (processed by Vite)
├── index.html         # Entry HTML (not in public/ — Vite processes it)
├── vite.config.ts
├── tsconfig.json
└── package.json
<!-- index.html — Vite processes this -->
<!DOCTYPE html>
<html>
  <body>
    <div id="root"></div>
    <!-- Entry point referenced directly — Vite handles bundling -->
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

This is one of the more surprising early differences from a traditional setup: index.html isn’t a static file served as-is (the way it would be from public/), it’s an actual build entry point Vite parses and processes — the <script type="module" src="/src/main.tsx"> tag is what tells Vite where the dependency graph starts, and Vite rewrites that path automatically for the production build. This is also why assets referenced directly in index.html (a <link rel="icon">, an <img> tag) get processed and hashed by Vite just like an imported asset would, while anything actually placed in public/ is copied to the output directory completely untouched — a distinction worth remembering, since assuming public/ assets get optimized (or that index.html-referenced assets don’t) is a common source of confusion.


vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],

  // Path aliases
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
      '@components': path.resolve(__dirname, './src/components'),
      '@hooks': path.resolve(__dirname, './src/hooks'),
    },
  },

  // Dev server
  server: {
    port: 3000,
    open: true,                    // Open browser on start

    // Proxy API calls to backend (avoids CORS in dev)
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
  // The proxy exists specifically to avoid CORS during local development:
  // the browser sees every request going to the same origin the dev server
  // is running on, and Vite's Node process — not the browser — forwards
  // matching requests to the real backend server-side, where CORS doesn't
  // apply. `changeOrigin: true` rewrites the request's Host header to
  // match the target, which some backends check and reject requests
  // without; `rewrite` here strips the `/api` prefix before forwarding, so
  // the backend sees a clean path with no knowledge the proxy exists.

  // Build settings
  build: {
    outDir: 'dist',
    sourcemap: true,               // Source maps for debugging
    minify: 'esbuild',             // Fast minification
    target: 'es2020',              // Browser target

    rollupOptions: {
      output: {
        // Code splitting: split vendor libraries into separate chunk
        manualChunks: {
          vendor: ['react', 'react-dom'],
          router: ['react-router-dom'],
        },
      },
    },
  },

  // CSS
  css: {
    modules: {
      localsConvention: 'camelCase',  // CSS Module class names as camelCase
    },
    preprocessorOptions: {
      scss: {
        additionalData: '@import "@/styles/variables.scss";',  // Global SCSS
      },
    },
  },
});

manualChunks is worth understanding rather than copy-pasting blindly: without it, Rollup groups modules into chunks using its own heuristics, which for a large dependency tree can mean a heavy, rarely-changing library like React ends up bundled together with your frequently-changing app code — so shipping a one-line app fix invalidates the browser cache for React too, forcing users to re-download it. Explicitly splitting stable vendor code into its own chunk means that chunk’s content (and therefore its cache-busting hash) only changes when you actually upgrade that dependency, while app-code changes only invalidate the app chunk — the same long-term-caching benefit covered in the webpack bundle-splitting discussion elsewhere on this site, just configured through Rollup’s options instead of webpack’s.


Environment Variables

Vite uses .env files. Variables must be prefixed with VITE_ to be exposed to the browser:

# .env (committed — default values)
VITE_APP_NAME=MyApp
VITE_API_URL=http://localhost:8000

# .env.local (not committed — overrides .env locally)
VITE_API_URL=http://localhost:3001

# .env.production (used when building for production)
VITE_API_URL=https://api.myapp.com

# .env.development (used during dev server)
VITE_DEBUG=true

The VITE_ prefix requirement isn’t arbitrary boilerplate — it’s a deliberate security boundary. Anything Vite exposes to import.meta.env gets compiled directly into the client-side JavaScript bundle, which means it ships to every visitor’s browser and is trivially readable via view-source or dev tools; without a prefix requirement, it would be easy to accidentally expose a server-side secret (a database password, an API key meant to stay server-only) into client code just by having it sitting in .env. Only variables explicitly opted in with VITE_ make that crossing — everything else in .env stays available to build tooling and server-side code but never reaches the browser.

// Access in your code
const apiUrl = import.meta.env.VITE_API_URL;
const appName = import.meta.env.VITE_APP_NAME;

// Built-in Vite env vars
const isDev = import.meta.env.DEV;         // true in dev
const isProd = import.meta.env.PROD;       // true in production
const mode = import.meta.env.MODE;         // 'development' | 'production'

.env.local is the one file in this hierarchy that deserves special attention: it’s meant for machine-specific overrides (a developer’s own local backend port, personal API keys for testing) and Vite’s own convention adds it to a default .gitignore pattern for exactly that reason — committing it would mean everyone’s personal local overrides fight each other in version control. Vite merges these files with a defined precedence (mode-specific files like .env.production override the base .env, and .local variants override their non-local counterpart), which is what lets the same codebase run with different configuration in dev, CI, and production without branching logic in application code.

// TypeScript: add types for your env vars
// src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_APP_NAME: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Without this declaration, import.meta.env.VITE_API_URL still works at runtime (Vite’s actual env replacement doesn’t depend on these types at all) but TypeScript sees import.meta.env as the generic, loosely-typed shape from Vite’s own ambient types, and any custom variable you access on it types as any — meaning a typo like VITE_API_URLL would silently compile instead of failing type-checking. Augmenting ImportMetaEnv like this is what gets autocomplete and a compile error on typos or missing required variables, catching a class of bug that would otherwise only surface at runtime as undefined.


Asset Handling

// Import images — Vite returns the URL
import logoUrl from './logo.svg';
import heroUrl from './hero.png';

function App() {
  return <img src={logoUrl} alt="Logo" />;
}

// Import as raw string
import svgRaw from './icon.svg?raw';
document.body.innerHTML = svgRaw;

// Import as URL explicitly
import fileUrl from './data.json?url';

// Import as inline base64 (small files)
import inlined from './small-icon.png?inline';

// Dynamic import (code splitting)
const { default: Chart } = await import('./Chart');

The default plain import logoUrl from './logo.svg' is what most code needs — Vite fingerprints the file for cache-busting and returns its final built URL, so you never hardcode a path that might change between dev and prod. The query-suffix variants (?raw, ?url, ?inline) exist for the less common cases where you need the asset in a different shape: ?raw for injecting an SVG’s actual markup into the DOM rather than referencing it as an image source, ?url to force a URL even for an asset small enough Vite would otherwise inline it as base64 automatically, and ?inline to force inlining even above the default size threshold. The dynamic import() at the bottom is a genuinely different mechanism from all of these — it’s a code-splitting boundary, telling Rollup to put Chart in its own separate chunk that’s only fetched when this line actually executes, not at initial page load.

// vite.config.ts — configure asset handling
export default defineConfig({
  build: {
    assetsInlineLimit: 4096,    // Inline assets < 4KB as base64 (default)
  },
});

This threshold is a genuine tradeoff, not a default to leave unexamined: inlining a small image as base64 avoids a separate HTTP request (good for many tiny icons), but base64 encoding itself inflates the asset’s size by roughly a third and, once inlined into a JS or CSS file, can no longer be cached independently by the browser — every time the containing file changes, the inlined asset re-downloads with it even though the image itself didn’t change. Raising the limit trades more requests for better independent caching of larger images; lowering it (or setting it to 0 to disable inlining entirely) is common for projects using an image CDN, where separately-cacheable URLs matter more than avoiding a request.


Hot Module Replacement (HMR)

Vite’s HMR updates only the changed module — not the whole page.

// Opt into HMR in custom modules
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // Called when this module or its deps update
    console.log('Module updated:', newModule);
  });

  import.meta.hot.dispose(() => {
    // Clean up before the module is replaced
    clearInterval(timer);
  });
}

For React, @vitejs/plugin-react uses React Refresh — components update without losing state. The import.meta.hot API shown above is the low-level primitive both React Refresh and Vue’s HMR support are built on: accept() tells Vite “when this module changes, don’t reload the whole page — just re-evaluate it and hand me the new version,” and dispose() is the required cleanup counterpart for anything the module set up (timers, event listeners, WebSocket connections) that would otherwise leak or duplicate every time the module gets hot-swapped. Framework plugins wire this up automatically for components, which is why most application code never touches import.meta.hot directly — it mainly matters for state managers, custom singleton modules, or other non-component code that needs to survive a hot update cleanly.


Plugins

Official Plugins

npm install -D @vitejs/plugin-react      # React with Babel transforms
npm install -D @vitejs/plugin-react-swc  # React with SWC (faster)
npm install -D @vitejs/plugin-vue        # Vue 3
npm install -D @vitejs/plugin-legacy     # Polyfills for older browsers

Worth knowing the difference between the two React plugins specifically: @vitejs/plugin-react uses Babel, which has a mature plugin ecosystem and is the safer default if a project needs a specific Babel transform (certain decorator proposals, custom JSX pragmas); @vitejs/plugin-react-swc uses a Rust-based compiler for meaningfully faster builds on large codebases but supports a narrower set of Babel-plugin-equivalent transforms. @vitejs/plugin-legacy solves a problem native-ESM-based tooling otherwise can’t: it generates a second, older-syntax bundle plus polyfills for browsers that don’t support ES modules at all, served conditionally via <script nomodule> — without it, a Vite production build assumes modern browser support by default.

# Auto-import — import React, useState, etc. without explicit import
npm install -D unplugin-auto-import

# SVG as React components
npm install -D vite-plugin-svgr

# Bundle analysis
npm install -D rollup-plugin-visualizer

# PWA support
npm install -D vite-plugin-pwa
// vite.config.ts with plugins
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),

    svgr({
      svgrOptions: { icon: true },
    }),

    visualizer({
      filename: 'dist/stats.html',    // Open after build to see bundle breakdown
      open: true,
      gzipSize: true,
    }),
  ],
});

rollup-plugin-visualizer is worth reaching for before guessing at bundle-size optimizations, the same way webpack-bundle-analyzer is on the webpack side — it renders an interactive treemap of exactly what’s taking up space in the production build, which routinely surfaces surprises (an accidentally-duplicated dependency at two different versions, a large library imported for one small utility function) that are hard to spot just by reading package.json.

Writing a Custom Plugin

// plugins/my-plugin.ts
import type { Plugin } from 'vite';

export function myPlugin(): Plugin {
  return {
    name: 'my-plugin',

    // Transform source files
    transform(code, id) {
      if (!id.endsWith('.ts')) return null;

      // Replace __BUILD_TIME__ with actual timestamp
      return code.replace(/__BUILD_TIME__/g, Date.now().toString());
    },

    // Inject HTML
    transformIndexHtml(html) {
      return html.replace(
        '<head>',
        `<head>\n  <meta name="build-time" content="${Date.now()}" />`
      );
    },

    // Handle virtual modules
    resolveId(id) {
      if (id === 'virtual:my-module') return id;
    },

    load(id) {
      if (id === 'virtual:my-module') {
        return `export const message = 'Hello from virtual module!';`;
      }
    },
  };
}

This example is worth studying closely because it demonstrates that Vite’s plugin system is built directly on top of Rollup’s — transform and resolveId/load are genuine Rollup plugin hooks that work identically for the production build, while transformIndexHtml is one of the few hooks Vite adds specifically for its own dev-server/HTML-processing needs that Rollup has no equivalent for. The resolveId/load pair together is how “virtual modules” work: resolveId claims ownership of a module specifier that doesn’t correspond to a real file on disk (returning it unchanged signals “yes, I’ll handle this”), and load then supplies that module’s actual source content on demand — this is the mechanism behind things like auto-generated route manifests or injected build-time constants that don’t exist as real files anywhere in the project.


Library Mode

Build a reusable library instead of an app. This mode flips several of Vite’s default assumptions: a normal app build bundles everything (including react) into a self-contained output meant to be deployed as-is, while a library is meant to be installed as a dependency of someone else’s app, which typically already has its own copy of React — bundling React into the library too would mean two separate React instances at runtime, a classic source of “Invalid hook call” errors and broken Context.

// vite.config.ts — library mode
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';

export default defineConfig({
  plugins: [
    react(),
    dts({ include: ['src'] }),    // Generate .d.ts files
  ],

  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'MyLibrary',
      fileName: (format) => `my-library.${format}.js`,
      formats: ['es', 'cjs'],     // ESM + CommonJS
    },
    rollupOptions: {
      // Exclude peer dependencies from bundle
      external: ['react', 'react-dom'],
      output: {
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
        },
      },
    },
  },
});

Two settings here directly address that: external: ['react', 'react-dom'] tells Rollup not to bundle those packages at all, trusting the consuming app to provide them (via its own node_modules), and formats: ['es', 'cjs'] outputs both an ES module and a CommonJS build because you generally don’t control whether a consumer’s project uses import or require — shipping both formats means the library works either way without forcing a choice on downstream users. dts({ include: ['src'] }) generating .d.ts files alongside the JS output is what gives TypeScript consumers real autocomplete and type-checking against the library, rather than falling back to implicit any types for everything it exports.

// package.json for the library
{
  "name": "my-library",
  "main": "./dist/my-library.cjs.js",
  "module": "./dist/my-library.es.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/my-library.es.js",
      "require": "./dist/my-library.cjs.js"
    }
  }
}

The exports field is the modern, more precise way to express this same dual-format story: it tells Node’s module resolver (and bundlers) exactly which file to load for import ("import") versus require ("require") without relying on the older, coarser main/module convention alone — main/module are kept here too for compatibility with older tooling that doesn’t understand exports yet, but exports takes precedence where it’s supported and is the field worth getting right for a library published today.


Multi-Page App (MPA)

// vite.config.ts — multiple entry points
export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        main: resolve(__dirname, 'index.html'),
        admin: resolve(__dirname, 'admin/index.html'),
        login: resolve(__dirname, 'login/index.html'),
      },
    },
  },
});

By default, Vite’s build assumes a single index.html entry point — this rollupOptions.input override is what tells it there are actually several independent pages, each with its own HTML entry and its own JavaScript dependency graph. This is genuinely different from client-side routing (a single-page app with multiple routes, all served from one index.html): an MPA here produces separate, independently-loadable HTML pages — useful for a marketing site with a distinct admin panel, or any project where full page loads between sections are acceptable and a shared JS bundle across all of them isn’t necessary.


Migration from webpack

// Common webpack → Vite equivalents

// webpack: require('./image.png')
// Vite:    import imageUrl from './image.png'

// webpack: require.context
// Vite:    import.meta.glob

// Glob import all files matching a pattern
const modules = import.meta.glob('./routes/*.tsx');
// Returns: { './routes/home.tsx': () => import('./routes/home.tsx'), ... }

// Eager loading (synchronous)
const modules = import.meta.glob('./routes/*.tsx', { eager: true });

// webpack: process.env.REACT_APP_*
// Vite:    import.meta.env.VITE_*

// webpack: DefinePlugin
// Vite:    define in vite.config.ts
export default defineConfig({
  define: {
    __APP_VERSION__: JSON.stringify(process.env.npm_package_version),
  },
});

import.meta.glob is worth calling out as more than a drop-in replacement for require.context — it returns an object mapping each matched file path to a function that dynamically imports that module, not the module itself, which is what preserves code-splitting: each matched route only gets fetched when its corresponding function is actually called. The { eager: true } variant trades that away deliberately, importing every match immediately and synchronously — useful when you need the actual module contents available at initial load (say, building a static route manifest), but it defeats the lazy-loading benefit, so it’s a choice to make consciously rather than a default to reach for automatically. define for build-time constants works essentially like webpack’s DefinePlugin: it’s a literal text substitution at build time, not a runtime variable, so __APP_VERSION__ gets replaced with the actual JSON-stringified value directly in the compiled output before the code ever runs.


npm run preview — Test Production Build

npm run build     # Build production output to dist/
npm run preview   # Serve the dist/ folder locally on port 4173

Always test the production build before deploying — dev and prod can behave differently (env vars, asset paths, code splitting). This isn’t a hypothetical caution: the dev server runs your code through esbuild transforms with native ESM and no real bundling, while the production build runs the entirely separate Rollup pipeline with tree-shaking, minification, and chunk splitting — bugs that only manifest in one path (a module with a side effect that tree-shaking eliminates because it looks unused, a dynamic import() path that resolves differently once file names are hashed) are a real and recurring category of “worked in dev, broke in prod” issue specific to this dual-pipeline architecture, and npm run preview is the cheapest way to catch them before a real deployment does.


Quick Reference

TaskConfig
Dev server portserver: { port: 3000 }
API proxyserver: { proxy: { '/api': 'http://localhost:8000' } }
Path aliasresolve: { alias: { '@': './src' } }
Source mapsbuild: { sourcemap: true }
Code splittingbuild: { rollupOptions: { output: { manualChunks: {...} } } }
Global SCSScss: { preprocessorOptions: { scss: { additionalData: '...' } } }
Analyze bundlerollup-plugin-visualizer

Frequently Asked Questions (FAQ)

Q. Why is my environment variable undefined in the browser?

A. Vite exposes only variables prefixed with VITE_ to client code through import.meta.env; others stay server-side so secrets do not leak into the bundle. process.env is not available in client code by default, so code ported from webpack or Create React App often reads undefined. The values are also replaced at build time, so changing a .env file requires restarting the dev server or rebuilding.