For two weeks in December we stopped shipping features and ripped out the frontend build system. Create React App out, Vite in. React 17 to 18. Five package.json files collapsed into one. We came out the other end with a dev server that boots in a few seconds instead of waiting half a minute, and HMR that feels live again.
This is what we did, in the order we did it, and the parts that were harder than we expected.
What we were dealing with
The app — internally we called it grow-frontend — had grown the way these things grow. It started as a Create React App, then someone needed code sharing, so it became a Turborepo monorepo with workspace packages. Then someone left, and the workspace boundaries stopped meaning anything, and we ended up with five package.json files, a Turborepo cache that nobody trusted, and import paths that bounced through @zomentum/ui-components for components that lived two folders over.
The numbers we cared about, before:
- Cold start: 30-60 seconds before the first render.
- HMR: 2-3 seconds on a good save, full reload on a bad one.
- Full reload: 10-15 seconds.
- Production build: 2-3 minutes.
None of those are catastrophic on their own. The problem is they compound. A CSS tweak costs you two seconds. A typo costs you two seconds. Multiply that across a day of saving constantly, then across a team, and the cost is real even if I’m not going to put a fake annual hour figure on it.
Underneath all that we were still on React 17.0.2, our Jest setup didn’t play nicely with anything ESM, and TypeScript checking was slow enough that nobody ran it locally. The whole stack felt like it belonged to a previous version of the team.
We had an active product and an active release schedule. There was no version of this where we could freeze features for a month, so the migration had to land in pieces, each piece shippable on its own.
Phase 1: clean up before you migrate
Before changing any build tooling, we deleted things.
Storybook was the easy one. We hadn’t actually opened it in months — the team had drifted to writing component tests instead — but its config and stories were still in the build graph. We pulled out hundreds of Storybook-related files.
Then we collapsed the proposal-builder workspace package back into the main app. It had been factored out for sharing that never happened. Pulling it back in meant updating dozens of external import sites and fixing the tests that referenced them, but it killed an entire workspace boundary.
We also pulled out the conditional logic for PartnerAlign, which was being sunset. Every removed branch is one we don’t have to keep porting.
The reason for doing all of this first: every line you delete is a line you don’t have to migrate. The Vite migration was easier two days later because there was less code in front of it.
Phase 2: collapse the monorepo
The monorepo wasn’t paying for itself. We weren’t versioning packages independently. We weren’t sharing code with anything outside this repo. The workspace boundaries existed because somebody once thought they might be useful, and nobody had taken them out.
So we took them out. One package.json. One node_modules. Imports that used to read @zomentum/ui-components/... became local paths under @/V2Components/..., with TypeScript path aliases doing the actual resolution. ProposalBuilder, Onboarding, and SalesActivity all became regular directories inside src/.
Hundreds of import paths changed. The CI pipelines got shorter — install, lint, test, build, with no Turborepo orchestration in front. The mysterious workspace-hoisting bugs went away because the workspace went away.
Doing this before the Vite migration was the single biggest unlock. Migrating a build system across one app is a tractable problem. Migrating it across a workspace graph is not.
Phase 3: CRA and CRACO out, Vite in
This was the hard one.
We pulled out react-scripts, CRACO, and Webpack and configured Vite 5 with esbuild for transpile and Rollup for the production bundle. The Vite config ended up doing a few specific things:
- Manual vendor chunking for the big dependencies — React, Redux, Ant Design, Uppy, Charts — so they cache independently.
- Asset organisation for images, fonts, and JS output.
- Multi-environment builds for local, dev, staging, canary, and production.
- Bundle size warnings so we’d notice if something blew up later.
The first wall we hit was getstream. It assumes Node globals — Buffer, process, util — exist in the browser. Webpack used to polyfill these silently. Vite doesn’t. The build broke immediately with Buffer is not defined.
Rather than fork getstream or wait for them to ship a browser build, we wrote a small polyfill module that registers Buffer, process, and util on globalThis before the app boots, and aliased the relevant imports in Vite config so anything reaching for those Node modules gets the browser-shaped version. No application code changed. No third-party dependency had to be replaced.
The test framework had to move at the same time. Jest didn’t fit cleanly with the new ESM-first world, so we moved to Vitest — same assertion API, native ESM, jsdom environment, junit output for CI. The migration was mostly mechanical: setup files, mock patterns, a few jest.fn / vi.fn swaps.
The smaller surprise was SVG imports. CRA’s import { ReactComponent as Icon } from './x.svg' pattern doesn’t work in Vite. We wired up the SVGR plugin and updated dozens of SVG import sites to use ?react suffixes where we wanted React components and ?url where we wanted the URL.
The lesson I took away: build system migration and framework upgrade are two different problems. Trying to do both in the same change is how you end up unable to tell which thing is broken.
Phase 4: React 17 to 18
With Vite stable, the React jump was almost boring.
Bumped React 17 to 18. Swapped the ReactDOM.render(<App />, root) entry for createRoot(root).render(<App />). Done, mostly.
The non-boring part was CKEditor. We had a stack of CKEditor plugins that mounted React components inside editor decorations using the old ReactDOM.render API. React 18’s createRoot requires you to track the root and call unmount on cleanup, or you leak memory every time the editor reinitialises.
The fix was the obvious one once you’d seen it: each plugin keeps a Map of container element to root, creates roots through that map on render, and iterates the map calling unmount in its destroy hook. The same pattern got applied to the notification system, the media preview modal, and the onboarding highlight masks — anywhere we’d been mounting React imperatively into an external system.
If we had tried this on Webpack/CRA we’d have spent the day fighting the build instead of the actual upgrade. That’s the whole argument for sequencing: each step makes the next one cheaper.
Phase 5: cleanup and quality gates
Last week of the work was the unglamorous part.
The TypeScript errors that surfaced after the migration were real — module resolution had changed, strict mode caught a few things, some interfaces were missing optional fields they’d always implicitly had. Several dozen errors total. We fixed them by category instead of file-by-file, which was faster than it sounds.
The git hooks had been disabled at some point and never re-enabled. We put them back: pre-commit lint-staged, pre-push type check, commitlint on the message. These are the things that prevent the repo from drifting back into the state we just spent two weeks fixing.
We also pulled out Beamer, which had been used for in-app product announcements once and never since. And we walked through every env configuration — local, the dev tiers, staging, canary, and the prod variants — to make sure each one actually built.
What we ended up with
Hard numbers, before and after:
| Before | After | |
|---|---|---|
| Dev cold start | 30-60s | 2-5s |
| HMR | 1-3s | sub-second |
| Full reload | 10-15s | 1-2s |
| Production build | 2-3 min | 30-60s |
| Test run | 15-30s | 5-10s |
| Type check | 20-30s | 10-15s |
The dev experience change is the one developers actually notice. Type-check and test speed matter, but they’re things you run a few times a day. The dev server is something you live inside.
Past the raw numbers: we’re on React 18 now, so concurrent features are available when we want them. The architecture is one config file instead of five. Strict TypeScript is on. The git hooks run. New folks coming into the repo can read the structure top-to-bottom in an afternoon instead of needing somebody to walk them through which workspace owns what.
What I’d tell someone about to do this
If your build is slow and your monorepo isn’t earning its keep, the order matters more than the tools.
Delete first. Code that doesn’t exist can’t be migrated, can’t break, can’t slow your build. Start by removing the Storybook nobody uses, the integration that’s being sunset, the workspace package that was supposed to be shared and never was.
Flatten before you swap. Going from monorepo-on-CRA to single-app-on-Vite is two changes pretending to be one. Do the structural change on the build system you already have, then swap the build system on the structure you’ve simplified.
Build first, framework second. The React 18 upgrade was a one-day job once Vite was stable. It would have been a week of debugging if we’d tried to do it through CRA.
Audit your dependencies for Node assumptions early. Webpack’s silent polyfills hide this. Vite makes it visible. Better to find out which packages assume Buffer exists on day one than on production deploy day.
And put the git hooks back. Two weeks of cleanup unwinds in two weeks of nobody enforcing it.