Webpack To Vite Migration

Contributors: Bhavuk Jain, Abhishek Kumar Singh, Aarnav Anand, Utsav Singh

Organization: Extramarks Education Pvt Ltd.


"In our constant effort to improve developer experience (DX) at Extramarks, we embarked on a journey to migrate our frontend build tool from Webpack to Vite. This transition was driven by a clear purpose: to reduce development time, improve DX, and keep up with the rapidly evolving frontend tooling ecosystem."

"The differences between Webpack 5 and Vite are significant - while Webpack bundles the entire application before serving it, Vite takes a different approach, leveraging native ES modules for lightning-fast hot module replacement (HMR) and significantly quicker builds."


What we achieved after this migration:

Metric (Time)Earlier (Webpack 5)Now (Vite)Improvement
Cold StartAround 5 minutes< 30 seconds> 90% improvement
Production Build10 - 15 minutes~ 5 minutes> 60% improvement

"Here is how we achieved it:-"


1. Base Setup & Initial Package Updates

a. Added Packages

1"@rollup/plugin-commonjs": "28.0.2",
2"@vitejs/plugin-react": "4.3.4",
3"vite": "6.3.5",
4"vite-plugin-compression2": "1.3.3",
5"vite-plugin-html": "3.2.2",
6"vite-plugin-node-polyfills": "0.23.0",
7"vite-tsconfig-paths": "5.1.4",
8"vite-plugin-node-externals": "0.0.1",

b. Updated Scripts (package.json)

1"start": "vite",  
2"build": "vite build",  
3"preview": "vite preview",

c. Removed config-overrides & Add vite.config.js in root folder

During the migration from CRA to Vite, the config-overrides.js file used to customize Webpack settings is no longer needed, as Vite uses a different bundler (Rollup) and has its own configuration system.

Instead, a new vite.config.js file is added at the root to manage build settings, plugins, and aliases in a cleaner way. Additionally, files like App.js and index.js are renamed to App.jsx and index.jsx respectively, as Vite strictly respects file extensions and expects .jsx for files containing JSX syntax, ensuring better compatibility and HMR support.

d. Renamed App.js to App.jsx & index.js to index.jsx (entry file)

As part of the Vite migration, files like App.js and index.js are renamed to App.jsx and index.jsx to align with Vite’s strict handling of file extensions.

Unlike CRA, Vite expects .jsx for files that contain JSX code to ensure proper parsing, improved editor support, and reliable hot module replacement (HMR).

This small change helps maintain clarity in the codebase and avoids potential runtime issues during development.

→ Key Parameters Of Config Files (Webpack v/s Vite) :-

FeatureWebpackVite
Aliasesresolve.alias in Webpackresolve.alias in vite.config.js
Define ENV variablesDefinePlugindefine in vite.config.js
Multiple Entry Pointsentry in Webpack configbuild.rollupOptions.input
CSS & JS MinificationCssMinimizerPlugin, TerserPluginHandled by esbuild
Source MapsSourceMapDevToolPluginVite handles it automatically
HTML HandlingHtmlWebpackPluginUses index.html directly
Node.js Polyfillsfallback in WebpackNot required, removed
CompressionCompressionPluginNeeds vite-plugin-compression

Resolving Aliases

1import tsconfigPaths from 'vite-tsconfig-paths'
2import nodeExternals from 'vite-plugin-node-externals';
3
4plugins: [
5  tsconfigPaths(),
6  {
7    name: "treat-js-files-as-jsx",
8    async transform(code, id) {
9      if (!id.match(/src\/.*\.js$/)) return null;
10
11      return transformWithEsbuild(code, id, {
12        loader: "jsx",
13        jsx: "automatic",
14      });
15    },
16  },
17   nodeExternals({
18      include: ['svg-to-dataurl']
19    }),
20];
1alias: {
2    '/@': path.resolve(__dirname, 'src/'),
3    '/components': path.resolve(__dirname, 'src/components/'),
4    '/component': path.resolve(__dirname, 'src/components/component/'),
5    '/redux': path.resolve(__dirname, 'src/redux/'),
6    '/constants': path.resolve(__dirname, 'src/constants/'),
7    '/layout': path.resolve(__dirname, 'src/layout/')
8}

