Here at Yoto, for a big part of the business at least, we are a JavaScript shop. And like every place that has used JavaScript in some shape or form over the last 10 years, we’ve been cursed with the wonderful frontend framework that is React.

We use it in two main ways in production. The main webstore is a Next.js application, and a set of older backoffice tools run as a single-page app built with React and React Router.

When I was hired as a fullstack engineer more than a year ago, I noticed that the backoffice tools had been scaffolded with create-react-app, the de facto tool to spin up a SPA from around 2016 onwards.

There were already some rumblings that CRA was on its way out, as it hadn’t been getting the attention and support it once had. And sure enough, a few months after I joined, the framework maintainer made it official in this post in February 2025.

That gave me, as a new hire, and the company as a whole, the chance to look at migrating the project to something more modern and stable.

I’ve used Webpack extensively in the past, and I hated every bit of it. For most of my projects over the last few years, around 90 percent of them, I’ve been using Vite. So I decided to try it out on a branch, as a proof of concept for a proper enterprise-grade app used daily by hundreds of power users.

Plan

The backoffice tool is, as mentioned, a SPA with a few routes that render depending on the logged-in user’s permissions. Its main structure was built with React Router.

There are plenty of tests, written in jest and wired up with react-scripts.

The first thing I did was look around to see if anyone had done this migration before. And of course, they had. Plenty of people had taken CRA apps and moved them over to Vite.

The path looked straightforward:

  1. Uninstall all the react-scripts dependencies.
  2. Install Vite and set up the config.
  3. Fix the index.html entry point.
  4. Done.

It sounded simple enough.

Since we were keeping it as a SPA, there wasn’t even much to change in the pipeline, maybe just a couple of tweaks to the commands executed during the pipeline.

Execution

After a couple of commits on a branch, and a few little tweaks:

git:feat/vite-migration
❯ npm run dev
> vite
VITE v6.2.0 ready in 120 ms

Here it was, the same app, running in dev mode in only 120ms instead of 5 seconds. But did it all work? Were only those 3 commits and 10 file changes enough?

PR changeset

But do not get fooled by that big number, most changes looked like this

-import React, { useState, useEffect } from "react";
+import { useState, useEffect } from "react";

The good old import React from "react".

To support the svg autoimport that create-react-app had you need to add:

export default defineConfig({
plugins: [
react(),
svgr() //THIS
],

And because we had a weird import error for react-moment a quick search revealed that it could be solved by adding

export default defineConfig({
plugins: [
react(),
svgr()
],
// This fixes react-moment
resolve: {
mainFields: []
},

Was it over? Not really…

Problems

The mechanical part of the migration — swapping out react-scripts for Vite, updating the config, moving index.html to the project root — went smoothly. What took longer was everything the migration surfaced around it.

Zombie dependencies. CRA had been quietly papering over a handful of packages that had long since stopped being maintained. The most visible of these was shortid, a once-popular ID generation library that had been effectively abandoned for years. With react-scripts gone, there was no longer any reason to keep dragging it along. We replaced it with nanoid, which is smaller, faster, ESM-native, and actually maintained.

react-beautiful-dnd. The app used drag-and-drop in a few places, powered by react-beautiful-dnd. The library had been in maintenance mode for a while, and the migration was a good forcing function to deal with it. We swapped it out for @hello-pangea/dnd, a community-maintained fork that is API-compatible, so the change was largely mechanical — update the import paths and move on.

react-dropzone. This one was messier. The version of react-dropzone we were using had a known issue with the upload flow we relied on, and the fix existed in a newer version — but upgrading wasn’t straightforward without reworking the upload logic. Rather than let that block the migration, we vendored the library: copied the source directly into the repo, fixed what needed fixing locally, and left a comment pointing at the plan to migrate properly once the upload process gets the attention it deserves. Not glamorous, but pragmatic.

Tests. The test suite was wired up through react-scripts, which bundled its own Jest configuration. Rather than migrating everything at once, we used the strangler fig pattern to transition gradually. New tests were written using Vitest and named with a .vitest.ts convention, while the existing suite kept running through npx react-scripts test in parallel. Team by team, file by file, the old tests were migrated across until there was nothing left to strangle — at which point react-scripts was gone for good.

Takeaways

The migration was worth it, and not just for the startup time — though going from 5 seconds to 120ms is the kind of improvement that genuinely changes how you work.

None of this was surprising. Vite has become the clear favourite in the JavaScript tooling ecosystem for good reason. In the State of JS 2025 survey, Vite topped the build tools category with consistently high satisfaction scores year over year — the kind of result that reflects real-world happiness rather than hype. Developers who use it tend to stay with it.

The bigger takeaway, though, is what the migration revealed. A forced dependency audit is a useful thing. We found packages that had been unmaintained for years, abstractions that had outlived their usefulness, and a few sharp edges that had been quietly accumulating under the surface. The migration itself was a few days of work. Cleaning up what it exposed took a bit longer — and the codebase is better for it, also being on modern tooling also opens up possibilities that simply weren’t on the table before. One avenue we’re exploring is splitting the backoffice into a micro-frontend architecture — where each team owns and deploys their own slice of the app independently, and a shell application composes them together at runtime. Rather than one monolithic codebase that every team has to coordinate around, each team could move at their own pace. None of that would have been a realistic conversation while we were still anchored to an old CRA, is it worth moving to a micro-frontend for an internal tool? Maybe that is worthy of a bigger discussion.

If you’re still running a CRApp, this is a reasonable time to move. The tooling has matured, the path is well-documented, and the community has largely standardised on Vite. The hardest part probably won’t be the migration itself. It’ll be confronting whatever the migration finds.