Vite
The default for applications with no SSR requirement — Vite plus TanStack Router, and a migration to Start that stays incremental.
Verified 2026-07-31 · Against
@tanstack/react-router, Vite 7, Node 22.
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
drives the facade split — a client bundle boundary only exists when there is a
server. See
the four facades.
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>/, hexagonal inside, four facades per feature |
| Routes | Adapters, nothing more — flat file-based routing, generated route tree never edited |
| Forms and data | @tanstack/react-form; router loaders, or Query where a cache is genuinely needed. Server state belongs to the feature's facade, never a global singleton |
| Styling | Tailwind + shadcn, always |
| Boundaries | dependency-cruiser as an architecture:check gate in CI |
What is different without a server
- There is no
server.tsorserver-only.tsfacade — the feature's public surface isindex.tsandclient.ts. Add the other two at 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
apilayer to a real backend. The facade boundary still matters: it is what makes the later move to server functions a change in one file per feature.
Traps
- Treating route files as components' home → routes accumulate logic and
become untestable. The component lives in the feature's
ui/; the route imports it viaclient.ts. - 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. - Deferring the facade split "until we need SSR" → the split is what makes SSR cheap. Doing it after the fact means auditing every import in the codebase.