DEV Community

Cover image for Migrating a legacy React app from webpack to Vite
Amanda Gama
Amanda Gama

Posted on

Migrating a legacy React app from webpack to Vite

The codebase was old. React 16 with class components everywhere. React Router v3 with routes-as-children. A webpack 4 config that had been edited by a dozen people over five years and contained loaders nobody could explain. The dev server took 45 seconds to come up. Hot reload was 8 seconds on a good day, 20 on a bad one. The production build was 6 minutes. CI deploys took 14 minutes end to end.

I'd been dismissing Vite for two years. "Webpack works." It did work. It was also bleeding an hour a day off the team in dev server restarts, slow HMR cycles, and CI queues piling up behind each other.

Here's the catch: you don't migrate a legacy webpack project to Vite on its own. You end up migrating webpack, React, React Router, your test runner, and half your dependencies, because the things that make Vite fast are the same things that won't tolerate code from 2018. This post is about what that actually looks like.

What Vite actually does differently

The mental model shift is small, but it changes everything that follows.

Webpack bundles your app before serving it. Every dev server start walks the dependency graph, compiles every module it touches, links them, and produces a bundle. The bigger your app, the longer the wait, and it scales linearly. HMR is fast in theory, but in practice the graph invalidation is heavy enough that a 200ms code change turns into a 4-second rebuild.

Vite serves source files directly to the browser as native ES modules. Your App.tsx is requested by the browser, transformed on demand by esbuild, and sent back. Nothing gets bundled in dev. The cost of starting the server is the cost of starting a Node process plus reading config, not the cost of compiling your app.

For production, Vite uses Rollup. Bundling still happens, just not in the dev loop where it hurts.

webpack dev:    parse all -> compile all -> bundle -> serve
vite dev:       start server -> compile what the browser asks for
Enter fullscreen mode Exit fullscreen mode

That's the whole insight. Everything else is a consequence.

The numbers

The dev server times told the story I expected:

Metric webpack Vite
Cold start 42s 1.1s
HMR (small change) 4-8s 50-200ms
Full reload 6-9s 400ms

The build times were less dramatic but still real:

Metric webpack Vite
Production build 5m 40s 1m 50s
Type check (separate) 45s 45s

The interesting part is type checking. Vite doesn't type-check your code. It strips types with esbuild and trusts you. If you want type safety in CI, you run tsc --noEmit separately. That stays exactly as slow as it was. So don't expect Vite to magically speed up TypeScript. It makes the transformation faster, not the checking.

What CI actually looked like before and after

The CI deploy was the win I didn't expect. Total deploy time went from 14 minutes to 5. The build itself only accounted for about 4 minutes of that improvement. The other 5 minutes came from second-order effects:

  • Smaller Docker layer. Rollup tree-shakes more aggressively than my webpack config ever did. Bundle size dropped 18%, and the image pushed faster.
  • No more cache thrashing. Webpack's persistent cache was 800MB, and CI was spending 90 seconds restoring it every run. Vite doesn't need one.
  • Parallel-friendly. Type check, lint, and build can run as three independent jobs. With webpack, the build dominated everything else, so splitting was pointless.

CI cost dropped about in line with wall-clock time. Not surprising, but watching the bill go down felt good.

The migration itself

The webpack-to-Vite swap on its own is a two-day job. On a legacy codebase, you don't get that luxury. The whole thing took closer to three weeks once you count the React upgrade, the router rewrite, and the test runner change that came along with it.

Start with the easy part: the build tool config.

Install:

npm install --save-dev vite @vitejs/plugin-react
Enter fullscreen mode Exit fullscreen mode

A minimal vite.config.ts:

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

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  server: {
    port: 3000,
  },
  build: {
    outDir: 'dist',
    sourcemap: true,
  },
})
Enter fullscreen mode Exit fullscreen mode

The index.html moves to the project root and becomes the entry point, not a template anymore. That's a real shift. Webpack treated HTML as an output. Vite treats it as the input. Your script tag points at your TS entry directly:

<script type="module" src="/src/main.tsx"></script>
Enter fullscreen mode Exit fullscreen mode

Once that clicks, half the config changes make sense.

What came along for the ride

This is the part the tutorials skip. Vite assumes a modern stack. A legacy app doesn't have one, and that gap is where most of the real work hides.

React itself. React 16 will technically run under Vite, but @vitejs/plugin-react expects the new JSX transform that landed in React 17. You can pin an older plugin and limp along, or take the upgrade. I took the upgrade. Going to React 18 was straightforward once the build was clean, and Strict Mode caught a handful of effect bugs that had been hiding for years. Class components keep working. They just look increasingly out of place next to everything else.