→ In the Vite configuration, several plugins and aliases are added to streamline development and maintain compatibility with the existing codebase:

- The tsconfigPaths plugin automatically resolves path aliases defined in tsconfig.json, eliminating the need for manual imports and improving code readability.

- The custom treat-js-files-as-jsx plugin ensures that .js files inside the src/ directory are treated as JSX, which is helpful when JSX syntax is used in .js files - something Vite doesn't assume by default.

- The nodeExternals plugin is configured to exclude specific Node modules like svg-to-dataurl from Vite's dependency optimization, ensuring better build performance and compatibility.

- Additionally, custom path aliases like /components and /redux are defined using path.resolve, making import paths cleaner and reducing the risk of deeply nested relative paths. This configuration aligns Vite’s behavior with the existing CRA setup while enhancing clarity, compatibility, and efficiency during development.


Replace CommonJS to ES6 Imports

When migrating from Webpack to Vite, one of the key changes is replacing CommonJS (require) syntax with ES6 module (import) syntax because Vite natively supports ES modules and does not transpile CommonJS by default.

- Convert require to import syntax

- Ensure dependencies (like Moment.js, Lodash, and Components) are correctly imported

- Adjust configurations if needed for Vite compatibility

1// FROM
2
3const moment = require("moment");
4const _ = require("lodash");
5
6const { PYPTest, PYPYearMonth } = require("redux/constants/assessments_test/PYPTest");
7
8const MyComponent = require("components/MyComponent");
9
10// TO
11
12import moment from "moment";
13import _ from "lodash";
14
15import { PYPTest, PYPYearMonth } from "redux/constants/assessments_test/PYPTest";
16
17import MyComponent from "components/MyComponent";

→ Approx 140 Files Needed This Change (require to import)


Manual React Fast Refresh Setup in Vite

1<script type="module">
2      import RefreshRuntime from "/@react-refresh"
3      RefreshRuntime?.injectIntoGlobalHook(window)
4      window.$RefreshReg$ = () => {}
5      window.$RefreshSig$ = () => (type) => type
6      window.__vite_plugin_react_preamble_installed__ = true
7</script>
8<script>var global = window</script>
9<script type="module" src="/src/main.jsx"></script>

"This script block in index.html is added to manually set up React Fast Refresh in a Vite environment, especially when the automatic setup isn't working due to custom configurations. It imports the React Refresh runtime and injects it into the global scope to ensure smooth hot module replacement (HMR) during development. Additionally, setting var global = window helps avoid runtime errors from libraries that expect a Node.js-like global object. By explicitly controlling the loading order of modules, this setup ensures that Fast Refresh and development features work reliably, maintaining state and improving the developer experience."


Migrating .env Variables from Webpack to Vite

Changes Made:

  • Updated Environment Variable Prefix
  • Webpack (React) used REACT_APP_ → Vite requires VITE_

Example:

1REACT_APP_PAPER_MSA_API_KEY=8FF8508F917BCC12FFDCD  ❌ (Old)
2VITE_PAPER_MSA_API_KEY=8FF8508F917BCC12FFDCD ✅ (New)
  • Updated Usage of Environment Variables

Webpack (React - process.env)

1let redirect_url = process.env.PUBLIC_URL.includes("localhost") || window.location.origin.includes("localhost");

Vite (import.meta.env)

1let redirect_url = import.meta.env.VITE_PUBLIC_URL.includes("localhost") || window.location.origin.includes("localhost");

→ Why This Change?

- Vite does not support process.env by default.

- Uses import.meta.env for environment variables.

- Automatically injects .env values during development and build time.

- Note: Don’t use optional chaining ?. while reading env variables.

→ This was changed in around 55 files


Misc. Configuration In Vite Config

1outDir: ‘build’  // To integrate with already written service workers!
2build: {
3rollupOptions: {
4treeshake: true,
5	},
6}

- In the Vite config, setting outDir: 'build' ensures that the build output is placed in the same directory structure used by Create React App (CRA), which is helpful when integrating with existing service workers or deployment setups expecting a build/ folder.

