DEV Community

Cover image for Migrating a 5-year-old React admin app from CRA + Webpack to Vite + SWC — 166 files, 70 days
Prince Panchani
Prince Panchani

Posted on

Migrating a 5-year-old React admin app from CRA + Webpack to Vite + SWC — 166 files, 70 days

Summer Bug Smash: Smash Stories 🐛🛹

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.


The build was fine. Everything else was on fire.

Every codebase has a number that nobody says out loud.

Ours was the time between hitting Ctrl+S and seeing the change in the browser. Long enough to check Slack. Long enough to forget what you were testing. On a cold start, long enough to make coffee.

DreamNet is the internal admin platform for ZURU's housing division — user management, RBAC, asset catalogues, project publishing, release builds, an order-pricing engine, event management, dashboards with charts and maps. Roughly 130 source files across src/scene alone. It was scaffolded with Create React App and had been running on react-scripts@3.0.1 — a release from 2019 — held together with rewire, env-cmd, and node-sass@4.14.1, which needed a specific Node version to even compile.

The proposal was simple: replace the build tool. Vite + SWC. Faster cold start, near-instant HMR, better production builds.

The reality was that the build tool was the only part that went smoothly.

This is the story of MR — 166 changed files, 28+ diff revisions, 19 review comments, and 70 days between "let's swap the bundler" and green on production.


Why a bundler swap is never a bundler swap

Here's the thing nobody tells you about CRA: CRA is not a bundler. ** CRA is an API.

Over five years, a codebase doesn't just use webpack. It absorbs webpack's semantics into its source. process.env, %PUBLIC_URL%, import { ReactComponent as Icon }, JSX inside .js files, implicit Node globals in browser code, automatic Babel transpilation of every CommonJS dependency you ever installed.

None of those is React features. All of them are load-bearing.

So the first thing I did was not write vite.config.mjs. I ran a survey: every place the source assumed that only webpack could satisfy. That list became the actual scope of work — and it was about ten times larger than the config file.

The config file, for the record, is 23 lines:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import svgr from 'vite-plugin-svgr';

export default defineConfig({
  plugins: [react(), svgr({ exportAsDefault: true })],
  envDir: 'environments',
  server: { port: 3000 },
  preview: { port: 3000 },
  build: { outDir: 'build' },
});
Enter fullscreen mode Exit fullscreen mode

build.outDir: 'build' and port: 3000 are deliberate. Vite defaults to dist and 5173. Our GitLab CI jobs, Docker image, and deploy scripts all expected build/ on :3000. Changing the bundler and the deployment contract in the same MR is how you end up with a broken pipeline you can't attribute to anything. Absorb the churn in config, not in infrastructure.


Constraint one: React 16 stays

The tempting move here is to bundle the migration with a React 18 upgrade. ReactDOM.render is deprecated, createRoot is right there, and you're already in the file.

I didn't. We stayed on react@16.14.0, ReactDOM.render, and react-router-dom@4.3.1. All of it.

The reasoning: a build migration has no user-visible intent. If nothing changes for the user, then every visual or behavioural difference is a regression, full stop. That is an incredibly powerful invariant to review against — the reviewer can just diff the two environments side by side, and any delta is a bug. Fold a React 18 upgrade in, and you lose it: now some differences are expected, some aren't, and every discussion becomes an argument about which is which.

The MR is labelled a breaking change for tooling. It should be a no-op for behaviour.


Chapter 1: type: module and the file-extension domino

Adding "type": "module" to package.json is one line. It cost a day.

Node now treats every .js file in the project as ESM. Which means:

  • .eslintrc.js used module.exportscrashes. Renamed to .eslintrc.cjs.
  • The Vite config had to be .mjs to be unambiguous.
  • .lintstagedrc.js had to be written as export default { ... }.

Then esbuild's rule hit: esbuild will not parse JSX inside a .js file. Babel happily did. esbuild refuses, by design, because the .js extension makes no promise about JSX and guessing costs parse time.

