大多数关于全栈React的教程都只停留在“Hello World”这个阶段。它们会教你如何渲染一个组件,或许还会演示如何获取一些数据,然后就结束了。

但当你真正开始构建一个真正的SaaS应用程序时,你会立刻遇到一系列亟待解决的问题:应该如何设计数据库结构?认证功能应该放在哪里实现?如何确保API调用具有类型安全性?在处理支付逻辑时又该如何避免丢失Webhook信号呢?

这本手册能够解答所有这些问题。你将使用TanStack Start、Elysia、Drizzle ORM、Neon PostgreSQL、Better Auth、Stripe以及Inngest这些工具,从零开始构建一个可投入生产的SaaS应用程序。

最终,你会得到一个具备认证功能、API类型安全性、数据库迁移机制、支付处理能力以及后台作业功能的完整应用程序。

在选择这套技术栈之前,我曾经使用Next.js、Express和Prisma构建过多个生产环境下的应用程序。而TanStack Start与Elysia的结合,再加上Eden Treaty的支持,能够为你提供端到端的类型安全性保障——从数据库架构到React组件,整个开发过程完全不需要生成任何代码。

如果你修改了数据库中的某列数据,TypeScript会自动提示你所有需要更新的地方。这种反馈机制会彻底改变你的软件开发方式。

以下是你将学到的内容:

  • 如何使用Vite和基于文件的路由系统来设置TanStack Start项目

  • 如何使用Drizzle ORM和Neon来配置PostgreSQL数据库

  • 如何将Elysia集成到你的Web应用程序中,从而构建出具有类型安全性的API

  • 如何利用Eden Treaty将前端与后端API连接起来

  • 如何使用Better Auth添加GitHub OAuth认证功能

  • 如何运用可复用的四层架构模式来开发完整的功能模块

  • 如何利用Stripe的Webhook功能处理支付业务

  • 如何使用Inngest来运行可靠的后台作业任务

  • 如何利用Neon将整个应用程序部署到Vercel平台上

为什么选择TanStack Start而不是Next.js?

你可能会想:为什么不直接使用Next.js呢?它毕竟是全栈React框架的首选,而且理由也很充分。Next.js率先实现了服务器端渲染技术,确立了许多影响整个React生态系统的开发规范,同时也拥有最大的开发者社区。

但对于这类项目来说,TanStack Start确实具有三个显著的优势。

1. 部署灵活性

TanStack Start编译后的代码是标准的JavaScript,因此可以在任何环境中运行:无论是Node.js、Bun、Deno、Cloudflare Workers、AWS Lambda,还是你自己的服务器。而Next.js则很难在Vercel之外进行自我托管。

如果你在Stack Overflow上搜索“Next.js Azure App Service container”或“Next.js ISR self-hosted”,你会看到许多关于那些只有在生产环境中才会出现的特殊情况的讨论。

2. 更简单的开发思维模型

Next.js已经变得越来越复杂了:App Router、React Server Components、Server Actions、部分预渲染机制、《cache()`函数、《unstable_cache()`函数,再加上各种渲染策略……

TanStack Start采用了全文档的SSR技术,并确保了数据内容的完整加载。因此,不存在服务器端与客户端之间的混淆问题。不过,这种方式的缺点是无法实现RSC所提供的精细流式处理功能,但它的优势在于代码结构更加清晰、可预测性更强。

3. 端到端的类型安全性

结合Elysia与Eden Treaty的技术,TanStack Start能够从数据库层面开始,一直到用户界面,实现编译时的类型推断。因此,无需进行任何代码生成操作,也无需维护额外的模式文件来确保数据一致性。

TanStack Router本身就提供了完全类型的路由功能,它可以自动识别路径参数、搜索参数以及加载器所需的数据类型。

这是一本指导手册,所以内容会相当深入。请抽出几个小时的时间,打开你的编辑器,让我们一起开始实际开发吧。

目录

先决条件

在开始之前,请确保你已经安装了以下工具:

  • Bun(v1.2或更高版本),用于包管理及脚本执行

  • Docker,用于在本地运行PostgreSQL数据库

  • Git,用于版本控制

  • 需要具备React和TypeScript的基本使用知识

此外,你还需要在这些服务上注册免费账户:

  • Neon,用于部署生产环境的PostgreSQL数据库

  • Vercel,用于应用部署

  • GitHub,用于OAuth认证(你需要创建一个 OAuth应用程序)

  • Stripe,用于支付处理功能(测试模式是免费的)

所有这些服务都提供了丰富的免费使用方案。阅读本教程时,您完全不需要支付任何费用。

您还需要能够理解TypeScript代码。本手册假定您已经掌握了泛型、类型推导以及async/await的相关知识。如果您是TypeScript新手,官方手册将是一个很好的入门资源。

如何设置项目

首先创建一个新的TanStack Start项目。TanStack提供了一个命令行工具,该工具能够快速搭建一个包含基于文件的路由系统、Vite框架以及服务器端渲染功能的项目结构。

bunx @tanstack/cli@latest create my-saas
cd my-saas
bun install

命令行工具会询问您一些问题。请选择React作为您的开发框架,其余选项则直接使用默认设置即可。

您将使用Bun作为包管理器和运行时环境。与npm相比,Bun在安装依赖项和执行脚本方面速度要快得多。此外,Bun还原生支持TypeScript的编译,这意味着您可以直接运行`.ts`文件而无需进行任何编译步骤。

如果您更喜欢使用npm或pnpm,这些命令也是类似的,但本教程全程都会使用Bun作为工具。

如何理解项目结构

在开始编写代码之前,我们先来看看这个项目的整体架构。一个关键的设计原则是将所有的库文件放在`src/lib/`目录下。对于各种集成模块(如数据库、认证系统、支付接口等),它们都会被放置在单独的目录中,并通过`index.ts`文件来提供统一的公共API接口。

以下就是您最终会构建出的项目结构:

my-saas/
├── src/
│   ├── components/          # React组件
│   ├── hooks/               # 自定义React钩子
│   ├── lib/
│   │   ├── auth/            # Better Auth认证系统
│   │   ├── db/              # Drizzle ORM数据库框架
│   │   ├── jobs/            # 后台任务处理模块
│   │   └── payments/        # Stripe支付接口集成
│   ├── routes/              # TanStack基于文件的路由系统
│   ├── server/
│   │   ├── api.ts           # Elysia API定义文件
│   │   └── routes/          # API路由模块
│   └── start.ts             | TanStack Start项目入口文件
├── docker-compose.yml       | 本地PostgreSQL数据库配置文件及Neon代理设置
├── drizzle.config.ts        | Drizzle Kit配置文件
├── vite.config.ts           | Vite与TanStack Start配置文件
└── package.json

所有这些组件之间的连接关系如下:

全栈SaaS架构图:TanStack Start负责处理前端逻辑,与Elysia API服务器相连;认证功能通过Better Auth实现,支付接口使用Stripe,后台任务由Inngest处理,而Drizzle ORM则提供了类型安全的数据库访问机制

TanStack Start负责处理前端逻辑。它与同一项目中嵌入的Elysia API服务器进行交互。而Elysia则会连接三个外部服务:用于身份验证的Better Auth、用于支付的Stripe,以及用于处理后台任务的Inngest。在API层之下,Drizzle ORM为Neon PostgreSQL提供了类型安全的数据库访问机制。

你会逐一构建这些组件,首先从数据库开始。

这种架构设计使得所有的集成模块都相互独立。当你需要修改身份验证的实现方式时,只需前往`src/lib/auth/`目录;而如果要调整数据库结构,则去`src/lib/db/`目录进行操作。这样,就不会有任何功能影响到其他部分。

如何配置Vite

TanStack Start是在Vite环境下运行的。你的`vite.config.ts`文件需要添加TanStack Start插件、React插件,以及用于处理`@/`导入别名的路径配置:


// vite.config.ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  server: {
    port: 3000,
  },
  plugins: [
    tsConfigPaths({
      projects: ["./tsconfig.json"],
    }),
    tanstackStart(),
    viteReact(),
  ],
});

`tsConfigPaths`插件会读取你`tsconfig.json`文件中的路径配置,因此你在代码中就可以使用`@/lib/db`代替`../../lib/db`了。

将以下内容添加到你的`tsconfig.json`文件中:


