Type something to search...
Migrating from Create React App to Vite

Migrating from Create React App to Vite

Create React App was the default way to start a React project for years, but it's now officially deprecated. The React team stopped recommending it in early 2025, react-scripts no longer gets meaningful updates, and every npm install prints a wall of deprecation warnings. Dev server startup gets slower as the project grows, and hot reloads that took a second now take five.

Vite fixes all of that. It serves your source as native ES modules during development, so startup time barely changes as your app grows, and it uses Rollup-compatible bundling for production. The good news is that most CRA apps can move to Vite in an afternoon, because the actual application code rarely needs to change. What changes is the tooling around it.

In this post I'll walk through a full migration of a typical CRA TypeScript project: swapping dependencies, moving index.html, adding the Vite config, converting environment variables, fixing SVG and asset imports, replacing Jest with Vitest, and updating the build output for deployment. I'll also cover the errors you're most likely to hit along the way.

Before You Start

Commit everything and create a branch. The migration touches package.json, config files, and possibly dozens of files that read environment variables, so you want an easy way to compare and roll back.

git checkout -b migrate-to-vite

It also helps to take inventory of what CRA was doing for you implicitly. Search your project for these, because each one needs a replacement:

  • process.env.REACT_APP_ references
  • %PUBLIC_URL% in public/index.html
  • import { ReactComponent as ... } from "./something.svg"
  • src/setupTests.ts and any Jest-specific config in package.json
  • src/proxy usage via the "proxy" field in package.json or src/setupProxy.js
  • Absolute imports configured through baseUrl in tsconfig.json

If you ejected from CRA, the migration is still possible, but you'll be removing a lot more webpack config by hand. The steps below assume a non-ejected project.

Step 1: Swap the Dependencies

Remove react-scripts and install Vite with the official React plugin:

npm uninstall react-scripts
npm install --save-dev vite @vitejs/plugin-react

If your project uses TypeScript, keep typescript installed. Vite strips types with esbuild but doesn't type-check, so you'll run tsc separately (more on that below).

Now update the scripts in package.json:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview",
    "test": "vitest"
  }
}

You can keep start as an alias for vite if your team's muscle memory depends on it. Also delete the eslintConfig and browserslist blocks from package.json if you're not using them elsewhere. Vite doesn't read browserslist for JavaScript output, it uses build.target instead.

Step 2: Move index.html to the Project Root

This is the biggest structural difference. In CRA, public/index.html is a template that webpack injects scripts into. In Vite, index.html lives at the project root and is the actual entry point. Vite reads it and follows the script tag to find your app.

Move the file:

git mv public/index.html index.html

Then make two edits. Remove every %PUBLIC_URL% prefix, since Vite serves files from public/ at the root path. And add a module script tag pointing at your entry file:

<!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" />
    <link rel="manifest" href="/manifest.json" />
    <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.tsx"></script>
  </body>
</html>

Many people rename src/index.tsx to src/main.tsx to match Vite's templates. That's optional. Just make sure the src attribute matches whatever filename you use.

Step 3: Add vite.config.ts

Create a vite.config.ts in the project root:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    port: 3000,
    open: true,
  },
  build: {
    outDir: "build",
  },
});

Two settings here keep things familiar. server.port: 3000 matches CRA's default, so bookmarks and OAuth redirect URLs keep working. build.outDir: "build" matches CRA's output folder, so your deploy pipeline doesn't need to change. Vite's default is dist, and you can switch later once everything else works.

Updating tsconfig.json

CRA's generated tsconfig.json targets webpack-style resolution. Update it for Vite:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "noEmit": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}

The important line is "types": ["vite/client"]. It gives you types for import.meta.env, for asset imports like .png and .svg?url, and for CSS modules. You can now delete src/react-app-env.d.ts, which pulled in react-scripts types.

isolatedModules: true matters too. Because esbuild transforms each file on its own, some TypeScript features that need cross-file information, like re-exporting types without export type, will fail. The flag makes tsc warn you about those cases ahead of time.

Step 4: Convert Environment Variables

CRA exposes variables prefixed with REACT_APP_ on process.env. Vite exposes variables prefixed with VITE_ on import.meta.env. process.env doesn't exist in the browser under Vite, so any leftover reference throws ReferenceError: process is not defined.

Rename the variables in every .env file:

