· vite / webpack / typescript

How to migrate from Webpack to Vite (real project)

Step-by-step Webpack 5 → Vite 8 migration on a TypeScript + React project: before/after configs, env var renaming, CJS interop fixes, and six concrete gotchas.

By Ethan

2,215 words · 12 min read

Disclosure: Some links in this article are affiliate links — if you click through and buy, toolchew earns a commission at no cost to you. We only link to tools we tested ourselves. Affiliate status doesn’t change the verdict; if a tool would lose to a non-affiliate competitor, we’d say so.

If you’re still running Webpack 5 on a TypeScript + React project, switching to Vite will cut your cold-start time from tens of seconds to under a second. This guide covers every concrete step — config translation, env var renaming, CommonJS interop, asset handling, and production build setup — so you know exactly what you’re getting into before you start.

Who this is for

Developers who have a working Webpack 5 project in TypeScript + React (or Vue) and want to migrate to Vite. You should be comfortable editing package.json and build config files. You don’t need prior Vite experience.

If your project uses Webpack Module Federation for micro-frontends, read the “When NOT to migrate” section first — this guide won’t solve that.

Why migrate in 2026

Webpack 5 is in maintenance mode. Tobias Koppers, webpack’s original author, shifted focus to Turbopack; the webpack changelog still sees incremental feature additions and security fixes, but no major architectural rewrite is underway. Vite has been gaining market share at record speed in recent State of JavaScript surveys.

The performance gap is real. On a 200-component React app, cold start drops from 34.2 s to 0.8 s (42×), HMR from 2.8 s to 50 ms (56×), and production build time from 142 s to 38 s (3.7×). Those numbers come from a published benchmark — not vendor marketing.

Vite 8 (current stable) also replaces Rollup and esbuild with Rolldown, a Rust-based bundler. Most of the migration steps in this guide work identically across Vite 5–8; where v8 differs, it’s called out.

Not sure yet whether migration is worth it for your project? Our Vite vs Webpack comparison covers the tradeoffs in depth. If Turbopack is also on your radar — particularly if you’re on Next.js — Turbopack vs Vite has a direct comparison with HMR and production build numbers.

Prerequisites

  • Node.js 20.19+ or 22.12+ — check with node -v
  • Vite 8.0.10 (npm install -D [email protected])
  • @vitejs/plugin-react (latest — uses Oxc Transformer internally)
  • Vitest 4.1.6 if you’re also migrating your test suite

If you want to support legacy browsers (IE 11 era): @vitejs/plugin-legacy — but also install terser separately. The plugin fails at build time if Terser isn’t present, and the error message isn’t obvious about why.

Want a structured course on Vite and modern JS tooling after you’ve done the migration? The Vite and modern JS tooling course on Udemy covers the internals in depth.

Step-by-step migration

Step 1: Install Vite and its plugins

npm install -D vite @vitejs/plugin-react

Remove Webpack and its ecosystem:

npm uninstall webpack webpack-cli webpack-dev-server html-webpack-plugin \
  css-loader style-loader ts-loader babel-loader \
  mini-css-extract-plugin webpack-merge

Leave typescript in place — Vite uses it for type checking, though compilation goes through esbuild/Oxc.

Step 2: Create vite.config.ts

Create this file at your project root:

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

export default defineConfig({
  plugins: [react()],
  base: '/',
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },
  build: {
    outDir: 'dist',
  },
});

Before (webpack.config.js):

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index.tsx',
  output: {
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  resolve: {
    alias: { '@': path.resolve(__dirname, 'src') },
  },
  module: {
    rules: [{ test: /\.tsx?$/, use: 'ts-loader' }],
  },
  plugins: [new HtmlWebpackPlugin({ template: './public/index.html' })],
};

The concept mapping:

WebpackVite
entryindex.html at project root
output.publicPathbase
output.pathbuild.outDir
resolve.aliasresolve.alias (same shape)
ts-loaderBuilt-in (Oxc/esbuild)
css-loader + style-loaderBuilt-in
HtmlWebpackPluginNot needed
webpack-dev-servervite CLI