- Additionally, enabling treeshake: true in rollupOptions helps remove unused code during the build, resulting in a smaller and more optimized bundle. This configuration aligns Vite’s output with the previous project structure while improving performance.


Automatic Alias Setup Under SRC Folder:-

1alias: {
2	"src": path.resolve(__dirname, 'src'),
3}
4jsconfig.json {
5	"compilerOptions": {
6		"baseUrl": "src"
7	},
8	"include": ["src", "babel.config.js"],
9	"exclude": ["node_modules", "**/node_modules/*"],
10	"module": "commonjs"
11}

- To simplify imports and avoid complex relative paths, an alias "src" is added in the Vite config, allowing any file within the src directory to be imported directly using absolute paths.

- Correspondingly, jsconfig.json is configured with "baseUrl": "src" to enable IntelliSense and path resolution in editors like VS Code.

- This setup ensures that any new folder (e.g., utils) inside src can be accessed using simplified import paths like import x from 'utils/x', regardless of nesting level, improving code readability and maintainability.


Webpack Magic Comments Support In Vite

1"vite-plugin-magic-comments": "^0.1.1",

- To enable Webpack-style magic comments (like /* webpackChunkName: "group" */) in Vite for dynamic imports, the vite-plugin-magic-comments plugin is used.

- This plugin allows you to annotate dynamic import() calls with chunk names or preload hints, similar to how it's done in Webpack. By integrating this plugin, Vite can recognize and apply these comments during the build, improving code-splitting control and performance.

- It's especially useful during migration from Webpack to Vite, ensuring compatibility with existing dynamic import patterns.


Migration from terser to esbuild for JavaScript Minification

Background


Terser has been the default minifier in many JavaScript bundlers like Webpack. However, due to its single-threaded nature and JavaScript implementation, it becomes a performance bottleneck for large applications.

esbuild is a modern bundler and minifier written in Go, designed for speed and parallel processing.


Refer Changes in vite.config.js

1 build: {
2     outDir: "build",
3     minify: "esbuild", // "terser" replaced with "esbuild"
4     rollupOptions: {
5         treeshake: true,
6     },
7     sourcemap: false,
8 },

Comparison: terser vs esbuild

FeatureTerseresbuild
LanguageJavaScriptGo (compiled binary)
Processing ModelSingle-threadedMulti-threaded
SpeedSlowerSignificantly faster
Tree Shaking SupportYesYes
IntegrationNative in WebpackPlugin-based integration
Output SizeMinifiedMinified

Observed Build Time Improvement

MetricTerser (Before)esbuild (After)Change
Average Build Time9–10 minutes3–4 minutesReduced by 60 - 65%
Minification EngineTerseresbuildSwitched

Technical Reasons for Speed Improvement

  • Compiled Language: esbuild is written in Go, which compiles to native binaries, making execution faster than JavaScript-based tools.
  • Parallelization: esbuild performs minification using multiple CPU cores.
  • Reduced Transformation Steps: esbuild minimizes internal intermediate representations (AST parsing and transformation), resulting in faster processing.
  • Simplified Minification Pipeline: esbuild handles parsing, transforming, and printing in one streamlined pass.

Changing Troublesome Libraries and Their Implementation

Problem:

The moment library was previously used as a JSX component (<Moment>{...}</Moment>), which worked under Webpack but led to issues after the migration. Routes where this component was used began to misbehave, and the rendered output returned undefined instead of formatted dates. This broke the UI in parts of the application that relied on date formatting for display logic or dynamic rendering.

Solution:

The usage of Moment was refactored from component-style JSX to a direct function call using the moment() API. This approach avoids reliance on any wrapper components and ensures that the date formatting is executed as plain JavaScript.

1// FROM 
2<Moment format="DD-MM-YYYY, (h:mm A)">
3  {item.start_date}
4</Moment>
5
6// TO
7moment(item.start_date).format("DD-MM-YYYY, (h:mm A)")

→ Why This Change?

Component-based usage of Moment caused some routes to break by returning undefined, likely due to improper integration or incompatibility under the new module system. Using moment() directly as a function ensures predictable behavior, avoids unnecessary abstraction, and keeps the codebase compatible with the new Vite-powered build pipeline.

