For years, Create React App (CRA) was the default way to start a React project. That era is over: the React team officially deprecated CRA in February 2025. It no longer receives active maintenance, and new projects are directed to a framework (such as Next.js or React Router in framework mode) or to a build tool such as Vite. If you still run a CRA app in production, you are running on an unmaintained Webpack setup, and dependency warnings will only get harder to resolve over time.
For most client-rendered single-page apps, Vite is the most direct replacement. The migration is mostly mechanical, but there are a handful of places where CRA conventions do not carry over. This guide covers why Vite is faster, how to migrate step by step, and the pitfalls worth checking before you start.
Why Vite Is Faster Than Create React App
CRA wraps Webpack. In development, Webpack bundles the whole application before it serves the first page, and on every change it rebuilds the affected parts of that bundle. As a codebase grows, both start-up and rebuild times grow with it.
Vite takes a different approach in development:
- Source code is served as native ES modules. The browser requests modules and Vite transforms each file on demand. Pages you never open are never compiled.
- Dependencies are pre-bundled once with a fast native-code tool and cached, so a library made of hundreds of small files does not mean hundreds of requests.
- Hot Module Replacement works per module. When you edit a file, only that module and its boundary are replaced, so updates stay fast regardless of app size.
For production, Vite bundles the app with a conventional optimising bundler. Historically that was Rollup, with esbuild used for transforms and minification. Recent Vite versions have been moving to Rolldown, a Rust-based bundler designed to replace both. Some option names have changed along the way (for example build.rollupOptions has a Rolldown-era equivalent), so check the docs for the Vite version you install.
What this means in practice:
| Create React App | Vite | |
|---|---|---|
| Dev server start | Bundles the whole app first; slows down as the app grows | Starts almost immediately; work happens per requested module |
| Edits during development | Rebuilds affected chunks | Replaces the changed module |
| Production build | Webpack (no longer maintained via CRA) | Optimised bundle via Rollup/Rolldown |
| Configuration | Hidden; needs eject, CRACO or react-app-rewired to change | Plain vite.config.js |
| Status | Deprecated (Feb 2025) | Actively maintained |
Exact speedups depend on the project, so we will not quote a universal number. The difference is usually most visible on larger apps, where CRA's start-up and rebuild times have crept up the most. Measure your own before and after.
Pre-Migration Assessment
Inventory Your CRA Customisations
If you use CRACO, react-app-rewired or an ejected config, list every customisation before you begin: path aliases, extra Webpack plugins and loaders, proxy settings, environment handling and Babel plugins. Each needs a Vite equivalent, a replacement, or a decision to drop it.
Search for CRA-Specific Patterns
A few greps reveal most of the work:
# Environment variables that need renaming
grep -rn "process.env" src/
# CommonJS in application code (Vite expects ES modules in src/)
grep -rn "require(" src/
# CRA-style SVG component imports
grep -rn "ReactComponent" src/
# Placeholders in index.html
grep -n "%PUBLIC_URL%" public/index.html
# JSX inside .js files (Vite expects .jsx/.tsx for JSX by default)
grep -rln "<[A-Za-z]" src --include="*.js"
The last one is noisy but useful. CRA happily compiles JSX inside .js files; Vite does not by default. Renaming those files to .jsx is the cleanest fix.
Check Your Dependencies
Most maintained packages work with Vite unchanged, and Vite's pre-bundling converts CommonJS dependencies to ES modules for you. Problems usually come from older packages that assume Node globals in the browser (process, Buffer, global) because Webpack 4 used to polyfill them. If a dependency fails at runtime with "process is not defined" or similar, look for a newer version or a browser-friendly alternative before reaching for polyfills.
Know Your Node Version
Current Vite releases require a recent Node.js LTS (Node 20.19+ or 22.12+ for Vite 7, for example). Check the requirement for the version you install and update local machines and CI runners first.
Step-by-Step Migration
1. Swap the Dependencies
npm uninstall react-scripts
npm install --save-dev vite @vitejs/plugin-react
# Only if you need them
npm install --save-dev vite-plugin-svgr # SVGs as React components
npm install --save-dev vite-tsconfig-paths # reuse tsconfig path aliases
npm install --save-dev @vitejs/plugin-legacy # very old browsers only
2. Create vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [react(), svgr()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 3000, // keep CRA's familiar port
open: true,
proxy: {
// replaces CRA's "proxy" field or setupProxy.js
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'build', // CRA's output folder, so deploy scripts keep working
sourcemap: true,
},
});
Fast Refresh is on by default in @vitejs/plugin-react; there is no option to set. If your CRA app used a proxy field in package.json, note that CRA forwarded the path unchanged, so do not add a rewrite unless your backend actually expects the prefix removed.
3. Move and Update index.html
Vite treats index.html as the entry point, so it moves from public/ to the project root, and it loads your entry file directly:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
Key differences from CRA:
- Remove
%PUBLIC_URL%; files inpublic/are served from/. - The
<script type="module">tag is required; CRA injected its script automatically. - Vite can also swap environment variables into
index.htmlat build time: wrap the variable name (for exampleVITE_APP_TITLE) in percent signs where you need the value.
4. Update package.json Scripts
{
"scripts": {
"dev": "vite",
"start": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest"
}
}
Keeping a start alias avoids breaking team habits and existing docs.
5. Migrate Environment Variables
CRA exposes variables prefixed REACT_APP_ through process.env. Vite exposes variables prefixed VITE_ through import.meta.env. Your .env, .env.development and .env.production files keep their names; only the variable names change.
// CRA
const apiUrl = process.env.REACT_APP_API_URL;
const isProd = process.env.NODE_ENV === 'production';
// Vite
const apiUrl = import.meta.env.VITE_API_URL;
const isProd = import.meta.env.PROD; // also: DEV, MODE
A careful search-and-replace handles most of it. Replace the prefixed form first, so you do not blindly turn every process.env into import.meta.env:
# Rename the variables in env files
sed -i 's/^REACT_APP_/VITE_/' .env*
# Rewrite usages in source
grep -rl "process.env.REACT_APP_" src | xargs sed -i 's/process\.env\.REACT_APP_/import.meta.env.VITE_/g'
Then handle remaining process.env.NODE_ENV checks by hand using import.meta.env.DEV, PROD or MODE. Review the diff before committing.
Remember that anything prefixed VITE_ ends up in the JavaScript bundle and is visible to anyone. Never put secrets there, which was equally true of REACT_APP_.
For TypeScript, declare your variables in src/vite-env.d.ts:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_ANALYTICS_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
6. Rename JSX Files and Fix Imports
Unlike some guides suggest, you do not need to add file extensions to your imports. Vite resolves .js, .jsx, .ts, .tsx and .json automatically, so import Button from './components/Button' keeps working. What you do need is for files containing JSX to use .jsx or .tsx. Rename them (using git mv keeps history), and update src/index.js to src/index.jsx to match the script tag in index.html.
If you used CRA's absolute imports via baseUrl (for example import Button from 'components/Button'), either switch to the @ alias configured above or add vite-tsconfig-paths so Vite reads the same paths as TypeScript.
7. Update SVG Imports
CRA allowed import { ReactComponent as Logo } from './logo.svg'. With current versions of vite-plugin-svgr, add a ?react query and use the default export:
import Logo from './logo.svg?react'; // React component
import logoUrl from './logo.svg'; // URL string, as before
TypeScript users should add /// <reference types="vite-plugin-svgr/client" /> to vite-env.d.ts.
8. Replace Jest with Vitest
CRA's test runner disappears with react-scripts. Vitest is the natural replacement: it reuses your Vite config and has a Jest-compatible API, so most tests run with few changes.
npm install --save-dev vitest jsdom @testing-library/react @testing-library/jest-dom
// vite.config.js (add a test section)
export default defineConfig({
// ...plugins, resolve, server, build as above
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setupTests.js'],
},
});
Your existing src/setupTests.js can usually stay; change its jest-dom import to import '@testing-library/jest-dom/vitest'. Replace jest.fn() and jest.mock() with vi.fn() and vi.mock(). If you use TypeScript, add /// <reference types="vitest/config" /> at the top of the config so the test key is typed.
Performance Tuning After the Switch
Code-Split by Route
Vite splits every dynamic import() into its own chunk. Lazy-loading route components is still the single most effective way to shrink the initial bundle:
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router';
const Dashboard = lazy(() => import('./features/dashboard/Dashboard'));
const Reports = lazy(() => import('./features/reports/Reports'));
function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}
Be Careful with Manual Chunks
Vite's default chunking is good. Only add manual vendor chunks if you have measured a problem, such as a large, rarely changing library being re-downloaded on every deploy. When you do, match exact package paths. A check like id.includes('react') also matches react-router, @emotion/react and dozens of other packages. Chunking options also differ between the Rollup and Rolldown eras, so follow the docs for your version.
Inspect the Bundle
Use rollup-plugin-visualizer (or vite-bundle-visualizer) to see what is in each chunk before optimising. A heavy date library, an icon set imported in full or a charting library on the landing page usually explains most bundle bloat.
Dependency Pre-Bundling
If a dependency is discovered late and causes a full page reload during development, add it to optimizeDeps.include. If you edit a linked package locally and changes do not appear, restart with vite --force to rebuild the cache.
Deployment
Serve the SPA Correctly
The build/ output contains index.html plus hashed files in build/assets/. Hashed assets can be cached for a long time; index.html should not be. For nginx:
server {
listen 80;
server_name example.com;
root /var/www/build;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
If the app is served from a sub-path, set base in the Vite config (the equivalent of CRA's homepage field).
Update CI
# .github/workflows/build.yml
name: Build
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test -- --run
- run: npm run build
env:
VITE_API_URL: ${{ vars.API_URL }}
Note the variable rename to VITE_ in your CI settings and hosting provider too. A build that silently reads undefined for the API URL is a common first-deploy surprise.
Migration Checklist
Before you start
- List CRACO, rewired or ejected customisations
- Grep for
process.env,require(,ReactComponentand%PUBLIC_URL% - Confirm your Node version meets Vite's requirement
Core migration
- Replace
react-scriptswithviteand@vitejs/plugin-react - Create
vite.config.js(aliases, proxy,outDir) - Move
index.htmlto the root and add the module script tag - Rename files containing JSX to
.jsx/.tsx - Rename
REACT_APP_variables toVITE_and useimport.meta.env - Update SVG component imports
- Move tests to Vitest
Verify
- Dev server, HMR and every major route work
-
vite buildandvite previewwork and the bundle size looks sensible - Environment variables resolve in staging
- Server rewrites, caching headers and CI are updated
- Old config files (
craco.config.js,config-overrides.js) are deleted
Results and Takeaways
For most teams, the clearest gain is the day-to-day development loop. The dev server comes up almost instantly, edits show up immediately, and builds in CI are usually noticeably quicker. Just as important, you end up with a maintained toolchain with a configuration file you can read and change, instead of a deprecated wrapper around an old Webpack setup.
A few lessons that come up again and again:
- Most of the work is mechanical. Environment variables,
index.html, JSX file extensions and SVG imports account for the bulk of the changes. - Tests are the long tail. Moving from Jest to Vitest is usually straightforward, but mocks and custom setup take the most time.
- Measure before and after. Record dev start time, build time and bundle size before you migrate, so you can see the real improvement for your codebase.
- Consider whether you need a framework. If you need server rendering, SEO-critical pages or server-side data loading, the React team recommends a framework instead of a plain SPA. For dashboards and internal tools, Vite plus a client-side router is often the simplest fit.
If you are planning a migration or a frontend rebuild and want an experienced team to help, see our web application development services or book a free consultation with TrackMy Tech.
