Skip to main content

Guide

Migrating Webpack to Vite 2026: Large App Guide

Step-by-step guide to migrating large Webpack codebases to Vite in 2026. Covers Module Federation, custom loaders, monorepos, and the most common blockers with.

·PkgPulse Team·
0

Migrating a small app from Webpack to Vite takes an afternoon. Migrating a large codebase — 200K+ LOC, dozens of custom Webpack plugins, Module Federation micro-frontends, a legacy CJS codebase — takes weeks or months of careful planning. The performance gains are real (50-100x faster dev server startup on large projects), but the path isn't "swap bundler, done."

This guide is for the teams that can't just follow the 5-step migration tutorial. It covers incremental migration strategies, the blockers you'll actually hit, and how to handle the edge cases that sink large migrations.

TL;DR

The key insight for large codebase migrations: don't do it all at once. Use Vite's legacy and commonjs plugins to handle the 80% that "just works," create a compatibility shim layer for custom Webpack behaviors, and migrate Module Federation last (it's the hardest part). Expect 2-6 weeks for a 100K+ LOC production app with a dedicated team. The dev server speedup (from 60+ seconds to under 2 seconds) makes the investment worth it for most teams.

Key Takeaways

  • Dev server startup: Webpack 60-120s → Vite <2s on 100K+ LOC projects — the primary motivator
  • Vite 8 + Rolldown (March 12, 2026): Rust-based unified bundler replaces both Rollup (prod) and esbuild (dev) — 10-30x faster production builds, true dev/prod parity
  • Biggest blockers: require() in app code, Module Federation, custom Webpack loaders with no Vite equivalent, process.env patterns, circular dependencies
  • Incremental strategy: run Webpack (production) + Vite (development) simultaneously during migration
  • Module Federation: use @module-federation/vite plugin — functionally equivalent but requires config migration
  • CommonJS: vite-plugin-commonjs handles most cases; true CJS app code needs rewriting

At a Glance: What Changes

Webpack PatternVite EquivalentNotes
process.env.REACT_APP_Ximport.meta.env.VITE_XPrefix change required
webpack.DefinePlugindefine in vite.config.tsSame purpose, different API
require('./file') (dynamic)await import('./file')CJS → ESM
require.context()import.meta.glob()Glob imports
webpack.ProvidePluginvite-plugin-node-polyfillsNode.js polyfills
ModuleFederationPlugin@module-federation/vitePlugin available
url-loader / file-loaderBuilt-in (Vite handles assets natively)No plugin needed
babel-loader@vitejs/plugin-react or @vitejs/plugin-vueFramework plugin
Custom loadersCustom Vite plugins (transform hook)Different API
splitChunksbuild.rollupOptions.output.manualChunksDifferent syntax

Phase 1: Audit Before You Migrate

Before touching config files, run a full audit to understand what you're dealing with.

Audit Checklist

# 1. Find all require() calls in application code (not just node_modules)
grep -r "require(" src/ --include="*.js" --include="*.ts" --include="*.jsx" --include="*.tsx" | wc -l

# 2. Find require.context() usage (Webpack-specific)
grep -r "require\.context" src/ --include="*.js" --include="*.ts"

# 3. Find process.env usage (needs prefix migration)
grep -r "process\.env\." src/ --include="*.js" --include="*.ts" | grep -v "NODE_ENV" | wc -l

# 4. List all Webpack plugins (each needs a Vite equivalent or workaround)
cat webpack.config.js | grep -A2 "plugins:"

# 5. Find circular dependencies (will fail loudly in Vite)
npx madge --circular src/ --extensions ts,tsx,js,jsx

# 6. Find dynamic requires (hardest to migrate)
grep -r "require(variable\|require(path\.\|require(\`" src/ --include="*.js" --include="*.ts"

Common Audit Findings in Large Codebases

IssueFrequencyMigration Effort
process.env referencesVery highLow (search/replace)
Static require() callsHighMedium (script + manual review)
Dynamic require() callsMediumHigh (case-by-case)
require.context()MediumLow (glob equivalent)
Custom loadersVariesHigh (rewrite as Vite plugins)
Module FederationMediumHigh (dedicated phase)
Circular dependenciesOften discoveredHigh (fix the code)
Node.js built-in polyfillsMediumLow (polyfill plugin)

Phase 2: The Incremental Migration Strategy

Don't replace Webpack overnight. For large teams, the safest approach is running both bundlers simultaneously during migration.

Strategy: Vite for Dev, Webpack for Prod

Week 1-2: Get Vite running in development mode
Week 3-4: Resolve blockers, migrate most files
Week 5-6: Switch production builds to Vite
Week 7+: Remove Webpack
// package.json — dual bundler scripts during migration
{
  "scripts": {
    "dev": "vite",                    // ← New: Vite dev server
    "dev:webpack": "webpack serve",   // ← Keep: fallback during migration
    "build": "webpack --config webpack.config.js",  // ← Still using Webpack for prod
    "build:vite": "vite build",       // ← Test Vite prod builds incrementally
    "build:types": "tsc --noEmit"
  }
}

This means developers get the fast Vite dev server immediately, while production continues shipping from Webpack until you're confident the Vite build is correct.


Phase 3: Install and Configure Vite

npm install -D vite @vitejs/plugin-react
# Or for Vue: @vitejs/plugin-vue
# For legacy browser support: @vitejs/plugin-legacy
// vite.config.ts — starting template for large React apps
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')

  return {
    plugins: [react()],

    // Path aliases (mirrors webpack resolve.alias)
    resolve: {
      alias: {
        '@': resolve(__dirname, './src'),
        '@components': resolve(__dirname, './src/components'),
        '@utils': resolve(__dirname, './src/utils'),
        // Add your existing webpack aliases here
      },
    },

    // Environment variables (replaces webpack.DefinePlugin)
    define: {
      // Migrate process.env.NODE_ENV usage
      'process.env.NODE_ENV': JSON.stringify(mode),
      // Legacy support while migrating (removes gradually)
      __WEBPACK_DEV_SERVER__: JSON.stringify(false),
    },

    // Dev server config
    server: {
      port: 3000,
      proxy: {
        // Proxy API calls during development
        '/api': {
          target: 'http://localhost:8080',
          changeOrigin: true,
        },
      },
    },

    // Build config
    build: {
      rollupOptions: {
        output: {
          // Manual chunks to match webpack's splitChunks behavior
          manualChunks: {
            vendor: ['react', 'react-dom'],
            // Add other large dependencies
          },
        },
      },
    },
  }
})