→ ~10 files were changed


JS to JSX Transformation

Problem:

In Vite .js files containing JSX led to issues during the production build. While development was patched using a Vite plugin that treated .js files as JSX (transformWithEsbuild + loader: 'jsx'), it broke in production. Clicking on <Link> components caused full page reloads due to Vite's route preloading logic failing on unresolved or misinterpreted .js files with JSX.

Solution:

All .js files containing JSX were identified and renamed to .jsx, and their import paths were updated accordingly to resolve the build and routing issues completely.


1. Detection of JSX in .js Files

Initial grep-based detection led to false positives and false negatives. So, AST-based parsing was implemented using Babel to accurately detect JSX syntax inside .js files.

1# GREP Based Detection
2if echo "$content" | grep -q '<[a-zA-Z]'; then
3    jsx_files_found+=("$file")
4fi

- Multiline JSX or fragments (<>...</>) could be missed.

- JSX written inside a deeply nested structure might be ignored.

1// Babel Based Detection
2const ast = parser.parse(code, { sourceType: 'module', plugins: ['jsx'] });
3traverse(ast, {
4  JSXElement() { containsJSX = true; },
5  JSXFragment() { containsJSX = true; }
6});

- Scans the entire src/ directory

Direct renaming made git treat files as deleted and newly added, breaking history. A script was created to git mv all JSX-containing .js files to .jsx, ensuring version history was preserved.

- Logs all .js files containing JSX into jsx-in-js-files.log


2. Renaming Files Using git mv

1# INCORRECT
2mv src/components/Button.js src/components/Button.jsx
3
4# CORRECT
5git mv src/components/Button.js src/components/Button.jsx

- Skips if .jsx version already exists

- Logs renames to renamed_files.txt and migration-rename.log


3. Updating Import Paths

Imports across the codebase still pointed to .js extensions. These were programmatically updated using a Node.js script:

1// FROM
2import { Button } from './components/Button.js'; 
3
4// TO
5import { Button } from './components/Button';     

- Handles import, require(), lazy(() => import()) patterns

- Preserves formatting and quote styles

- Supports webpack comments and multiline import formats

- Skipped and updated files are logged in skipped_imports.txt and updated_imports.txt


→ Why This Change?

- Vite does not fully support JSX in .js files for preloading and static analysis, especially during production builds. Partial fixes in development mode weren’t enough.

- Renaming .js files to .jsx and updating their references was necessary to eliminate hard reloads, ensure proper module resolution, and fully align with Vite's expectations.

→ How Git Manages The Extension Change?

Git detects the same file with different extensions (like .js in one branch and .jsx in another) by using content similarity-based rename detection during merges or diffs rather than relying strictly on filenames.

How Git Does This:

  • Git internally stores contents of files as blobs identified by SHA-1 hashes, not filenames.
  • When merging, if a file with the same or similar content appears deleted under one name (e.g., file.js) and added under another (e.g., file.jsx), Git calculates the similarity of the two files.
  • If the similarity passes a threshold (default around 50%), Git infers it as a rename even if the extensions or filenames differ.
  • This similarity check compares the file contents ignoring extensions or paths, focusing on the actual code/text inside.

What Makes This Robust?

  • If you used git mv to rename file.js to file.jsx without changing much of the content, the similarity is very high.
  • When pulling changes from another branch that still modifies file.js, Git matches this to your renamed file.jsx because the contents remain similar.
  • Git then applies the changes from .js branch files into your .jsx renamed files, maintaining history and merge consistency.

"→ 3150+ files were changed from .js to .jsx and respective imports updated."


Optimizing Dependency Loading in Vite

Problem:-

During development, we observed full page reloads when navigating to routes that imported new dependencies. Vite would trigger these reloads with messages like "optimizing new dependencies," which created a poor developer experience by:

- Breaking application state during navigation

- Causing unnecessary delays in development workflow

- Disrupting the hot module replacement (HMR) flow

Solution:-

To prevent these full page reloads, we:

1. Identified all production dependencies from package.json

2. Preloaded them during Vite's initial optimization phase

3. Excluded only problematic dependencies (like @babel runtime and dev-specific packages)

