SM
Skills Monitor
Back to skills
Everything Claude Code
vite-patterns
Vite build tool patterns including config, plugins, HMR, env variables, proxy setup, SSR, library mode, dependency pre-bundling, and build optimization. Activate when working with vite.config.ts, Vite plugins, or Vite-based projects.
affaan-m
May 11, 2026
affaan-m/everything-claude-code

SKILL.md

skills/vite-patterns/SKILL.md

YAML Frontmatter3 lines
Frontmatter
name: vite-patterns
description: Vite build tool patterns including config, plugins, HMR, env variables, proxy setup, SSR, library mode, dependency pre-bundling, and build optimization. Activate when working with vite.config.ts, Vite plugins, or Vite-based projects.
origin: ECC

Vite Patterns

Build tool and dev server patterns for Vite 8+ projects. Covers configuration, environment variables, proxy setup, library mode, dependency pre-bundling, and common production pitfalls.

When to Use

  • Configuring vite.config.ts or vite.config.js
  • Setting up environment variables or .env files
  • Configuring dev server proxy for API backends
  • Optimizing build output (chunks, minification, assets)
  • Publishing libraries with build.lib
  • Troubleshooting dependency pre-bundling or CJS/ESM interop
  • Debugging HMR, dev server, or build errors
  • Choosing or ordering Vite plugins

How It Works

  • Dev mode serves source files as native ESM — no bundling. Transforms happen on-demand per module request, which is why cold starts are fast and HMR is precise.
  • Build mode uses Rolldown (v7+) or Rollup (v5–v6) to bundle the app for production with tree-shaking, code-splitting, and Oxc-based minification.
  • Dependency pre-bundling converts CJS/UMD deps to ESM once via esbuild and caches the result under node_modules/.vite, so subsequent starts skip the work.
  • Plugins share a unified interface across dev and build — the same plugin object works for both the dev server's on-demand transforms and the production pipeline.
  • Environment variables are statically inlined at build time. VITE_-prefixed vars become public constants in the bundle; everything unprefixed is invisible to client code.

Examples

Config Structure

Basic Config

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { '@': new URL('./src', import.meta.url).pathname },
  },
})

Conditional Config

// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig(({ command, mode }) => {
  const env = loadEnv(mode, process.cwd())   // VITE_ prefixed only (safe)

  return {
    plugins: [react()],
    server: command === 'serve' ? { port: 3000 } : undefined,
    define: {
      __API_URL__: JSON.stringify(env.VITE_API_URL),
    },
  }
})

Key Config Options

KeyDefaultDescription
root'.'Project root (where index.html lives)
base'/'Public base path for deployed assets
envPrefix'VITE_'Prefix for client-exposed env vars
build.outDir'dist'Output directory
build.minify'oxc'Minifier ('oxc', 'terser', or false)
build.sourcemapfalsetrue, 'inline', or 'hidden'

Plugins

Essential Plugins

Most plugin needs are covered by a handful of well-maintained packages. Reach for these before writing your own.

PluginPurposeWhen to use
@vitejs/plugin-react-swcReact HMR + Fast Refresh via SWCDefault for React apps (faster than Babel variant)
@vitejs/plugin-reactReact HMR + Fast Refresh via BabelOnly if you need Babel plugins (emotion, MobX decorators)
@vitejs/plugin-vueVue 3 SFC supportVue apps
vite-plugin-checkerRuns tsc + ESLint in worker thread with HMR overlayAny TypeScript app — Vite does NOT type-check during vite build
vite-tsconfig-pathsHonors tsconfig.json paths aliasesAny time you already have aliases in tsconfig.json
vite-plugin-dtsEmits .d.ts files in library modePublishing TypeScript libraries
vite-plugin-svgrImports SVGs as React componentsReact apps using SVGs as components
rollup-plugin-visualizerBundle treemap/sunburst reportPeriodic bundle size audits (use enforce: 'post')
vite-plugin-pwaZero-config PWA + WorkboxOffline-capable apps

