CRA has been in maintenance mode for years. The React team stopped recommending it. The ecosystem moved on. If you're still bootstrapping projects with npx create-react-app, you're inheriting a slow build pipeline, an outdated Babel config, and a webpack setup that nobody wants to debug. Here's a proper React project setup without CRA — Vite for bundling, Biome for linting and formatting, Vitest for tests, and TanStack Router for type-safe routing. Every opinionated choice explained.
Why This Stack
- Vite — Fast. Native ESM dev server, HMR that actually feels instant. If you're evaluating alternatives before committing, the Vite vs Turbopack vs Rspack comparison covers the real-world tradeoffs across cold start, HMR, and build size.
- Biome — Replaces ESLint + Prettier in one binary. Milliseconds to lint, zero config conflicts between formatters and linters arguing about trailing commas. The Biome vs ESLint + Prettier breakdown has the full analysis if you need convincing.
- Vitest — Shares your Vite config directly. No separate Jest transform pipeline, no "it works in dev but fails in tests" module resolution mysteries.
- TanStack Router — Fully type-safe routing. No more
useParams()returningstring | undefinedwhen you know the param exists. File-based routing, generated route trees, the lot.
Right. Commands.
Step-by-Step: React Project Setup Without CRA
1. Scaffold with Vite
Use pnpm. npm and yarn work fine — swap the commands — but pnpm's lockfile is stricter and disk usage is significantly better on a machine with a dozen projects.
pnpm create vite my-app --template react-ts
cd my-app
pnpm install
React 19, TypeScript, Vite config. No Babel to maintain. No webpack.config.js written by someone who left the company in 2021.
2. Tighten the TypeScript Config
Vite's default config is intentionally permissive. Fix it now before you have 80 files to untangle later. Replace tsconfig.app.json:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
noUncheckedIndexedAccess is the flag the docs don't push hard enough. Enable it and half your array[0] accesses get flagged — correctly, because arrays can be empty. Takes a few minutes to adjust to. Catches real bugs. Worth it.
Update vite.config.ts to wire up the path alias:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
})
3. Add Biome
ESLint and Prettier aren't installed yet, so nothing to remove. Straight in:
pnpm add -D @biomejs/biome
pnpm biome init
Edit the generated biome.json:
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all"
}
}
}
Add these scripts to package.json:
"lint": "biome lint ./src",
"format": "biome format --write ./src",
"check": "biome check --write ./src"
pnpm check lints and formats in a single pass. That's your CI command.
4. TanStack Router
Use the Vite plugin approach — it generates the route tree automatically from your file structure. The docs are scattered on this and it took me 20 minutes to find the correct plugin package name the first time, so: it's @tanstack/router-plugin, not anything else you'll find in older Stack Overflow answers.
pnpm add @tanstack/react-router
pnpm add -D @tanstack/router-plugin
Update vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
import { resolve } from 'path'
export default defineConfig({
plugins: [
TanStackRouterVite({ routesDirectory: './src/routes' }),
react(),
],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
})
Create the route files:
mkdir -p src/routes
touch src/routes/__root.tsx src/routes/index.tsx
src/routes/__root.tsx:
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => <Outlet />,
})
src/routes/index.tsx:
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: Index,
})
function Index() {
return <h1>Home</h1>
}
src/main.tsx:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
The routeTree.gen.ts file is auto-generated when you run pnpm dev. Commit it — it's not a transient build artefact. Other developers need it for type inference without having to spin up the dev server first.
5. Vitest
pnpm add -D vitest @vitest/ui jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
Add a test block inside vite.config.ts:
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
Create src/test/setup.ts:
import '@testing-library/jest-dom'
Add scripts:
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run"
test:run exits after one pass — use it in CI. test:ui opens a browser-based test runner that's genuinely useful when you're debugging a flaky assertion and console.log isn't cutting it.
Deploying the Result
For most teams, Vercel is the obvious deploy target — zero config for Vite, automatic preview deployments on every PR, free tier covers most side projects. Set the build command to pnpm build and output directory to dist. Once you're shipping to real users, add Sentry for error monitoring — their Vite plugin uploads source maps automatically, so stack traces in production actually point to your source files instead of minified noise.
The Final Structure
my-app/
├── src/
│ ├── routes/
│ │ ├── __root.tsx
│ │ └── index.tsx
│ ├── test/
│ │ └── setup.ts
│ ├── routeTree.gen.ts ← commit this
│ └── main.tsx
├── biome.json
├── tsconfig.json
├── tsconfig.app.json
├── vite.config.ts
└── package.json
Sub-300ms cold starts. Lint and format in one command. Type-safe routing where useParams() knows exactly which params exist. Tests that share the Vite config with no separate transform pipeline to maintain.
CRA solved a real problem when the ecosystem needed a turnkey starting point. That problem is solved. This is what production React projects actually look like in 2026 — faster, better-maintained, and with no eject button to dread.