That forced a wave of renames — src/app.jsapp.jsx, src/index.jsindex.jsx, src/service/apollo_wrapper.js.jsx — and one genuine refactor. src/constants.js was a data module that had quietly grown JSX in it: nav configs, ingredient lists, session-OS maps, all carrying icon: <AacIngredientIcon />. I split it into a new src/constant.jsx holding everything JSX-bearing, leaving constants.js as pure data.

That split turned out to be worth doing on its own merits — it stopped a 200-line render-bearing module from being imported by things that only wanted string constants.

Takeaway: extension discipline feels like bureaucracy under Babel. Under esbuild it's a type system. It's a better default.


Chapter 2: process.env doesn't exist in the browser

CRA injected process.env into browser code. Vite doesn't — it exposes import.meta.env, and only for variables prefixed VITE_.

Every REACT_APP_* reference had to move. src/config.js alone was a solid block of it:

- const cloudFunctionsEnvironment = process.env.REACT_APP_API_ENV;
- export const serverProxyURL   = process.env.REACT_APP_PROXY_URL;
- export const redirectURL      = process.env.REACT_APP_REDIRECT_URL;
+ const cloudFunctionsEnvironment = import.meta.env.VITE_API_ENV;
+ export const serverProxyURL   = import.meta.env.VITE_PROXY_URL;
+ export const redirectURL      = import.meta.env.VITE_REDIRECT_URL;
Enter fullscreen mode Exit fullscreen mode

…plus the entire cookie-name map, which derives ten keys off VITE_COOKIE_PREFIX, and the OAuth token-refresh config in helper.jsx.

The subtle part isn't the rename. It's that process.env.FOO on an unset variable silently yields undefined, and you get a broken URL at runtime. There is no build-time error. A single missed rename ships a undefinedaccess_token cookie to production, and you find out from a support ticket.

Two things de-risked this:

  1. A grep-and-verify pass, not a find-and-replace pass. Every hit reviewed individually, because some process.env references were in Node-side config that should stay process.env.
  2. Consolidating the env files. CRA's env-cmd setup had .env, .env.local, .env.localV2, .env.development, .env.staging scattered at the repo root, selected by npm script. I moved them into environments/ and let Vite's native mode flag drive selection:
"local":       "vite --mode localhost",
"build:dev":   "vite build --mode development",
"build:stage": "vite build --mode staging",
"build":       "vite build"
Enter fullscreen mode Exit fullscreen mode

with envDir: 'environments' in the config. env-cmd deleted. One mechanism instead of two, and the mode name is now visible in the command you actually type.

index.html also moved from public/ to the project root — Vite treats it as the build entry, not a template — and %PUBLIC_URL% placeholders became plain absolute paths.


Chapter 3: the white screen with no stack trace

First successful build. Open the app. White screen.

Console: Uncaught ReferenceError: global is not defined.

This is the migration bug that eats an afternoon, because the error points at bundled vendor code and tells you nothing about why it's there.

The cause: global is a Node identifier. It does not exist in browsers. Webpack, being a Node-first bundler, silently shimmed it for every CommonJS dependency that reached for it. Vite — an ESM-first, browser-first bundler — does not.

Our dependency tree was old enough to be full of candidates. socket.io-client@2.3.0 and draft-js@0.10.5 are both from an era when "just assume global" was normal library code. Because the reference is inside a dependency, you can't fix it in your own source.

The fix I shipped is deliberately blunt — a shim in index.html, before the module entry:

<script type="module" src="./src/index.jsx"></script>
<script>
  var global = global || window
</script>
Enter fullscreen mode Exit fullscreen mode

Why this and not define: { global: 'window' } in the Vite config?

define does a raw textual substitution across every module at build time. That's a shotgun: it rewrites the identifier global everywhere, including inside strings and comments in dependency code, and it behaves differently in dev vs build. I hit a real inconsistency between vite dev and vite build output while testing it.