{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

如何安装依赖项

请安装本教程中所需的所有核心依赖项:


# 框架与路由相关依赖
bun add @tanstack/react-router @tanstack/react-start react react-dom

# API层相关依赖
bun add elysia @elysiajs/eden

# 数据库相关依赖
bun add drizzle-orm @neondatabase/serverless ws
bun add -d drizzle-kit

# 身份验证相关依赖
bun add better-auth

# 支付相关依赖
bun add stripe

# 后台任务处理相关依赖
bun add inngest

# 构建工具相关依赖
bun add -d @vitejs/plugin-react vite vite-tsconfig-paths typescript

现在,你已经拥有了一个包含了所有所需依赖项的可用TanStack Start项目。请启动开发服务器,确认一切都能正常运行:


bun run dev

访问`http://localhost:3000`,你应该能看到你的应用程序正在运行。

如何使用Drizzle和Neon配置数据库

任何SaaS应用都需要数据库。在这里,你会使用Drizzle ORM与Neon PostgreSQL配合使用。Drizzle能提供类型安全的数据库查询语句,其语法类似于SQL;而Neon则提供了一个无服务器架构的PostgreSQL数据库,在你不使用它的时候,系统会自动将其资源消耗降为零。

为什么选择Drizzle而不是Prisma?

如果你之前在TypeScript生态系统中使用过ORM,那么很可能是Prisma。Prisma在很多场景下都非常适用,但在这种架构中它存在一个关键缺陷:它需要通过代码生成来生成相应的类型定义。

你需要编写一个`.prisma`文件,然后运行`prisma generate`命令,Prisma会为此生成TypeScript客户端代码。这种生成过程会增加你的开发效率,并且还会产生一些需要保持同步的中间文件。

而Drizzle采用了不同的方式:你的数据库模式和查询语句都是TypeScript编写的,类型信息会在编译时自动推断出来,无需任何额外的生成步骤。

当你向表格中添加一个列时,相关类型会立即得到更新。这种设计与其他技术栈非常兼容——类型信息可以从Drizzle传递到Elysia,再进入Eden Treaty,整个过程没有任何中间环节。

此外,Drizzle生成的SQL语句与传统的SQL语法完全一致。如果你熟悉PostgreSQL,就可以直接理解Drizzle的查询语句;根本不需要学习Prisma特有的查询语言。

如何使用Docker搭建本地PostgreSQL环境

对于本地开发来说,你可以在Docker中运行PostgreSQL,并使用与Neon兼容的代理服务器。这样,你就可以在本地使用与生产环境中相同的Neon无服务器驱动程序。

在项目根目录下创建一个`docker-compose.yml`文件:

# docker-compose.yml
services:
  postgres:
    image: postgres:17
    container_name: my-saas-postgres
    restart: unless-stopped
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: my_saas
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  neon-proxy:
    image: ghcr.io/timowilhelm/local-neon-http-proxy:main
    container_name: my-saas-neon-proxy
    restart: unless-stopped
    environment:
      - PG_CONNECTION_STRING=postgres://postgres:postgres@postgres:5432/my_saas
    ports:
      - "4444:4444"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:

neon-proxy容器是这个方案中的关键部分。它负责将HTTP请求转换成PostgreSQL能够识别的协议格式,这样你的Neon无服务器驱动程序就可以在本地正常运行了,而无需对代码进行任何修改。

在生产环境中,Neon会在他们的基础设施上完成这种转换工作;而在本地开发时,这个代理服务器就起到了连接HTTP协议的Neon驱动程序与PostgreSQL容器的作用。
PostgreSQL容器的`healthcheck`配置确保了只有当数据库准备好之后,代理服务器才会启动。如果没有这个机制,代理服务器会尝试连接到还在初始化中的数据库,从而导致启动失败。
现在来启动这些容器:
class="language-bash">docker compose up -d

如何定义数据结构

首先创建数据库客户端及相应的数据结构。连接操作可以从src/lib/db/index.ts文件开始进行:


// src/lib/db/index.ts
import { neon, neonConfig } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import ws from "ws";

import * as schema from "./schema";

const isProduction = process.env.NODE_ENV === "production";
const LOCAL_DB_HOST = "db.localtest.me";

let connectionString = process.env.DATABASE_URL;

if (!connectionString) {
  throw new Error("环境变量DATABASE_URL未设置");
}

neonConfig.webSocketConstructor = ws;

if (!isProduction) {
  connectionString = `postgres://postgres:postgres@${LOCAL_DB_HOST}:5432/my_saas`;
  neonConfig.fetchEndpoint = (host) => {
    const [protocol, port] =
      host === LOCAL_DB_HOST ? ["http", 4444] : ["https", 443];
    return `\({protocol}://\){host}:${port}/sql";
  };
  neonConfig.useSecureWebSocket = false;
  neonConfig.wsProxy = (host) =>
    host === LOCAL_DB_HOST ? `\({host}:4444/v2` : `\){host}/v2`;
}

const client = neon(connectionString);
export const db = drizzle({ client, schema });

export * from "./schema";

主机名db.localtest.me实际上对应的是127.0.0.1,这是使用本地Neon代理的标准方式。在生产环境中,Neon驱动程序会直接通过环境变量DATABASE_URL连接到你的Neon数据库。

现在可以在src/lib/db/schema.ts文件中定义数据结构了。对于SaaS应用程序来说,你需要用户信息、会话记录、账户信息(用于OAuth认证),以及表示核心业务实体的表格。以下是一个实际的生产环境数据结构示例:


// src/lib/db/schema.ts
import {
  boolean,
  integer,
  pgEnum,
  pgTable,
  text,
  timestamp,
  varchar,
} from "drizzle-orm/pg-core";

export const purchaseTierEnum = pgEnum("purchase_tier", ["pro"));
export const purchaseStatusEnum = pgEnum("purchase_status", [
  "completed",
  "partially_refunded",
  "refunded",
]);

export const users = pgTable("users", {
  id: text("id").primaryKey(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  name: text("name"),
  image: text("image"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const sessions = pgTable("sessions", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() =&> users.id, { onDelete: "cascade" }),
  token: text("token").notNull().unique(),
  expiresAt: timestamp("expires_at").notNull(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const accounts = pgTable("accounts", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() =&> users.id, { onDelete: "cascade" }),
  accountId: text("account_id").notNull(),
  providerId: text("provider_id").notNull(),
  accessToken: text("access_token"),
  refreshToken: text("refresh_token"),
  accessTokenExpiresAt: timestamp("access_token_expires_at"),
  refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
  scope: text("scope"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const verifications = pgTable("verifications", {
  id: text("id").primaryKey(),
  identifier: text("identifier").notNull(),
  value: text("value").notNull(),
  expiresAt: timestamp("expires_at").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const purchases = pgTable("purchases", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() =&> crypto.randomUUID()),
  userId: text("user_id")
    .notNull()
    .references(() =&> users.id, { onDelete: "cascade" }),
  stripeCheckoutSessionId: text("stripe_checkout_session_id")
    .notNull()
    .unique(),
  stripeCustomerId: text("stripe_customer_id"),
  stripePaymentIntentId: text("stripe_payment(intent_id"),
  tier: purchaseTierEnum("tier").notNull(),
  status: purchaseStatusEnum("status").notNull().default("completed"),
  amount: integer("amount").notNull(),
  currency: text("currency").notNull().default("usd"),
  purchasedAt: timestamp("purchased_at").notNull().defaultNow(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

// 为你的应用程序提供类型导出
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Purchase = typeof purchases.$inferSelect;
export type NewPurchase = typeof purchases.$inferInsert;

推送该模式以创建相应的表格:

bun run db:push

关于这个模式,有几点需要注意:

  1. `users`、`sessions`、`accounts`和`verifications`这些表格是Better Auth所必需的。在下一节中,你会配置认证库来使用这些表格。

  2. `purchases`表格是你的核心业务实体。它用于记录Stripe结算过程中的相关信息,并将这些信息与用户关联起来。

  3. 像`User`和`Purchase`这样的类型导出,会根据模式自动为你生成TypeScript类型定义。你无需手动定义类型,这些类型都是从模式定义中得来的。

  4. 在`purchases.id`列上使用了`$defaultFn`,这样在插入数据时就会自动生成UUID。由于Better Auth会自己生成ID,因此认证表格中也使用文本形式的ID。

如何配置Drizzle Kit

在项目根目录下创建`drizzle.config.ts`文件:

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./src/lib/db/schema.ts",
  out: "./drizzle",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

将以下脚本添加到`package.json`文件中:

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:push": "drizzle-kit push",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}

现在将你的模式推送到本地数据库中:

bun run db:push

Drizzle Kit会读取你的模式文件,将其与数据库中的数据进行对比,并应用任何必要的更改。在开发环境中,使用`db:push`命令非常快捷方便;而在生产环境中,你应该使用`db:generate`和`db:migrate`命令来生成分版本的SQL迁移脚本。

你还可以打开Drizzle Studio来直观地查看数据库内容:

bun run db:studio

这样会在`https://local.drizzle.studio`这个地址打开一个Web界面,在那里你可以浏览表格、运行查询并检查数据。

如何使用Elysia构建API

在这里,这种技术架构就显得非常有趣了。你不需要单独运行API服务器,而是可以直接将Elysia嵌入到TanStack Start中。这样,你的Web应用和API就会运行在同一个进程中,共享相同的类型定义,并且可以作为一个整体进行部署。

为什么选择Elysia而不是Express?

如果你之前曾经开发过Node.js API,那么很可能使用过Express。Express已经存在了15年,拥有庞大的生态系统。但是Express是在TypeScript、async/await这些技术出现之前设计的,因此它在支持类型安全方面并不完善。

Elysia采取了不同的实现方式。从一开始,它就是为TypeScript设计的。请求体、响应类型以及路径参数都是在编译时被推断出来的。

结合Eden Treaty(你将在下一节中配置它),当你的前端调用API时,就能享受到完整的类型安全性。无需生成任何代码,也无需维护OpenAPI规范文件,只需依靠TypeScript的类型推断机制即可。

Elysia还内置了请求验证功能,这要归功于它的t(TypeBox)模式构建工具:

import { Elysia, t } from "elysia";

new Elysia().post(
  "/users",
  ({ body }) => {
    // body被定义为{ name: string, email: string }类型
    return createUser(body);
  },
  {
    body: t.Object({
      name: t.String(),
      email: t.String(),
    }),
  }
);

该模式在运行时进行验证,而在编译时则为代码提供TypeScript类型信息。同一个定义可以同时满足这两种需求。

如何定义你的API

创建文件src/server/api.ts,所有API路由都会放在这个文件中:

// src/server/api.ts
import { Elysia, t } from "elysia";
import { eq } from "drizzle-orm";

import { auth } from "@/lib/auth";
import { db, purchases, users } from "@lib/db";

export const api = new Elysia({ prefix: "/api" })
  .onRequest(({ request }) => {
    console.log(`[API] \({request.method} \){request.url}`);
  })
  .onError(({ code, error, path }) => {
    console.error(`[API ERROR] \({code} on \){path}:`, error);
  })
  .get("/health", () => ({
    status: "ok",
    timestamp: new Date().toISOString(),
  }))
  .get("/me", async ({ request, set }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "Unauthorized" };
    }

    return { user: session.user };
  })
  .get("/payments/status", async ({ request, set }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "Unauthorized" };
    }

    const purchase = await db
      .select()
      .from(purchases)
      .where(eq(purchases.userId, session.user.id))
      .limit(1);

    return {
      userId: session.user.id,
      purchase: purchase[0] ?? null,
    };
  });

