Build1 publisherNot yet confirmed elsewhere3 min readPublished
A build-tool swap that took 70 days and 166 files, and the build was the easy part
The Vite config came to 23 lines. The merge request came to 166 files and 70 days, nearly all of it paying down what Create React App had been quietly supplying.
The Engineer · Build desk

What happened
- A merge request to replace Create React App and Webpack with Vite and SWC on ZURU's DreamNet admin app ran to 166 changed files and 70 days.
- The app was still on react-scripts 3.0.1, a 2019 release, propped up by rewire, env-cmd and a node-sass build that needed a specific Node version.
- The Vite config that replaced all of it is 23 lines, using the SWC React plugin and an SVGR plugin.
- React 18 was left out on purpose: the app stayed on react 16.14.0, ReactDOM.render and react-router-dom 4.3.1.
- Adding "type": "module" was one line and cost a day, forcing .cjs and .mjs renames plus a wave of .js to .jsx moves.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- cost Seventy days of one engineer's attention were charged against a change no user can see, and the invoice was dependency archaeology rather than bundler configuration.
- constraint Refusing the framework upgrade is what kept review cheap: with no intended behaviour change, any difference between the two environments is a defect and nobody has to argue about which deltas were...
- exposure Any team still on react-scripts 3 is carrying the same undocumented contract in its source, and it stays invisible until the thing satisfying it is removed.
- precedent Pinning the output directory and port to the old defaults keeps CI, Docker and the deploy scripts ignorant of the swap, which means the migration debt moves into config and waits there for whoever...
The load-bearing sentence in the whole account is that Create React App was never a bundler in the first place, it was an API [16]. Over five years, DreamNet's source had absorbed webpack's semantics: `process.env` reads in browser code, `%PUBLIC_URL%`, `import { ReactComponent as Icon }`, JSX living in `.js` files, implicit Node globals, and automatic Babel transpilation of every CommonJS dependency the team had ever installed [17]. None of those are React features and all of them were holding something up, the author writes [17]. So the first task was not the config file but a survey of every place the source assumed only webpack could satisfy, and that list came out roughly ten times the size of the config [18].
The `constants.js` episode is the mechanism in miniature. It was a data module that had quietly grown JSX inside it, nav configs and ingredient lists and session-OS maps each carrying an `icon: <AacIngredientIcon />` [15]. What exposed it was esbuild's refusal to parse JSX in a `.js` file, which Babel had been doing without complaint [13]. The fix was to split the JSX-bearing exports into `constant.jsx` and leave `constants.js` as data, a change the author says was worth making on its own merits [15]. Note the direction of causation. The new toolchain did not create that mess; it declined to keep concealing it.
Then there is the shape of the review. Twenty-eight or more diff revisions attracted 19 review comments [2], which is fewer than one comment per revision [23], spread across 70 days at roughly one revision every two and a half days [24]. That is not a merge request stuck in an argument. That is one engineer repeatedly finding the next thing the old build had been supplying for free. The 166 changed files also exceed the roughly 130 source files in `src/scene` alone [22], so the blast radius reached well past the screens.
Two things temper the read. This is a single first-person write-up, submitted to DEV's Summer Bug Smash powered by Sentry [21], and the payoff is stated as intent rather than measurement: faster cold start, near-instant HMR, better production builds [19]. The old feedback loop is described in units of checking Slack and, on a cold start, making coffee [20]. Anyone approving this work on those grounds is buying 70 days of attention against a number nobody in the account has yet published.
The transferable part is the discipline around the deployment contract. Vite defaults to `dist` and port 5173, but the GitLab CI jobs, Docker image and deploy scripts expected `build/` on port 3000, so both were pinned in config [7]. The author's rule: absorb the churn in configuration, not in infrastructure, because changing the bundler and the deployment contract together produces a broken pipeline you cannot attribute to either [8].
What to watch
- Whether a follow-up MR takes React 18 and the router upgrade now that the .jsx boundaries have already been drawn, and how many files it touches.
- Any published before-and-after timings for cold start and HMR, which the write-up promises as intent but does not measure.
- Post-cutover regression reports: the account ends at green on production, not after a week of live traffic.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence54
- Adoption24
- Hype gap+8
- Incentives41
- Confidence49
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
MR !907 migrated DreamNet from Create React App plus Webpack to Vite plus SWC: 166 changed files and 70 days between the proposal to swap the bundler and green on production.
- [2]
MR !907 carried 28 or more diff revisions and 19 review comments.
- [3]
DreamNet is the internal admin platform for ZURU's housing division, covering user management, RBAC, asset catalogues, project publishing, release builds, an order-pricing engine, event management and dashboards with charts and maps.
- [5]
The app was scaffolded with Create React App and was still running react-scripts@3.0.1, a 2019 release, held together with rewire, env-cmd and node-sass@4.14.1, which needed a specific Node version to compile.
- [6]
The replacement vite.config.mjs is 23 lines, using @vitejs/plugin-react-swc and vite-plugin-svgr with envDir set to 'environments'.
- [7]
build.outDir was set to 'build' and the port to 3000 deliberately, because Vite defaults to dist and 5173 while the GitLab CI jobs, Docker image and deploy scripts all expected build/ on port 3000.
- [8]
The author's stated rule is to absorb the churn in config rather than infrastructure, because changing the bundler and the deployment contract in the same MR produces a broken pipeline you cannot attribute to anything.
- [9]
The migration deliberately did not fold in a React 18 upgrade; the app stayed on react@16.14.0, ReactDOM.render and react-router-dom@4.3.1.
- [10]
The author's reasoning: a build migration has no user-visible intent, so any visual or behavioural difference is a regression, which lets a reviewer diff the two environments side by side and treat any delta as a bug; folding in React 18 would forfeit that invariant because some differences would then be expected.
- [11]
The MR was labelled a breaking change for tooling and intended as a no-op for behaviour.
- [12]
Adding "type": "module" to package.json was one line and cost a day: Node then treated every .js file as ESM, so .eslintrc.js was renamed .eslintrc.cjs, the Vite config had to be .mjs, and .lintstagedrc.js had to be rewritten as export default.
- [13]
esbuild will not parse JSX inside a .js file by design, because the extension makes no promise about JSX and guessing costs parse time, whereas Babel parsed it happily.
- [14]
The extension rule forced renames including src/app.js to app.jsx, src/index.js to index.jsx and src/service/apollo_wrapper.js to .jsx.
- [15]
src/constants.js was a data module that had grown JSX inside it, including nav configs, ingredient lists and session-OS maps carrying icon: <AacIngredientIcon />; it was split into a new src/constant.jsx holding the JSX-bearing entries, leaving constants.js as pure data, a split the author says was worth doing on its own merits.
- [16]
The author's framing: CRA is not a bundler, CRA is an API.
- [17]
Over five years the codebase absorbed webpack's semantics into its source: process.env, %PUBLIC_URL%, import { ReactComponent as Icon }, JSX inside .js files, implicit Node globals in browser code, and automatic Babel transpilation of every CommonJS dependency installed; none of those are React features and all of them were load-bearing.
- [18]
The first step was not writing vite.config.mjs but running a survey of every place the source assumed only webpack could satisfy; that list became the real scope of work and was about ten times larger than the config file.
- [19]
The proposal was pitched as faster cold start, near-instant HMR and better production builds.
- [20]
The pre-migration feedback loop is described qualitatively: the gap between Ctrl+S and seeing the change was long enough to check Slack, long enough to forget what you were testing, and on a cold start long enough to make coffee.
- [21]
The account is a first-person submission to DEV's Summer Bug Smash: Smash Stories, powered by Sentry.
- [22]
The 166 changed files exceed the roughly 130 source files in src/scene by about 36 files.
- [23]
19 review comments across 28 or more diff revisions is fewer than one comment per revision, about 0.68.
- [24]
70 days across 28 or more revisions works out at roughly one revision every two and a half days.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toMigrating a 5-year-old React admin app from CRA + Webpack to Vite + SWC — 166 files, 70 days
1 article · August 23, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.