| | |

Vite 28 ⚡ Migration from Webpack — Common pitfalls & config translation

⏱️ Estimated read: 14 min | |

The migration from Webpack to Vite is not a configuration translation. It is a change in the development model. Webpack bundles the entire dependency graph before the browser loads anything. Vite serves the source files as native ES modules and lets the browser request them on demand, pre-bundling only the dependencies that need transformation . The config translation is the mechanical part—entry becomes build.rolldownOptions.input, module.rules becomes Vite plugins, devServer becomes server—but the failures that block a migration come from the assumptions that Webpack made implicitly and Vite does not.

The first assumption is the file extension. Webpack’s loaders were configured by pattern, and a .js file containing JSX was processed by the Babel loader regardless of its extension. Vite is stricter: a file with JSX syntax must have the .jsx or .tsx extension, or the transform fails . The second assumption is the entry point. Webpack’s entry could be an object whose keys mapped to arrays of modules, and the arrays were the mechanism for injecting polyfills into every bundle. Vite’s build.rolldownOptions.input expects an object whose values are file paths, not arrays, and the polyfills must be imported explicitly in the source or injected through a plugin . The third assumption is process.env. Webpack defined it globally, and the source code read it directly. Vite does not define it, and the client-side variables must be prefixed with VITE_ and read from import.meta.env .

Key point: The migration starts with the file extensions (.js → .jsx for JSX files) and the index.html relocation (from public/ to the project root). The config translation follows: entry → build.rolldownOptions.input, module.rules → Vite plugins, devServer → server, and process.env → import.meta.env.VITE_*. The CommonJS interop and the dependency pre-bundling are the two areas where the behavior differs enough to break existing code.


Why the migration fails at the implicit assumptions

The JSX extension problem. Webpack’s module.rules matched the file path with a regular expression, and the loader processed the content. A .js file with JSX worked because the Babel loader did not care about the extension. Vite uses the extension to decide which parser to use. A .js file with JSX is parsed as plain JavaScript, and the < character is a syntax error . The fix is a bulk rename of every file that contains JSX from .js to .jsx, and an update of the imports that reference those files . The rename is mechanical, but it touches every file, and a missed file produces a build error that points to the wrong cause.

The index.html location problem. Webpack’s HtmlWebpackPlugin generated the HTML from a template and injected the bundle scripts. The template lived in src/ or public/, and the plugin handled the script injection. Vite treats index.html as the entry point, and it must be in the project root, not in public/. The script tag must reference the source entry directly (<script type="module" src="/src/main.jsx"></script>), not a %PUBLIC_URL% placeholder . A project that leaves the HTML in public/ and expects Vite to process it will see a blank page or a 404.

The process.env problem. Webpack’s DefinePlugin replaced process.env.NODE_ENV and any other variables with their values at build time. The source code read process.env.REACT_APP_API_URL and the value was inlined. Vite does not define process.env, and the browser has no process global. The variables must be renamed to the VITE_ prefix in the .env file, and the source code must read import.meta.env.VITE_API_URL . The replacement is a global search-and-replace, and it is the largest mechanical change in a typical migration.

The trade-off. The migration is a rewrite of the development workflow, not a refactor of the application code. The application logic does not change; the way the code is loaded and configured changes. A project that treats the migration as a config translation will hit the extension errors, the HTML location errors, and the process.env errors in sequence, and each error will look like a Vite bug rather than a Webpack assumption that Vite does not share.


a. The config translation and the CommonJS interop

The Webpack config is an object with entry, output, module, resolve, plugins, and devServer. The Vite config is an object with build, resolve, plugins, and server. The translation is not one-to-one for the module.rules section, because Vite does not have loaders. The functionality is provided by plugins, and the plugin ecosystem is different.

// webpack.config.js — the entry and output
module.exports = {
  entry: {
    main: ['./src/index.js', './src/polyfills.js'],
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js',
  },
};
// vite.config.ts — the entry and output translation
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';

export default defineConfig({
  plugins: [react()],
  build: {
    // The entry is a single file path, not an array.
    // The polyfills must be imported in the source.
    rolldownOptions: {
      input: {
        main: path.resolve(__dirname, 'src/index.jsx'),
      },
    },
  },
});

