Create React App is no longer the recommended starting point for new React applications, but that does not mean every existing CRA codebase needs an emergency rewrite.
I migrate when the cost of staying on the current toolchain has become higher than the cost and risk of changing it. Development-server speed is one input to that decision, not the decision itself.
For an existing product, the useful question is not “is Vite faster?” It is: what assumptions are hidden inside the current build, and what will it cost to move them safely?
I start by defining why the migration exists
If an application is stable, changes rarely, and the build is not blocking the team, migrating only to look more modern has limited value.
The reasons I take more seriously are:
- the current toolchain is no longer actively maintained in a useful way;
- React or dependency upgrades are creating friction with the old build;
- development feedback has become slow enough to affect daily work;
- the team needs build behavior or plugins that are difficult to control in CRA;
- maintainers need a more visible, understandable configuration;
- the current deployment or asset pipeline has become a real constraint.
If none of those are true, postponing the migration may be the correct engineering choice.
I inventory CRA assumptions before installing Vite
The highest-risk parts of a migration are usually not the React components. They are the capabilities the old toolchain provided implicitly.
My audit includes:
package.jsonscripts;- environment variables;
- path aliases;
publicfiles and imported assets;- SVG handling;
- development proxy behavior;
- test runner and setup files;
- service-worker or PWA setup;
- browser targets;
- dependencies that assume Webpack loaders or Node globals;
- build and deployment scripts that expect a particular output directory.
That list defines the real scope. Without it, a project can look fine in development and fail later because production assets or environment variables behave differently.
The goal is to replace the shell, not rewrite the application
If the component tree, routing, state architecture, and CSS are healthy, a build-tool migration is not a reason to replace them.
I keep the cutover focused on tooling:
- Introduce the Vite config and entry point.
- Move the existing React root mount.
- Map environment variables and aliases.
- Verify asset and CSS imports.
- Adapt test, dev, and build scripts.
- Test the production output on the same deployment target.
Any component rewrite described as “required for Vite” should have a concrete reason. This constraint prevents a tooling upgrade from becoming an open-ended refactor.
Environment variables are a real migration boundary
CRA commonly exposes client variables using the REACT_APP_ prefix through process.env. Vite exposes client-side values through import.meta.env, with variables prefixed by VITE_ included in client code.
For example:
// CRA
const apiUrl = process.env.REACT_APP_API_URL;
// Vite
const apiUrl = import.meta.env.VITE_API_URL;
The syntax change is small, but the design implications matter.
First, values exposed to client code are not secrets. A database password or private API key does not become safe because it lives in an .env file. Second, if environment access is scattered through dozens of components, the migration is a good moment to create a small configuration module so build-tool details do not leak across the application.
A component should ideally depend on config.apiUrl, not care whether the current bundler uses process.env or import.meta.env.
I test aliases and asset paths independently
An alias such as @/components may have worked through CRA customizations, TypeScript config, or editor tooling. In Vite, build-time aliasing and TypeScript resolution need to agree.
Assets also have two important categories: files imported from source and files served from a public directory with a stable URL. I do not convert one model to the other without a reason.
For important assets I verify that:
- they resolve in development;
- the production URL is correct;
- deployment base paths are respected;
- bundled assets retain cache-busting behavior;
- a public file actually needs a fixed name rather than an import.
This is the kind of bug that often stays hidden during npm run dev and appears after deployment.
Webpack-specific dependency assumptions are real risk
Some packages quietly depend on loaders, macros, process, Buffer, or polyfills that the previous toolchain supplied.
I audit sensitive dependencies rather than looking only at my own imports. If a package has not been maintained and survives only through workarounds, the migration may expose a dependency problem that already existed.
I still prefer not to replace several major dependencies during the same cutover. Fewer simultaneous variables make regressions easier to isolate.
Tests do not become optional because the bundler changed
If the application has tests, a migration that leaves them behind is not complete.
I identify dependencies on:
- Jest-specific APIs;
- setup files;
- the DOM environment;
- asset mocks;
- aliases;
- environment variables;
- coverage scripts.
The team may choose Vitest or keep parts of the current test setup temporarily. The exact tool matters less than preserving the safety net during the migration.
I evaluate the production build separately from the dev server
Fast HMR is valuable, but users never run the development server.
After the migration I check things such as:
vite buildcompletes without important unexplained warnings;- output and asset paths match the hosting environment;
- direct refreshes on SPA routes do not return server 404s;
- the
basepath is correct when the app is deployed under a subdirectory; - source maps and error tracking match project requirements;
- browser support matches the actual audience;
- HTML and asset cache headers fit the deployment strategy.
Vite has explicit production browser targets, and older-browser support is something to decide deliberately. Compatibility should be a requirement recorded before migration, not a surprise discovered after release.
Deployment is part of the migration
If an existing pipeline expects build/ and the new output is dist/, the application can be technically correct while the release still fails.
The same applies to Dockerfiles, CI scripts, CDN paths, environment injection, and SPA rewrite rules.
For me, “done” means the new artifact runs in staging or the real hosting environment, direct routes refresh correctly, and assets load from the right path. It does not mean the home page opened once on localhost.
When I consider Next.js instead of Vite
Sometimes the build tool is not the actual limitation.
If the product now needs deeper routing conventions, integrated data fetching, server rendering, metadata/SEO control, server-side capabilities, or other framework-level behavior, CRA-to-Vite can become an unnecessary intermediate step.
That is when I evaluate a React framework separately. A Next.js migration has a much larger scope and should not be disguised as a bundler switch, but if the product requirements genuinely need a framework, preserving SPA architecture only to minimize the diff can also be the wrong choice.
On the other hand, an internal client-rendered dashboard or a static-hosted tool may be exactly the kind of application where Vite is the right level of abstraction.
When I do not migrate
I am comfortable leaving CRA in place for a while when:
- the project is close to end-of-life and receives minimal change;
- the current build is not blocking security or maintenance and the team lacks regression capacity;
- a larger product rewrite is already planned soon;
- a critical dependency is incompatible and replacing it costs more than the migration is worth;
- the only justification is “Vite is newer.”
Choosing not to migrate can be a valid technical decision when the cost is understood and documented.
My cutover checklist
Before changing tooling
- The reason for migration and definition of done are clear.
- Scripts, env variables, aliases, assets, tests, and deployment are audited.
- Browser support is documented.
- Key UI and user-flow baselines exist.
During migration
- Application architecture is not rewritten without a requirement.
- Environment mapping and secret boundaries are reviewed.
- TypeScript and Vite aliases agree.
- Public and imported assets are checked separately.
- Webpack-specific dependency assumptions are identified.
Before release
- Development and production builds both succeed.
- Tests still run.
- Direct-route refresh works on the real host.
- Assets and fonts produce no 404s.
- New console warnings are investigated.
- A rollback path exists.
The decision matters more than the benchmark
I do not move a CRA project to Vite because a benchmark says the dev server is faster. I migrate when the resulting toolchain is easier to understand, maintain, and evolve for the remaining life of the product.
The lower-risk path is to audit first, replace the tooling shell, preserve healthy application architecture, and verify production deployment at the end. If the migration starts demanding a broad rewrite, I stop and ask again whether the real problem is the build tool or whether the product now needs a different framework.