Phase 4: The Hard Blockers

Blocker 1: CommonJS require() in App Code

Vite's dev server serves ES modules natively. require() in your app code (not node_modules) will fail.

Approach A: Plugin-based compatibility (fast, temporary)

npm install -D vite-plugin-commonjs
// vite.config.ts
import commonjs from 'vite-plugin-commonjs'

export default defineConfig({
  plugins: [
    commonjs(), // Converts require() calls to import() at dev time
    react(),
  ],
})

This buys time but doesn't fix the underlying issue. Create a script to systematically migrate:

# Use codemod to migrate static requires
npx codemod --transform @codemod/transform-cjs-to-esm src/
# Review and manually fix edge cases

Approach B: Codemod the requires (permanent)

// Before (CommonJS)
const { utils } = require('./utils')
const config = require('../config.json')
const LazyComponent = require('./LazyComponent').default

// After (ESM)
import { utils } from './utils'
import config from '../config.json' with { type: 'json' }
import LazyComponent from './LazyComponent'

Blocker 2: require.context() for Glob Imports

Webpack's require.context() is commonly used to dynamically import multiple modules:

// Before (Webpack)
const context = require.context('./components', true, /\.vue$/)
const modules = context.keys().map(key => context(key))

// After (Vite)
const modules = import.meta.glob('./components/**/*.vue', { eager: true })
// OR lazy (returns functions returning Promises)
const lazyModules = import.meta.glob('./components/**/*.vue')
// Common pattern: auto-register Vue/React components
// Before (Webpack)
const requireComponent = require.context('./components', false, /Base[A-Z]\w+\.(vue|js)$/)
requireComponent.keys().forEach(fileName => {
  const component = requireComponent(fileName)
  const componentName = path.basename(fileName, path.extname(fileName))
  app.component(componentName, component.default || component)
})

// After (Vite)
const components = import.meta.glob('./components/Base*.vue', { eager: true })
Object.entries(components).forEach(([path, module]) => {
  const componentName = path.split('/').pop().replace('.vue', '')
  app.component(componentName, module.default)
})

Blocker 3: process.env Variable Migration

The rename from REACT_APP_ to VITE_ is the most common migration task:

# Find all env var references in your codebase
grep -r "process\.env\.REACT_APP_" src/ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" -l