The entry array in Webpack was a mechanism for injecting shared modules into every bundle. Vite’s input does not accept an array. The polyfills must be imported at the top of the entry file (import './polyfills'), or a plugin must inject the import . The output.filename pattern is replaced by Vite’s build.rollupOptions.output.entryFileNames (or the Rolldown equivalent in Vite 8), and the [contenthash] placeholder is Vite’s [hash] .

The CommonJS interop is the second translation problem. Webpack’s commonjs handling was configured through the @rollup/plugin-commonjs or the built-in Webpack behavior. Vite pre-bundles the CommonJS dependencies with Rolldown (or esbuild in Vite 7 and earlier), and the default import behavior changed in Vite 8 . A package that exports module.exports = function() {} is imported as import fn from 'pkg' in Webpack, and the default import is the function. In Vite 8, the default import is the module.exports value only if the importer is .mjs or the nearest package.json has "type": "module" . A CommonJS package imported from a .js file in a CommonJS project may resolve to module.exports.default instead of module.exports, and the import fails with a “not a function” error. The legacy.inconsistentCjsInterop: true option restores the previous behavior temporarily, but the fix is to report the package or switch to a package that exports ESM .


b. The dependency pre-bundling and the linked package problem

Vite’s dev server does not bundle the source code. It serves the source as native ESM and pre-bundles the dependencies that are CommonJS or have many internal modules. The pre-bundling is automatic for the packages in node_modules, but it does not catch every dependency, and it does not catch the linked packages in a monorepo.

// vite.config.ts — the optimizeDeps configuration
export default defineConfig({
  optimizeDeps: {
    // The dependencies that Vite's scanner misses.
    // A dynamic import or a CJS package with a complex export map
    // may need to be listed explicitly.
    include: ['lodash', 'echarts/core'],
    // The linked package that should be served as source.
    // The pre-bundling would cache it and miss the changes.
    exclude: ['@myorg/ui'],
  },
});

A CommonJS package that is not pre-bundled produces a “does not provide an export named” error, because the browser cannot import a CommonJS module natively . The optimizeDeps.include array is the fix. A linked package in a monorepo is a different case: Vite treats it as source, but the pre-bundling may still cache it, and the changes to the linked package are not picked up. The optimizeDeps.exclude array tells Vite to serve the package as source, and the changes are reflected in the dev server .

The @vitejs/plugin-react-swc plugin was reported to panic on syntax errors, forcing a restart of the dev server. The @vitejs/plugin-react plugin, which uses Babel, was more stable and had better error handling . The plugin choice is a trade-off between speed and stability, and the Babel-based plugin is the safer default for a migration.


Complete Example Session

// ============================================
// PART 1: THE FILE EXTENSION RENAME
// ============================================

// A bulk rename of the files with JSX syntax.
// npx renamer --find "*.js" --replace "*.jsx" "src/**/*.js"
//
// The imports that reference the renamed files must be updated.
// The Vite build fails with a syntax error if a .js file has JSX.

// ============================================
// PART 2: THE INDEX.HTML RELOCATION
// ============================================

// public/index.html (Webpack)
// <div id="root"></div>
// <script src="%PUBLIC_URL%/bundle.js"></script>

// index.html (Vite)
// <div id="root"></div>
// <script type="module" src="/src/main.jsx"></script>

// The file moves from public/ to the project root.
// The placeholder is replaced with the source entry path.

// ============================================
// PART 3: THE PROCESS.ENV REPLACEMENT
// ============================================

// Webpack: process.env.REACT_APP_API_URL
// Vite: import.meta.env.VITE_API_URL

// The .env file:
// VITE_API_URL=https://api.example.com

// The source code:
const apiUrl = import.meta.env.VITE_API_URL;

// The global search-and-replace is the largest mechanical change.

// ============================================
// PART 4: THE ENTRY TRANSLATION
// ============================================

// webpack.config.js
entry: {
  main: ['./src/index.js', './src/polyfills.js'],
}

// vite.config.ts
build: {
  rolldownOptions: {
    input: {
      main: path.resolve(__dirname, 'src/index.jsx'),
    },
  },
}

// The polyfills are imported in the source:
// import './polyfills';

// ============================================
// PART 5: THE COMMONJS INTEROP
// ============================================

// The Vite 8 default import behavior changed.
// A CJS package may resolve to module.exports.default.
// The temporary fix:
legacy: {
  inconsistentCjsInterop: true,
}

// The permanent fix is to report the package
// or switch to an ESM version.

