How to Set Up a Modern React Project in 2026
TL;DR
The 2026 React stack: Vite + TypeScript + Biome + Vitest + TanStack Query + Zustand + shadcn/ui. Create React App is deprecated. This guide sets up a production-ready project from scratch — typed, linted, tested, and styled — using the tools developers actually choose in 2026.
Key Takeaways
- Vite: dev server + build (not CRA, not webpack)
- Biome: linting + formatting (not ESLint + Prettier)
- Vitest: unit testing (not Jest)
- TanStack Query: server state (not Redux for API data)
- Zustand: client state (not Redux)
- shadcn/ui: component library (copy-paste, not npm package)
Why This Stack
Create React App served its purpose but is now officially deprecated — the React team no longer recommends it. The JavaScript ecosystem moved on: Vite is 10-100x faster for development, ESM is now the standard, and TypeScript is the default rather than an optional add-on.
The modern stack reflects what experienced React developers actually use in 2026:
Vite replaced webpack because it uses native ESM in development — no bundling, just instant module serving. Cold starts in ~200ms, HMR in ~50ms. Webpack hot reloads can take 2-10 seconds in large projects.
Biome replaces ESLint + Prettier because it's a single Rust-based tool that handles both, runs 30x faster, and eliminates the configuration headache of making ESLint and Prettier not conflict with each other.
TanStack Query replaces Redux for server state because 80% of what people stored in Redux was actually remote data. TanStack Query handles fetching, caching, background refetching, and optimistic updates — better than any Redux pattern.
Zustand replaces Redux for client state because it's simpler (a store is just a function), smaller (~1KB vs Redux's ~8KB + react-redux), and requires no boilerplate.
Step 1: Scaffold with Vite
# Official Vite React TypeScript template
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
# Start dev server
npm run dev
# → http://localhost:5173 in ~200ms
Step 2: Add Core Dependencies
# Routing
npm install react-router-dom
# Data fetching + server state
npm install @tanstack/react-query @tanstack/react-query-devtools
# Client state
npm install zustand
# HTTP client
npm install ky
# Form handling + validation
npm install react-hook-form @hookform/resolvers zod
# Date utilities
npm install date-fns
# Class name utilities
npm install clsx tailwind-merge
Step 3: Tailwind CSS + shadcn/ui
# Tailwind CSS
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
# Initialize shadcn/ui
npx shadcn@latest init
# Prompts: style (Default/New York), base color, CSS variables
# Add components as needed
npx shadcn@latest add button
npx shadcn@latest add input
npx shadcn@latest add form
npx shadcn@latest add dialog
shadcn/ui components are copied into your project at src/components/ui/. You own the code — modify it freely. This is unlike traditional component libraries where you're locked into the library's API.
Step 4: Biome (Linting + Formatting)
npm install -D --save-exact @biomejs/biome
npx @biomejs/biome init
// biome.json
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "error",
"useExhaustiveDependencies": "warn"
},
"suspicious": { "noConsoleLog": "warn" }
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "es5"
}
},
"files": { "ignore": ["dist/**", "node_modules/**"] }
}
// package.json scripts
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"check": "biome check --apply .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
Step 5: Vitest + Testing Library
npm install -D vitest @vitest/ui jsdom
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
Step 6: TanStack Query Setup
// src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
retry: 1,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>
);
Step 7: Project Structure
src/
├── components/
│ ├── ui/ # shadcn/ui components (auto-generated)
│ └── [feature]/ # Feature-specific components
├── pages/ # Route-level components
├── hooks/ # Custom React hooks
├── lib/
│ ├── api.ts # ky instance + API helpers
│ ├── queryClient.ts
│ └── utils.ts # cn() and other utilities
├── stores/ # Zustand stores
├── test/
│ └── setup.ts
├── types/ # TypeScript type definitions
├── App.tsx
└── main.tsx
Step 8: Environment Variables
# .env.local
VITE_API_URL=http://localhost:3001
VITE_APP_NAME="My App"
// src/vite-env.d.ts — type your env vars
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_APP_NAME: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Step 9: TypeScript Path Aliases
npm install -D vite-tsconfig-paths
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
// vite.config.ts
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
});
Path aliases let you write import { Button } from '@/components/ui/button' instead of import { Button } from '../../../components/ui/button'. They also make refactoring easier — moving a file doesn't require updating relative import paths.
Common Mistakes to Avoid
Don't use Redux for API data. If you reach for Redux to store data you fetched from an API, use TanStack Query instead. Redux is for client state (UI state, user preferences, form state). Remote data has its own lifecycle (stale, loading, error, refetching) that TanStack Query handles.
Don't skip TypeScript path aliases. Starting a project without path aliases means every moved file requires hunting down relative imports. Set them up on day one.
Don't forget suppressHydrationWarning if using SSR. If you later add Next.js or any SSR layer, your components that read localStorage or window need to handle SSR carefully to avoid hydration mismatches.
Don't co-locate all state in URL. URL state is great for shareable state (filters, current page, search query). But not everything belongs in the URL — transient UI state like modal open/close belongs in Zustand.
Final Checklist
✅ Vite + React + TypeScript — scaffolded
✅ Tailwind CSS + shadcn/ui — styled
✅ Biome — linting and formatting configured
✅ Vitest + Testing Library — test infrastructure ready
✅ TanStack Query — server state management
✅ Zustand — client state management
✅ React Router — routing
✅ React Hook Form + Zod — forms and validation
✅ Environment variables typed
✅ Path aliases (tsconfigPaths)
Compare React setup tools on PkgPulse. Also see our Vitest vs Jest guide for testing setup details and best form libraries for React for form patterns.
Related: How to Migrate from Create React App to Vite.
Zustand and TanStack Query: Working Together
The most common source of confusion in this stack is the boundary between Zustand (client state) and TanStack Query (server state). Getting this boundary right prevents a whole class of bugs and avoids duplicating state.
The mental model: TanStack Query owns anything that came from a server. Zustand owns anything that didn't. The rule of thumb: if the state would be stale if someone else updated the same data in another browser tab, it belongs in TanStack Query. If it's purely UI state that only makes sense for this user's session, it belongs in Zustand.
// stores/uiStore.ts — Zustand for UI state
import { create } from 'zustand';
interface UIState {
sidebarOpen: boolean;
selectedUserId: string | null;
theme: 'light' | 'dark';
toggleSidebar: () => void;
selectUser: (id: string | null) => void;
setTheme: (theme: 'light' | 'dark') => void;
}
export const useUIStore = create<UIState>((set) => ({
sidebarOpen: true,
selectedUserId: null,
theme: 'light',
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
selectUser: (id) => set({ selectedUserId: id }),
setTheme: (theme) => set({ theme }),
}));
// hooks/useUser.ts — TanStack Query for server state
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import ky from 'ky';
export function useUser(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => ky.get(`/api/users/${userId}`).json<User>(),
enabled: !!userId,
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateUserDto) =>
ky.patch(`/api/users/${data.id}`, { json: data }).json<User>(),
onSuccess: (updatedUser) => {
// Invalidate the cache so the next read fetches fresh data
queryClient.invalidateQueries({ queryKey: ['users', updatedUser.id] });
},
});
}
A common mistake is storing the selected user's data in Zustand: selectedUser: User | null. Instead, store only the ID in Zustand and fetch the data via TanStack Query using that ID. This way, TanStack Query's caching, background refetching, and cache invalidation all work correctly.
Performance Optimization Patterns
A freshly scaffolded Vite + React project is fast by default, but production apps need deliberate optimization to stay fast as they grow.
Code splitting with React.lazy. Vite automatically splits code at dynamic import boundaries. Use React.lazy for route-level components so users only load the JavaScript for the pages they visit:
// App.tsx — route-based code splitting
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
export function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/users/:id" element={<UserProfile />} />
</Routes>
</Suspense>
);
}
Manual chunk splitting in Vite. For vendor libraries that change less frequently than your application code, split them into separate chunks. Browsers cache chunks independently, so users who return to your app after you deploy a small feature change don't need to re-download React and React DOM.
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-query': ['@tanstack/react-query'],
'vendor-router': ['react-router-dom'],
'vendor-forms': ['react-hook-form', 'zod'],
},
},
},
},
});
Memoization where it counts. useMemo and useCallback have a cost — they add overhead on every render to check whether dependencies changed. Only memoize when you have evidence of a performance problem: a component that re-renders frequently with expensive computation. The React DevTools Profiler tab shows which components are rendering and how long they take. Profile before memoizing.
TanStack Query's staleTime tuning. By default, TanStack Query marks data as stale immediately after it's fetched. Every time a component mounts or the window regains focus, it re-fetches. For data that changes infrequently (user profiles, configuration), set a longer staleTime. For data that changes constantly (notifications, prices), leave it at the default or use shorter polling intervals.
Deploying to Production
This stack deploys well to any modern hosting platform. Here's what to configure for production.
Vercel (recommended for Vite SPAs). Drop the repo in, Vercel auto-detects Vite, and deployments take 30-60 seconds. Set environment variables in the Vercel dashboard — they're injected at build time via import.meta.env.
# vercel.json — configure SPA fallback for React Router
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
React Router requires this rewrite. Without it, refreshing a non-root route returns a 404 because Vercel looks for a file at that path rather than serving index.html.
Environment-specific configs. Vite supports .env.local, .env.development, and .env.production files. Only variables prefixed with VITE_ are exposed to client code — this is a safety mechanism to prevent accidentally leaking server secrets.
# .env.production
VITE_API_URL=https://api.myapp.com
VITE_ANALYTICS_ID=G-XXXXXXXXXX
# .env.development
VITE_API_URL=http://localhost:3001
Bundle analysis before deploying. Before your first production deploy, run npx vite-bundle-visualizer to inspect your bundle. Common surprises: date libraries (moment.js imports the entire locale collection if not tree-shaken), icon libraries (importing react-icons without tree-shaking adds megabytes), and accidentally bundling Node.js-only packages.
FAQ
Should I use React Router or TanStack Router? Both are solid choices in 2026. React Router v7 (now also the Remix framework) is the more widely used option with the larger ecosystem. TanStack Router is fully type-safe out of the box — search params, route params, and loaders are all typed end-to-end without additional configuration. If type safety in routing matters to your team, TanStack Router's ergonomics are noticeably better. If you want maximum ecosystem compatibility and familiarity, React Router is the safer pick.
Is Create React App completely gone? Create React App is archived and no longer maintained. The npm package still works, but it uses React 17, an old version of Webpack, and doesn't support many modern patterns. The React team's official recommendation since 2023 is to use a framework (Next.js, Remix) or Vite for new projects. If you're on CRA, migrating to Vite takes 1-2 hours for most apps.
Do I need Redux in 2026? For most new applications: no. The combination of TanStack Query (server state) + Zustand (client state) covers what Redux was commonly used for with less boilerplate, smaller bundle size, and better TypeScript ergonomics. Redux Toolkit (not vanilla Redux) remains a valid choice for teams with existing Redux knowledge, large apps with complex state interactions, or cases where Redux DevTools' time-travel debugging is valuable. But for greenfield projects, starting with Zustand + TanStack Query and reaching for Redux only if you hit limitations is the more pragmatic approach.
How do I handle authentication in this stack?
Authentication typically combines Zustand (for storing the current user's session/token) with React Router's data layer (for protecting routes). A common pattern: store auth state in Zustand, use a protected route wrapper component that checks auth state and redirects to login if unauthenticated, and use TanStack Query's enabled option to skip data fetching for unauthenticated users. For production apps, consider a managed auth provider (Clerk, Auth0, Supabase Auth) rather than rolling your own — the edge cases in auth are genuinely hard.
See the live comparison
View vite vs. webpack on PkgPulse →