# Batch rename with sed (macOS)
find src/ -type f -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" | \
  xargs sed -i '' 's/process\.env\.REACT_APP_/import.meta.env.VITE_/g'

# Don't forget .env files
find . -name ".env*" -not -path "*/node_modules/*" | \
  xargs sed -i '' 's/^REACT_APP_/VITE_/g'

Blocker 4: Module Federation

Module Federation is the hardest part of large codebase migrations. The @module-federation/vite plugin provides a compatible API:

npm install -D @module-federation/vite
// Shell app vite.config.ts
import { federation } from '@module-federation/vite'

export default defineConfig({
  plugins: [
    federation({
      name: 'shell',
      remotes: {
        // Same as webpack's ModuleFederationPlugin remotes
        'mfe-products': 'http://localhost:3001/remoteEntry.js',
        'mfe-cart': 'http://localhost:3002/remoteEntry.js',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
  build: {
    target: 'esnext', // Required for Module Federation
  },
})
// Remote app vite.config.ts
import { federation } from '@module-federation/vite'

export default defineConfig({
  plugins: [
    federation({
      name: 'mfe-products',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductList': './src/components/ProductList',
        './ProductDetail': './src/components/ProductDetail',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
})

Known limitation: @module-federation/vite works well for production builds but has some rough edges in Vite's dev server. Test thoroughly before cutting over.

Blocker 5: SVG and Asset Imports

If you're using @svgr/webpack to import SVGs as React components, you'll hit this immediately:

npm install -D vite-plugin-svgr
// vite.config.ts
import svgr from 'vite-plugin-svgr'

export default defineConfig({
  plugins: [svgr(), react()],
})
// After: use ?react query suffix
import Logo from './logo.svg?react'

// Or configure default to always treat SVGs as components:
// svgr({ include: '**/*.svg' })  ← then import without suffix

For file-loader / url-loader patterns (inline base64, asset URLs): Vite handles these natively. Import any asset and you get its URL:

import logoUrl from './logo.png'  // '/assets/logo-a1b2c3.png'

Blocker 6: Custom Webpack Loaders

Each custom Webpack loader needs to be rewritten as a Vite plugin:

// Webpack loader: my-svg-loader.js
module.exports = function(source) {
  return source.replace(/fill="#[0-9a-fA-F]{6}"/g, 'fill="currentColor"')
}

// Vite plugin equivalent:
// vite.config.ts
function svgCurrentColorPlugin() {
  return {
    name: 'svg-current-color',
    transform(code, id) {
      if (!id.endsWith('.svg')) return
      return code.replace(/fill="#[0-9a-fA-F]{6}"/g, 'fill="currentColor"')
    },
  }
}

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

TypeScript Path Aliases and tsconfig Migration

TypeScript path aliases configured in tsconfig.json under compilerOptions.paths require explicit reproduction in vite.config.ts as resolve.alias entries — Vite does not automatically read tsconfig.json path aliases during the dev server or build. This is one of the most common sources of "works in tsc but fails in Vite" errors during migration. The vite-tsconfig-paths plugin automates this: it reads your tsconfig.json (and the referenced tsconfig files in a monorepo) and applies all path aliases to Vite's resolver automatically. For large codebases with dozens of path aliases across multiple tsconfig files, this plugin is effectively mandatory during migration to avoid manual duplication and the drift that comes when alias additions are only added to one config.

Project references in TypeScript monorepos (where tsconfig.json files use references to point to sibling packages) interact with Vite's module resolution in ways that require careful configuration. Vite's optimizeDeps.include array should list internal workspace packages when their source TypeScript is resolved directly (rather than through their built dist/ output) — this tells Vite's esbuild pre-bundling step to process those packages as part of the dependency optimization, avoiding slow cold-start issues on first load. Teams using Nx or Turborepo that have configured TypeScript project references as the primary module resolution mechanism should test Vite's dev server thoroughly with the vite-tsconfig-paths plugin before removing any resolve.alias configuration.

Testing Pipelines During Migration

A frequently overlooked aspect of large Webpack-to-Vite migrations is the impact on the testing pipeline. Jest (still common in large codebases) uses Babel or ts-jest for transformation and has no dependency on Webpack or Vite — Jest tests should continue working unchanged during the migration. However, if your test suite uses Webpack-specific features like require.context for automatic test discovery, or if it imports from process.env.WEBPACK_DEV_SERVER for environment detection, those tests will need updates alongside the main codebase.

Vitest is the natural companion to Vite for testing — it uses the same vite.config.ts configuration and shares the module resolution setup, meaning path aliases, environment variable handling, and plugin transforms work identically in tests and in the app. Migrating from Jest to Vitest is optional during the Webpack-to-Vite migration and is best treated as a separate effort, but teams that migrate both simultaneously report that test reliability improves because test and production code share the same transformation pipeline. For teams keeping Jest, the vitest migration can be deferred while still getting the full Vite dev server speedup — Jest's configuration is independent of the build tool.

Vite 8 + Rolldown: The Large Codebase Game-Changer

Vite 8 (released March 12, 2026) ships Rolldown as a unified bundler, replacing both Rollup (production) and esbuild (development transforms). Rolldown is built in Rust by the VoidZero team — the impact on large codebases is significant:

Real-world production results from Vite 8's March 2026 GA release:

Real-world build improvements (Vite 8 + Rolldown):
  Linear's production build:  46s → 6s  (7.7x faster)
  Generic large project (500+ modules): 10-30x faster builds
  HMR patch time: ~60s → <50ms
  Memory usage: up to 80% reduction vs Webpack

Dev/prod parity: same Rolldown engine in both modes
  → Eliminates entire class of "works in dev, breaks in prod" issues

Vite 8 requires Node.js 20.19+ or 22.12+. Audit your .nvmrc, CI base images, and engines field in package.json before upgrading.

Vite 8 Breaking Changes for Large Apps

If you're migrating directly to Vite 8, or upgrading from Vite 7:

// Vite 7: rollupOptions
build: {
  rollupOptions: {
    output: { manualChunks: { vendor: ['react', 'react-dom'] } }
  }
}

// Vite 8: rolldownOptions (compat shim exists but explicit migration preferred)
build: {
  rolldownOptions: {
    output: {
      advancedChunks: {
        groups: [
          { name: 'react-vendor', test: /react|react-dom/ },
          { name: 'vendor', test: /node_modules/ },
        ]
      }
    }
  }
}

Other Vite 8 changes that hit large apps:

  • Stricter CJS interop — default imports from CJS packages may break; use legacy.inconsistentCjsInterop: true as a temporary shim
  • Custom plugin hooksmoduleType: 'js' required in some transform hooks
  • HMR API changeimport.meta.hot.accept no longer accepts a URL string

Recommended two-step upgrade: Install rolldown-vite as a drop-in first to validate Rolldown-specific issues, then upgrade to Vite 8 for the remaining Vite-level breaking changes.

If you're evaluating whether to migrate in 2026, Vite 8 + Rolldown closes the last remaining criticism of Vite for large apps. The case for migration is now stronger than ever.


Monorepo Considerations

For Nx, Turborepo, or custom monorepos:

// packages/web-app/vite.config.ts — shared config pattern
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      // Reference sibling packages directly in monorepo
      '@company/ui': resolve(__dirname, '../../packages/ui/src'),
      '@company/utils': resolve(__dirname, '../../packages/utils/src'),
    },
  },
  // Optimize pre-bundling for monorepo packages
  optimizeDeps: {
    include: ['@company/ui', '@company/utils'],
  },
})
// turbo.json — include vite cache
{
  "pipeline": {
    "build": {
      "outputs": [".vite/**", "dist/**"],
      "inputs": ["src/**", "vite.config.ts", "index.html"]
    }
  }
}

Migration Metrics: What to Expect

Real-world and community migration reports for large codebases:

Project / MetricBefore (Webpack)After (Vite 8)Source
Linear prod build46s6s (7.7x)Vite 8 release notes
Swimm web app cold start2+ minNear-instantSwimm blog
~500 components / 150K LOC HMR~60s<50msCommunity benchmark
Generic dev cold start60-120s0.5-2sMultiple reports
Generic prod build (Vite 8)45-90s3-8sMultiple reports
CI build time2-4 min20-40sMultiple reports
Memory usageBaseline-50-80%Rolldown benchmarks
Bundle sizeBaseline-5-15% (tree-shaking)Multiple reports

Bundle Analysis Migration

Replace webpack-bundle-analyzer with rollup-plugin-visualizer:

npm install -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    visualizer({ filename: 'dist/stats.html', open: true }),
  ],
})

Run vite build and dist/stats.html opens with an interactive treemap — same workflow as webpack-bundle-analyzer.


Compare Vite vs Webpack download trends on PkgPulse.

Related: Vite vs Rspack vs Webpack 2026 · How to Migrate Webpack to Vite · Vite vs Webpack: Is Migration Worth It?

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.