// ============================================
// PART 6: THE DEPENDENCY PRE-BUNDLING
// ============================================

optimizeDeps: {
  // The CJS packages that Vite misses.
  include: ['lodash', 'echarts/core'],
  // The linked packages that should be served as source.
  exclude: ['@myorg/ui'],
}

// ============================================
// PART 7: THE PLUGIN TRANSLATION
// ============================================

// Webpack: module.rules with loaders
// Vite: plugins

import react from '@vitejs/plugin-react';

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

// The CSS handling is built into Vite.
// The Sass support is a package: npm install -D sass.

// ============================================
// PART 8: THE DEV SERVER TRANSLATION
// ============================================

// Webpack: devServer: { port: 3000, proxy: {...} }
// Vite: server: { port: 3000, proxy: {...} }

export default defineConfig({
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
});

// ============================================
// PART 9: THE VERIFICATION
// ============================================

// 1. Start the dev server: npm run dev
// 2. Open the browser and check every route.
// 3. The dev server is where 90% of the errors appear.
// 4. Run the build: npm run build
// 5. The build is where the tree-shaking and chunk errors appear.
// 6. Run the preview: npm run preview
// 7. Verify the production assets load.

// ============================================
// PART 10: THE CHECKLIST
// ============================================

// [ ] The JSX files are renamed to .jsx.
// [ ] The index.html is in the project root.
// [ ] The script tag references the source entry.
// [ ] The process.env variables are renamed to VITE_*.
// [ ] The source code reads import.meta.env.VITE_*.
// [ ] The entry is translated to build.rolldownOptions.input.
// [ ] The CJS packages are in optimizeDeps.include.
// [ ] The linked packages are in optimizeDeps.exclude.
// [ ] The plugins are translated from loaders.
// [ ] The dev server is translated to server.

The ten parts covered the extension rename, the HTML relocation, the process.env replacement, the entry translation, the CommonJS interop, the pre-bundling, the plugin translation, the dev server, the verification, and the checklist. The through-line is that the migration is a change in the development model, and the config translation is the smaller part.


Quick Reference

The config translation

WebpackVite
entrybuild.rolldownOptions.input
output.filenamebuild.rolldownOptions.output.entryFileNames
module.rulesPlugins
resolve.aliasresolve.alias
devServerserver
devServer.proxyserver.proxy
DefinePlugindefine or import.meta.env

The common errors

ErrorCauseFix
Unexpected token <JSX in a .js fileRename to .jsx
Blank pageindex.html in public/Move to the project root
process is not definedprocess.env usedUse import.meta.env.VITE_*
does not provide an export namedCJS package not pre-bundledAdd to optimizeDeps.include
default is not a functionCJS interop changedUse legacy.inconsistentCjsInterop

The migration order

StepAction
1Rename the JSX files to .jsx
2Move index.html to the project root
3Replace process.env with import.meta.env
4Translate the config
5Add the CJS packages to optimizeDeps.include
6Verify the dev server
7Verify the build

Best Practices

✅ Do This:

// Rename the JSX files
// src/App.js → src/App.jsx                                         // ✅

// Move the index.html
// public/index.html → index.html                                   // ✅

// Use import.meta.env
const apiUrl = import.meta.env.VITE_API_URL;                       // ✅

// Add the CJS packages to optimizeDeps.include
optimizeDeps: { include: ['lodash'] }                              // ✅

❌ Don’t Do This:

// Leave JSX in a .js file
// The Vite build fails with a syntax error.                        // ❌

// Keep index.html in public/
// Vite does not process it.                                        // ❌

// Use process.env in the client code
// process is not defined in the browser.                           // ❌

// Expect the Webpack entry array to work
// Vite's input is a single file path.                              // ❌

Common Pitfalls

PitfallWhy It HappensFix
The build fails on JSXA .js file has JSXRename to .jsx
The page is blankindex.html in public/Move to the root
process is not definedprocess.env in the sourceUse import.meta.env
The CJS package failsNot pre-bundledAdd to optimizeDeps.include
The linked package changes are ignoredPre-bundledAdd to optimizeDeps.exclude

Real-World Examples

1. The JSX rename

npx renamer --find "*.js" --replace "*.jsx" "src/**/*.js"

2. The index.html

<script type="module" src="/src/main.jsx"></script>

3. The env variable

const apiUrl = import.meta.env.VITE_API_URL;

4. The entry