Critical callout: vite build transpiles but does NOT type-check. Type errors silently ship to production unless you add vite-plugin-checker or run tsc --noEmit in CI.

Authoring Custom Plugins

Authoring is rare — most needs are covered by existing plugins. When you do need one, start inline in vite.config.ts and only extract if reused.

// vite.config.ts — minimal inline plugin
function myPlugin(): Plugin {
  return {
    name: 'my-plugin',                       // required, must be unique
    enforce: 'pre',                           // 'pre' | 'post' (optional)
    apply: 'build',                           // 'build' | 'serve' (optional)
    transform(code, id) {
      if (!id.endsWith('.custom')) return
      return { code: transformCustom(code), map: null }
    },
  }
}

Key hooks: transform (modify source), resolveId + load (virtual modules), transformIndexHtml (inject into HTML), configureServer (add dev middleware), hotUpdate (custom HMR — replaces deprecated handleHotUpdate in v7+).

Virtual modules use the \0 prefix convention — resolveId returns '\0virtual:my-id' so other plugins skip it. User code imports 'virtual:my-id'.

For full plugin API, see vite.dev/guide/api-plugin. Use vite-plugin-inspect during development to debug the transform pipeline.

HMR API

Framework plugins (@vitejs/plugin-react, @vitejs/plugin-vue, etc.) handle HMR automatically. Reach for import.meta.hot directly only when building custom state stores, dev tools, or framework-agnostic utilities that need to persist state across updates.

// src/store.ts — manual HMR for a vanilla module
if (import.meta.hot) {
  // Persist state across updates (must MUTATE, never reassign .data)
  import.meta.hot.data.count = import.meta.hot.data.count ?? 0

  // Cleanup side effects before module is replaced
  import.meta.hot.dispose((data) => clearInterval(data.intervalId))

  // Accept this module's own updates
  import.meta.hot.accept()
}

All import.meta.hot code is tree-shaken out of production builds — no guard removal needed.

Environment Variables

Vite loads .env, .env.local, .env.[mode], and .env.[mode].local in that order (later overrides earlier); *.local files are gitignored and meant for local secrets.

Client-Side Access

Only VITE_-prefixed vars are exposed to client code:

import.meta.env.VITE_API_URL   // string
import.meta.env.MODE            // 'development' | 'production' | custom
import.meta.env.BASE_URL        // base config value
import.meta.env.DEV             // boolean
import.meta.env.PROD            // boolean
import.meta.env.SSR             // boolean

Using Env in Config

// vite.config.ts
import { defineConfig, loadEnv } from 'vite'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd())          // VITE_ prefixed only (safe)
  return {
    define: {
      __API_URL__: JSON.stringify(env.VITE_API_URL),
    },
  }
})

Security

VITE_ Prefix is NOT a Security Boundary

Any variable prefixed with VITE_ is statically inlined into the client bundle at build time. Minification, base64 encoding, and disabling source maps do NOT hide it. A determined attacker can extract any VITE_ var from the shipped JavaScript.

Rule: Only public values (API URLs, feature flags, public keys) go in VITE_ vars. Secrets (API tokens, database URLs, private keys) MUST live server-side behind an API or serverless function.

The loadEnv('') Trap

// BAD: passing '' as the third arg loads ALL env vars — including server secrets —
// and makes them available to inline into client code via `define`.
const env = loadEnv(mode, process.cwd(), '')

// GOOD: explicit prefix list
const env = loadEnv(mode, process.cwd(), ['VITE_', 'APP_'])

Source Maps in Production

Production source maps leak your original source code. Disable them unless you upload to an error tracker (Sentry, Bugsnag) and delete locally afterward:

build: {
  sourcemap: false,                                  // default — keep it this way
}

