Skip to main content

Webpack to Vite Migration: Large Codebases 2026

·PkgPulse Team

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()],
})

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?

Comments

Stay Updated

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