# .env (before)
REACT_APP_API_URL=https://api.example.com
REACT_APP_SENTRY_DSN=abc123

# .env (after)
VITE_API_URL=https://api.example.com
VITE_SENTRY_DSN=abc123

Then update the code. A quick search and replace handles most of it:

grep -rl "process.env.REACT_APP_" src | xargs sed -i '' 's/process\.env\.REACT_APP_/import.meta.env.VITE_/g'

On Linux, drop the empty '' after -i. CRA's built-in process.env.NODE_ENV also needs to change. Vite gives you import.meta.env.MODE, plus the booleans import.meta.env.DEV and import.meta.env.PROD:

// before
if (process.env.NODE_ENV === "development") {
  enableMocks();
}

// after
if (import.meta.env.DEV) {
  enableMocks();
}

To get autocomplete and type checking for your own variables, declare them in a src/vite-env.d.ts file:

// src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_SENTRY_DSN: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

If a third-party package reads process.env.NODE_ENV and breaks in the browser, you can define it in the Vite config. Only do this for that specific key, not the whole process.env object, or you risk leaking server variables into the bundle:

export default defineConfig({
  plugins: [react()],
  define: {
    "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
  },
});

For a deeper look at prefixes, modes, and keeping secrets out of client bundles, see environment variables in React apps done right.

Step 5: Fix SVG and Asset Imports

Plain asset imports work the same in Vite. import logo from "./logo.png" gives you a URL string in both tools.

The difference is SVGs imported as components. CRA supports this out of the box through SVGR:

import { ReactComponent as Logo } from "./logo.svg";

Vite doesn't. Install the SVGR plugin:

npm install --save-dev vite-plugin-svgr

Register it in the config:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";

export default defineConfig({
  plugins: [react(), svgr()],
  build: { outDir: "build" },
});

Current versions of vite-plugin-svgr use a ?react query suffix instead of the named export. Update the imports:

import Logo from "./logo.svg?react";

export function Header() {
  return (
    <header>
      <Logo width={32} height={32} aria-hidden="true" />
      <span>My App</span>
    </header>
  );
}

Add the plugin's types to src/vite-env.d.ts with /// <reference types="vite-plugin-svgr/client" /> so TypeScript understands the ?react suffix.

Step 6: Absolute Imports and Path Aliases

If your CRA project used "baseUrl": "src" to write imports like import Button from "components/Button", Vite won't resolve them by default. The cleanest fix is an explicit alias with a prefix:

// vite.config.ts
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
});

Mirror it in tsconfig.json with "paths": { "@/*": ["./src/*"] }. If you'd rather not rewrite imports, the vite-tsconfig-paths plugin reads baseUrl and paths from your tsconfig and applies them automatically.

Step 7: Replace the Dev Proxy

CRA lets you set "proxy": "http://localhost:8080" in package.json to forward API calls during development. Vite has a more flexible version in server.proxy:

export default defineConfig({
  plugins: [react()],
  server: {
    port: 3000,
    proxy: {
      "/api": {
        target: "http://localhost:8080",
        changeOrigin: true,
      },
    },
  },
});

Unlike CRA, Vite only proxies the paths you list, so requests for /api/users go to the backend and everything else is served by Vite. If you had a src/setupProxy.js with http-proxy-middleware, translate each rule into an entry here and delete the file.

Step 8: Move Tests from Jest to Vitest

CRA bundles Jest with a preconfigured Babel transform. Once react-scripts is gone, npm test has nothing to run. You could set up Jest manually, but Vitest is the natural choice: it reuses your Vite config, understands the same aliases and plugins, and has a Jest-compatible API.

npm install --save-dev vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

Add a test block to the Vite config. Use the reference comment so TypeScript knows about the extra key:

/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  build: { outDir: "build" },
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: "./src/setupTests.ts",
  },
});

Update src/setupTests.ts to use the Vitest entry point of jest-dom:

// src/setupTests.ts
import "@testing-library/jest-dom/vitest";

With globals: true, describe, it, and expect work without imports, just like Jest. Add "vitest/globals" to the types array in tsconfig.json so the editor knows about them. The main code change is replacing jest.fn(), jest.mock(), and jest.spyOn() with vi.fn(), vi.mock(), and vi.spyOn():

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { SaveButton } from "./SaveButton";

