Back to all posts

Starting a New React Project in 2025: The Stack I Keep Coming Back To

Jul 02, 2026
12 min read
Starting a New React Project in 2025: The Stack I Keep Coming Back To

Starting a New React Project in 2025: The Stack I Keep Coming Back To

I was three weeks into a new client engagement — a mid-sized insurance company building a policy administration CRM from scratch. No legacy code, no inherited debt. Just a blank repo and a brief that said "build it right this time."

The first real decision hit before I wrote a single component. Not React vs. something else — that was settled. The question was: what handles routing? What handles server state? What handles forms? What handles client state? Each one is a separate choice. Each one affects the others.

I've been in that moment before. Different company, different domain, same paralysis. And I've watched teams make the same mistake each time: they pick tools independently, without thinking about how they work together. React Router because someone knows it. Redux Toolkit because it was used at the last job. React Hook Form because of a tutorial someone bookmarked. Three months in, you have routing that's loosely typed, server state duplicated in Redux, and form validation that doesn't talk to your query layer. The tools work in isolation. The system doesn't.

That insurance CRM was where I fully committed to TanStack as an ecosystem. Not because it was new. Because it was the most coherent answer I'd found to a question I'd been asking for years: how do these pieces actually fit together?

This is the first article in the React Playbook series — a hands-on guide to building production React apps with TanStack and a thoughtful architecture. We're starting here, at the beginning, because the choices you make in the first hour of a project shape everything that comes after.

What TanStack Actually Is

TanStack is a family of headless, framework-agnostic libraries maintained primarily by Tanner Linsley. The word family matters. These packages are designed to work independently — you can use TanStack Query with React Router, or TanStack Router with SWR — but they're built by the same team with consistent APIs, consistent TypeScript patterns, and a consistent philosophy about what a library should own vs. what your application should own.

The four packages that form the core of this series:

  • TanStack Query — async state. Fetching, caching, background refetches, mutations, optimistic updates. It doesn't care how you fetch; it cares about what happens to the data after you fetch it.
  • TanStack Router — type-safe routing. Every route param, every search param, every navigate call is typed against your actual route tree. No useParams<{ id: string }>() manual type assertions.
  • TanStack Form — form state management. Headless, composable, works with any validation library. Deep integration with Query mutations comes naturally.
  • TanStack Start — full-stack framework built on top of TanStack Router. SSR, server functions, streaming. We'll cover this in article #15. For now, know it exists.

What's not in TanStack: client state management — the kind of state that lives only in the browser and doesn't come from a server. For that, this series uses Reatom. I'll explain that choice properly in article #7. For now, note that useState and TanStack Query handle the vast majority of state in a typical app. Reatom enters the picture when you have genuinely complex reactive graphs.

The best stack isn't the one with the individually best tools. It's the one where the tools share a language.

Two Ways to Start

Option 1: Vite (SPA)

If you're building a client-rendered SPA — with a separate API, no SSR, no server functions — Vite is the starting point:

npm create vite@latest my-crm-app -- --template react-ts cd my-crm-app npm install

Then add the TanStack core:

npm install @tanstack/react-query @tanstack/react-router @tanstack/react-form npm install -D @tanstack/router-devtools @tanstack/react-query-devtools

Add Zod for schema validation — it integrates directly with TanStack Form and pairs naturally with TypeScript:

npm install zod

That's the complete starting dependency set. Not ten packages. Five.

Option 2: TanStack Start (SSR / Full-stack)

If you need SSR, server functions, or want to colocate data fetching with your routes:

npx create-tsrouter-app@latest my-crm-app --template start-basic cd my-crm-app npm install

TanStack Start comes with React, TanStack Router, and Vinxi (the underlying build layer) preconfigured. You add TanStack Query and Form separately — same commands as above.

For the rest of this article — and the majority of this series — I'll use the Vite setup. We'll revisit TanStack Start in article #15 when we get to SSR and full-stack patterns.

The Router Setup

TanStack Router supports both file-based and code-based route definitions. I use code-based for new projects. It's more explicit, easier to understand when you're learning the ecosystem, and easier to refactor when your route tree changes.

Start by creating the route tree:

// src/router.ts import { createRouter, createRoute, createRootRoute } from '@tanstack/react-router'; import { RootLayout } from './layouts/RootLayout'; import { DashboardPage } from './pages/DashboardPage'; import { PolicyListPage } from './pages/PolicyListPage'; import { PolicyDetailPage } from './pages/PolicyDetailPage'; const rootRoute = createRootRoute({ component: RootLayout, }); const dashboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: DashboardPage, }); const policyListRoute = createRoute({ getParentRoute: () => rootRoute, path: '/policies', component: PolicyListPage, }); const policyDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/policies/$policyId', component: PolicyDetailPage, }); const routeTree = rootRoute.addChildren([ dashboardRoute, policyListRoute, policyDetailRoute, ]); export const router = createRouter({ routeTree }); declare module '@tanstack/react-router' { interface Register { router: typeof router; } }

The Register declaration at the bottom is what makes everything type-safe. From this point forward, every useParams(), every <Link to="">, every navigate() call is checked against your actual route tree. If you rename $policyId to $id, TypeScript will tell you every single broken reference in the codebase. That's not a nice-to-have at scale. That's essential.