This ensures all necessary dependencies are optimized upfront, eliminating the need for page reloads when navigating to new routes.

Implementation:-

We modified vite.config.mjs to include all production dependencies in the initial optimization:

1const excludedOptimizedDeps = [
2  "@babel/runtime",
3  "devextreme",
4  "firebase",
5  "react-scripts",
6  "serve",
7  "wrap-ansi-cjs",
8  "request-promise"
9];
10
11const allDeps = Object.keys(pkg.dependencies || {})?.filter(
12  (dep) =>
13    !dep?.startsWith("@babel/") &&
14    !dep?.startsWith("@types/") &&
15    !excludedOptimizedDeps?.includes(dep)
16);
17
18optimizeDeps: {
19  include: allDeps,
20  esbuildOptions: {
21    treeShaking: true,
22    minify: true,
23    loader: {
24      ".js": "jsx",
25    },
26  },
27},

Key aspects:

- Dynamic reading of package.json dependencies

- Filtering of unnecessary dependencies

- Enabling tree-shaking and minification during optimization


Hot Reload Enhancement

To improve the development experience further, we added automatic page reload on code changes by implementing Vite's HMR (Hot Module Replacement) listener in the main entry file:

1if (import.meta.hot) {
2import.meta?.hot?.on?.('vite:beforeUpdate', () => {
3window?.location?.reload?.();
4	});
5}

Key benefits:

- Automatic refresh when saving code changes

- Maintains development workflow continuity

- Works in conjunction with the dependency optimization

- Uses Vite's built-in HMR system for reliability


HMR Behavior: Vite vs Webpack

Default Vite Approach (Lazy Loading)

  • HMR only works on loaded chunks - files that have been visited/imported are kept in memory
  • Unvisited routes/components remain "cold" and won't receive HMR updates until accessed
  • On-demand compilation - only processes files when they're actually needed
  • Trade-off: Faster development server startup and file saves, but may miss errors in unloaded modules

Webpack Approach (Eager Loading)

  • Compiles entire application on every file save, regardless of what's currently loaded
  • All files are "hot" from the start - comprehensive HMR coverage across the entire codebase
  • Full dependency graph analysis on each change
  • Trade-off: Slower initial startup and longer compilation times on each save, but catches all errors immediately

Custom Vite Plugin Option

We explored creating a custom plugin to preload all modules for Webpack-like behavior:

1// Forces Vite to load all src/ files into memory
2await server.transformRequest(/${relativePath});

This would provide comprehensive HMR coverage but at the cost of performance.

Decision: Default Vite Approach

We chose to stick with Vite's default lazy loading!

Because:

- Significantly faster development server startup

- Lightning-fast file saves without compilation overhead

- Better development experience for daily coding workflow

- Errors are caught when components are actually used (more realistic testing)

"Result: Optimal development speed while maintaining code quality through other mechanisms (ESLint, pre-commit hooks, build-time validation)."


Static ↔ Dynamic Linking and Dev Preamble Injection

→ In local development, we often run the main (static) app on one port (for example, 3000) and the dynamic React app (Vite-based) on another (for example, 3001).

→ This setup allows developers to test real integration flows between static and dynamic services while maintaining production-like behavior.

Objective

  • Dynamically switch the app entry between static and dev server without manually editing index.html.
  • Inject the React preamble dynamically when HTML is served through a proxy (like /login) for consistent runtime behavior.
  • Keep production builds clean and unaffected by development logic.

Below are the steps to link both the static + dynamic app:-

1. Environment Setup

To activate conditional linking and preamble injection, ensure the DEV_SERVER environment flag is set to true in all development contexts.

Set it in three places:

- In appConstants.js inside the main (teacher) app:

export const DEV_SERVER = true;

- In .env of the teacher app:

DEV_SERVER=true

- In .env of the static app repo:

DEV_SERVER=true

- In the index.html, we need to update the main.jsx import (to point to dynamic app)

1<script type="module" src="http://localhost:3001/src/main.jsx"></script>

These flags are read both at runtime and build-time, enabling coordinated behavior between the static and dynamic servers.

2. Conditional Entrypoint Injection (Vite Plugin)