it("calls onSave when clicked", async () => {
  const onSave = vi.fn();
  render(<SaveButton onSave={onSave} />);

  await userEvent.click(screen.getByRole("button", { name: /save/i }));

  expect(onSave).toHaveBeenCalledTimes(1);
});

One behavior difference catches people: vi.mock() calls are hoisted like jest.mock(), but a module factory must return an object with every export you use. Jest's automock of missing exports doesn't exist. The Vitest and Testing Library guide covers the setup in more detail.

Step 9: Type Checking and Linting

Vite never type-checks. A type error that would have shown in CRA's overlay now goes silently through the dev server. That's deliberate, it keeps hot updates fast, but you need another safety net.

The build script above runs tsc --noEmit before vite build, so CI fails on type errors. During development, rely on your editor, or run tsc --noEmit --watch in a second terminal. If you want errors in the browser overlay, vite-plugin-checker can run tsc and ESLint in a worker.

For linting, CRA's eslint-config-react-app is unmaintained. Replace it with a flat config using typescript-eslint, eslint-plugin-react-hooks, and eslint-plugin-react-refresh, which is what the official Vite React template uses.

Step 10: Verify the Production Build

Run the build and preview it locally:

npm run build
npm run preview

vite preview serves the build folder on port 4173. Click through the app, especially routes that load lazily, and check the browser console for 404s on assets.

If you deploy to a subpath like https://example.com/app/, CRA used the homepage field in package.json. In Vite, set base:

export default defineConfig({
  base: "/app/",
  plugins: [react()],
});

Inside your code, import.meta.env.BASE_URL gives you that value, which is useful for a router basename.

Common Migration Errors

  • process is not defined. Some code still reads process.env. Search for it in src and convert it, or add a narrow define entry for a dependency that needs process.env.NODE_ENV.
  • Failed to resolve import "components/Button". CRA's baseUrl absolute imports don't work. Add an alias or use vite-tsconfig-paths.
  • JSX in .js files fails to parse. Vite expects JSX only in .jsx and .tsx files. Rename the files, which is the better long-term fix, or configure esbuild loaders.
  • require is not defined. Vite serves ES modules, so CommonJS require() calls in your own source break. Convert them to import.
  • Blank page after build, assets 404. Your base doesn't match the deployment path. Set it to the subpath, or "./" for relative paths.
  • Environment variable is undefined. It's missing the VITE_ prefix, or you edited .env without restarting the dev server.

Frequently Asked Questions (FAQ) About Migrating from Create React App to Vite

Yes. The React team announced the deprecation in February 2025. Existing projects keep working, but react-scripts doesn't receive feature updates, and the React docs now recommend frameworks or build tools like Vite for new projects.

No. Your components, hooks, and styles stay the same. The migration changes tooling: the entry HTML, config files, environment variable access, SVG component imports, and the test runner. Application code only changes where it touches those things.

You can, but you'll need to configure Babel or ts-jest, module name mappers for CSS and assets, and any path aliases yourself, duplicating work Vite already does. Vitest reuses your Vite config and has a nearly identical API, so most teams switch.

Vite uses esbuild to strip types without checking them, which is what makes it fast. Run tsc --noEmit in your build script and in CI, rely on your editor during development, or add vite-plugin-checker if you want errors in the browser overlay.

For a small to medium app that hasn't ejected, a few hours is typical. Most of the time goes into renaming environment variables, converting tests from jest to vi, and fixing a handful of import edge cases. Ejected apps with custom webpack config take longer.

If your app is a client-rendered SPA and you're happy with that, Vite is the smallest change. If you want server rendering, file-based routing, or server components, moving to a framework is a bigger rewrite that might still be worth it. Many teams migrate to Vite first to get off CRA quickly, then evaluate a framework later.

Conclusion

Moving from Create React App to Vite comes down to a predictable checklist: replace react-scripts with vite and the React plugin, move index.html to the root with a module script tag, add a config that keeps port 3000 and the build folder, rename REACT_APP_ variables to VITE_ and read them from import.meta.env, add SVGR for component SVGs, and switch Jest to Vitest. Then type-check separately, because Vite won't do it for you.

Once the app runs, take the next steps gradually. Switch the output folder to dist, adopt the official ESLint flat config, and look at code splitting with lazy routes now that builds are fast enough to experiment. If you want to tune the setup further, the guide on building React apps with Vite covers plugins, build targets, and chunking in more depth.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading