跳转到主要内容
vibestrap 是一个 Next.js 15 应用,TypeScript strict、Drizzle on Postgres、 Better Auth 管 session、next-intl 做 i18n、Tailwind v4 出样式。设计目标是 「一个周末把付费 SaaS 上线」——每个架构决定都为这个目标服务,并且有一小撮关键决定撑住整体。 本页就是那一小撮。

技术栈

技术为什么
框架Next.js 15(App Router、RSC、Turbopack)server-first 渲染、生态成熟。
RuntimeReact 19async server components、use() hook。
语言TypeScript 5(strict)上线前抓住类型形状错误。
样式Tailwind v4 + shadcn/uiOKLCH 色彩、globals.css 里 CSS-first 配置。
ORMDrizzle类型化 schema、无 codegen、可写裸 SQL。
数据库仅 Postgres(v0.1)真相单一来源、事务、JSONB。
认证Better Auth + Drizzle adapter服务端 session、OAuth、邮件验证。
i18nnext-intlEN/ZH 双语,URL 前缀 as-needed
文档Fumadocs MDX双语 *.zh.mdx,按 locale 查找。
支付Stripe / Paddle / Lemon Squeezy / Creem同一个 facade 后面。
AIMock / OpenRouter / OpenAI / Anthropic / Replicate / falPhase 2;同样的 facade 模式。
邮件Resend + React Email模板化、类型安全、dev 可预览。

目录结构

vibestrap/
├── content/                 # MDX 内容(双语)
│   ├── docs/                # *.mdx + *.zh.mdx 平行对
│   ├── blog/
│   └── changelog/
├── messages/                # next-intl 文案 bundle
│   ├── en.json
│   └── zh.json
├── public/                  # 静态资源、OG 图、logo
├── drizzle/                 # 生成的 SQL migration(已 commit)
├── src/
│   ├── app/                 # Next.js App Router
│   │   ├── [locale]/        # 带 locale 前缀的路由
│   │   │   ├── (marketing)/ # 公共页——home、pricing、blog、docs
│   │   │   ├── (auth)/      # login / register / forgot-password
│   │   │   └── (app)/       # 已登录——settings、admin、dashboard
│   │   ├── api/             # route handler(auth、webhooks、ping)
│   │   ├── layout.tsx       # 根 html、字体、主题
│   │   └── globals.css      # Tailwind v4 @theme + tokens
│   ├── components/          # blocks、layout、ui(shadcn)、features
│   ├── config/site.ts       # 中心配置,< 250 行
│   ├── db/                  # 拆分 schema:auth / app / affiliate / ai / license
│   ├── payment/             # facade + 4 个 provider + webhook handlers
│   ├── ai/                  # facade + 5 个 provider + cost / pricing
│   ├── credits/             # 4 类账目(仅服务端)
│   ├── mail/                # facade + Resend + React Email 模板
│   ├── newsletter/          # facade + Resend / Beehiiv
│   ├── customer-service/    # widget loader(Crisp / Tawk / Intercom / Chatwoot)
│   ├── affiliate/           # script loader + 内部追踪
│   ├── analytics/           # GA / PostHog / Plausible / Umami 扇出
│   ├── i18n/                # routing.ts + request.ts + 导航助手
│   ├── lib/                 # auth、safe-action、server、utils
│   ├── env.ts               # zod 校验过的 env 变量
│   └── middleware.ts        # i18n 路由 + 认证守卫
├── wrangler.toml.example    # Cloudflare Workers 配置模板
└── package.json
app/(在 src/ 里)是路由表面。src/app/ 之外的模块是页面和 route handler 调用的业务逻辑。 app/[locale]/ 段是 i18n 路由发生的地方;(marketing)(auth)(app) 这些 route group 是为了 layout 分组和认证守门,不影响 URL 形状。

关键决定

Provider facade 模式

每个对接外部服务的模块都导出一个对象(facade),背后是可互换的 provider。 facade 在启动时从 siteConfig.<module>.provider 选实现。消费者只见 facade。
// src/payment/index.ts
function pickProvider(): PaymentProvider {
  switch (siteConfig.payment.provider) {
    case 'stripe': return stripeProvider;
    case 'paddle': return paddleProvider;
    case 'lemonsqueezy': return lemonSqueezyProvider;
    case 'creem': return creemProvider;
  }
}
export const paymentManager: PaymentProvider = pickProvider();
src/ai/src/mail/src/newsletter/src/customer-service/src/affiliate/ 都是同一套。给 mail 加 Postmark 就是 src/mail/provider/postmark.ts 一个新文件加一条 switch 分支——消费者代码一行不改。

server-only 强制

碰 DB 或读 secret 的文件在顶部 import 'server-only'。这种模块要是被 client component 引用, Next.js build 会失败。已保护:src/db/index.tssrc/lib/auth.tssrc/lib/server.tssrc/lib/safe-action.ts、所有 src/payment/provider/*src/mail/index.tssrc/credits/index.tssrc/credits/server.ts。新建仅服务端模块时记得加这一行—— 半秒钟敲完,省掉一次意外泄密。

三档 server actions

所有写操作走 next-safe-action,三种 client 在 src/lib/safe-action.ts
  • actionClient —— 公共,不需要认证。
  • userActionClient —— 需要登录;提供 ctx.user
  • adminActionClient —— 需要登录;要求 user.role === 'admin'
每个 action 用 Zod 声明输入 schema。输出端到端类型化。守门集中——不必在每个 action 散落 if (!user) throw 样板。

幂等 webhook

支付 webhook 在任何非 2xx 响应时都会重试。如果你在重试时重复发积分,就等于一笔付款付了两次。 脚手架在 src/payment/handlers/ 里靠两条保证解决:
  1. payment.invoiceId 有 unique index——重复 insert 会让事务失败。
  2. 每个 handler 在 insert 前先做 payment.sessionId 查询,命中就早退。
发积分和 insert payment 行在同一个事务里——要么一起落,要么一起不落。加新事件类型 (订阅续费、退款)时照这个形状写——绝不要把发积分单独再调一次。

拆分的 DB schema

src/db/ 按职责拆开:auth.schema.ts(Better Auth)、app.schema.ts(payment + credits + transactions)、affiliate.schema.tsai.schema.tslicense.schema.tsschema.ts 统一 re-export 给 Drizzle Kit 和 Better Auth adapter。id 用 text(nanoid 前缀或 snowflake)—— 不用 serial,因为 text id 让你换 provider 时不破坏外键,也不会通过枚举泄露用户数。所有外键 在用户删除时级联(开箱即合 GDPR)。

双语 MDX

每篇文档两个平行文件:path/to/doc.mdxpath/to/doc.zh.mdx。Fumadocs 配置成先找带 locale 后缀的文件,找不到回落默认。同一套机制用于 blog 文章和 changelog。两种 locale 都存在才算上线。

Locale 路由——as-needed 前缀

src/i18n/routing.ts 把 next-intl 配成英文留在根(/about)、中文加前缀(/zh/about)。 全程用 @/i18n/navigation 里的 Link / useRouter——它们自动保留 locale 前缀。 直接 import next/linknext/navigation 会在客户端导航时悄悄丢掉 locale。

钱用 micro-cent

AI 推理和积分账务都涉及小于一分钱的数。用 float 存钱招舍入 bug;用整数 cent 存又丢亚分精度 (百万 token 的 Claude 调用每 token 价 $0.000003)。脚手架统一存整数 micro-cent(百万分之一 USD),到边缘再换显示单位。算法见 src/ai/pricing.ts

Edge runtime —— 只给 OG 图

src/app/opengraph-image.tsx 跑在 Edge runtime,因为这是唯一一处 cold start 直接影响用户可见 性能的路径(社交平台抓图器超时很快)。其他全部——包括所有访问 Postgres 的 API 路由——用 Node runtime,因为 pg 驱动和不少 auth 依赖需要完整 Node API。除非你清楚在让出什么,否则别把路由 搬到 Edge。

数据流——结账

一个穿透大半个架构的典型流程:
用户点击「$49 立即获取 vibestrap」
  → /register?plan=vibestrap-promo
  → 注册后 → /settings/billing
  → createCheckoutAction({plan})    [userActionClient]
    → paymentManager.createCheckout()   [facade → 当前 provider]
    → 返回 session.url
  → window.location = session.url
  → 用户在 provider 托管页支付
  → POST /api/webhooks/<provider>   [Node runtime]
    → verifyWebhook() —— 用 node:crypto 校签名
    → handler → 用 payment.sessionId 做幂等检查
    → DB 事务:
        INSERT payment 行
        addCredits()  // 同事务里发对应数量
  → 用户跳回 /settings/billing?status=success

另见