Web Development · Tooling
Webpack to Vite Migration Guide, Plus Jest to Vitest (2026)
Move a Webpack app to Vite 8 step by step: index.html, env vars, require.context, loaders and config. Then move Jest tests to Vitest 5 one file at a time.
Anurag Verma
12 min read · Updated Oct 19, 2026
Sponsored
This is a working guide for moving a Webpack app (including an ejected or plain Create React App project) to Vite, then moving its Jest tests to Vitest. Everything below is checked against Vite 8.1 and Vitest 5, which is where both projects are as of October 2026.
A Webpack config, a Babel config and a Jest config can usually collapse into one vite.config.ts.
How to migrate from Webpack to Vite
Install vite and your framework plugin, move index.html to the project root and point a <script type="module"> at your entry file, write a short vite.config.ts, rename REACT_APP_* variables to VITE_*, and replace the npm scripts. Then fix the Webpack-only features one at a time: require.context, custom loaders and process.env.
The rest of this guide goes through those steps in order, then covers the Vite 8 config names that differ from older tutorials, and finally the Jest to Vitest move.
Before you start: versions and scope
Check three things first.
- Node.js. Vite 8 needs Node.js 20.19+ or 22.12+, according to the Vite 8 announcement. Vitest 5 needs Node.js 22.12 or newer and Vite 6.4 or newer, per the Vitest 5 announcement. If you want both, plan on Node 22.12+.
- The framework. If the app is Next.js, stop here. Next.js does not use Vite, and since Next.js 16 Turbopack is its default bundler for dev and production builds.
- The Webpack surface area. Search the codebase for
require.context,process.env,module.hot,__webpack_public_path__, custom loaders inwebpack.config.js, and any@company/webpack-configstyle package. That list is your real migration backlog.
Step 1: install Vite and the framework plugin
For a React app:
npm install --save-dev vite @vitejs/plugin-react
npm uninstall react-scripts webpack webpack-cli webpack-dev-server babel-loader
@vitejs/plugin-react v6 uses Oxc for the React Refresh transform and no longer depends on Babel. If you use the React Compiler, the plugin exposes a reactCompilerPreset helper that runs it through @rolldown/plugin-babel, which you opt into explicitly. Version 5 of the plugin still works with Vite 8 if you would rather upgrade it separately.
Step 2: move index.html to the project root
Webpack (and Create React App) inject the bundle into a template in public/. Vite treats index.html as the entry point and serves it from the project root, so the file moves up a level and references your entry script directly:
<!-- index.html, at the project root -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<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 every %PUBLIC_URL% placeholder. Files in public/ are served from / as they were.
Step 3: write vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
resolve: {
tsconfigPaths: true, // use "paths" from tsconfig.json
},
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:8080',
},
},
build: {
outDir: 'build', // keep CRA's output folder if CI or hosting expects it
sourcemap: true,
},
})
Two notes. resolve.tsconfigPaths is new in Vite 8 and replaces the vite-tsconfig-paths plugin that older guides recommend. It is off by default because it has a small performance cost, per the Vite 8 post. And if you had Webpack resolve.alias entries that are not in tsconfig.json, move them to resolve.alias in this file.
Step 4: environment variables
This is the change that breaks the most code at runtime, because nothing fails until a value comes back undefined.
The env docs are short. Only variables prefixed with VITE_ are exposed to client code, and they are read from import.meta.env, not process.env:
// before (Webpack / CRA)
const apiUrl = process.env.REACT_APP_API_URL
// after (Vite)
const apiUrl = import.meta.env.VITE_API_URL
Rename the variables in every .env* file and in your CI secrets. If renaming is politically hard (shared secrets across many services, say), the envPrefix option lets you change the prefix instead. Vite also gives you import.meta.env.MODE, DEV, PROD, BASE_URL and SSR for free.
For TypeScript, declare your variables in src/vite-env.d.ts:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
Step 5: replace require.context with import.meta.glob
require.context is Webpack-only. The Vite replacement is import.meta.glob, documented in the features guide. By default it returns lazy loaders; pass eager: true to import everything up front:
// before (Webpack)
const ctx = require.context('./icons', false, /\.svg$/)
const icons = ctx.keys().map((key) => ctx(key))
// after (Vite), lazy: each value is a function returning a Promise
const lazyIcons = import.meta.glob('./icons/*.svg')
// after (Vite), eager, default export only
const icons = import.meta.glob('./icons/*.svg', {
eager: true,
import: 'default',
})
// raw file contents as strings
const docs = import.meta.glob('./docs/*.md', {
query: '?raw',
import: 'default',
})
Every argument to import.meta.glob has to be a literal, so you cannot build the pattern from a variable the way some require.context code does. Vite 8.1 also added a caseSensitive option for glob matching.
Step 6: loaders become plugins (or nothing)
A lot of Webpack loaders have no replacement because Vite handles the file type natively. TypeScript, JSX, CSS, CSS modules (any file ending in .module.css), JSON and static asset imports work without configuration. PostCSS config is picked up automatically.
For the rest, look for a Vite or Rollup-compatible plugin. Common ones from CRA-era projects are SVG-as-component imports (a community plugin such as vite-plugin-svgr) and anything that relied on Babel macros. Rolldown supports the Rollup and Vite plugin API, so many Rollup plugins work too. If a loader did something truly custom, a small Vite plugin with a transform hook is usually less code than people expect.
Step 7: scripts and type checking
{
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest"
}
}
The tsc --noEmit matters. Vite transpiles .ts files but does not type check them, and the Vite docs say so in bold. Without that step, type errors that Webpack’s ForkTsCheckerWebpackPlugin used to catch will reach production.
Vite 8 names that differ from older tutorials
Most Vite guides on the web were written for Vite 5 to 7, when Vite used esbuild in dev and Rollup for production. Vite 8 uses Rolldown for both and Oxc for transforms. A compatibility layer converts the old options automatically, but you should write the new ones. From the Vite migration guide:
| Older config | Vite 8 |
|---|---|
build.rollupOptions | build.rolldownOptions (old name deprecated) |
worker.rollupOptions | worker.rolldownOptions |
esbuild.jsx, esbuild.define | oxc.jsx, oxc.define |
optimizeDeps.esbuildOptions | optimizeDeps.rolldownOptions |
build.minify default: esbuild | Oxc Minifier ('esbuild' is deprecated and needs esbuild installed) |
build.cssMinify default: esbuild | Lightning CSS |
output.manualChunks as an object | Removed; the function form is deprecated in favor of codeSplitting |
build.commonjsOptions | No longer has any effect |
Two behavior changes are worth testing for explicitly. Default imports from CommonJS modules are now handled consistently, which can change what import x from 'some-cjs-package' gives you; legacy.inconsistentCjsInterop: true restores the old behavior while you fix call sites. And the default browser targets moved up (Safari 16.4, Chrome and Edge 111, Firefox 114), so check this if you support older browsers. For the build-speed side of the Rolldown switch, including the production numbers companies reported to the Vite team, see our Vite 8 and Rolldown migration post.
Vite 8 is also about 15 MB larger to install than Vite 7, mostly from lightningcss and the Rolldown binary. Not a reason to wait, but it shows up in CI cache sizes.
Moving Jest tests to Vitest
Once the app builds with Vite, keeping Jest means maintaining a second transform pipeline (Babel or ts-jest) just for tests, with its own module resolution and alias config. Vitest reads the Vite config, so tests resolve imports exactly like the app does.
Configure Vitest in the same file
Add a test block to vite.config.ts, with the triple-slash reference so TypeScript knows about it:
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
resolve: { tsconfigPaths: true },
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setupTests.ts'],
},
})
The environment docs say the default is Node, so component tests need jsdom or happy-dom set explicitly (and installed as a dev dependency). globals: true gives you Jest-style global describe, it and expect; the Jest migration page points out that without globals, Testing Library will not clean up the DOM between tests automatically. Add "types": ["vitest/globals"] to tsconfig.json so the globals type check.
If you need a separate vitest.config.ts, use mergeConfig with your Vite config. A vitest.config.* file takes priority, and Vitest ignores vite.config.* entirely when one exists, which surprises people.
What changes in the test files
Most tests only need jest. replaced with vi.. The Vitest Jest migration guide lists the cases that need real edits:
jest.mockfactories. Jest treats the factory’s return value as the default export. Vitest needs an object with each export, so() => 'hello'becomes() => ({ default: 'hello' }).jest.requireActualbecomesawait vi.importActual(...). It is async.__mocks__folders only apply whenvi.mock()is called for that module. To mock one everywhere, call it in a setup file.donecallbacks are not supported. Useasync/awaitor return a Promise.- Jest’s legacy fake timers are not supported.
jest.setTimeout(...)becomesvi.setConfig({ testTimeout: ... }).- Test names join with
>, so-tfilters change to something like-t 'math > adds'. JEST_WORKER_IDisVITEST_POOL_ID.
Vitest 5 added a few changes that hit Jest migrants in particular, according to the Vitest migration guide:
clearMocksnow defaults totrue, so mock call history resets before every test. Tests that counted calls across tests will fail until you fix them or setclearMocks: false.vi.mock,vi.unmockandvi.hoistedmust be at the top level of the file. Nested calls throw.vi.doMockis the non-hoisted escape hatch.- Unawaited
resolves,rejectsandtoMatchFileSnapshotassertions now fail the test instead of printing a warning. That catches real bugs, and it will also turn a few green suites red on day one. - Custom matcher types declared on the global
jest.Matchersinterface are no longer read. Augmentvitest.Matcherstoo.
Jest and Vitest side by side
| Jest 30 | Vitest 5 | |
|---|---|---|
| Config | Separate jest.config.js and transform pipeline | test block in vite.config.ts |
| TypeScript | Through a transformer such as ts-jest or @swc/jest | Uses Vite’s transform |
| ES modules | Experimental, needs --experimental-vm-modules | Native |
| Globals | On by default | Opt in with globals: true |
| Default environment | Node | Node |
| Mock clearing between tests | Off unless configured | On by default since v5 |
| Browser testing | jsdom simulation | Real browsers through a provider package |
The ESM row is from the Jest 30.5 ESM docs, which still open with a warning that the implementation “may have bugs and lack features”. That gap is one of the more practical reasons to switch on a Vite codebase.
Optional: real-browser tests
Vitest’s browser mode runs tests in an actual browser. It needs a provider package, and the browser mode docs recommend Playwright or WebdriverIO for CI:
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
browser: {
provider: playwright(),
enabled: true,
instances: [{ browser: 'chromium' }],
},
},
})
Vitest 5 also added a trace view for browser mode (browser.traceView), which helps when a test fails only in CI. For more on the Jest to Vitest decision itself, see Vitest vs Jest for modern testing.
Where migrations actually stall
The technical steps above are rarely the hard part. The projects that stall usually have one of these:
- A shared Webpack config published as an internal npm package, used by a dozen services. Nobody owns migrating all of them, so nobody migrates any.
- Module federation or another Webpack-specific runtime feature at the center of the architecture. That needs a design decision, not a find-and-replace.
- A test suite full of
jest.mockfactories returning bare values, which all need the{ default: ... }rewrite.
My advice for the first two: migrate one small service or internal tool end to end, keep notes on every surprise, and use that as the template. A working example in your own repo is worth more than any blog post, this one included.
Why teams are leaving Webpack (briefly)
Create React App, the most common way React apps ended up on Webpack, was deprecated for new apps on February 14, 2025. The React team recommended a framework for most apps, or a build tool such as Vite, Parcel or Rsbuild for custom setups. The Why Vite page explains the dev-side difference: Vite serves source “on-demand over native ESM” and transforms each file as the browser requests it, so startup does not wait for a full bundle. Frameworks including Astro build on it. Next.js went its own way with Turbopack.
Should you migrate at all?
Base it on pain, not fashion. If your Webpack dev server starts quickly, HMR is reliable and nobody complains, the migration buys little and you can leave it alone. If startup is slow, HMR breaks often, or you are stuck on an unmaintained react-scripts, the move pays back in daily developer time, and the Vitest step removes a whole second config to maintain. While you are cleaning up tooling, the ESLint 9 flat config migration is a reasonable next job.
CODERCOPS builds production applications on Vite, Vitest and TypeScript. If you are moving off a legacy toolchain, our team can help.
Frequently asked questions
- How long does a Webpack to Vite migration take?
- It depends almost entirely on how much Webpack-specific behavior the project relies on. A Create React App style project with standard TypeScript, CSS and asset handling has very little to change. Projects that lean on require.context, custom loaders, a shared internal Webpack config package or module federation take longer, because each of those needs a Vite equivalent or a rewrite.
- Do I need Vite 8 specifically, or can I migrate to an older Vite?
- Start on the current major. Vite 8 shipped on March 12, 2026 with Rolldown as its single bundler, and 8.1 followed on June 23, 2026. It needs Node.js 20.19+ or 22.12+. If you plan to use Vitest 5, note that it needs Node.js 22.12 or newer.
- Should I switch from Jest to Vitest at the same time?
- If you are moving the app to Vite, yes, it is worth planning, because Vitest reuses the Vite config and you stop maintaining a second transform pipeline for tests. If your Jest suite works and you are short on time, migrate the build first and the tests later. Vitest runs Jest-style tests with mostly import changes, so moving one file at a time is realistic.
- Does Jest support ES modules now?
- Only experimentally. The Jest 30.5 docs still describe ESM support as experimental and require running Node with --experimental-vm-modules. Vitest handles ESM natively, which is one of the more practical reasons to switch on a Vite project.
- Does this apply to Next.js apps?
- No. Next.js has its own build pipeline, and since Next.js 16 Turbopack is the default bundler for both next dev and next build, with --webpack as the opt-out. A Next.js app on Webpack should move to Turbopack, not Vite.
Sources
- Vite blog: Announcing Vite 8
- Vite blog: Announcing Vite 8.1
- Vite docs: Migration from v7
- Vite docs: Features (import.meta.glob, TypeScript, CSS modules)
- Vite docs: Env variables and modes
- Vitest blog: Announcing Vitest 5.0
- Vitest docs: Migration guide
- Vitest docs: Migrating from Jest
- Vitest docs: Configuring Vitest
- Vitest docs: environment option
- Vitest docs: Browser mode
- Vitest docs: globals option
- Vite docs: Why Vite
- Jest docs: ECMAScript Modules (v30.5)
- Jest docs: Configuring Jest (testEnvironment, clearMocks defaults)
- React blog: Sunsetting Create React App
- Next.js blog: Next.js 16
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored