Why (and how) we migrated from Create React App to Vite

We migrated our SPA from Create React App to Vite and Jest to Vitest to escape security vulnerabilities, dependency constraints, and slow builds
Every software project built on a solid foundation eventually feels the ground start to shift.
For years, Create React App (CRA) was our reliable bedrock. But over time it became a source of technical debt, security concerns, and developer friction.
We were stuck with outdated dependencies, accumulating security vulnerabilities, and a build process that was slowing us down.
It was time to tear out the old foundation and build something new, escape a deprecated toolchain, and build a more secure approach for evolving the Codacy platform.
TL;DR
We migrated Codacy's Single Page Application from the deprecated Create React App to Vite and Jest to Vitest, which allowed us to:
- Eliminate critical security vulnerabilities from outdated webpack dependencies
- Unblock library upgrades that were previously impossible
- 80% faster CI workflows (15 min → 3 min) and improved test performance
- Measurable code quality improvements: 5 issues fixed, 1550 complexity reduction
This post details our migration strategy, the challenges we faced, and why moving away from deprecated tooling was crucial for our long-term success.
Why we made the switch
The ticking clock of technical debt
The pain points of our CRA setup were no longer minor annoyances; they were active risks and roadblocks:
- Deprecation and abandonment: With the React team officially stepping back from maintenance, CRA was no longer actively evolving. We were building on a toolchain with no clear future.
- Accumulating vulnerabilities: We were forced to patch our
package.json
with 11 forced dependency resolutions just to mitigate security vulnerabilities in legacy dependencies likewebpack
. This was a constant, reactive battle. - Development gridlock: These patches and outdated dependencies blocked us from upgrading other libraries. We couldn’t adopt modern React features or leverage the performance of modern build tools, effectively freezing our ability to innovate.
What Vite brings to the table
Vite represented a clear path forward, directly addressing our biggest concerns and future-proofing our development environment:
- Security and maintenance: As an actively maintained project with a modern dependency tree, Vite immediately eliminated the legacy
webpack
vulnerabilities that had plagued us. - Developer experience: The difference was night and day. Dev server startups dropped from ~15 seconds to under 3, and Hot Module Replacement (HMR) became nearly instantaneous.
- A solid foundation: Vite’s architecture, built on modern tools like esbuild and Rollup, gave us the stable, forward-looking foundation we needed.
How Vite compares to CRA:
Aspect |
CRA |
Vite |
Dev server startup |
~15 seconds |
~3 seconds |
HMR speed |
1-3 seconds |
<100ms |
Maintenance status |
🔴 Deprecated |
🟢 Active |
Dependencies |
Legacy webpack |
Modern esbuild + Rollup |
Our migration strategy
Phase 1: Dependencies and security
The goal was to clean up the dependency tree and establish a solid foundation. The most significant change was updating our package.json
:
// Before: CRA + Jest dependencies
{
"dependencies": {
"react-scripts": "5.0.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@types/jest": "^27.0.3"
},
"scripts": {
"start": "react-scripts start",
"build": "REACT_APP_VERSION=$npm_package_version react-scripts build",
"test": "react-scripts test"
},
// 11 forced dependency resolutions due to vulnerabilities
"resolutions": {
"recursive-readdir": "^2.2.3",
"node-forge": "^1.3.0",
"webpack-dev-server": "^4.8.0",
"express": "4.21.2"
// ... 7 more forced resolutions
}
}
{
"type": "module", // Enable ES modules
"devDependencies": {
"@vitejs/plugin-react": "^4.5.2",
"@vitest/coverage-istanbul": "^1.3.1",
"vite": "^5.1.4",
"vitest": "^1.3.1"
},
"scripts": {
"start": "VITE_APP_VERSION=$npm_package_version vite",
"build": "VITE_APP_VERSION=$npm_package_version tsc && vite build",
"test": "vitest"
},
// Only 5 essential resolutions remaining (to be upgraded as next steps)
"resolutions": {
"node-forge": "^1.3.0",
"nth-check": "^2.0.1"
// ... 3 more essential resolutions
}
}
Key wins:
- Eliminated critical security vulnerabilities by removing deprecated webpack dependencies
- Reduced forced resolutions from 11 to 5
- Unblocked library upgrades that were previously impossible with CRA
- Modernized to ES modules with
"type": "module"
- Future-proofed our build system for upcoming React features
Phase 2: Build configuration revolution
The goal was to replace CRA's hidden webpack configuration with a visible, customizable Vite setup.
Core Vite configuration
// vite.config.ts - Our new build system
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import viteTsconfigPaths from "vite-tsconfig-paths";
import svgr from "vite-plugin-svgr";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
plugins: [
react({
jsxImportSource: "@emotion/react",
babel: {
plugins: [
[
"@emotion/babel-plugin",
// ...
],
],
},
}),
viteTsconfigPaths(), // Automatic path resolution
svgr({
// SVG as React components
svgrOptions: {
dimensions: false,
// ...
},
}),
],
server: {
port: 3000,
proxy: {
// ...
},
},
build: {
outDir: "build",
sourcemap: true,
assetsDir: "static/assets",
},
// Environment variable injection
define: {
"import.meta.env.VITE_VERSION": JSON.stringify(version),
// ...
},
};
});
HTML template migration
One of the more straightforward parts was migrating from CRA's public/index.html
to Vite's root-level approach:
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="icon" href="%PUBLIC_URL%/static/favicon.ico" />
<script src="%PUBLIC_URL%/static/js/env.js?v=%REACT_APP_VERSION%"></script>
</head>
<body>
<div id="root"></div>
<!-- CRA automatically injects scripts here -->
</body>
</html>
<!-- After: Vite index.html (moved to project root) -->
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="icon" href="/static/favicon.ico" />
<script src="/static/js/env.js?v=%VITE_APP_VERSION%"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
Key changes:
- File location:
public/index.html
→index.html
(project root) - Environment variables:
%REACT_APP_*%
→%VITE_*%
%PUBLIC_URL% → / (absolute paths)
- Add explicitly
<script type="module">
tag
Phase 3: Testing infrastructure overhaul
The goal was to migrate from Jest to Vitest while maintaining test reliability. Key changes and benefits included:
- Mocking syntax: All
jest.mock()
calls becamevi.mock()
- Global imports:
jest
globals replaced withvi
from 'vitest' - Dramatically faster CI/CD - our test workflow dropped from 15 minutes to 3 minutes
Mocking strategy transformation
The biggest challenge was rewriting our mocking approach. Vitest uses a different mocking philosophy:
jest.mock('recharts', () => ({
...jest.requireActual('recharts'),
ResponsiveContainer: (props: any) => <div {...props} />,
}))
// After: Vitest mocking
vi.mock('recharts', async (importOriginal) => {
const actual = await importOriginal<typeof import('recharts')>()
const MockComponent = () => React.createElement('div')
return {
...actual,
ResponsiveContainer: MockComponent,
// ... and more components
}
})
Why this took time:
- 200+ test files needed updates across the project
- Complex mocks required complete rewrites
- Test utilities needed significant updates
- Timing differences: Vitest's async behavior required adding many
async
/await
and Promise handling that Jest didn't need
The challenges we overcame
1. Environment variables: from implicit to explicit
CRA automatically injects all REACT_APP_*
environment variables, but Vite requires explicit configuration.
We created a comprehensive mapping system that handles both legacy process.env
and modern import.meta.env
patterns:
define: {
// Explicit injection of all environment variables
...Object.keys(env).reduce((acc, key) => {
// Support both access patterns during transition
acc[`process.env.${key}`] = JSON.stringify(env[key])
acc[`import.meta.env.${key}`] = JSON.stringify(env[key])
return acc
}, {}),
}
Migration Path:
- Support both
process.env.*
andimport.meta.env.*
- Gradually replace
process.env.*
usage - Remove legacy
process.env.*
definitions
2. SVG handling: from built-in to plugin-based
CRA has built-in SVG support, but Vite requires explicit configuration. We tackled this by creating different SVG configurations for different environments:
svgr({
svgrOptions: {
icon: false,
dimensions: false,
},
});
// Testing: Simplified for faster test execution
svgr({
svgrOptions: {
icon: true, // Strip dimensions for cleaner test output
},
});
3. Export patterns: export *
caused bundle issues
Vite's tree-shaking is more aggressive than webpack's, causing issues with barrel exports.
// src/context/index.tsx
export * from "./ApiContext";
export * from "./UserContext";
// This caused hooks to be bundled with components!
As such, we used explicit, named exports for better tree-shaking:
// src/context/index.tsx
export { APIProvider, useAPIContext } from "./ApiContext";
export { UserProvider, useUserContext } from "./UserContext";
Why this mattered:
- Bundle size: Prevented unused hooks from being included
- Performance: Better code splitting and lazy loading
4. Test infrastructure: a complete rewrite
Our extensive Jest setup didn't translate directly to Vitest, so we rebuilt our testing infrastructure from the ground up:
import "@testing-library/jest-dom";
import { vi } from "vitest";
// Mock window APIs that don't exist in jsdom
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Enhanced mocking for complex libraries
vi.mock("@stripe/stripe-js", () => ({
loadStripe: vi.fn(() =>
Promise.resolve({
createToken: vi.fn(),
elements: vi.fn(() => ({
create: vi.fn(() => ({
mount: vi.fn(),
on: vi.fn(),
addEventListener: vi.fn(),
})),
getElement: vi.fn(),
})),
})
),
}));
Now we're faster, safer, and future-proof
This migration was about more than just speed. The benefits touched every part of our development process:
- Security and Maintainability: We can now keep our dependencies current without workarounds, backed by a toolchain that evolves with the modern web.
- Developer Productivity: A lightning-fast development server and an 80% reduction in CI wait times means our team spends more time building and less time waiting.
- Code Quality and Freedom: We're no longer blocked from adopting React 18+ features. Better tree-shaking from Vite has helped us identify and remove unused code, and we now have the freedom to adopt new libraries as they become available.
Moving from CRA to Vite wasn't just a simple upgrade; it was an escape from a deprecated ecosystem that was actively creating security risks and holding us back.
The journey was challenging, but the result is a faster, safer, and more maintainable application, with a development team empowered to build the future.
The payoff is worth it. 🚀