The HTML shim is honest about what it is: one global, defined once, in the document, visible to anyone who opens index.html. It's not elegant. It's legible, and for a shim whose entire purpose is to be deleted the day those two dependencies get upgraded, legibility beats elegance.

That's the trade-off I'd defend in review, and did.


Chapter 4: five dependencies that couldn't make the jump

This is where the migration stopped being about the build and started being about the product.

Some packages simply don't survive contact with ESM + esbuild — they ship CommonJS-only, depend on Node builtins, or were abandoned before ESM mattered. Five had to go:

Package Replaced with
reactjs-localstorage a 15-line storageWrapper
rc-time-picker-date-fns native <input type="time">
react-sortable-hoc + array-move plain function-returned JSX
react-numeric-input native numeric input
node-sass@4.14.1 sass@1.69.7 (Dart Sass)

node-sass deserves a footnote: it's a native binding compiled against a specific Node ABI. It's the reason the project was pinned to an old Node in the first place. Deleting it is what let CI move from node:14.16.1 to node:16.20.2 — which Vite 4 requires anyway. One dependency was holding the entire toolchain hostage.

reactjs-localstorage was the easy one. The library's whole surface is get/set/getObject/setObject, and helper.jsx had grown nine thin wrappers around it (StoreCookie, GetDcIdToken, StoreLastLoggedInUser, DeleteIdTokenCookie…) most of which nothing called any more. I replaced the package and the nine wrappers with one object:

export const storageWrapper = {
  set: (key, value) => {
    localStorage.setItem(key, typeof value === 'string'
      ? value
      : JSON.stringify(value));
  },
  get: (key, defaultValue) => localStorage.getItem(key) || defaultValue,
  remove: (key) => localStorage.removeItem(key),
  clear: () => localStorage.clear(),
};
Enter fullscreen mode Exit fullscreen mode

Net: one dependency and ~40 lines of dead abstraction deleted, and the call sites got clearer — GetStorePermission() now visibly does JSON.parse(storageWrapper.get('DC_PERMISSION', [])) instead of hiding the parse inside a library method named getObject.

react-sortable-hoc was the interesting one. The reviewer's call was not to find a replacement drag-and-drop library at all — the sortable behaviour on the jobs dashboard wasn't actually used — and instead unwrap SortableContainer / SortableItem back into plain functions returning JSX, preserving the code structure so the diff stayed reviewable. Removing a feature is a legitimate answer to "this dependency won't build." It's just the one nobody suggests.


Chapter 5: ReactComponent was never a real thing

CRA let you do this:

import { ReactComponent as ClockIcon } from './clock.svg';
Enter fullscreen mode Exit fullscreen mode

That named export is a webpack loader convention. It is not part of any standard. Vite has no idea what you mean.

vite-plugin-svgr covers the common case, and I added it. But the reviewer caught a genuine inconsistency: I was using svgr in some files and <img src={...} /> in others, with no rule for which. That's the kind of thing that looks like a nit and is actually a maintenance tax — the next person has to read the imports to know what an SVG is in this codebase.

The rule we landed on, and the reason for it:

  • Static, decorative SVGs<img src={Icon} />. Cheap, cacheable, no parse cost.
  • SVGs that need to respond to the theme → hand-authored inline JSX components in src/images/svg_helper.jsx.

That second category is the interesting one. DreamNet has a dark mode driven by CSS custom properties. An <img> is an opaque replaced element — it cannot inherit --aurora-colors-text from the page. So the dropzone's upload icons, which need to be visible in both themes, had to become real DOM:

export const MobileIcon = ({ className }) => (
  <svg className={className} viewBox="0 0 27 43" ...>
    <rect stroke="var(--aurora-colors-text)" ... />
    <path fill="var(--aurora-colors-text)" ... />
    <path fill="var(--aurora-colors-primary-300)" ... />
  </svg>
);
Enter fullscreen mode Exit fullscreen mode

The two .svg files they replaced were deleted outright. A pile of the review churn in this MR — fix: download icon dark mode issue, fix: calendar icon colour in dark mode, fix: Edit-icon in collection page in dark mode, fix: dark mode empty table text issue — is this exact class of bug, found one screen at a time.