export type Api = typeof api;

最后那一行代码非常重要。export type Api = typeof api这一句代码用于导出你的API的完整类型签名。Eden Treaty会利用这个类型信息在前端生成一个类型完备的客户端代码。

你很快就会了解到这是如何实现的。

请注意,对于需要身份验证的接口来说,其实现方式非常直观:只需调用auth.api.getSession()并传入请求头信息,然后检查会话是否存在;如果不存在,则返回401错误码。这种设计既简单明了,也不需要使用任何装饰器或中间件。

onRequestonError这两个钩子函数可以为每一个请求提供日志记录功能。在生产环境中,你应该将这些日志直接发送到你的监控平台中。

如何在TanStack Start中配置Elysia

TanStack Start采用基于文件的路由机制。若要使用Elysia处理所有API请求,需在src/routes/api.$.ts文件中创建一个通用路由规则:

// src/routes/api.$.ts
import { createFileRoute } from "@tanstack/react-router";

import { api } from "../server/api";

const handler = ({ request }: { request: Request }) => api.fetch(request);

export const Route = createFileRoute("/api/$") {
  server: {
    handlers: {
      GET: handler,
      POST: handler,
      PUT: handler,
      PATCH: handler,
      DELETE: handler,
      OPTIONS: handler,
    },
  },
};