React Router. This was the biggest single chunk of work. v3's API (routes-as-children, browserHistory as a singleton, onEnter hooks for auth) is just not compatible with v6's declarative <Routes> and useNavigate. There's no codemod that does it cleanly. I migrated in stages:

// v3
<Router history={browserHistory}>
  <Route path="/" component={App}>
    <Route path="users/:id" component={UserPage} onEnter={requireAuth} />
  </Route>
</Router>

// v6
<BrowserRouter>
  <Routes>
    <Route path="/" element={<App />}>
      <Route path="users/:id" element={<RequireAuth><UserPage /></RequireAuth>} />
    </Route>
  </Routes>
</BrowserRouter>
Enter fullscreen mode Exit fullscreen mode

The mechanical translation is easy. The painful part is everywhere the old code reached into the router imperatively: browserHistory.push() from a saga, withRouter HOCs wrapping class components, route lifecycle hooks doing data fetching. Each one needs a real rewrite.

Budget honestly for this. The router upgrade took longer than the actual Vite swap.

Babel plugins you forgot you had. The old webpack config was running half a dozen Babel plugins for proposal-stage syntax that's been in the language for years: optional chaining, nullish coalescing, class properties. esbuild handles all of those natively, so you can delete them. But if any plugin was doing real work (a custom transform, an i18n extractor, an instrumentation pass), that has to become a Vite plugin or move to a separate step.

The dependency graveyard. Every legacy project has one. A charting library that hasn't published since 2019. A date picker pinned to a specific patch version. A state library the company forked internally. Anything shipping pure CommonJS without an exports field is going to fight Vite's dep optimizer. Some you can pre-bundle. Some need replacing. A few you'll end up patching with patch-package because the maintainer is gone.

Vite-specific gotchas

Beyond the legacy upgrade work, these are the traps that hit any webpack-to-Vite migration:

Environment variables. process.env.FOO becomes import.meta.env.VITE_FOO, and only variables prefixed with VITE_ are exposed to the client. That's a security feature (webpack would happily leak any env var you referenced), but it broke about 40 references in my codebase. Find-and-replace is your friend.

Dynamic imports with variable paths. This works in webpack and breaks in Vite:

const mod = await import(`./locales/${locale}.json`)
Enter fullscreen mode Exit fullscreen mode

Vite needs to know the set of possible matches at build time. Use import.meta.glob:

const modules = import.meta.glob('./locales/*.json')
const mod = await modules[`./locales/${locale}.json`]()
Enter fullscreen mode Exit fullscreen mode

CommonJS dependencies. Vite is ESM-first. Most modern packages are fine. Older ones with module.exports need optimizeDeps.include to pre-bundle them. Otherwise you get cryptic "default export" errors at runtime, and they only show up in production, because dev doesn't bundle.

Jest doesn't speak Vite. If you used jest with babel-jest configured to match webpack, your test setup is now misaligned with your build. The cleanest path is Vitest, which shares Vite's transform pipeline. The migration is mostly mechanical (describe, it, expect are the same), but the mocking syntax differs from Jest in spots. Budget half a day.

Public path / base URL. If your app is served from a subpath, webpack's publicPath becomes Vite's base. Forget this and you get a working dev build and a broken production deploy. I learned that one the hard way.

What didn't get better

Bundle analysis is worse. Webpack's analyzer ecosystem is mature. Vite's is fine, but less detailed. If you spend real time tuning bundle size, you'll feel the gap.

The plugin ecosystem is smaller. Most things you need exist, but expect to occasionally write a Rollup plugin where webpack would've had three options.

SSR setup is more hands-on. Vite supports it, but you wire it together yourself. If you were running a framework on top of webpack that handled SSR for you, switch to a framework on top of Vite (Remix, Next, SvelteKit) instead of doing it raw.

Was it worth it

For a greenfield project, Vite is a no-brainer. For a legacy app, it's messier. You're not just swapping a build tool. You're paying down years of tech debt because Vite forces you to. That's a feature, not a bug. Just budget for it honestly.

The dev experience win paid for itself the first week the team was on the new stack. After years of training your muscle memory to tolerate slow HMR, watching it feel instant is a small daily joy.

The deploy time win mattered more than I expected. Halving CI meant we merged to main more often, which meant smaller PRs and faster review cycles. That compound effect over a quarter was bigger than the raw numbers suggest.

The hidden win was the dependency cleanup, and I didn't see it coming. The migration forced a real audit of every package in package.json. A third of them were dead code or had modern replacements. The codebase that came out the other side was lighter and easier to onboard onto, and that part had nothing to do with Vite directly.

If you're on legacy webpack and your dev server takes more than 20 seconds to start, migrate. Just don't pitch it to your manager as a two-day job. It's a quarter of modernization work with a fast build tool at the end of it, and the payoff keeps showing up long after you're done.

Top comments (0)