build: { rolldownOptions: { input: { main: 'src/index.jsx' } } }

5. The CJS include

optimizeDeps: { include: ['lodash'] }

6. The linked exclude

optimizeDeps: { exclude: ['@myorg/ui'] }

7. The plugin

plugins: [react()]

8. The server proxy

server: { proxy: { '/api': { target: 'http://localhost:8080' } } }

9. The legacy interop

legacy: { inconsistentCjsInterop: true }

10. The verification

npm run dev && npm run build && npm run preview

Visual

The Migration Order

┌──────────────────────────────────────────────────────────────┐
│  FROM WEBPACK TO VITE                                         │
│                                                              │
│  Step 1: Rename the JSX files                                │
│  ┌─────────────────────┐                                    │
│  │ .js → .jsx          │                                    │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  Step 2: Move the index.html                                 │
│  ┌─────────────────────┐                                    │
│  │ public/ → root      │                                    │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  Step 3: Replace process.env                                 │
│  ┌─────────────────────┐                                    │
│  │ process.env →       │                                    │
│  │ import.meta.env     │                                    │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  Step 4: Translate the config                                │
│  ┌─────────────────────┐                                    │
│  │ entry → input       │                                    │
│  │ rules → plugins     │                                    │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  Step 5: Verify the dev server and the build                │
└──────────────────────────────────────────────────────────────┘

The CommonJS Interop

┌──────────────────────────────────────────────────────────────┐
│  THE DEFAULT IMPORT BEHAVIOR                                  │
│                                                              │
│  Webpack:                                                    │
│  import fn from 'cjs-pkg' → fn is module.exports            │
│                                                              │
│  Vite 8:                                                     │
│  import fn from 'cjs-pkg' → fn is module.exports.default    │
│  (unless the importer is .mjs or the package is ESM)        │
│                                                              │
│  The legacy option restores the old behavior temporarily.   │
└──────────────────────────────────────────────────────────────┘

The Pre-Bundling

┌──────────────────────────────────────────────────────────────┐
│  WHY THE CJS PACKAGE FAILS                                    │
│                                                              │
│  The browser requests the source.                            │
│  The source imports a CJS package.                           │
│  The browser cannot import CJS natively.                     │
│                                                              │
│  Vite pre-bundles the CJS package into ESM.                 │
│  The optimizeDeps.include array lists the packages          │
│  that the scanner missed.                                    │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
JSX extension.js with JSX must be renamed to .jsx
index.htmlMust be in the project root, not public/
process.envReplaced by import.meta.env.VITE_*
Entrybuild.rolldownOptions.input (not an array)
CJS packagesoptimizeDeps.include
Linked packagesoptimizeDeps.exclude
CJS interoplegacy.inconsistentCjsInterop: true (temporary)
VerificationDev server first, then build, then preview

Key takeaways:

  • The JSX extension is the first blocker. Webpack’s loaders processed .js files with JSX. Vite parses the file based on its extension, and a .js file with JSX fails with a syntax error. The rename is mechanical, and a missed file produces a build error that points to the wrong cause .
  • The index.html must be in the project root. Webpack’s HtmlWebpackPlugin generated the HTML from a template in public/ or src/. Vite treats index.html as the entry point, and it must reference the source entry directly, not a %PUBLIC_URL% placeholder .
  • The process.env replacement is the largest mechanical change. The variables must be renamed to the VITE_ prefix in the .env file, and the source code must read import.meta.env.VITE_*. A global search-and-replace is the pattern, and the define option is the compatibility layer for the transition .
  • The CommonJS interop changed in Vite 8. A default import from a CJS module may resolve to module.exports.default instead of module.exports. The legacy.inconsistentCjsInterop: true option is the temporary fix, and the permanent fix is to report the package or switch to an ESM version .
  • The pre-bundling is the dependency translation. A CJS package that Vite’s scanner misses must be added to optimizeDeps.include. A linked package in a monorepo must be added to optimizeDeps.exclude so the changes are picked up .

Remember: The migration from Webpack to Vite is a change in the development model, not a config translation. The JSX extension, the index.html location, and the process.env replacement are the three mechanical changes that must happen before the config translation. The CommonJS interop and the pre-bundling are the two behavior changes that break existing code. The dev server is where the errors appear first, and the build is where the tree-shaking and chunk errors appear. The migration is a rewrite of the workflow, and the application code is the part that stays the same.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!