Chapter 6: Fast Refresh has opinions about your exports

Two commits in this MR read resolve: resolve warning of fast refresh component and fix: remove warning of fastRefresh, and I want to explain them because the fix looks like a no-op.

React Fast Refresh can only hot-reload a module if it can statically prove the module's exports are all components. An anonymous expression export defeats that:

// Fast Refresh can't track this
export default withToast(Dropzone);
Enter fullscreen mode Exit fullscreen mode
// It can track this
const DropZoneComponent = withToast(Dropzone);
export default DropZoneComponent;
Enter fullscreen mode Exit fullscreen mode

Identical at runtime. Completely different for HMR: the first form silently forces a full page reload on every edit, which quietly deletes most of the developer-experience benefit you migrated for.

We wired eslint-plugin-react-refresh into the config so this is enforced rather than remembered:

plugins: ['react-refresh'],
rules: {
  'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
}
Enter fullscreen mode Exit fullscreen mode

allowConstantExport: true matters — Vite supports mixing constant exports with component exports, and without it the rule fires constantly on legitimate files.

The same file also turned off react/react-in-jsx-scope and react/jsx-uses-react, because SWC's automatic JSX runtime means import React from 'react' is no longer required. That's why dozens of files in this diff lose their React import — it's not cosmetic, it's the transform contract changing.


Chapter 7: the chart.js version that went backwards

One line in package.json got a direct challenge in review:

Reviewer: Any specific reason to go backwards? Is it a compatibility issue with Node?

chart.js went 3.9.1 → 3.8.0. Downgrading a dependency during a modernisation MR looks like giving up, and it deserved the question.

The answer: at medium and small viewports, the Doughnut chart's legend swatches stopped picking up the light/dark theme colour. I bisected across chart.js patch versions, confirmed 3.8.0 was clean, and 3.9.1 was not, and found the matching upstream report — chartjs/Chart.js#10372.

Before

After

Pinning back to 3.8.0 with a link to the upstream issue is the correct call here, and I'd make it again. The alternative was writing a custom legend renderer to work around a known upstream regression, in an MR that already had 166 changed files and a hard invariant of "nothing user-visible changes." Scope discipline is a technical decision, not a project-management one.


Chapter 8: the reviewer who wanted zero

The single most valuable thing that happened to this MR was this comment:

After I ran npm run eslint-fix, I still see 90 warnings!!!! I think I have asked you to resolve it. Here is the patch I did in one of the files to resolve two of these. You can apply the patch with git apply mypatch.patch […] I want 0 errors/warnings here.

Attached: an actual .patch file demonstrating the fix pattern.

Ninety warnings is a normal number for a five-year-old codebase, and the reflexive response is "those are pre-existing, out of scope." That would have been wrong, and here's the concrete reason why.

While clearing them, ESLint surfaced this in event_details.jsx:

LocationOptions.prototype = {   // ← prototype
  locations: PropTypes.arrayOf(...),
};
Enter fullscreen mode Exit fullscreen mode

prototype, not propTypes. A typo. Which means that component had never validated its props — the runtime type checks had been silently disabled since the day the line was written. No error, no warning, no symptom. Just a safety net that was never actually attached.

It's now propTypes, with a matching defaultProps. That's a real bug, in production, found by a lint pass that a lazier version of me would have argued was out of scope.

The lint gate now runs on every commit rather than on trust — husky's package.json hooks block replaced with a .husky/pre-commit script driving lint-staged, so only touched files get checked and the hook stays fast:

// .lintstagedrc.js
export default {
  '*.{js,jsx,ts,tsx}': ['eslint --fix'],
};
Enter fullscreen mode Exit fullscreen mode

How this was actually validated (the honest version)

DreamNet has no automated test suite. react-scripts test existed in package.json and ran nothing. I'm not going to dress that up.

So validation was three things, and I'd argue the first one is underrated:

1. The invariant did the work. Because we deliberately changed no behaviour, "correct" was defined as "byte-for-byte identical to dev, on every screen." That turns verification from a judgement call into a comparison. The reviewer could — and repeatedly did — post side-by-side screenshots of dev vs the branch and say "this spacing is different," and there was no argument to have. Six weeks of review comments are almost entirely of that form.

2. The migration was kept alive against a moving target. dev did not freeze for 70 days. The MR absorbed merges of 31, 23, 15, 18, 35, 57 and 17 commits from dev over its lifetime. That was a deliberate choice over rebasing at the end: every merge is a small, attributable conflict resolution. One giant rebase at the end is an unreviewable mess where a subtle behaviour change hides indefinitely.

3. The deploy chain proved itself. .gitlab-ci.yml moved to node:16.20.2 across the lint, dev, stage and prod jobs, and the build was then exercised through the real pipeline — dev, then stage, then production — rather than trusted from a local npm run build. Vite's dev server and its Rollup production build are different code paths; a dev-only smoke test proves less than it feels like it does. react-numeric-input in particular only failed at build time, not in dev — commit fix: remove react-numeric-input package and resolve breaking changes in build.

Merged 4 January 2024. Still the build system today, now serving dev, stage, production and a Hong Kong deployment.


What it bought

⚠️ TODO before publishing: fill these in from your own machine. Run time npm run build on the merge commit f275733 and on its parent d46df1c, and time a cold vite/react-scripts start for both. Don't publish estimates.

CRA + Webpack Vite + SWC
Cold dev server start __ __
HMR / file change reflected __ __
Production build (CI) __ __
Node version required 14.16.1 16.20.2
Direct dependencies 34 26
Build-tool devDependencies 15 7

The dependency numbers are the ones I care about most, honestly. react-scripts@3.0.1 was a single line in package.json that pulled in Babel, webpack, PostCSS, ESLint, Jest and their entire transitive universe, all pinned to 2019, all unupgradeable independently. Replacing it with vite + @vitejs/plugin-react-swc + vite-plugin-svgr means every one of those concerns is now a package we can move on its own schedule.


Takeaways

1. Migrate the build tool, not the app. Every change that isn't strictly required by the new bundler is a change that makes "is this a regression?" unanswerable. Staying on React 16 wasn't conservatism — it was what made the MR reviewable at all.

2. The dependencies are the migration. The config was 23 lines and one afternoon. Five incompatible packages were ten weeks. Audit package.json against ESM compatibility before you estimate, not after.

3. Removing a library is a domain-modelling exercise in disguise. rc-time-picker-date-fns didn't just render a widget — it imposed a data model where time and date were fused into one Date. Replacing it forced us to name the operation we'd always been doing badly (compareTimes), which is how the "can't schedule a future event in the afternoon" bug finally surfaced.

4. global is not defined means a dependency is older than ESM. Shim it visibly in index.html rather than invisibly via define. Leave a shim you can find and delete later.

5. Take the lint warnings seriously, especially the pre-existing ones. prototype instead of propTypes had silently disabled runtime prop validation on a production component. Ninety warnings hid exactly one real bug. That ratio is worth it.

6. Merge from main constantly on a long-lived branch. Seven merges from dev over 70 days, each a small resolvable conflict. The alternative is one enormous rebase where a behavioural change can hide with nobody noticing.

7. No test suite isn't an excuse — it's a constraint you design around. Ours was replaced by a hard invariant ("nothing user-visible changes") plus a reviewer with screenshots. Not as good as tests. Considerably better than vibes.


Thanks also to the open-source projects that made the destination worth the trip: Vite, SWC, @vitejs/plugin-react-swc, vite-plugin-svgr, Dart Sass, and Chart.js — whose maintainers had already documented #10372 by the time I went looking, which saved me a day.


If you've done a CRA → Vite migration on a codebase older than three years, I'd genuinely like to hear which dependency was your rc-time-picker. Everyone has one.

Top comments (0)