I've been putting together a TypeScript project template that I can reuse over the long term.
Most of my projects are AI tools, admin dashboards, workflow apps, editors, and desktop companion apps. The stack centers on React, TypeScript, and Vite, while the backend uses Node.js, PostgreSQL, and Drizzle.
At first, I considered simply picking a React full-stack framework that would unify routing, SSR, data fetching, APIs, and deployment. I focused on Next.js, React Router's Framework Mode, and TanStack Start, while also keeping an eye on Remix 3.
After studying the options for a while, I realized that a framework isn't necessarily a better fit just because it does more. For interaction-heavy applications, SSR and an isomorphic runtime model introduce quite a few extra concepts, and those concepts don't always translate into product benefits.
The direction I settled on was:
公开站点使用 SSR 或 SSG
登录后的产品使用 SPA
业务能力通过统一 HTTP API 提供
This article documents the decision-making process.
The Appeal of TanStack Start
TanStack Start lines up well with what I want from a modern TypeScript framework.
It's built on TanStack Router and Vite and provides type-safe routing, search params, loaders, server functions, middleware, and SSR. Paired with TanStack Query, Form, and Table, it can provide a complete stack for frontend application development.
A simple server-side query can look like this:
Call it directly from a route:
The component reads the result through the route:
This approach avoids manually maintaining an HTTP client and return types. On the client, a server function behaves like an ordinary async function, and its input and output types can be inferred directly.
For simple pages, this model works smoothly.
The trouble starts when interactions on a page become more complex.
For example, if the list needs to refresh manually, you can call:
If the list uses TanStack Query, you'd write:
After adding a record, you can rerun the route loader:
Or invalidate the query cache:
At that point, a project may end up with two data lifecycles at once:
Route Loader 管理路由数据
TanStack Query 管理客户端缓存
Individually, each module has a clear purpose. Combined, though, framework code can account for a large share of the implementation of even a simple feature.
The TanStack ecosystem itself doesn't require projects to be written this way. The issue is the breadth of its capabilities: it's easy for developers to lay out the full solution at the very beginning of a project.
These abstractions pay off for complex dashboards, real-time data, infinite lists, and optimistic updates. For ordinary CRUD applications, they can feel heavy.
Next.js Gets Complex at Runtime Boundaries
Next.js usually means less code.
A Server Component can query the database directly:
Mutations can be written as a Server Action:
The page submits the form directly:
This model works well for content sites, e-commerce, and pages that rely primarily on server rendering. The page, its data fetching, and its mutation logic can sit close together, and the project doesn't require much code at the outset.
As client-side interaction increases, the code gradually starts to involve:
Server Component
Client Component
Server Action
Route Handler
Suspense
缓存失效
序列化边界
Next.js's extra cost usually isn't a mass of wrapper functions; it's the runtime model and the framework's rules.
For editors, workflow apps, and admin dashboards, pages often have a lot of local state, polling, real-time data, modals, and interactions spanning multiple areas. In that setting, you have to keep maintaining the boundary between Server and Client Components.
Next.js is still a mature production solution. It does, however, favor organizing applications around server components and page rendering, which doesn't fully fit the shape of my current product.
React Router Has a More Unified Data Model
React Router's Framework Mode uses loaders and actions to organize route data.
Its model is closer to traditional web development:
读取数据使用 loader
修改数据使用 action
页面提交使用 Form 或 fetcher
After an action completes, React Router revalidates the relevant loaders, so in a typical form scenario you don't have to maintain a separate query cache.
This approach suits form- and route-driven applications. Requests, responses, cookies, sessions, and file uploads all use standard Web APIs, and there's a fairly clear path when debugging.
When a page has lots of actions, the action may need to dispatch to different business operations based on intent:
You can also split these into separate resource routes.
React Router usually requires somewhere between the amount of code in Next.js and a full TanStack setup. It fits traditional web data flows well, but if a project needs complex client-side caching or remote state shared across multiple components, it may still end up bringing in TanStack Query.
An SPA's Data Flow Suits Authenticated Products Better
After comparing several full-stack frameworks side by side, I took a fresh look at the conventional SPA.
The remote data flow in an SPA project is straightforward:
React Component
↓
TanStack Query
↓
HTTP API
↓
业务逻辑
↓
数据库
Queries:
Manual refresh:
Refreshing the list after creating a record:
This model has only one layer of remote data state.
查询使用 useQuery
修改使用 useMutation
缓存刷新使用 invalidateQueries
页面参数使用 Router Search Params
本地状态使用 useState 或 Jotai
That model is enough for an authenticated admin system.
Admin dashboards, AI tools, and workflow apps generally don't depend on search engines indexing their internal pages. Once users sign in, they tend to stay for long stretches, and most of the interaction happens in the browser.
SSR offers limited value on these pages, but it adds hydration, server-client module boundaries, loader lifecycles, and cache synchronization.
I therefore decided not to put the entire product into an SSR model.
Use SSR and SPA for Different Route Areas
The final product's routes can be divided into three areas:
The public area uses SSR or SSG and handles:
- SEO;
- above-the-fold content;
- Open Graph;
- blogs and documentation;
- product overviews;
- public tool pages.
The /app area uses an SPA and handles:
- Dashboard;
- workflows;
- editors;
- AI generation;
- real-time data;
- complex tables;
- multi-panel interactions.
The API handles:
- authentication;
- authorization;
- business logic;
- database access;
- file uploads;
- Webhooks;
- desktop and other clients.
That way, each area maintains a single primary data model.
Final Monorepo Structure
The structure that works best for me right now is:
Each app has a clear responsibility:
Keep shared packages deliberately lean.
packages/ui contains base components, style tokens, and animation presets.
packages/contracts contains the Zod schemas, error structures, and API types that genuinely need to be shared across clients.
packages/db contains Drizzle schemas, migrations, and the database client.
Business code stays in the relevant app by default; I won't extract it to packages/core at the start of a project.
Use Hono for the API
I chose Hono for the API layer.
Its routing code is lightweight:
Clients can use Hono RPC:
Call it from TanStack Query:
The full call chain looks like this:
Component
↓
TanStack Query
↓
Hono RPC Client
↓
Hono Route
↓
Feature Function
↓
Drizzle
This API can serve the web app, desktop app, browser extensions, Agents, and webhooks at the same time.
Compared with a framework-specific server function, an HTTP API can be reused much more broadly.
Organize the Backend by Feature
Splitting the frontend and backend doesn't mean the backend has to copy Java's layered architecture.
I wouldn't use this global directory structure:
controllers/
services/
repositories/
mappers/
dto/
The API is still organized by feature:
apps/api/src/features/project/
├── project.routes.ts
├── project.schema.ts
├── project.query.ts
├── project.command.ts
└── project.policy.ts
A simple feature keeps only the files it needs:
project.routes.ts
project.schema.ts
The database instance can be imported directly:
I'd use parameter injection or constructor injection only when there are multiple implementations, complex tests, or a need to swap implementations at runtime.
I won't create a repository, service, and mapper for every table just to preserve the shape of the architecture.
Keep the Toolchain Ambitious and the Runtime Stable
The project toolchain will use relatively recent tools:
Vite
Rolldown
Oxc
Oxlint
Oxfmt
Vite+
Vitest
Playwright
These tools don't directly touch production business data. Even if a particular version has compatibility issues, I can fall back to the equivalent lower-level command.
The application layer uses:
React
TanStack Router
TanStack Query
TanStack Form
TanStack Table
Jotai
Tailwind CSS
shadcn/ui
Base UI
Motion
Lucide
TanStack Form, Table, and Jotai are used on demand, rather than being fixed dependencies of every page.
The data and security layers take a more conservative approach:
Node.js LTS
PostgreSQL
Drizzle ORM
Better Auth 稳定版本
Zod
正式数据库 Migration
Cookie Session
A failed tooling upgrade affects developer productivity, while failed authentication or database upgrades can affect users and their data. The two shouldn't share the same upgrade strategy.
Deployment
Splitting the source code into multiple apps doesn't mean production needs to run a large number of services.
You can use a single domain:
example.com/
→ Site
example.com/app/*
→ SPA
example.com/api/*
→ API
The gateway can route traffic by path:
/ → Site
/app/* → Web
/api/* → API
Authentication stays on a same-origin cookie. When the SPA calls /api, it doesn't need to handle a cross-origin token; external clients such as desktop apps can use a separate authentication method.
For an early-stage personal project, Hono can serve both the API and the SPA's static assets, so you still have just one Docker image and one Node process.
Conclusion
This round of technology selection didn't settle on a single full-stack framework to manage every capability. Instead, I put each kind of page back into the runtime model that suits it better.
公开内容使用 SSR 或 SSG
登录后的产品使用 SPA
通用业务能力使用 HTTP API
The final stack is roughly:
This structure preserves a modern TypeScript toolchain while allowing business admin apps to continue using a familiar SPA data flow.
Framework capabilities can be added as a project needs them. For most tool-oriented products, clear boundaries between the client, API, and data layers are easier to maintain than combining SSR, Loader, Server Function, and Query in every page.