.gitignore Checklist

  • .env.local, .env.*.local — local secret overrides
  • dist/ — build output
  • node_modules/.vite — pre-bundle cache (stale entries cause phantom errors)

Server Proxy

// vite.config.ts — server.proxy
server: {
  proxy: {
    '/foo': 'http://localhost:4567',                    // string shorthand

    '/api': {
      target: 'http://localhost:8080',
      changeOrigin: true,                               // needed for virtual-hosted backends
      rewrite: (path) => path.replace(/^\/api/, ''),
    },
  },
}

For WebSocket proxying, add ws: true to the route config.

Build Optimization

Manual Chunks

// vite.config.ts — build.rolldownOptions
build: {
  rolldownOptions: {
    output: {
      // Object form: group specific packages
      manualChunks: {
        'react-vendor': ['react', 'react-dom'],
        'ui-vendor': ['@radix-ui/react-dialog', '@radix-ui/react-popover'],
      },
    },
  },
}
// Function form: split by heuristic
manualChunks(id) {
  if (id.includes('node_modules/react')) return 'react-vendor'
  if (id.includes('node_modules')) return 'vendor'
}

Performance

Avoid Barrel Files

Barrel files (index.ts re-exporting everything from a directory) force Vite to load every re-exported file even when you import a single symbol. This is the #1 dev-server slowdown flagged by the official docs.

// BAD — importing one util forces Vite to load the whole barrel
import { slash } from '@/utils'

// GOOD — direct import, only the one file is loaded
import { slash } from '@/utils/slash'

Be Explicit with Import Extensions

Each implicit extension forces up to 6 filesystem checks via resolve.extensions. In large codebases, this adds up.

// BAD
import Component from './Component'

// GOOD
import Component from './Component.tsx'

Narrow tsconfig.json allowImportingTsExtensions + resolve.extensions to only the extensions you actually use.

Warm-Up Hot-Path Routes

server.warmup.clientFiles pre-transforms known hot entries before the browser requests them — eliminating the cold-load request waterfall on large apps.

// vite.config.ts
server: {
  warmup: {
    clientFiles: ['./src/main.tsx', './src/routes/**/*.tsx'],
  },
}

Profiling Slow Dev Servers

When vite dev feels slow, start with vite --profile, interact with the app, then press p+enter to save a .cpuprofile. Load it in Speedscope to find which plugins are eating time — usually buildStart, config, or configResolved hooks in community plugins.

Library Mode

When publishing an npm package, use build.lib. Two footguns matter more than config detail:

  1. Types are not emitted — add vite-plugin-dts or run tsc --emitDeclarationOnly separately.
  2. Peer dependencies MUST be externalized — unlisted peers get bundled into your library, causing duplicate-runtime errors in consumers.
// vite.config.ts
build: {
  lib: {
    entry: 'src/index.ts',
    formats: ['es', 'cjs'],
    fileName: (format) => `my-lib.${format}.js`,
  },
  rolldownOptions: {
    external: ['react', 'react-dom', 'react/jsx-runtime'],  // every peer dep
  },
}

SSR Externals

Bare createServer({ middlewareMode: true }) setups are framework-author territory. Most apps should use Nuxt, Remix, SvelteKit, Astro, or TanStack Start instead. What you will tweak as a framework user is the externals config when deps break in SSR:

// vite.config.ts — ssr options
ssr: {
  external: ['node-native-package'],           // keep as require() in SSR bundle
  noExternal: ['esm-only-package'],            // force-bundle into SSR output (fixes most SSR errors)
  target: 'node',                              // 'node' or 'webworker'
}

Dependency Pre-Bundling

Vite pre-bundles dependencies to convert CJS/UMD to ESM and reduce request count.

// vite.config.ts — optimizeDeps
optimizeDeps: {
  include: [
    'lodash-es',                              // force pre-bundle known heavy deps
    'cjs-package',                            // CJS deps that cause interop issues
    'deep-lib/components/**',                 // glob for deep imports
  ],
  exclude: ['local-esm-package'],             // must be valid ESM if excluded
  force: true,                                // ignore cache, re-optimize (temporary debugging)
}