Step 3: Move index.html to the project root

Vite treats index.html as the entry point, not a build artifact. Move it from public/index.html to your project root, then add the module script pointing to your main file:

<!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>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Remove the %PUBLIC_URL% references that Create React App added — Vite doesn’t use that convention. Use base in vite.config.ts to set the public URL instead.

Step 4: Update environment variables

Only variables prefixed with VITE_ are exposed to client code. Rename everything:

# .env.development (before)
REACT_APP_API_URL=https://api.example.com
REACT_APP_FEATURE_FLAGS=true

# .env.development (after)
VITE_API_URL=https://api.example.com
VITE_FEATURE_FLAGS=true

Then update every reference in your source:

// Before
const apiUrl = process.env.REACT_APP_API_URL;
const isDev = process.env.NODE_ENV === 'development';

// After
const apiUrl = import.meta.env.VITE_API_URL;
const isDev = import.meta.env.DEV;   // built-in boolean
const isProd = import.meta.env.PROD; // built-in boolean

For TypeScript to recognize your custom vars, create src/vite-env.d.ts:

/// <reference types="vite/client" />

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

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

If any third-party library you use still reads process.env.NODE_ENV directly, add this to vite.config.ts:

define: {
  'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
},

Step 5: Fix CommonJS interop

Vite pre-bundles npm packages (lodash, moment, axios, etc.) to ESM automatically during dev. What breaks is your own code.

Your own require() calls — convert to import:

// Before
const fs = require('fs');
const { format } = require('date-fns');

// After
import fs from 'fs';
import { format } from 'date-fns';

Dynamic require(variable) — convert to await import():

// Before
const plugin = require(`./plugins/${name}`);

// After
const plugin = await import(`./plugins/${name}`);

CJS default import ambiguity — if you get “Element type is invalid: expected a string, but got: object”, the package exports a CJS module and the default is wrapped:

// Fix
import * as M from 'some-cjs-package';
const fn = M.default ?? M;

ESM config file — if vite.config.js uses require(), rename it to vite.config.ts or add "type": "module" to package.json.

As a temporary bridge, @originjs/vite-plugin-commonjs can transform CJS syntax in your source files automatically. It covers simple cases; it has known issues with complex dynamic requires. Use it to unblock yourself, then migrate the actual calls.

Step 6: Update path aliases in tsconfig.json

Vite’s bundler and TypeScript’s type-checker are independent processes. If you only add aliases to vite.config.ts, your IDE and tsc still won’t resolve them.

Add the matching paths to tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Or install vite-tsconfig-paths and let it sync the two automatically:

// vite.config.ts
import tsconfigPaths from 'vite-tsconfig-paths';

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

Step 7: Update package.json scripts

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }
}

Remove webpack, webpack-dev-server, webpack --watch, and any related custom scripts.

Step 8: Replace asset loaders

Webpack’s file-loader, raw-loader, and url-loader don’t exist in Vite. Use query suffixes instead:

import logo from './logo.svg';                    // URL — same as file-loader
import shader from './shader.glsl?raw';            // file content as string
import workletUrl from './audio-worklet.js?url';   // explicit URL
import icon from './small-icon.png?inline';        // base64 data URI
import MyWorker from './heavy.worker.ts?worker';   // Web Worker

SVG as a React component (previously @svgr/webpack):

npm install -D vite-plugin-svgr
import { ReactComponent as Logo } from './logo.svg?react';

Step 9: Configure code splitting

Webpack’s splitChunks maps to Rollup’s manualChunks in Vite 5–7:

build: {
  rollupOptions: {
    output: {
      manualChunks(id) {
        if (id.includes('node_modules')) {
          return 'vendor';
        }
      },
    },
  },
},

Vite 8 note: build.rollupOptions becomes build.rolldownOptions in Vite 8. The object form of manualChunks was removed; the function form above still works but is deprecated — the Vite 8 migration guide recommends codeSplitting instead. Check the migration guide for the full diff.

Step 10: Migrate tests to Vitest

Vitest 4.1.6 reads your vite.config.ts automatically and drops the need for separate Jest transform configuration.

