我的 TypeScript 全栈技术栈取舍:SSR、SPA 与 API 的边界
最近在整理一套可以长期复用的 TypeScript 项目模板。
我的项目大多是 AI 工具、管理后台、工作流、编辑器和桌面端配套应用。技术栈以 React、TypeScript 和 Vite 为主,后端会使用 Node.js、PostgreSQL 和 Drizzle。
一开始考虑的是直接选择一个 React 全栈框架,把路由、SSR、数据请求、API 和部署统一起来。重点看了 Next.js、React Router Framework Mode 和 TanStack Start,也顺带关注了 Remix 3。
研究了一段时间后,我发现框架能力并不是越完整越合适。对于偏重交互的应用,SSR 和同构运行模型会引入不少额外概念,而这些概念未必能直接转化为产品收益。
最后确定的方向是:
公开站点使用 SSR 或 SSG
登录后的产品使用 SPA
业务能力通过统一 HTTP API 提供
这篇文章记录一下具体的判断过程。
TanStack Start 的吸引力
TanStack Start 很符合我对现代 TypeScript 框架的期待。
它建立在 TanStack Router 和 Vite 之上,提供类型安全路由、Search Params、Loader、Server Function、Middleware 和 SSR。再配合 TanStack Query、Form 和 Table,可以形成一套完整的前端应用开发体系。
一个简单的服务端查询可以写成:
export const listProjectsFn = createServerFn()
.handler(async () => {
return db.select().from(projects)
})
路由中直接调用:
export const Route = createFileRoute('/projects')({
loader: () => listProjectsFn(),
component: ProjectsPage,
})
组件通过路由读取结果:
function ProjectsPage() {
const projects = Route.useLoaderData()
return <ProjectList projects={projects} />
}
这种写法不需要手动维护 HTTP Client 和返回类型。Server Function 在客户端表现得像一个普通的异步函数,输入和输出类型可以直接推导。
对于简单页面,这套模式很顺畅。
问题主要出现在页面交互开始变复杂之后。
例如,列表需要手动刷新,可以调用:
router.invalidate()
如果列表使用 TanStack Query,则会写成:
query.refetch()
新增一条记录后,可以重新执行路由 Loader:
await router.invalidate()
也可以让 Query Cache 失效:
await queryClient.invalidateQueries({
queryKey: ['projects'],
})
这时项目里可能同时存在两套数据生命周期:
Route Loader 管理路由数据
TanStack Query 管理客户端缓存
如果继续加入 Query Options、Mutation、TanStack Form、TanStack Table 和 Zod,一个普通 Feature 很容易拆出很多文件:
project.server.ts
project.functions.ts
project.queries.ts
project.mutations.ts
project.schema.ts
project.form.ts
project.columns.tsx
这些模块单独看都有合理用途,但组合起来后,简单业务的框架代码占比会比较高。
TanStack 生态本身没有要求项目必须这样写。问题在于它提供的能力很多,开发者很容易在项目初期就把完整方案铺开。
对于复杂 Dashboard、实时数据、无限列表和乐观更新,这些抽象能够发挥作用。对于普通 CRUD,它们可能显得偏重。
Next.js 的复杂度在运行边界
Next.js 的代码通常更短。
Server Component 可以直接查询数据库:
export default async function ProjectPage() {
const projectList = await db
.select()
.from(projects)
return <ProjectList projects={projectList} />
}
修改操作可以写成 Server Action:
'use server'
export async function createProject(
formData: FormData,
) {
await db.insert(projects).values({
name: String(formData.get('name')),
})
revalidatePath('/projects')
}
页面直接提交:
<form action={createProject}>
<input name="name" />
<button type="submit">创建</button>
</form>
这种模式对内容站、电商和以服务端渲染为主的页面很合适。页面、数据读取和修改逻辑可以放在相近的位置,项目初期的代码量也不大。
随着客户端交互增加,代码会逐渐涉及:
Server Component
Client Component
Server Action
Route Handler
Suspense
缓存失效
序列化边界
Next.js 的额外成本通常不表现为大量包装函数,而是运行模型和框架规则。
对于编辑器、工作流和管理后台,页面里往往有较多本地状态、轮询、实时数据、弹窗和多区域交互。此时 Server Component 与 Client Component 的边界需要持续维护。
Next.js 依然是成熟的生产方案,只是它更偏向以服务端组件和页面渲染为中心组织应用,不完全符合我目前的产品形态。
React Router 的数据模型比较统一
React Router Framework Mode 使用 Loader 和 Action 组织路由数据。
export async function loader() {
return {
projects: await listProjects(),
}
}
export async function action({
request,
}: Route.ActionArgs) {
const formData = await request.formData()
return createProject({
name: String(formData.get('name')),
})
}
export default function ProjectsPage({
loaderData,
}: Route.ComponentProps) {
return (
<>
<ProjectList projects={loaderData.projects} />
<Form method="post">
<input name="name" />
<button type="submit">创建</button>
</Form>
</>
)
}
它的模型比较接近传统 Web 开发:
读取数据使用 loader
修改数据使用 action
页面提交使用 Form 或 fetcher
Action 完成后,React Router 会重新验证相关 Loader,因此普通表单场景不需要额外维护 Query Cache。
这种方式适合表单和路由驱动的应用。Request、Response、Cookie、Session 和上传都使用标准 Web API,调试路径也比较清楚。
页面内操作较多时,Action 可能需要根据 intent 分发不同业务:
switch (formData.get('intent')) {
case 'create':
return createProject(formData)
case 'archive':
return archiveProject(formData)
case 'delete':
return deleteProject(formData)
}
也可以拆成独立 Resource Route。
React Router 的代码量通常介于 Next.js 和完整 TanStack 方案之间。它适合传统 Web 数据流,但对于复杂客户端缓存和多个组件共享远程状态,项目最终仍可能引入 TanStack Query。
SPA 的数据流更适合产品后台
把几个全栈框架放在一起比较之后,我重新审视了普通 SPA。
一个 SPA 项目的远程数据流很直接:
React Component
↓
TanStack Query
↓
HTTP API
↓
业务逻辑
↓
数据库
查询:
const projectsQuery = useQuery({
queryKey: ['projects'],
queryFn: projectApi.list,
})
手动刷新:
<button onClick={() => projectsQuery.refetch()}>
刷新
</button>
新增后刷新列表:
const createProjectMutation = useMutation({
mutationFn: projectApi.create,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['projects'],
})
},
})
这套模型只有一份远程数据状态。
查询使用 useQuery
修改使用 useMutation
缓存刷新使用 invalidateQueries
页面参数使用 Router Search Params
本地状态使用 useState 或 Jotai
对于登录后的管理系统,这样的模型已经足够。
管理后台、AI 工具和工作流应用通常不会依赖搜索引擎索引内部页面。用户登录后会停留较长时间,主要操作发生在浏览器端。
SSR 在这些页面中的收益有限,但会增加 Hydration、服务端与客户端模块边界、Loader 生命周期和缓存同步等问题。
因此,我决定不让整个产品都进入 SSR 模型。
SSR 与 SPA 按路由区域拆分
最终的产品路由可以分成三个区域:
/ 官网首页
/pricing 定价页面
/blog/* 博客
/docs/* 文档
/tools/* 公开工具页面
/app/* 登录后的产品
/app/projects/* 项目管理
/app/workflows/* 工作流
/app/analytics/* 数据分析
/api/* 业务 API
公开区域使用 SSR 或 SSG,负责:
- SEO;
- 首屏内容;
- Open Graph;
- 博客和文档;
- 产品介绍;
- 公开工具页面。
/app 区域使用 SPA,负责:
- Dashboard;
- 工作流;
- 编辑器;
- AI 生成;
- 实时数据;
- 复杂表格;
- 多面板交互。
API 负责:
- 认证;
- 权限;
- 业务逻辑;
- 数据库访问;
- 文件上传;
- Webhook;
- Desktop 和其他客户端。
这样每个区域只维护一种主要数据模型。
最终的 Monorepo 结构
目前比较适合我的结构是:
repo/
├── apps/
│ ├── site/
│ ├── web/
│ ├── api/
│ ├── desktop/
│ └── worker/
│
├── packages/
│ ├── ui/
│ ├── contracts/
│ ├── db/
│ ├── shared/
│ └── config/
│
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json
各应用的职责如下:
apps/site
官网、博客、文档和公开页面
apps/web
登录后的 SPA 产品
apps/api
统一业务 API
apps/desktop
Tauri 客户端,需要时增加
apps/worker
异步任务和 AI Job,需要时增加
共享包保持克制。
packages/ui 存放基础组件、样式 Token 和动画预设。
packages/contracts 存放真正需要跨客户端共享的 Zod Schema、错误结构和 API 类型。
packages/db 存放 Drizzle Schema、Migration 和数据库 Client。
业务代码默认留在具体 App 中,不会在项目开始阶段就提取到 packages/core。
API 使用 Hono
API 层选择 Hono。
它的路由写法比较轻:
const app = new Hono()
const projectRoutes = app
.get('/projects', async (c) => {
return c.json(await listProjects())
})
.post(
'/projects',
zValidator('json', createProjectSchema),
async (c) => {
const input = c.req.valid('json')
const project = await createProject(input)
return c.json(project, 201)
},
)
客户端可以使用 Hono RPC:
const client = hc<AppType>('/api')
在 TanStack Query 中调用:
export function useProjects() {
return useQuery({
queryKey: ['projects'],
queryFn: async () => {
const response =
await client.projects.$get()
if (!response.ok) {
throw new Error(
'Failed to load projects',
)
}
return response.json()
},
})
}
完整调用链如下:
Component
↓
TanStack Query
↓
Hono RPC Client
↓
Hono Route
↓
Feature Function
↓
Drizzle
这套 API 可以同时提供给 Web、Desktop、浏览器扩展、Agent 和 Webhook。
相比框架内部专用的 Server Function,HTTP API 的复用范围更大。
后端按 Feature 组织
前后端拆分不意味着后端需要复制 Java 的分层方式。
不会采用这种全局目录:
controllers/
services/
repositories/
mappers/
dto/
API 仍然按 Feature 组织:
apps/api/src/features/project/
├── project.routes.ts
├── project.schema.ts
├── project.query.ts
├── project.command.ts
└── project.policy.ts
简单 Feature 只保留必要文件:
project.routes.ts
project.schema.ts
数据库实例可以直接导入:
import { db } from '@repo/db'
只有存在多个实现、复杂测试或运行时替换需求时,才使用参数注入或构造器注入。
不会为了保持架构形式,为每张表创建 Repository、Service 和 Mapper。
工具链保持激进,运行层保持稳定
项目工具链会使用相对新的方案:
Vite
Rolldown
Oxc
Oxlint
Oxfmt
Vite+
Vitest
Playwright
这些工具不会直接处理线上业务数据。即使某个版本出现兼容问题,也可以回退到对应的底层命令。
应用层使用:
React
TanStack Router
TanStack Query
TanStack Form
TanStack Table
Jotai
Tailwind CSS
shadcn/ui
Base UI
Motion
Lucide
其中 TanStack Form、Table 和 Jotai 都按需使用,不作为每个页面的固定依赖。
数据与安全层相对保守:
Node.js LTS
PostgreSQL
Drizzle ORM
Better Auth 稳定版本
Zod
正式数据库 Migration
Cookie Session
工具升级失败会影响开发效率,认证和数据库升级失败则可能影响用户和数据。两部分不应该使用相同的升级策略。
部署方式
源码上拆成多个 App,不代表生产环境必须维护大量服务。
可以使用同一个域名:
example.com/
→ Site
example.com/app/*
→ SPA
example.com/api/*
→ API
网关按路径分流:
/ → Site
/app/* → Web
/api/* → API
认证保持同源 Cookie。SPA 调用 /api 时不需要处理跨域 Token,Desktop 等外部客户端再使用单独的认证方式。
个人项目早期也可以让 Hono 同时提供 API 和 SPA 静态文件,保持一个 Docker 镜像和一个 Node 进程。
结论
这次技术选型没有确定一个统一管理所有能力的全栈框架,而是把不同类型的页面放回它们更适合的运行模型中。
公开内容使用 SSR 或 SSG
登录后的产品使用 SPA
通用业务能力使用 HTTP API
最终技术栈大致是:
Site
Astro 或其他 SSR / SSG 方案
Web
Vite
React
TanStack Router
TanStack Query
API
Hono
Drizzle
PostgreSQL
Better Auth
Zod
UI
Tailwind CSS
shadcn/ui
Base UI
Motion
Lucide
Tooling
Vite
Rolldown
Oxc
Oxlint
Oxfmt
Vitest
Playwright
这套结构保留了现代 TypeScript 工具链,也让业务后台继续使用熟悉的 SPA 数据流。
框架能力可以按项目需求增加。对于大多数偏工具型的产品,保持清楚的客户端、API 和数据边界,比在每个页面中同时组合 SSR、Loader、Server Function 和 Query 更容易维护。