文件名中的$是TanStack Router的通配符语法。该路由规则会匹配任何以/api/开头的路径,而serverhandlers对象会将各种HTTP方法映射到对应的Elysia处理函数上。所有发往/api/*的请求都会被转发给Elysia的fetch方法进行处理。

这一设计的关键在于:Elysia是直接嵌入到TanStack Start中的,因此不存在独立的API服务器。你的Web应用和API共享同一个进程、同一个端口,也采用相同的部署方式。

这样的架构设计可以有效解决CORS问题,简化部署流程,并且还能让你在前端直接导入API相关类型。

你可以通过访问http://localhost:3000/api/health来测试你的API。你应该会看到如下响应:

{ "status": "ok", "timestamp": "2026-03-28T12:00:00.000Z" }

如何使用Eden Treaty实现类型安全的API调用

Eden Treaty是Elysia配套提供的客户端库。它是一个端到端的、类型安全的HTTP客户端,其路由结构会以JavaScript对象的形式被映射出来。你无需手动编写fetch("/api/users")这样的代码并处理响应,只需调用api.api.users.get(),就能享受到自动补全、参数验证以及返回类型推断等功能——所有这些功能都是在编译时根据你的服务器代码生成的,且完全不需要额外编写任何代码。

正是这一特性使得整个技术栈变得格外特别。Eden Treaty会读取你Elysia API中定义的类型信息,从而生成一个类型完整的客户端。每个接口、每个参数、以及每种响应格式都会在编译时被确定下来。

如何配置Treaty客户端

由于Elysia已经嵌入到了你的TanStack Start应用中(属于同一来源域),因此你无需向Treaty客户端传递URL地址。你可以直接从Elysia应用实例中创建客户端用于服务器端开发,而对于浏览器端开发,则可以使用基于URL的客户端。

// src/lib/treaty.ts
import { treaty } from "@elysiajs/eden";

import type { Api } from "@/server/api";

// 用于浏览器端时,连接到相同的来源域
export const api = treaty( 
  typeof window !== "undefined" 
    ? window.location.origin 
    : (process.env.BETTER_AUTH_URL ?? "http://localhost:3000") 
);

现在,你可以在应用程序的任何地方使用api,而且能够享受到完整的类型安全性:

// 调用GET /api/health接口
const { data } = await api.api.health.get();
// data的数据类型为{ status: string, timestamp: string }

// 调用GET /api/me接口(需要认证)
const { data: me, error } = await api.api.me.get();
// data的数据类型为{ user: { id: string, email: string, ... } }
// error的数据类型为{ error: string } | null

请注意,方法链的构成与你的路由结构是完全一致的。/api/health这个接口对应着api.api.health.get()这种调用方式。路径中的各个部分会变成对象的属性,而HTTP方法则会成为最终的函数调用。

所有这些类型信息都是通过type Api = typeof api这一导出语句推断出来的。

类型数据是如何从服务器传递到客户端的

下面是类型数据在整个系统中的流动过程的完整示意图:

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  Drizzle模式        │     │    Elysia API    │     │   Eden Treaty   │
│  (schema.ts)     │────▶│   (api.ts)       │────▶│   (客户端代码)      │
│                  │     │                  │     │                  │
│  type User =     │     │  .get("/me",     │     │  api.api.me     │
│  typeof users    │     │    () => user)   │     │    .get()       │
│  .$inferSelect   │     │                  │     │    → { user }   │
└─────────────────┘     └─────────────────┘     └─────────────────┘

首先,Drizzle会根据你的表格定义来推断TypeScript类型。例如User这个类型就是从users表格的结构中推断出来的。

然后Elysia会在路由处理函数中使用这些类型。当某个处理函数返回{ user: session.user }时,Elysia会捕获到这个返回值的数据类型。

最后,Eden Treaty会读取type Api = typeof api这一导出语句,并生成客户端代码,在其中每个接口的定义都会包含完整的类型信息。

如果你在users表格的结构中添加了新的字段,Drizzle推断出的类型也会相应地更新。如果你的Elysia处理函数返回了这个新字段,那么Eden Treaty生成的客户端代码中的类型也会随之改变。而如果你在React组件中尝试访问一个已经不存在的字段,TypeScript会在编译阶段就捕获到这个错误。

完全不需要编写任何额外的代码,也不会产生任何运行时的开销,只是让TypeScript自动完成它的类型推断工作而已。

如何使用Eden Treaty处理错误

每次调用Eden Treaty时,都会返回一个{ data, error }类型的对象。这并不是一个被抛出的异常,而是一种“区分联合类型”,它强制你同时处理成功和失败两种情况:

const { data, error } = await api.api.me.get();

if (error) {
  // error的数据类型是根据Elysia处理函数可能返回的内容来确定的
  console.error("获取用户信息失败:", error);
  return null;
}

// data现在就被限定为成功情况下的数据类型
console.log(data.user.email);

这种模式可以有效避免那些在使用 `fetch` 或 Axios 时常见的错误——即错误被抛出后却很容易被忽略。而通过 Eden Treaty,TypeScript 编译器会提醒你这些错误。

如何在路由加载器中使用 Eden Treaty

TanStack Start 路由配置中包含了 `loader` 函数,这些函数会在服务器端进行 SSR 处理时执行,在客户端进行导航操作时也会被调用。你可以在这些加载器中使用 Eden Treaty,在页面渲染之前先获取数据:


// src/routes/_authenticated/dashboard.tsx
import { createFileRoute } from "@tanstack/react-router";

import { api } from "@/lib/treaty";

export const Route = createFileRoute("/_authenticated/dashboard")({
  loader: async () => {
    const { data } = await api.api.payments.status.get();
    return { purchase: data?.purchase ?? null };
  },
  component: DashboardPage,
});

function DashboardPage() {
  const { purchase } = Route.useLoaderData();

  return (
    

控制面板

{purchase ? (

你的套餐:{purchase.tier}

) : (

没有激活的套餐。

)}
); }

`loader` 函数会在组件渲染之前执行,因此页面在初始加载时不会出现加载提示。`Route.useLoaderData()` 会根据加载器返回的数据生成类型明确的数据。如果你更改了加载器返回的数据类型,TypeScript 会立即检测到类型不匹配的问题。

如何使用 Better Auth 添加认证功能

所有的 SaaS 服务都需要认证功能。在本教程中,你将学习如何结合 GitHub OAuth 和 Better Auth 来实现认证机制。Better Auth 是一个与特定框架无关的认证库,它可以与 Drizzle 无缝配合使用,并且也完美支持 TanStack Start。

如何创建 GitHub OAuth 应用程序

在开始编写代码之前,首先需要创建一个 GitHub OAuth 应用程序:

  1. 访问 GitHub 开发者设置

  2. 点击“新建 OAuth 应用程序”

  3. 将主页 URL 设置为 `http://localhost:3000`

  4. 将授权回调 URL 设置为 `http://localhost:3000/api/auth/callback/github`

  5. 点击“注册应用程序”

  6. 复制客户端 ID 并生成客户端密钥

将这些信息添加到项目根目录下的 `.env` 文件中:


# .env
DATABASE_URL=postgres://postgres:postgres@db.localtest.me:5432/my_saas
BETTER_AUTH_SECRET=你的随机32位字符串
BETTERAUTH_URL=http://localhost:3000
GITHUB_CLIENT_ID=你的GitHub客户端ID
GITHUB_CLIENT_SECRET=你的GitHub客户端密钥

为 `BETTER_AUTH_SECRET` 生成一个随机的密钥:

openssl rand -base64 32

如何配置认证服务器

创建文件`src/lib/auth/index.ts`。这是服务器端的认证配置代码:


// src/lib/auth/index.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";

import * as schema from 「/lib/db」;
import { db } from 「/lib/db」;

const isDev = process.env.NODE_ENV !== "production";
const baseURL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";

export const auth = betterAuth({
  baseURL,
  database: drizzleAdapter(db, {
    provider: "pg",
    usePlural: true,
    schema: {
      users: schema.users,
      sessions: schema.sessions,
      accounts: schema.accounts,
      verifications: schema.verifications,
    },
  },

  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID ?? "",
      clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
    },
  },

  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7天
    updateAge: 60 * 60 * 24,      // 每日更新
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // 5分钟
    },
  },

  trustedOrigins: isDev
    ? ["http://localhost:3000"]
    : [baseURL],

  plugins: [tanstackStartCookies()],
});

export type Auth = typeof auth;
export type Session = typeof auth.$Infer.Session;

此配置中的关键细节如下:

  • drizzleAdapter用于将Better Auth与您的Drizzle数据库连接起来。选项`usePlural: true`表示您的表格名称应为`users`而非`user`,`sessions`应为`sessions`而非`session`,依此类推。
  • tanstackStartCookies()是一个插件,用于处理TanStack Start的服务器端渲染过程中的cookie管理。如果没有这个插件,会话信息在服务器端渲染时将无法正确保存。
  • cookieCache会将会话数据存储在cookie中,有效期为5分钟,从而减少每次请求时对数据库的访问次数。

如何配置认证客户端

为浏览器端的认证客户端创建文件`src/lib/auth/client.ts`:


// src/lib/auth/client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: "",
});

export const { signIn, signOut, useSession } = authClient;

由于Elysia已经被集成到您的TanStack Start应用中,因此`baseURL`被设置为空字符串。认证请求会发送到同源域下的`/api/auth/*`路径,因此不需要单独的认证服务器。

如何配置认证路由

Better Auth需要处理`/api/auth/*`路径上的请求。由于Elysia已经负责处理所有`/api/*`路径的请求,因此只需将Better Auth的处理逻辑嵌入到Elysia中即可。

请在文件`src/server/api.ts`中添加以下代码:

// 在 src/server/api.ts 文件中,添加 Better Auth 的处理函数
export const api = new Elysia({ prefix: "/api" });
  // 将 Better Auth 绑定到 /api/auth/* 路由上以进行处理
  .mount(auth.handler);
  // ... 其余路由配置

.mount(authhandler) 这一行代码告诉 Elysia,将所有符合 Better Auth 路由规则的请求转发给相应的处理函数。这些请求包括登录、登出、会话管理以及 OAuth 回调操作。

如何保护路由

TanStack Start 使用布局路由来保护一组页面。请创建 src/routes/_authenticated.tsx 文件:

// src/routes/_authenticated.tsx
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";

import { auth } from "@/lib/auth";

const getCurrentUser = createServerFn().handler(async () => {
  const rawHeaders = getRequestHeaders();
  const headers = new Headers(rawHeaders as HeadersInit);
  const session = await auth.api.getSession({ headers });
  return session?.user ?? null;
});

export const Route = createFileRoute("/_authenticated")({
  beforeLoad: async ({ location }) => {
    const user = await getCurrentUser();

    if (!user) {
      throw redirect({
        to: "/login",
        search: { redirect: location.pathname },
      });
    }

    return { user };
  },
  component: AuthenticatedLayout,
});

function AuthenticatedLayout() {
  return ;
}

_authenticated 这个前缀表示这是一个布局路由。任何嵌套在 src/routes/_authenticated/ 下的路由都会首先执行 beforeLoad 检查。如果用户尚未登录,系统会将其重定向到 /login 页面,并通过查询参数确保用户登录后能够返回到原来的页面。

createServerFn 在服务器端进行 SSR 处理时会被调用。它会读取请求中的 cookies,检查是否存在有效的会话信息,并返回当前用户的信息。这意味着身份验证操作是在任何 HTML 内容被发送到浏览器之前在服务器端完成的。

现在,任何位于 src/routes/_authenticated/ 下的文件都会自动受到保护。例如,src/routes/_authenticated/dashboard.tsx 这个文件就需要用户进行身份验证才能访问。

如何构建登录页面

src/routes/login.tsx 文件中创建登录页面:

// src/routes/login.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { z } from "zod";

import { signIn } from"@lib/auth/client";

const searchSchema = z.object({
  redirect: z.string().optional(),
});

export const Route = createFileRoute("/login") {
  validateSearch: searchSchema,
  component: LoginPage,
};

functionLoginPage() {
  const { redirect: redirectTo } = Route.useSearch();
  const [isLoading, setIsLoading] = useState(false);

  const handleGitHubLogin = async () => {
    setIsLoading(true);
    const callbackURL = redirectTo
      ? `\({window.location.origin}\){redirectTo}`
      : `${window.location.origin}/dashboard`;

    await signIn.social({
      provider: "github",
      callbackURL,
    });
  };

  return (
    

登录

); }

TanStack Router的validateSearch方法会使用Zod库来验证查询参数。redirect参数被定义为可选的字符串类型,而Route.useSearch()会返回一个类型安全的对象,因此无需进行手动解析。

如何添加登录重定向中间件

对于已经通过身份验证的用户,你也应该将他们重定向到其他页面,而不是登录页面。你可以在src/start.ts文件中创建相应的代码:

// src/start.ts
import { redirect } from "@tanstack/react-router";
import { createMiddleware, createStart } from "@tanstack/react-start";
import { getRequestHeaders, getRequestUrl } from "@tanstack/react-start/server";

import { auth } from "@/lib/auth";

const authMiddleware = createMiddleware({ type: "request" }).server(
  async ({ next }) => {
    const rawHeaders = getRequestHeaders();
    const headers = new Headers(rawHeaders as HeadersInit);
    const url = getRequestUrl();

    if (url.pathname !== "/login") {
      return next();
    }

    const session = await auth.api.getSession({ headers });

    if (session?.user) {
      const redirectTo = url.searchParams.get("redirect");
      throw redirect({
        to: redirectTo || "/dashboard",
      });
    }

    return next();
  }
);

export const startInstance = createStart(() => ({
  requestMiddleware: [authMiddleware],
]));

这个中间件会在每一个请求被处理时被执行。如果用户已经通过了身份验证,但仍然访问了/login页面,他们就会被重定向到仪表板页面(或者他们原本想要访问的任何页面)。

如何使用四层架构模式构建完整的功能

现在你已经拥有了数据库、API、类型安全的客户端以及身份验证机制,是时候开始构建具体的功能了。在这个架构中,每一个功能都是按照相同的四层模式来设计的:

本教程中使用的四层功能架构模式:第1层为数据结构定义,第2层提供CRUD操作接口,第3层将React与API连接起来,第4层负责用户界面的渲染及交互处理

一旦你理解了这种架构模式,添加新的功能就会变得非常简单。让我们一起来构建一个允许已认证用户查看购买记录的功能吧。

第1层:数据结构定义

你之前已经在数据结构定义中创建了purchases表,下面是具体的代码:

// src/lib/db/schema.ts
export const purchases = pgTable("purchases", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  userId: text("user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  stripeCheckoutSessionId: text("stripe_checkout_session_id")
    .notNull()
    .unique(),
  stripeCustomerId: text("stripe_customer_id"),
  stripePaymentIntentId: text("stripe_payment(intent_id"),
  tier: purchaseTierEnum("tier").notNull(),
  status: purchaseStatusEnum("status").notNull().default("completed"),
  amount: integer("amount").notNull(),
  currency: text("currency").notNull().default("usd"),
  purchasedAt: timestamp("purchased_at").notNull().defaultNow(),
  creadoAt: timestamp("created_at").notNull().defaultNow(),
 .updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

如果你要添加新功能,就应该从这里开始。首先定义相关表格结构,然后运行 `bun run db:push` 命令,接着进入第二层开发流程。

第二层:API开发

在 `src/server/routes/purchases.ts` 文件中创建一个API路由模块:


// src/server/routes/purchases.ts
import { eq } from "drizzle-orm";
import { Elysia } from "elysia";

import { auth } from "@/lib/auth";
import { db, purchases } from "@lib/db";

export const purchasesRoute = new Elysia({ prefix: "/purchases" })
  .get("/status", async ({ request, set }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session?.user) {
      set.status = 401;
      return { error: "未经授权" };
    }

    const purchase = await db
      .select()
      .from(purchases)
      .where(eq(purchases.userId, session.user.id))
      .limit(1);

    return purchase[0] ?? null;
  });

然后在你主API文件中注册这个路由模块:


// src/server/api.ts
import { purchasesRoute } from "./routes/purchases";

export const api = new Elysia({ prefix: "/api" })
  .mount(auth.handler)
  .use(purchasesRoute)
  // ... 其他路由配置

`.use()` 方法用于将各个路由模块组合到一起。每个路由模块都是一个独立的Elysia实例,它们各自拥有自己的前缀,而 `use` 方法会将这些模块整合到主应用程序中。由于Eden Treaty能够识别这种组合后的结构,因此客户端会自动知道新增的接口地址。

第三层:钩子函数

创建一个自定义钩子函数,将你的React组件与API连接起来:


// src/hooks/use-purchase-status.ts
import { useQuery } from "@tanstack/react-query";

import { api } from "@lib/treaty";

export function usePurchaseStatus() {
  return useQuery({
    queryKey: ["purchase-status"],
    queryFn: async () => {
      const { data, error } = await api.api.purchases.status.get();
      if (error) throw new Error("获取购买状态信息失败");
      return data;
    },
  });
}

TanStack Query负责处理缓存、数据重新请求、加载状态以及错误处理等功能。`queryKey`用于在缓存中识别这些数据。如果多个组件调用了 `usePurchaseStatus()`,系统也只会发起一次网络请求。

对于数据的创建、更新或删除操作,可以使用 `useMutation` 钩子函数:


// src/hooks/use-checkout.ts
import { useMutation } from "@tanstack/react-query";

import { api } from "@lib/treaty";

export function useCheckout() {
  return useMutation({
    mutationFn: async () => {
      const { data, error } = await api.api.payments.checkout.post();
      if (error) throw new Error("创建结账会话失败");
      return data;
    },
    onSuccess: (data) => {
      // 重定向到Stripe结算页面
      if (data?.url) {
        window.location.href = data.url;
      }
    },
  });
}

第四层:用户界面

在您的React组件中使用这些钩子:

// src/components/purchase-status.tsx
import { usePurchaseStatus } from 「@hooks/use-purchase-status";

export function PurchaseStatus() {
  const { data: purchase, isLoading, error } = usePurchaseStatus();

  if (isLoading) {
    return 
正在加载中...
; } if (error) { return
无法加载购买状态。
; } if (!purchase) { return (

尚未购买任何套餐。

您还没有购买任何套餐。

); } return (

状态:{purchase.status}

各层之间的交互方式

以下是数据在四层结构中流动的具体过程(以读取操作为例):

用户点击“仪表盘”
  → TanStack Router触发路由加载器
    → 加载器通过Eden Treaty调用api.api.purchases.status.get()
      → Elysia接收到GET /api/purchases/status请求
        → 处理程序调用auth.api.getSession()来验证用户身份
        → 处理程序通过Drizzle查询db.select().from(purchases)
        → 处理程序返回包含类型信息的{purchase}对象
      → Eden Treaty接收到格式化后的响应数据
    → 加载器返回处理后的数据
  → 组件利用Route.useLoaderData()来渲染页面

对于写入操作(例如创建新资源),数据流动的过程类似,但会使用mutation机制:

用户点击“立即购买”
  → onClick通过useMutation钩子调用checkout.mutate()
    → mutationFn通过Eden Treaty调用api.api.payments.checkout.post()
      → Elysia接收到POST /api/payments/checkout请求
        → 处理程序创建Stripe支付会话
        → 处理程序返回支付链接(url)
      → Eden Treaty接收到格式化后的响应数据
    → 成功后系统会重定向到Stripe支付页面

如何添加新功能

为了进一步说明这一结构,我们来看一下如何添加用户资料更新功能。这个例子会展示整个四层结构在写入操作中的应用过程。

第一层:数据模型。 `users`表中已经存在可以更新的`name`字段,因此不需要修改数据模型。

第二层:API。 需要添加一个PATCH接口:

// 在src/server/api.ts文件中
patch(
  "/me",
  async ({ request, body, set }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "未经授权" };
    }

    const [updatedUser] = await db
      .update(users)
      .set({
        name: body.name,
        updatedAt: new Date(),
      })
      .where(eq(users.id, session.user.id))
      .returning();

    return { user: updatedUser };
  },
  {
    body: t.Object({
      name: t.String({ minLength: 1, maxLength: 100 }),
    }),
  },
)

body选项会在运行时验证请求体,并在编译时提供TypeScript类型定义。如果有人发送的请求中缺少name字段,Elysia会自动返回400错误代码。因此,你无需自行编写任何验证逻辑。

第三层:Hook函数。 需要创建一个mutation hook函数:

// 在src/hooks/use-update-profile.ts文件中
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { api } from "@/lib/treaty";

export function useUpdateProfile() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: { name: string }) => {
      const { data: result, error } = await api.api.me.patch(data);
      if (error) throw new Error("更新个人资料失败");
      return result;
    },
    onSuccess: () => {
      // 清除所有依赖于用户数据的查询缓存
      queryClientinvalidateQueries({ queryKey: ["me"] });
    },
  });
}

onSuccess回调函数会清除与用户数据相关的查询缓存。这意味着任何显示用户信息的组件都会自动重新获取数据并显示更新后的姓名。

第四层:用户界面。 需要在表单组件中使用这个hook函数:

// 在src/components/profile-form.tsx文件中
import { useState } from "react";

import { useUpdateProfile } from "@/hooks/use-update-profile";

export function ProfileForm({ currentName }: { currentName: string }) {
  const [name, setName] = useState(currentName);
  const updateProfile = useUpdateProfile();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    updateProfile.mutate({ name });
  };

  return (
    
显示名称 {updateProfile.isPending ? "保存中..." : "保存"} {updateProfile.isError && (

)} > ); }

共有四层结构,每层都遵循相同的模式。

这种重复性是故意设计的——重复本身是一种设计特征,而非错误。当所有组件都遵循相同的结构时,人们就能清楚地知道该去哪里查找所需的信息。

新代码会被添加到预定的位置中。如果你使用人工智能编码辅助工具,它可以从你的代码库中学习这种模式,并为新的功能生成全部四层结构。

如何使用Stripe添加支付功能

大多数SaaS应用程序都需要处理支付业务。对于一次性购买场景,你可以使用Stripe Checkout来实现支付功能。关键在于要使用后台作业来可靠地处理Webhook请求,具体实现方法将在下一节中介绍。

如何配置Stripe

创建文件src/lib/payments/index.ts


// src/lib/payments/index.ts
import Stripe from "stripe";

let stripeClient: Stripe | null = null;

function getStripe(): Stripe {
  if (!stripeClient) {
    const secretKey = process.env.STRIPE_SECRET_KEY;
    if (!secretKey) {
      throw new Error(
        "STRIPE_SECRET_KEY未设置,因此支付功能无法使用。"
      );
    }
    stripeClient = new Stripe(secretKey);
  }
  return stripeClient;
}

// 使用Proxy机制来延迟初始化Stripe SDK,这样即使环境变量缺失,模块导入也不会出错
export const stripe = new Proxy({} as Stripe, {
  get(_, prop) {
    return Reflect.get(getStripe(), prop);
  },
});

export async function createOneTimeCheckoutSession(params: {
  priceId: string;
  successUrl: string;
  cancelUrl: string;
  metadata: Record;
  customerEmail?: string;
  couponId?: string;
}) {
  const client = getStripe();

  const session = await client.checkout Sessions.create({
    mode: "payment",
    line_items: [{ price: params.priceId, quantity: 1 }],
    success_url: params.successUrl,
    cancel_url: params.cancelUrl,
    metadata: params.metadata,
    ...(params.customerEmail && {
      customer_email: params.customerEmail,
    }),
    ...(params.couponId
      ? { discounts: [{ coupon: params.couponId }] }
      : { allow_promotion_codes: true },
  });

  return session;
}

export async function retrieveCheckoutSession(sessionId: string) {
  const client = getStripe();
  return client.checkoutSessions.retrieve(sessionId);
}

export async function constructWebhookEvent(
  payload: string | Buffer,
  signature: string
) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!webhookSecret) {
    throw new Error("STRIPE/WebHOOK_SECRET未设置");
  }
  const client = getStripe();
  return client.webhooks.constructEventAsync(payload, signature, webhookSecret);
}

对于Stripe客户端来说,使用Proxy机制是一种成熟的开发技巧。这种机制可以延迟初始化Stripe SDK,因此即使环境变量STRIPE_SECRET_KEY缺失,模块导入也不会出错。这在构建项目或某些服务尚未配置的环境中非常有用。

如何创建结账端点

在您的API中添加一个结账端点:

// 在src/server/api.ts文件中
.post("/payments/checkout", async ({ set }) => {
  const priceId = process.env.STRIPE_PRO PRICE_ID;

  if (!priceId) {
    set.status = 500;
    return { error: "价格信息未配置" };
  }

  const baseUrl = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";

  const checkoutSession = await createOneTimeCheckoutSession({
    priceId,
    successUrl: `${baseUrl}/dashboard?purchase=success&session_id={CHECKOUT_SESSION_ID}`,
    cancelUrl: `${baseUrl}/pricing`,
    metadata: { tier: "pro" },
  });

  return { url: checkoutSession.url };
})

{CHECKOUT SESSION_ID}这个占位符是Stripe提供的模板变量。当Stripe将用户重定向回您的应用程序时,它会用实际的会话ID来替换这个占位符。

如何处理Webhook事件

当支付交易完成时,Stripe会发送Webhook事件。您的Webhook处理程序需要验证签名、解析事件内容,并对事件进行相应的处理。

这里有一个重要的设计原则:不要在Webhook处理程序中执行复杂的处理操作。Stripe要求您在几秒钟内做出响应;如果处理时间过长,Stripe会重新尝试发送Webhook事件,这可能会导致数据被重复处理。

因此,应该采用“接收Webhook事件后由后台作业进行处理”的模式:

// 在src/server/api.ts文件中
.post("/payments/webhook", async ({ request, set }) => {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

  if (!sig) {
    set.status = 400;
    return { error: "签名信息缺失" };
  }

  try {
    const event = await constructWebhookEvent(body, sig);
    console.log(`[Webhook] 收到类型为 ${event.type} 的事件`);
    
    if (event.type === "charge.refunded") {
      const charge = event.data.object as {
        id: string,
        payment(intent: string,
        amount: number,
        amount_refunded: number,
        currency: string,
      };
      await inngest.send({
        name: "stripe/charge/refunded",
        data: {
          chargeId: charge.id,
          paymentIntentId: charge.paymentintent,
          amount_refunded: charge.amount_refunded,
          originalAmount: charge.amount,
          currency: charge.currency,
        },
      });
    }

    return { received: true };
  } catch (error) {
    console.error("[Webhook] Stripe签名验证失败:", error);
    set.status = 400;
    return { error: "Webhook签名验证失败" };
  }
})

Webhook处理程序主要完成三项任务:验证签名、确定事件类型,然后将数据转发给后台作业进行进一步处理。它会立即返回{ received: true }这一响应信号;而实际的业务逻辑操作(如发送邮件、授权用户或更新数据库记录)则由后台作业来完成,这部分内容我们接下来会进行开发。

如何在前端申请购买款项的退款

在成功完成结账流程后,Stripe会将用户重定向回您的应用程序,并附带一个会话ID。您需要创建一个端点,通过验证该会话ID并在数据库中创建相应记录来申请退款:

// 在src/server/api.ts文件中
.post(
  "/purchases/claim",
  async ({ body, request, set }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "未经授权" };
    }

    const { sessionId } = body;

    // 检查该购买记录是否已被申请退款
    const existing = await db
      .select()
      .from(purchases)
      .where(eq(purchases.stripeCheckoutSessionId,sessionId))
      .limit(1);

    if (existing[0]) {
      return { success: true, alreadyClaimed: true, tier: existing[0].tier };
    }

    // 验证付款状态
    const stripeSession = await retrieveCheckoutSession(sessionId);

    if (stripeSession.payment_status !== "paid") {
      set.status = 400;
      return { error: "付款未完成" };
    }

    const tier = (stripeSession.metadata?.tier ?? "pro") as "pro";

    // 创建购买记录
    await db.insert(purchases).values({
      userId: session.user.id,
      stripeCheckoutSessionId: sessionId,
      stripeCustomerId:
        typeof stripeSession.customer === "string"
          ? stripeSession.customer
          : stripeSession.customer?.id ?? null,
      stripePaymentIntentId:
        typeof stripeSession.payment(intent === "string"
          ? stripeSession.paymentintent
          : stripeSession.payment_intent?.id ?? null,
      tier,
      status: "completed",
      amount: stripeSession.amount_total ?? 0,
      currency: stripeSession.currency ?? "usd",
    });

    // 触发后台处理流程
    await inngest.send({
      name: "purchase/completed",
      data: {
        userId: session.user.id,
        tier,
        sessionId,
      },
    });

    return { success: true, tier };
  },
  {
    body: t.Object({
     sessionId: t.String(),
    }),
  }
)

请注意,代码中进行了幂等性检查。如果用户重新访问成功页面,或者前端再次尝试申请退款,该端点会返回已存在的购买记录,而不会创建重复记录。

这对于确保支付流程的准确性至关重要——您绝对不能意外地让某人被收取两次费用,也不能创建重复的记录。

调用`inngest.send()`会触发购买款项的后台处理流程。通过这个流程,您可以发送确认邮件、授予用户访问资源的权限、跟踪分析数据,以及执行任何其他售后操作。

如何在本机环境中测试支付功能

首先安装Stripe CLI,然后将Webhook回调地址设置为您的本地服务器:

# 在macOS系统上安装Stripe CLI
brew install stripe/stripe-cli/stripe

# 登录Stripe账户
stripe login

# 将Webhook回调地址设置为本地服务器
stripe listen --forward-to localhost:3000/api/payments/webhook

Stripe CLI会提供一段以whsec_开头的Webhook签名密钥。请将其添加到您的.env文件中:

STRIPE_WEBHOOK_SECRET=whsec_your-local-webhook-secret

在Stripe的控制面板中创建一个测试产品并设置其价格(或者使用Stripe CLI),然后将该价格的ID添加到您的.env文件中:

STRIPE_SECRET_KEY=sk_test_your-test-secret-key
STRIPE_PRO PRICE_ID=price_your-test-price-id

如何使用Inngest添加后台任务

对于任何SaaS产品来说,后台任务都是至关重要的。您可以使用它们来处理Webhook请求、发送电子邮件、授予用户访问资源的权限,以及执行那些不会阻塞API响应的操作。Inngest提供了具备内置检查点功能的可靠且可重试的任务执行服务。

为什么后台任务如此重要

想象一下,当有人购买了您的SaaS产品时,会发生以下这些步骤:

  1. Stripe会验证付款信息。

  2. 系统会在数据库中创建购买记录。

  3. 会向客户发送确认邮件。

  4. 也会向管理员发送通知邮件。

  5. 会允许用户访问私有的GitHub仓库。

  6. 会在分析平台上跟踪这次购买事件。

  7. 还会安排后续的电子邮件发送流程。

如果尝试在单个API接口中完成所有这些步骤,很可能会出现问题。例如,邮件服务可能会出现故障,GitHub API可能会设置访问速率限制,或者分析请求可能会超时。

任何一次失败都会导致用户看到错误信息,而您则需要弄清楚哪些步骤已经成功执行,哪些步骤没有完成。

Inngest通过提供可靠的任务执行机制来解决这些问题。每个步骤都会被标记为检查点。如果第3步失败了,Inngest会重新尝试执行第3步,而不会重新运行第1步和第2步。

如果整个任务流程都失败了,Inngest也会重新尝试整个流程。这样,您至少能够确保任务会被执行一次,并且系统还会自动避免重复执行相同的操作。

如何设置Inngest

src/lib/jobs/client.ts文件中创建Inngest客户端:

// src/lib/jobs/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-saas",
});

如何编写您的第一个Inngest函数

src/lib/jobs/functions/stripe.ts文件中编写处理购买完成事件的函数:

// src/lib/jobs/functions/stripe.ts
import { eq } from "drizzle-orm";

import { inngest } from "../client";
import { db, purchases, users } from "@/lib/db";

export const handlePurchaseCompleted = inngest.createFunction(
  {
    id: "purchase-completed",
    triggers: [{ event: "purchase/completed" }],
  },
  async ({ event, step }) => {
    const { userId, tier, sessionId } = event.data as {
      userId: string;
      tier: string;
      sessionId: string;
    };

    // 第1步:查询用户信息和购买记录
    const { user, purchase } = await step.run(
      "lookup-user-and-purchase",
      async () => {
        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
          })
          .from(users)
          .where(eq(users.id, userId))
          .limit(1);

        const foundUser = userResult[0];
        if (!foundUser) {
          throw new Error(`未找到用户:${userId}`);
        }

        const purchaseResult = await db
          .select({
            amount: purchases.amount,
            currency: purchases.currency,
          })
          .from(purchases)
          .where(eq(purchases.stripeCheckoutSessionId, sessionId))
          .limit(1);

        return {
          user: foundUser,
          purchase: purchaseResult[0] ?? {
            amount: 0,
            currency: "usd",
          },
        };
      }
    );

    // 第2步:发送购买确认邮件
    await step.run("send-purchase-confirmation", async () => {
      // 使用您的邮件服务发送确认邮件(例如Resend、SendGrid等)
      console.log(
        `正在向${user.email}发送购买确认邮件`
      );
      // // await sendEmail({
      //   to: user.email,
      //   subject: "您的购买已确认!",
      //   template: PurchaseConfirmationEmail,
      // });
    });

    // 第3步:通知管理员
    await step.run("send-admin-notification", async () => {
      const adminEmail = process.env ADMIN_EMAIL;
      if (!adminEmail) return;

      console.log(
        `正在通知管理员关于${user.email}的购买信息`
      );
      // // await sendEmail({
      //   to: adminEmail,
      //   subject: `新销售记录:${user.email}`,
      //   template: AdminNotificationEmail,
      // });
    });

    // 第4步:更新购买记录
    await step.run("update-purchase-record", async () => {
      await db
        .update(purchases)
        .set({.updated: new Date() })
        .where(eq(purchases.stripeCheckoutSessionId, sessionId));
    });

    return { success: true, userId, tier };
  }
);

export const stripeFunctions = [handlePurchaseCompleted];

每个`step.run()`都代表一个检查点。如果函数在执行到第2步时失败,Inngest会从第3步开始重新尝试,而不会从头开始。已完成步骤的结果会被缓存起来。

如何注册你的函数

创建一个索引文件,用于收集你所有的函数:

// src/lib/jobs/functions/index.ts
import { stripeFunctions } from "./stripe";

export const functions = [...stripeFunctions];

同时还需要进行导出操作:

// src/lib/jobs/index.ts
export { inngest } from "./client";
export { functions } from "./functions";

如何将Inngest连接到你的API

在Elysia API中配置Inngest处理程序。将其添加到`src/server/api.ts`文件中:

// src/server/api.ts
import { serve } from "inngest/bun";

import { inngest, functions } from "@/lib/jobs";

const inngestHandler = serve({
  client: inngest,
  functions,
});

export const api = new Elysia({ prefix: "/api" })
  // Inngest端点——用于处理函数的注册和执行
  .all("/inngest", async (ctx) => {
    return inngestHandler(ctx.request);
  })
  // ... 其他路由配置

`/inngest`这个路由可以处理来自Inngest的GET请求(用于函数注册)和POST请求(用于函数执行)。

如何在本地运行Inngest

Inngest提供了一个可以在本地运行的开发服务器,并提供了用于监控函数的仪表板:

npx inngest-cli@latest dev -u http://localhost:3000/api/inngest --no-discovery

这样就会在`http://localhost:8288`地址启动Inngest开发服务器。在浏览器中打开这个网址,你就能看到显示已注册函数、事件记录以及函数执行日志的仪表板。

`-u`选项用于指定你的应用程序运行所在的地址;`--no-discovery`选项则可以禁用自动应用发现功能,这对于本地开发来说更为可靠。

你可以将这段命令添加到`package.json`文件中的`scripts`部分:

{
  "scripts": {
    "inngest:dev": "npx inngest-cli@latest dev -u http://localhost:3000/api/inngest --no-discovery"
  }
}

现在你可以通过API发送事件来触发函数的执行:

await inngest.send({
  name: "purchase/completed",
  data: {
    userId: "user_123",
    tier: "pro",
    sessionId: "cs_test_abc",
  },
});

该事件会出现在Inngest仪表板上,函数会逐步执行,你可以看到每一步的执行结果。如果某一步失败了,你可以在仪表板上手动重新尝试。

如何使用后台作业处理退款操作

下面是一个更复杂的例子,它说明了为什么可靠的执行机制如此重要。在处理退款操作时,你需要更新购买状态、取消用户的访问权限、发送通知以及进行相关数据分析。如果其中任何一个步骤失败了,其余的步骤仍然应该能够顺利完成:

// src/lib/jobs/functions/stripe.ts
export const handleRefund = inngest.createFunction(
  {
    id: "refund-processed",
    triggers: [{ event: "stripe/charge.refunded" }],
  },
  async ({ event, step }) => {
    const { paymentIntentId, amountRefunded, originalAmount, currency } =
      event.data as {
        chargeId: string;
        paymentIntentId: string;
        amountRefunded: number;
        originalAmount: number;
        currency: string;
      };

    const isFullRefund = amountRefunded >= originalAmount;

    // 第一步:查找相应的购买记录和用户信息
    const { user, purchase } = await step.run(
      "lookup-purchase",
      async () => {
        const purchaseResult = await db
          .select()
          .from(purchases)
          .where(eq(purchases.stripePaymentIntentId, paymentIntentId))
          .limit(1);

        if (!purchaseResult[0]) {
          return { user: null, purchase: null };
        }

        const userResult = await db
          .select()
          .from(users)
          .where(eq(users.id, purchaseResult[0].userId))
          .limit(1);

        return {
          user: userResult[0] ?? null,
          purchase: purchaseResult[0],
        };
      }
    );

    if (!purchase || !user) {
      return { success: false, reason: "no_matching_purchase" };
    }

    // 第二步:更新购买状态
    await step.run("update-purchase-status", async () => {
      await db
        .update(purchases)
        .set({
          status: isFullRefund ? "refunded" : "partially_refunded",
         .updated: new Date(),
        })
        .where(eq(purchases.id, purchase.id));
    });

    // 第三步:向客户发送通知
    await step.run("notify-customer", async () => {
      console.log(
        `正在向 \{user.email}\ 发送 \({isFullRefund ? "全额退款" : "部分退款"} 的通知`
      );
      // await sendEmail({ ... });
    });

    return { success: true, isFullRefund };
  }
);

即使在第三步中电子邮件发送服务出现故障,第二步(更新数据库的操作)也已经完成,因此不会被重新执行。inngest只会重试那些失败了的步骤。

正是这种可靠的执行机制使得支付处理过程更加高效。你无需自己编写重试逻辑,就能获得稳定、可重复执行的处理结果。

如何使用Neon将应用部署到Vercel上

现在,你的应用程序已经具备了认证功能、数据库支持、类型安全的API接口以及后台处理机制。是时候将其部署到生产环境了。

如何配置Neon数据库

  1. 访问neon.tech注册新项目

  2. 选择距离你的用户群体较近的AWS区域

  3. 从控制面板中复制数据库连接字符串

连接字符串的格式如下:

postgresql://username:password@ep-something.us-east-1.aws.neon.tech/my_saas?sslmode和要求

如何在生产环境中运行迁移操作

在生产环境中,应使用带有版本号的迁移文件,而不是db:push命令。首先根据你的数据库架构生成相应的迁移文件:

bun run db:generate

这样会生成一些SQL脚本文件,这些文件会被保存在drizzle/目录中。请仔细检查生成的SQL代码,确保它们符合你的需求。之后再执行迁移操作:

DATABASE_URL="your-neon-connection-string" bun run db:migrate

如何将应用部署到Vercel平台上

  1. 将你的代码上传到GitHub仓库中。

  2. 访问vercel.com/new,然后导入你的GitHub仓库。

  3. Vercel会自动检测并配置相关的构建设置。

在Vercel的仪表板上设置以下环境变量:

变量名
DATABASE_URL 你的Neon连接字符串
BETTER_AUTH_SECRET 你生成的32个字符以上的随机密码
BETTER AUTH_URL https://your-app.vercel.app
GITHUB_CLIENT_ID 你的GitHub OAuth客户端ID
GITHUB_CLIENT_SECRET 你的GitHub OAuth客户端密钥
STRIPE_SECRET_KEY 你的Stripe秘密密钥
STRIPE_WEBHOOK_SECRET 你的Stripe Webhook秘密密钥(用于生产环境)
STRIPE_PRO PRICE_ID 你的Stripe价格ID

点击“Deploy”按钮,Vercel会构建你的应用并将其部署到.vercel.app地址上。

如何更新OAuth回调地址

部署完成后,请更新你的GitHub OAuth应用的回调URL:

  1. 进入你的GitHub OAuth应用设置页面。

  2. 授权回调URL修改为https://your-app.vercel.app/api/auth/callback/github

  3. https://your-app.vercel.app设置为首页URL

如何为生产环境配置Stripe Webhook

在Stripe的仪表板上创建一个Webhook端点:

  1. 访问Stripe仪表板 > 开发者 > Webhook

  2. 点击“添加端点”按钮。

  3. 将URL设置为https://your-app.vercel.app/api/payments/webhook

  4. 选择你希望接收的事件类型(例如charge.refundedcheckout.session.expired等)。

  5. 复制Webhook签名密钥,并将其添加到Vercel的环境变量中。

如何在生产环境中配置Inngest

Inngest提供了一项云服务,用于处理生产环境中的函数执行任务:

  1. 请访问inngest.com进行注册。

  2. 创建一个应用,并复制你的事件密钥和签名密钥。

  3. INNGEST_EVENT_KEYINNGEST_SIGNING_KEY添加到Vercel的环境变量中。

  4. 在Inngest的仪表板中,将你的应用URL设置为https://your-app.vercel.app/api/inngest

Inngest会自动检测到你的函数并开始处理相关事件。

常见的部署陷阱

1. SSR外部依赖项。某些包不适用于Vite的SSR打包机制。如果在构建过程中遇到与elysiainngest相关的错误,请将它们添加到vite.config.ts文件中的ssr.external数组中:

// vite.config.ts
export default defineConfig({
  ssr: {
    external: ["elysia", "inngest"],
  },
  // ...
});

2>环境变量的访问权限。在TanStack Start环境中,服务器端代码可以直接访问process.env;而客户端代码只能访问以VITE_为前缀的环境变量。你的Stripe密钥和数据库URL绝对不能带有VITE_前缀。

3>Neon连接池的使用。在生产环境中,应使用Neon提供的连接池字符串(该连接池使用端口5432,而非直接连接的端口5433)。连接池能够更有效地处理并发请求。

4>构建失败的问题。如果构建过程中出现错误,最常见的原因通常是TypeScript相关的问题。在上传代码之前,请先在本地运行bun run type-check命令进行检查。在部署之前务必修复所有错误。

5>环境变量缺失的问题。如果应用在部署后立即崩溃,请查看Vercel的功能日志。最常见的原因就是缺少某个环境变量。Neon连接字符串、Stripe密钥以及Better Auth相关的配置信息,都必须在首次部署之前设置完毕。

如何设置自定义域名

一旦你的应用被部署到Vercel上:

  1. 进入Vercel平台,查看你项目的设置选项。

  2. 点击“域名”选项。

  3. 添加你的自定义域名。

  4. 按照提示更新DNS记录(通常需要添加一条指向cname.vercel-dns.com的CNAME记录)。

添加自定义域名后,需要在Vercel中更新以下环境变量:

  • BETTER_AUTH_URL设置为https://yourdomain.com

  • 将你的GitHub OAuth应用的回调URL更新为https://yourdomain.com/api/auth/callback/github

  • 将Stripe Webhook的端点地址更新为https://yourdomain.com/api/payments/webhook

Vercel会自动为您的自定义域名配置SSL证书,无需进行任何额外设置。

如何验证您的部署环境

部署完成后,请按照以下步骤进行检查:

  1. 健康检查。访问https://yourdomain.com/api/health,系统应会返回一个包含{ "status": "ok" }的JSON响应。

  2. 身份验证。点击“使用GitHub登录”并完成OAuth认证流程,随后您应该会被重定向到控制面板。

  3. 数据库检查。登录后查看Neon控制面板,您会在users表中看到新添加的记录。

  4. 支付功能测试。在定价页面点击“购买”,使用Stripe的测试卡(4242 4242 4242 4242)完成交易,确认数据库中出现了相应的购买记录。

  5. 后台任务运行情况。进行测试购买后,查看Inngest控制面板,应能看到purchase/completed事件以及对应的函数执行记录。

如果其中任何步骤出现故障,请查阅Vercel的功能日志(设置→功能→日志)以查找错误信息。大多数部署问题都是由于环境变量配置错误或webhook密钥缺失造成的。

总结

您刚刚搭建了一个可投入生产环境的SaaS应用。下面我们来回顾一下所使用的技术组件:

  • TanStack Start负责处理服务器端渲染、基于文件的路由配置以及开发服务器的运行。

  • Elysia提供了一个类型安全的API,该API与您的Web应用运行在同一个进程中。

  • Eden Treaty提供了无需代码生成即可使用的完整类型化API客户端。

  • Drizzle ORM与Neon结合使用,支持类型安全的数据库查询操作,并利用无服务器架构的PostgreSQL进行数据管理。

  • Better Auth通过GitHub OAuth实现用户身份验证,同时提供会话管理和路由保护功能。

  • Stripe负责处理支付事务,并支持webhook接口。

  • Inngest能够自动重试并保存中间结果,从而确保后台任务的可靠运行。

  • Vercel完全免去了基础设施管理的麻烦,帮助您轻松部署整个应用。

  • 这种四层架构模式(数据模型、API接口、钩子函数、用户界面)使得添加新功能时能够遵循统一的流程:首先定义数据结构,通过API暴露这些数据,利用钩子函数将它们与React组件连接起来,最后在用户界面上展示结果。

    这种架构具有良好的扩展性。由于各层之间有明确的边界,因此您可以随时替换其中某个组件而无需重新编写全部代码。

    如果Neon无法满足您的需求,您可以切换到自托管的PostgreSQL数据库;如果需要更换支付服务提供商,只需替换Stripe模块即可,其余部分的应用逻辑不会受到影响。

    接下来您要做什么完全取决于您自己的计划。以下是一些常见的后续发展方向:

    采用`src/lib/`这种目录结构,添加新的集成模块会变得非常方便。只需创建一个新的目录,编写一个`index.ts`文件,然后在其需要的地方导入即可。每个集成模块都是独立运行的,因此添加数据分析功能不会影响到支付相关代码的运行。

    如果你想跳过设置步骤,立即开始开发产品,Eden Stack提供了本文中提到的所有功能(甚至更多),这些功能都已经经过预先配置和生产环境测试。它还内置了30多种Claude Code技能,这些技能能够帮助AI编码助手根据你的代码规范自动生成所需的功能。

    无论你开发什么产品,都应该确保其具备类型安全性。“修改数据结构 → 查看错误 → 修复错误”这一反馈循环,是我所知道的最快的方式,能够帮助你开发出可靠的软件。

    Magnus Rodseth专注于开发基于AI的应用程序,同时也是Eden Stack的创建者。Eden Stack是一个为SaaS开发准备的入门套件,其中包含了30多种Claude Code技能,这些技能能够帮助开发者遵循生产环境中的最佳实践进行开发。

Comments are closed.