npm install -D vitest @vitest/coverage-v8 jsdom

Create vitest.config.ts (or add to vite.config.ts):

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/setupTests.ts',
  },
});

Key API differences from Jest:

FeatureJestVitest
Mock factory defaultAutoMust export { default: ... } explicitly
Import actual modulejest.requireActual()await vi.importActual()
Global APIsOn by defaultSet globals: true in config
Timer legacy modeSupportedNot supported

If you want to keep Jest for now — valid if you have a large test suite — you can run Vite for the app and Jest for tests in parallel. Use ts-jest with preset: 'ts-jest'. It’s a slower path but requires fewer changes at once.

For a full comparison of the two test runners — including watch mode speed, coverage, and migration gotchas — see our Vitest vs Jest breakdown.

Common gotchas

1. Path aliases need dual config. vite.config.ts aliases control the bundler; tsconfig.json paths control TypeScript. Both must match. If you add one and not the other, your app builds but your IDE shows red underlines — or the inverse.

2. __dirname is undefined in ESM config. If you convert vite.config.js to ESM format and get __dirname is not defined, use:

import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

3. process.env.NODE_ENV in third-party libs. Libraries like react and styled-components read process.env.NODE_ENV at runtime. Vite doesn’t define it by default. Add the define block from Step 4 or you’ll get runtime errors in production builds.

4. JSX in .js or .ts files fails. Vite only transforms JSX in .jsx and .tsx by default. If you have JSX in .js files, either rename them or configure the plugin:

react({ include: '**/*.{js,ts,jsx,tsx}' })

5. CSS import order differs. Vite doesn’t guarantee the same cross-module CSS ordering as Webpack. If you’re depending on stylesheet cascade from import order across separate modules, you’ll get visual regressions. Make specificity explicit instead.

6. @vitejs/plugin-legacy fails silently without Terser. The plugin requires Terser as a peer dependency but doesn’t explain itself when Terser isn’t installed — you get a generic build error. Always run:

npm install -D @vitejs/plugin-legacy terser

Performance comparison

From a 200-component React app benchmark:

MetricWebpack 5Vite 5Improvement
Cold start34.2 s0.8 s42×
HMR (component)2.8 s50 ms56×
HMR (CSS)1.2 s20 ms60×
Production build142 s38 s3.7×
Memory (dev server)1.8 GB0.4 GB4.5× less

The dev speed gap is structural. Webpack bundles every module before the dev server serves anything. Vite serves native ES modules directly from disk and pre-bundles only npm dependencies once. For hot reload, Vite only transforms the changed file; Webpack re-traverses the dependency graph.

Production build improvement is smaller (3.7×) but still material for teams paying for CI minutes. Vite 8’s Rolldown is expected to bring further gains on large codebases as the Rust-based bundler matures.

Once your build artifacts are ready, deploying to Cloudflare Pages takes under a minute — Vite’s static output drops straight in, no server config required. Cloudflare’s first-party Vite plugin integrates the Workers runtime directly into the Vite dev server, enabling full-stack development with hot reload.

When NOT to migrate

Don’t migrate if any of these apply to your project:

Module Federation micro-frontends. @originjs/vite-plugin-federation exists but has rough edges in the dev server. Webpack Module Federation is more battle-tested for production micro-frontend setups.

Electron main process bundling. Webpack’s target: 'electron-main' has no clean Vite equivalent. The renderer process migrates fine; the main process doesn’t.

Multi-compiler setups. If you run separate Webpack compilers for client, server, and Web Worker targets, Vite can handle similar setups but requires rearchitecting your build pipeline, not just swapping config files.

Heavy custom loader ecosystem. If your build relies on Webpack loaders that have no Vite plugin equivalent — custom binary format loaders, specialized transform pipelines — budget time to write replacements or you’ll block the migration.

1,000+ module first page loads. Vite’s dev server fetches each native ES module separately over the network. On very large apps, this can mean hundreds of parallel requests on first load, which is slower than a single bundle. The symptom is a fast cold start that feels slow the first time you open the browser. Production builds aren’t affected — Rolldown bundles everything normally.

References