Common Pitfalls

Dev Does Not Match Build

Dev uses esbuild/Rolldown for transforms; build uses Rolldown for bundling. CJS libraries can behave differently between the two. Always verify with vite build && vite preview before deploying.

Stale Chunks After Deployment

New builds produce new chunk hashes. Users with active sessions request old filenames that no longer exist. Vite has no built-in solution. Mitigations:

  • Keep old dist/assets/ files live for a deployment window
  • Catch dynamic import errors in your router and force a page reload

Docker and Containers

Vite binds to localhost by default, which is unreachable from outside a container:

// vite.config.ts — Docker/container setup
server: {
  host: true,                                  // bind 0.0.0.0
  hmr: { clientPort: 3000 },                   // if behind a reverse proxy
}

Monorepo File Access

Vite restricts file serving to the project root. Packages outside root are blocked:

// vite.config.ts — monorepo file access
server: {
  fs: {
    allow: ['..'],                             // allow parent directory (workspace root)
  },
}

Anti-Patterns

// BAD: Setting envPrefix to '' exposes ALL env vars (including secrets) to the client
envPrefix: ''

// BAD: Assuming require() works in application source code — Vite is ESM-first
const lib = require('some-lib')                // use import instead

// BAD: Splitting every node_module into its own chunk — creates hundreds of tiny files
manualChunks(id) {
  if (id.includes('node_modules')) {
    return id.split('node_modules/')[1].split('/')[0]   // one chunk per package
  }
}

// BAD: Not externalizing peer deps in library mode — causes duplicate runtime errors
// build.lib without rolldownOptions.external

// BAD: Using deprecated esbuild minifier
build: { minify: 'esbuild' }                  // use 'oxc' (default) or 'terser'

// BAD: Mutating import.meta.hot.data by reassignment
import.meta.hot.data = { count: 0 }           // WRONG: must mutate properties, not reassign
import.meta.hot.data.count = 0                 // CORRECT

Process anti-patterns:

  • vite preview is NOT a production server — it is a smoke test for the built bundle. Deploy dist/ to a real static host (NGINX, Cloudflare Pages, Vercel static) or use a multi-stage Dockerfile.
  • Expecting vite build to type-check — it only transpiles. Type errors silently ship to production. Add vite-plugin-checker or run tsc --noEmit in CI.
  • Shipping @vitejs/plugin-legacy by default — it bloats bundles ~40%, breaks source-map bundle analyzers, and is unnecessary for the 95%+ of users on modern browsers. Gate it on real analytics, not assumption.
  • Hand-rolling 30+ resolve.alias entries that duplicate tsconfig.json paths — use vite-tsconfig-paths instead. Observed in Excalidraw and PostHog; avoid in new projects.
  • Leaving stale node_modules/.vite after dep changes — pre-bundle cache causes phantom errors. Clear it when switching branches or after patching deps.

Quick Reference

PatternWhen to Use
defineConfigAlways — provides type inference
loadEnv(mode, root, ['VITE_'])Access env vars in config (explicit prefix)
vite-plugin-checkerAny TypeScript app (fills the type-check gap)
vite-tsconfig-pathsInstead of hand-rolled resolve.alias
optimizeDeps.includeCJS deps causing interop issues
server.proxyRoute API requests to backend in dev
server.host: trueDocker, containers, remote access
server.warmup.clientFilesPre-transform hot-path routes
build.lib + externalPublishing npm packages
manualChunks (object)Vendor bundle splitting
vite --profileDebug slow dev server
vite build && vite previewSmoke-test prod bundle locally (NOT a prod server)

Related Skills

  • frontend-patterns — React component patterns
  • docker-patterns — containerized dev with Vite
  • nextjs-turbopack — alternative bundler for Next.js