本文由 Huifer 撰写,TanStack Ship 的独立开发者与维护者。在 14 个生产 SaaS 应用——计费平台、分析仪表盘、开发工具、两个 B2B 市场——中,我收敛出了一套单一架构形态:TanStack Start 运行于 Cloudflare Workers,数据用 D1 关系存储,文件用 R2 Blob 存储,热读用 KV,异步用 Queues,实时状态用 Durable Objects,支付用 Stripe,整个系统之上再套一层轻量可观测性。本指南将端到端地走过这套形态。没有供应商赞助;每个模式都来自已交付的代码,不是幻灯片演示。
经验证来源:TanStack Start 文档 · Cloudflare Workers 文档 · Cloudflare D1 文档 · Cloudflare R2 文档 · Cloudflare Durable Objects 文档 · Cloudflare Queues 文档 · Stripe Webhooks 文档 · TanStack Ship GitHub 组织 · TanStack Query 文档
最后更新:2026-07-09 · 更新日志
TL;DR: 2026 年独立交付的 SaaS 不需要十二个服务。它需要一个边缘运行时、一个关系存储、一个对象存储、一个键值缓存、一个队列、一个持久状态原语、一个支付提供商和一条可观测性管道。有趣的工作在于这八个原语如何连接在一起——而这正是本指南要讲的内容。如果你正在选技术栈,参见 TanStack Ship 功能页面;如果你想看运行时对比,参见 2026 技术栈指南 和 TanStack Ship 博客索引。
2026 年独立 SaaS 的形态
大多数独立 SaaS 架构在一个方向上失败:要么"以防万一"引入十二个服务,结果第一季度都在接线而不是交付功能;要么选择一个单一的托管平台,六个月后发现缺少一个原语——异步工作、实时状态或文件存储——导致需要重写。
经过 14 个应用验证,始终存活的模式是一组小型、可组合的原语。根据 Cloudflare Workers 文档,Workers 是 V8 隔离实例,毫秒级启动,按请求计费。根据 Cloudflare D1 文档,D1 是边缘的 SQLite,通过 HTTP 访问。根据 Cloudflare R2 文档,R2 是 S3 兼容的对象存储,无出口费用。根据 Cloudflare Durable Objects 文档,Durable Objects 是单租户计算,具有强一致性按对象存储。这些原语单独看都不新鲜,但它们组合在一起的形态,我将称之为"2026 独立 SaaS 技术栈"。
本指南的其余部分将走过该技术栈的每一层,包含真实代码片段、真实的取舍和诚实的负载上限。技术栈的完整对比参见 TanStack Start vs Next.js 指南 和 SaaS 技术栈 2026 详解。
前端架构:基于文件的路由、类型安全的加载器和单一查询缓存
基于文件的路由是正确的默认选择
第一个决策是路由、布局和守卫如何组合。就我的经验而言,TanStack Start 的基于文件的路由是独立开发者最无聊但正确的选择:路由即文件,布局即嵌套文件,守卫即路由级别的 beforeLoad 回调,搜索参数由 Zod 解析。我交付的应用支持嵌套认证门、并行路由和流式 SSR,都不需要自定义路由。根据 TanStack Router 文档,src/routes/ 下的每个文件都会成为一个路由;同目录的 _layout.tsx 文件成为共享布局。
计费模块中的一个真实守卫:
// src/routes/_authed/billing.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { z } from 'zod'
export const Route = createFileRoute('/_authed/billing')({
beforeLoad: async ({ context }) => {
if (!context.user) throw redirect({ to: '/login' })
if (!context.user.stripeCustomerId) throw redirect({ to: '/billing/setup' })
},
validateSearch: z.object({
tab: z.enum(['invoices', 'subscription', 'history']).default('invoices'),
}),
loader: async ({ context }) => {
return await context.queryClient.ensureQueryData(billingQueryOptions(context.user.id))
},
component: BillingPage,
})
_authed 分段前缀使守卫具备文件系统级别的约束。我不需要写任何全局认证检查;路由形态本身强制执行了这一点。关于嵌套布局与并行路由的更多细节,参见 TanStack Router 高级模式指南。
一个查询客户端,三层缓存
我看到独立开发者犯的最大的前端错误是运行两个状态库——用 TanStack Query 管理服务端状态,用 Zustand 管理"其他一切"——然后发现"其他一切"悄悄变成了服务端状态。更清晰的形态是一个查询客户端配三层缓存:
- 服务端状态 — 任何从后端获取、由后端拥有的数据,通过标签失效。
- URL 状态 — 搜索参数,由路由拥有,Zod 解析,绝不复制到 store。
- UI 状态 — 模态框、悬停、焦点,由组件拥有。
TanStack Query 文档 明确指出了这一点。基于标签的失效是承载负载的关键部分。每个服务端函数发出一个标签;每个查询订阅一个标签;变更通过标签失效。关于更深的模式,参见 TanStack Query 服务端函数指南 和 缓存失效指南。
API 架构:服务端函数就是 API
停止写控制器
第二大错误是把 API 当作一个独立层,有自己的路由、自己的认证、自己的验证和自己的类型——这些都与前端重复。TanStack Start 的服务端函数将所有这些合并了。同一份 TypeScript 文件中包含请求形态、认证检查、Zod 解析、数据库调用和类型化响应。构建步骤创建 RPC;运行时在边界处验证。
// src/server-fn/billing.ts
import { createServerFn } from '@tanstack/start'
import { z } from 'zod'
import { db } from '~/lib/db'
import { requireUser } from '~/lib/auth'
export const createCheckout = createServerFn({ method: 'POST' })
.validator(z.object({ priceId: z.string().startsWith('price_') }))
.handler(async ({ data, request }) => {
const user = await requireUser(request)
const session = await stripe.checkout.sessions.create({
customer: user.stripeCustomerId,
line_items: [{ price: data.priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${request.headers.get('origin')}/billing?status=success`,
cancel_url: `${request.headers.get('origin')}/billing?status=cancel`,
})
await db.insert(checkouts).values({
userId: user.id, sessionId: session.id, priceId: data.priceId, status: 'pending',
})
return { url: session.id }
})
没有 /api/create-checkout 路由。没有需要维护的 OpenAPI 文档。前端直接导入服务端函数:
import { createCheckout } from '~/server-fn/billing'
const session = await createCheckout({ data: { priceId } })
window.location = `https://checkout.stripe.com/c/pay/${session.url}`
关于错误处理的细节,参见 TanStack Start 服务端函数错误处理指南。
何时不选用服务端函数
服务端函数适用于大约 95% 的 API 场景。不适用的 5% 场景:
- Webhook 接收器。 Stripe、GitHub 和任何第三方 POST 需要一个公开的 HTTP 端点,具有原始请求体访问权限用于签名验证。Webhook 不走
createServerFn。 - 长时间运行的工作。 任何超过请求-响应预算的工作(Workers CPU 约 30 秒,实际远短于此)走 Queues。
- 跨区域广播。 服务端函数在距离调用者最近的区域运行;若需全局广播,需要 Queue 或 Durable Object 广播。
"服务端函数"、"队列"和"Durable Object"之间的边界,是你在路由形态之后做出的最重要的架构决策。我将在异步部分回来讨论它。
数据架构:D1、R2 和你真正需要的索引
一个关系存储,按你拥有的查询建索引
D1 是边缘的 SQLite。诱惑在于因为"边缘数据库"听起来很新潮而过度规范化。别这样做。精心索引的三表 schema 比花哨的四表 schema 加满地 NULL 运行得更快。索引工作的回报方式与任何 SQLite 数据库相同——Cloudflare D1 最佳实践指南 列出了四个杠杆,D1 查询优化指南 用 EXPLAIN 输出走过了每一个。
我使用最多的模式,按收益排序:
- 按谓词顺序的组合索引。
(user_id, status, created_at)索引无需排序即可服务查询WHERE user_id = ? AND status = ? ORDER BY created_at DESC。 db.batch()处理多语句写入。 延迟按语句计费;批处理将其压缩。resolve: "primary"用于写后读取。 读副本延迟毫秒级;对于"刚写入、立即读取"路径,命中主库。- KV 作为热读缓存,D1 作为真实数据源。 根据 Cloudflare KV 文档,KV 最终一致且全局;我将它用于公开落地页指标、仪表盘计数瓦片和限流计数器。
R2 用于 Blob,不用于热路径
对象存储用于文件。用户上传、导出的 CSV、生成的 PDF、视频片段——任何超过几百 KB 的内容属于 R2,通过签名 URL 或公开 Bucket 提供服务。根据 Cloudflare R2 文档,R2 仅按存储和操作计费,不收出口费,这使其成为 SaaS 最便宜的可靠对象存储。文件上传架构指南 走过了从浏览器上传的带预签名 URL 模式和完成确认 Webhook 的完整流程。
不属于 R2 的场景:任何需要通过二级索引查询的内容。对象存储不是数据库;如果你发现自己想过滤"本周该用户的所有上传",这本质上是元数据问题,应该放在 D1 中。
认证架构:边缘会话,而非 localStorage 中的 JWT
Workers 上的 Better-auth 是无聊但正确的选择
我交付的认证架构建立在运行于 Workers 上的 Better Auth 之上,会话存储在 D1 中并在边缘验证。该模式已被广泛验证,避免了 JWT 最严重的缺陷——撤销、刷新轮换、跨标签页竞态。我遇到的完整会话生命周期问题和竞态 bug 记录在 Better Auth 边缘会话竞态事后分析中。
形态如下:
// src/lib/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db } from '~/lib/db'
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite' }),
emailAndPassword: { enabled: true, requireEmailVerification: true },
socialProviders: { github: { clientId: env.GITHUB_ID, clientSecret: env.GITHUB_SECRET } },
session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24 },
})
// 每个服务端函数在访问数据库前调用 requireUser(request)。
唯一规则:永远不信任客户端的 cookie。 会话 ID 是浏览器发送的值;唯一建立身份的是服务端查找。每个服务端函数、每个加载器、每个变更用户数据的 Webhook 都首先调用 requireUser。
RBAC 是路由问题,不是认证问题
角色和权限是路由级别的问题。认证层回答"这是谁";路由决定"该用户能做什么"。管理员路由上的简单 beforeLoad 检查就足够了:
beforeLoad: async ({ context }) => {
const user = await requireUser(context.request)
if (user.role !== 'admin') throw redirect({ to: '/' })
}
对于多租户应用——角色按工作区范围而非全局范围——参见 多租户架构指南,其中走过工作区范围的守卫模式。在你有至少四个角色和至少三种资源之前,我不建议引入第三方 RBAC 库;在此之前,路由守卫更简单,调试也更快。
支付架构:带幂等键的 Stripe Webhook
Webhook 是真实数据源
我交付过的最大支付 bug 是把 Stripe 重定向 URL 当作订阅状态的真实数据源。它不是。Webhook 才是。用户可能关闭标签页、断网或永远看不到成功页面;Webhook 无论如何都会到达。根据 Stripe Webhooks 文档,Webhook 是发往公开端点的 POST 请求,带签名头;你验证签名,然后处理事件。
不重复扣费、不丢事件、不静默失步的 Webhook 处理器形态:
// src/routes/api/webhooks/stripe.ts
import { createFileRoute } from '@tanstack/react-router'
import Stripe from 'stripe'
const stripe = new Stripe(env.STRIPE_SECRET_KEY)
export const Route = createFileRoute('/api/webhooks/stripe')({
server: {
handlers: {
POST: async ({ request }) => {
const sig = request.headers.get('stripe-signature')!
const body = await request.text()
const event = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET)
const stored = await db.insert(webhookEvents).values({
id: event.id, type: event.type, payload: JSON.stringify(event), receivedAt: new Date(),
}).onConflictDoNothing()
if (stored.changes === 0) {
return Response.json({ ok: true, duplicate: true })
}
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await syncSubscription(event.data.object)
break
case 'invoice.payment_failed':
await flagDunning(event.data.object)
break
}
return Response.json({ ok: true })
},
},
},
})
第一次 INSERT——以 Stripe 事件 ID 为主键——是幂等性守卫。如果重复的 Webhook 到达,INSERT 静默失败,处理器返回 200。完整的事后分析,包括导致该形态的重复投递 bug,参见 Stripe Webhook 重复投递事后分析。关于更广泛的支付失败处理,参见 催款与支付失败指南,其中走过恢复阶梯。
异步架构:服务端函数结束、队列开始
三秒规则
我使用的心理模型:如果服务端函数做的工作对于任何真实请求都需要超过三秒,该工作属于队列。根据 Cloudflare Queues 文档,Queues 是持久化、批处理的消息消费者,具有每消息可见性超时和至少一次投递保证。模式如下:
// 生产者 — 服务端函数完成热路径后
await env.BILLING_QUEUE.send({
userId: user.id,
type: 'send-invoice-email',
invoiceId: invoice.id,
attempt: 0,
})
// 消费者 — 带故障重试的带外运行
export default {
async queue(batch, env) {
for (const msg of batch.messages) {
try {
await sendInvoiceEmail(msg.body, env)
msg.ack()
} catch (e) {
if (msg.body.attempt < 5) msg.retry({ delaySeconds: 2 ** msg.body.attempt * 10 })
else { await dlq.send(msg.body); msg.ack() }
}
}
},
}
我使用 Queues 的场景:邮件、PDF 生成、CSV 导出、对外系统的 Webhook 广播,以及任何"用户离开页面后执行一次"的工作。Cloudflare Queues 指南 更深入地走过了生产者-消费者模式,邮件队列重复批次事后分析 涵盖了幂等性陷阱。
Durable Objects 用于有状态的实时场景
对于有状态的实时场景——聊天室、协作光标、实时仪表盘、多人游戏——Durable Objects 是正确的原语。根据 Cloudflare Durable Objects 文档,每个 DO 是一个单租户隔离实例,具有强一致性按对象存储和 WebSocket 支持。Durable Objects 实时指南 走过了我交付的用于实时存在的 WebSocket 休眠模式。
边界很简单:如果你需要跨实例亚秒级一致性状态,你需要 DO。如果你需要跨实例工作且能容忍至少一次投递,你需要 Queue。如果两者都不需要,你需要服务端函数。这句话就是整个异步架构。
可观测性架构:一种日志形态,三种信号
日志、追踪和缺失的那个指标
我交付的可观测性架构是三种信号:结构化日志、请求追踪,以及每个路由真正重要的那个指标(用户可见路径的 p95 延迟,Webhook 路径的错误率)。根据 Cloudflare Workers 日志文档,当配置 tail: true 时,Workers 将 console.log 作为结构化 JSON 输出,而 Workers Analytics Engine 提供亚秒级分辨率的低基数时间序列存储。
我不交付的是:三十个小组件的每个事件仪表盘。仪表盘就是追踪。如果追踪看起来有问题,日志流会显示原因。真实用户监控指南 涵盖了浏览器端信号;监控与可观测性指南 走过了服务端管道。
每个事件都幸存的模式:每个服务端函数发出一行带 userId、route、durationMs、status 的结构化日志。每个 Webhook 发出相同形态的日志,带 eventId。当凌晨 2 点出问题,我做的第一件事是 tail -f 并 grep status: error——那行日志准确告诉我哪个用户、哪个路由、耗时多长、哪个下游调用失败了。这就是整个可观测性故事。
本指南的边界
这就是 2026 年独立 SaaS 的形态。八个原语,一套连接方式,和一条硬规则:栈中的每一层都可以被等价的原语替换,但连接方式保持不变。如果你把 Cloudflare 换成 Fly 或 Render,数据架构依然成立。如果你把 TanStack Start 换成 Next.js 或 Remix,API 架构依然成立。形态是持久的;提供商不是。
本指南未涵盖的是业务架构的长尾——定价、流失、客户成功、增长。这些是独立的指南。这里描述的栈是它们运行的底层基础,而非运行在其上的公司。
结束 CTA: TanStack Ship 启动器是本指南中每个模式的 production 版本,连接在一起并在十四个交付应用中得到测试。查看功能页面 了解模块分解,或者如果你正在选启动器,将 TanStack Ship 与其他方案对比。如果你想看更广泛的技术栈决策,SaaS 技术栈 2026 指南 和 TanStack Start 部署指南 涵盖了运行时和托管方面。关于数据层,D1 生产指南 和 D1 查询优化技术 走过了上述引用的模式。