Vite
The default for applications with no SSR requirement — Vite plus TanStack Router, and a migration to Start that stays incremental.
Verified 2026-10-03 · Shared rules re-checked against a production TanStack Start monorepo (Vite 8.2.2,
@tanstack/react-router1.170.31). The SPA-only sections were not re-verified.
No SSR needed → Vite + TanStack Router. SPA, simplest build, fastest dev loop. If SSR is genuinely required it is TanStack Start instead — same router, same file conventions.
Never Next.js. Standing decision, with one narrow exception that is not an application.
Why the SPA is the default
Migrating Vite + Router → Start is incremental. Both are Vite underneath, so config, plugins and test setup transfer wholly between them, and the router and its file conventions do not change. That is what makes starting without SSR cost nothing — you are not choosing a dead end, you are declining a cost you do not yet have.
ssr: false is not a plan. Decide up front whether SSR is coming, because it
decides whether features have a server/ folder — a client bundle boundary only
exists when there is a server. See
client/server safety.
A content-led product is not an application
If the product is pages — content is the point, most of it static — it is a website → Astro. Only sessions (auth, dashboards, forms, long-lived client state) make it an application.
Full line: website versus application.
What carries over unchanged
| Concern | Rule |
|---|---|
| Structure | src/features/<feature>/ with core/ components/ hooks/ form/, one public door per feature |
| Routes | Thin — file-based routing, generated route tree never edited |
| Forms and data | TanStack Query everywhere, seeded by route loaders with ensureQueryData; mutations declare meta.invalidates. Forms edit a draft mapped to the API |
| Styling | Tailwind + shadcn, always |
| Boundaries | A boundary script and the door check in CI |
What is different without a server
- There is no
server/folder — the feature's queries and hooks call the generated client directly. Addserver/*.server.tsat the moment a server appears, not before. - Every secret is the backend's. An SPA ships its whole bundle to the browser; there is no server-only config module to hide anything in. Anything the client holds is public — see secrets.
- Reads go through the feature's query options to a real backend. Keeping
every call inside
queryFnandmutationFnis what makes the later move to server functions a change in one place per feature.
Traps
- Treating route files as components' home → routes accumulate logic and
become untestable. The component lives in the feature's
components/. - Reintroducing stringly-typed navigation next to a router whose type-safe params are the reason to use it.
- Node and package-manager version drift — pin
enginesand the package manager version. - Calling the backend from components "until we need SSR" → every call site
moves later. Keep calls in query options and hooks from the start, so SSR adds a
server/folder instead of an audit of every import.