The App Entry Point

// src/main.tsx import React from 'react'; import ReactDOM from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { router } from './router'; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, retry: 1, }, }, }); ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <QueryClientProvider client={queryClient}> <RouterProvider router={router} /> <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider> </React.StrictMode>, );

Two decisions embedded here that are worth naming explicitly.

staleTime: 1000 * 60 * 5 — five minutes. By default, TanStack Query considers data stale immediately and refetches on every component mount and every window focus event. For a CRM where users navigate between the same policy records repeatedly, that's a lot of unnecessary network traffic. Five minutes as a global baseline is a reasonable starting point. You'll override it per-query when freshness matters more.

retry: 1 — retry failed requests once, not three times (the default). The default behavior means a failed API call spends up to 30 seconds retrying before your user sees an error. In a CRM, users notice latency. Fail fast, show the error, let the user decide whether to retry.

Your First Query

A policy list page. Here's what the data layer looks like end to end:

// src/api/policies.ts import type { Policy } from '../types/policy'; export async function fetchPolicies(): Promise<Policy[]> { const res = await fetch('/api/policies'); if (!res.ok) throw new Error(`Failed to fetch policies: ${res.status}`); return res.json(); }
// src/pages/PolicyListPage.tsx import { useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { fetchPolicies } from '../api/policies'; export function PolicyListPage() { const { data: policies, isLoading, isError } = useQuery({ queryKey: ['policies'], queryFn: fetchPolicies, }); if (isLoading) return <div>Loading policies...</div>; if (isError) return <div>Failed to load policies</div>; return ( <ul> {policies.map((policy) => ( <li key={policy.id}> <Link to="/policies/$policyId" params={{ policyId: policy.id }}> {policy.policyNumber} </Link> </li> ))} </ul> ); }

A few things worth making explicit.

queryKey: ['policies'] — this is the identity of the data. TanStack Query caches, deduplicates, and invalidates data by key. When you update a policy, you'll call queryClient.invalidateQueries({ queryKey: ['policies'] }) and this list will automatically refetch. The key design decision: keep keys predictable and structured. We'll go deep on key patterns in article #5.

<Link to="/policies/$policyId" params={{ policyId: policy.id }}> — fully type-checked. The to prop only accepts valid routes from your route tree. The params prop is typed to exactly what that route expects. You cannot pass a typo here and have it compile.

What You Don't Need Yet

I keep coming back to this piece of advice because I've seen it ignored repeatedly on greenfield projects.

Do not install:

  • An i18n library until you actually have a second language requirement
  • A component library until you understand your design system constraints
  • A global state manager beyond useState until Query's cache isn't enough
  • A charting library until you're building a chart
  • An analytics package until the product is real

Every package you add before you need it is a decision made without enough information. The "complete stack" instinct — wanting to assemble everything before writing features — consistently results in wrong choices that are expensive to undo.

Honestly, the most common refactor I do on client codebases is removing things. Unused dependencies, abstractions that outlived their problems, config for features that were never built.

Start with the minimum. Add what you need when you need it.

Project Structure

Article #3 covers FSD (Feature-Sliced Design) properly, so I won't go deep here. But the starting skeleton that works for the first 20-30 features of any project:

src/ api/ ← fetch functions, typed response shapes pages/ ← page-level components wired to routes layouts/ ← RootLayout, AuthedLayout, etc. components/ ← shared, reusable UI hooks/ ← shared React hooks types/ ← TypeScript interfaces and domain types router.ts ← route tree definition main.tsx ← app entry point

This structure is deliberately flat. It scales to about 25-30 features before it starts feeling crowded. At that point, you'll naturally feel the pull toward domain-based organization — and that's when FSD becomes the right tool. We'll get there.

Running It

npm run dev

You'll see two devtools panels appear: the TanStack Router devtools in the bottom corner and the React Query devtools floating panel. Both are worth learning. Router devtools shows your matched route, route params, and pending navigations. Query devtools shows every active query, its status, its data, its stale time, and lets you manually invalidate or refetch. Remove them for production by checking import.meta.env.DEV.

What Comes Next

This setup — Vite + TanStack Query + TanStack Router — is the foundation everything else in this series builds on. Over the next 29 articles:

  • Article #2: TypeScript patterns specific to this stack — generics in query hooks, typed API layers, discriminated unions for UI states
  • Article #3: FSD architecture — when the flat pages/ structure starts hurting and what replaces it
  • Article #4: Error handling — Error Boundaries, query error states, global error strategies
  • Article #7: Reatom — when your client state grows beyond what useState handles cleanly

The same QueryClient, the same router, the same project structure — growing incrementally instead of being replaced. That's the goal.


That insurance CRM shipped twelve months later. Four developers, significant business logic, a forms-heavy UI with dozens of policy types. The TanStack ecosystem handled all of it without once making us fight our own tools. That's the standard I hold stacks to: not "is it powerful," but "does it get out of the way."

If you're starting a new React project and want to talk through stack choices before committing — feel free to reach out. I've had this conversation more times than I can count and it rarely takes as long as people expect.

Found this helpful?

Let's discuss your project needs.

Get in touch