Skip to main content

How to Migrate from Webpack to Vite: A Step-by-Step Guide

·PkgPulse Team

TL;DR

Most Webpack → Vite migrations take 2-4 hours and result in 10x faster dev server startup. The biggest changes: environment variables (REACT_APP_VITE_), process.envimport.meta.env, index.html moves to project root, and Webpack-specific loaders need Vite plugin equivalents. If you're on Create React App, this guide covers that migration specifically. For existing Webpack configs, 90% of plugins have Vite equivalents.

Key Takeaways

  • Dev server: 8,000ms → 200ms — Vite's ESM-native dev server vs Webpack's full bundle
  • HMR: 400ms → 20ms — esbuild transforms vs Babel, plus ESM module graph
  • process.env.REACT_APP_Ximport.meta.env.VITE_X — env var migration required
  • index.html moves to root — Vite serves from root, not public/
  • Most plugins have equivalents — see mapping table below

Step 1: Install Vite

# Install Vite and framework plugin
npm install -D vite @vitejs/plugin-react

# Or for Vue:
npm install -D vite @vitejs/plugin-vue

# Remove Webpack and CRA (if applicable)
# For CRA projects:
npm uninstall react-scripts

# For custom Webpack setup:
npm uninstall webpack webpack-cli webpack-dev-server \
  babel-loader @babel/core @babel/preset-env @babel/preset-react \
  css-loader style-loader file-loader url-loader \
  html-webpack-plugin mini-css-extract-plugin

Step 2: Create vite.config.ts

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';  // For path aliases

export default defineConfig({
  plugins: [
    react(),          // Handles JSX, React Fast Refresh (HMR)
    tsconfigPaths(),  // Reads paths from tsconfig.json
  ],

  // Dev server config
  server: {
    port: 3000,       // Match your old Webpack port
    open: true,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },

  // Build config
  build: {
    outDir: 'build',       // CRA default was 'build'
    sourcemap: true,
    target: 'es2020',
  },

  // Define global constants (replaces Webpack DefinePlugin)
  define: {
    __APP_VERSION__: JSON.stringify(process.env.npm_package_version),
  },
});

Step 3: Update package.json Scripts

// Before (CRA):
{
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  }
}

// After (Vite):
{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "vitest run"
  }
}

Step 4: Move index.html to Project Root

# CRA puts index.html in /public
# Vite serves index.html from root

mv public/index.html ./index.html

# Update the script tag in index.html:
# Before (CRA): <script> is injected by html-webpack-plugin, no explicit tag needed
# After (Vite): add a script tag pointing to your entry:
<!-- index.html (now in root) -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My App</title>
    <!-- Public assets: reference with / directly -->
    <link rel="icon" href="/favicon.ico" />
  </head>
  <body>
    <div id="root"></div>
    <!-- Add this script tag — Vite requires explicit entry point -->
    <script type="module" src="/src/index.tsx"></script>
  </body>
</html>

Step 5: Migrate Environment Variables

# Before: Create React App env vars
REACT_APP_API_URL=https://api.example.com
REACT_APP_FEATURE_FLAG=true

# After: Vite env vars (rename your .env files)
VITE_API_URL=https://api.example.com
VITE_FEATURE_FLAG=true

# Access pattern changes too:
// Before (CRA / Webpack):
const apiUrl = process.env.REACT_APP_API_URL;
const isDev = process.env.NODE_ENV === 'development';

// After (Vite):
const apiUrl = import.meta.env.VITE_API_URL;
const isDev = import.meta.env.DEV;  // Boolean, not string
const isProd = import.meta.env.PROD;

// TypeScript: add type declarations
// src/vite-env.d.ts (Vite creates this automatically)
/// <reference types="vite/client" />

// For custom env vars, extend the interface:
// src/vite-env.d.ts
interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_FEATURE_FLAG: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Step 6: Map Webpack Plugins → Vite Plugins

Webpack PluginVite EquivalentNotes
HtmlWebpackPluginBuilt-inindex.html at root, no config needed
MiniCssExtractPluginBuilt-inCSS extracted automatically in build
DefinePlugindefine in configSame purpose
CopyWebpackPluginvite-plugin-static-copyCopy non-standard assets
BundleAnalyzerPluginrollup-plugin-visualizerBundle analysis
svg (file-loader)vite-plugin-svgrImport SVGs as React components
@loadable/componentReact.lazy + dynamic importVite handles code splitting natively
webpack-dev-server proxyserver.proxy in configBuilt-in
EnvironmentPluginimport.meta.envNo plugin needed
# Install Vite plugin equivalents you need
npm install -D vite-plugin-svgr          # SVG → React components
npm install -D vite-plugin-static-copy   # Copy static assets
npm install -D rollup-plugin-visualizer  # Bundle analysis
// Updated vite.config.ts with common plugins
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import svgr from 'vite-plugin-svgr';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    tsconfigPaths(),
    svgr(),  // import { ReactComponent as Logo } from './logo.svg'
    // Bundle analysis: run `vite build --mode analyze`
    process.env.ANALYZE === 'true' && visualizer({
      open: true,
      gzipSize: true,
    }),
  ].filter(Boolean),
});

Common Gotchas

Gotcha 1: CommonJS require() in ESM

// Vite is ESM-first. Dynamic require() doesn't work:
// ❌ This fails in Vite:
const config = require('./config.json');

// ✅ Use ESM import:
import config from './config.json';

// ✅ Or dynamic import:
const config = await import('./config.json');

Gotcha 2: Global Variables

// Webpack injects global variables automatically
// Vite doesn't — you need to declare them

// ❌ global.Buffer used in some legacy packages fails in browser
// ✅ Fix with vite-plugin-node-polyfills:
import { nodePolyfills } from 'vite-plugin-node-polyfills';

plugins: [nodePolyfills({ protocolImports: true })]

// Or shim specifically:
define: {
  global: 'window',
  'process.env': '{}',
}

Gotcha 3: CSS Modules with Webpack-style classNames

// Webpack CSS Modules:
import styles from './Button.module.css';
// styles.primaryButton → "Button_primaryButton__abc123"

// Vite CSS Modules — same import syntax, same result
// No changes needed for standard CSS Modules usage

// Difference: Vite uses a different hash algorithm
// This means class names will change in your CSS snapshots
// Update snapshot tests: npx vitest run --update-snapshots

Verify the Migration

# 1. Start dev server
npm run dev
# Should start in <1 second, open at localhost:3000

# 2. Check production build
npm run build
npm run preview
# Preview the production build locally

# 3. Run tests
npm test
# Verify test suite passes (may need Jest → Vitest migration too)

# 4. Check bundle size
ANALYZE=true npm run build
# Compare to Webpack output

Compare Vite and Webpack package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.