This Vite plugin replaces the default static entry script in index.html with a dynamic loader script when DEV_SERVER=true.

It automatically decides whether to load the app from the dev server or from the local static bundle.

1*// vite.plugins/conditionalEntryDevOnly.js*
2
3import { loadEnv } from "vite";
4
5export default function conditionalEntryDevOnly() {
6  let devPort = 3001;
7
8  return {
9    name: "conditional-entry-dev-only",
10    apply: "serve",
11
12    configureServer(server) {
13      const updatePort = () => {
14        try {
15          const addr = server?.httpServer?.address?.();
16          devPort =
17            (addr && typeof addr == "object" && addr?.port) ||
18            server?.config?.server?.port ||
19            devPort;
20        } catch {}
21      };
22
23      updatePort();
24      server?.httpServer?.once?.("listening", updatePort);
25    },
26
27    transformIndexHtml(html) {
28      const env = loadEnv("", process.cwd(), "");
29      const devServerFlag =
30        env?.DEV_SERVER == "true" || process.env.DEV_SERVER == "true";
31      if (!devServerFlag) return html;
32
33      const cleaned = html.replace(
34        /<script\s+type="module"[^>]*src="(?:http:\/\/localhost:\d+)?\/src\/main\.jsx"[^>]*><\/script>\s*/g,
35        ""
36      );
37
38      const loader = `(function(){
39        var h = location.hostname;
40        var isLocal = (h === 'localhost');
41        var entry = isLocal
42          ? 'http://localhost:${devPort}/src/main.jsx'
43          : '/src/main.jsx';
44        var s = document.createElement('script');
45        s.type = 'module';
46        s.src = entry;
47        document.head.appendChild(s);
48      })();`;
49
50      return {
51        html: cleaned,
52        tags: [{ tag: "script", children: loader, injectTo: "head" }],
53      };
54    },
55  };
56}

3. HTML Preamble Injection During Proxy Handling

- When the static host proxies a page like /login to the dynamic dev server, React runtime initialization can become inconsistent.

- To fix this, we inject a lightweight preamble script for development mode only (static app’s serverSetup.js).

1*// Express middleware snippet*
2
3if (req.path.includes("/login")) {
4  console.log("Login form fetched from dynamic server");
5
6  if (process.env.DEV_SERVER === "true") {
7    const contentType = response.headers["content-type"];
8    if (contentType && contentType.includes("text/html")) {
9      let html = response.data.toString();
10      const preamble = `<html><script>
11        window.$RefreshReg$ = () => {};
12        window.$RefreshSig$ = () => t => t;
13        window.__vite_plugin_react_preamble_installed__ = !0;
14      </script>`;
15      html = html.replace(/<html[^>]*>/, preamble);
16      console.log("DEV: Preamble injected, no compression");
17      return res.send(html);
18    }
19  }
20
21  *// Fallback to compression or production logic*
22}

4. Adding react preamble support for hard refresh case

- Adding this script in index.html of dynamic app for ensured presence of vite_preamble in window.

- In case of hard refresh, the vite preamble plugin was being removed from the window. To fix this, we introduced this script below to get rid of that issue:-

1<script>
2 window.$RefreshReg$ = () => {};
3 window.$RefreshSig$ = () => (type) => type;
4 window.__vite_plugin_react_preamble_installed__ = true;
5</script>

Key Benefits

  • Automatic Linking: No manual index.html edits when switching between dynamic and static setups.
  • Unified Environment Control: All dev servers respond to the same flag for predictable behavior.
  • Reliable HTML Behavior: Ensures correct initialization of runtime components across servers.
  • Safe Production Default: Runs only with DEV_SERVER=true; production builds remain unaffected.

Final Thoughts….

"This migration from Webpack to Vite has successfully modernized our frontend tooling with measurable benefits:"

Key Outcomes:

  • Faster development startup (5min → 30s)
  • Faster production builds (15min → 5min)
  • 3150+ files standardized to proper JSX format
  • Smoother HMR with automatic refresh on save
  • Optimized dependency loading during development

"For any queries, you can either contact Bhavuk Jain or Abhishek Kumar Singh."