☰
T3 Stack全栈应用模板t3code:类型安全、认证与部署实践
2026/10/28 5:16:28 网站建设 项目流程

t3code 是我最近基于 T3 Stack 整理出来的一套全栈应用模板。名字没有太多讲究,就是把 T3 生态里几个核心成员——TypeScript、Tailwind CSS、tRPC 和 Next.js——拼在同一个代码基座上,让它成为我开新项目时第一个克隆的仓库。这个模板并不是简单跑一下 create-t3-app 就完事,我在里面沉淀了完整的用户认证、数据模型设计、接口层划分规范和部署方案。几个内部项目跑下来之后,整体稳定性我还是比较满意的,所以动了整理成文的心思。这篇文章适合两类人看:一类是想试 T3 Stack 但不知道从哪下手的同学,另一类是已经在用、却总在类型、认证、部署这些环节上反复折腾的人。我会把实际项目里的设计决策、关键代码和踩坑记录都摊开来讲,尽量做到看完了能直接照着干。

1. 项目定位:t3code 到底解决了什么问题

1.1 为什么选 T3 Stack,而不是传统前后端分离

技术选型的时候,我实打实纠结过三条路线:传统 REST 前后端分离、GraphQL 全家桶,以及 T3 Stack。REST 方案最大的问题不是技术落后,而是类型契约的维护成本太高。前端要维护一份接口定义,后端要维护一份路由返回声明,两边一旦忘记了同步,类型上不会有任何提示,等来的就是生产环境里各种诡异的 undefined 报错。就算引入 OpenAPI 自动生成代码,也绕不开另一个麻烦:生成出来的类型很难按业务语义定制,每次改接口都要重新生成一遍,CI 里还得盯着版本是不是对得上。

GraphQL 在类型安全方面确实做得不错,但引入 Apollo Server、Codegen、客户端缓存这些配套之后,学习成本和心智负担会明显上升。对中小型应用来说,很多团队根本用不上订阅、缓存命中这类高级特性,最后往往只是把 REST 换了一种更复杂的形态,还多背了一套 schema 维护工作,收益并不划算。

T3 Stack 解决这个问题的方式完全不同,核心是用 tRPC 把前后端之间的调用类型直接打通。服务端写一个 procedure,客户端 import 进来之后,入参类型、返回类型、错误类型全部自动对齐,过程中不需要生成任何代码,也不需要维护任何契约文件。打个比方:REST 是两个人通过书信沟通,每次都要写地址、贴邮票、等回信,信寄丢了才发现;tRPC 是两个人直接打电话,同一套语法自然对齐,不会出现类型对不上的情况。这种开发体验是我最终选择 T3 的最根本原因。

1.2 t3code 的目录组织与模块边界

项目骨架我沿用 create-t3-app 的推荐结构,但在模块划分上做了更明确的约定。下面是我实际使用的目录布局:

t3code/ ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ # 按业务模块拆分 tRPC router │ │ │ └── root.ts # 根路由,合并所有子路由 │ │ ├── auth.ts # NextAuth 配置 │ │ └── db.ts # Prisma client 单例 │ ├── trpc/ │ │ ├── context.ts # 请求上下文,注入 session/db │ │ ├── trpc.ts # procedure 封装(含 protectedProcedure) │ │ └── react.tsx # 客户端 hooks 封装 │ ├── app/ # Next.js App Router 页面 │ │ ├── api/trpc/[trpc]/ # tRPC HTTP 接口入口 │ │ ├── (auth)/ │ │ └── (dashboard)/ │ ├── components/ │ ├── styles/ │ └── types/ └── package.json

这套结构背后的原则是:按业务拆 router、按页面拆 UI、跨层共享类型放 types。server/api/routers 只放业务路由,不要在 trpc 初始化文件里堆逻辑,否则项目一复杂,那个文件就会变成谁都不想动的巨型模块;server/db.ts 负责提供 Prisma 单例;trpc/context.ts 负责把 session 和 db 注入到每个请求的 ctx 里。这样拆完之后,新增一个业务模块的路径非常固定:先在 schema.prisma 加模型,再在 routers 下加文件,最后在 root.ts 里合并一行。团队协作时边界清晰,代码评审也好做,不会出现谁都能往里乱塞文件的局面。

2. 核心模块设计与关键技术细节

2.1 Prisma 数据模型:主键、关联与索引的取舍

t3code 内置的领域模型是一个轻量协作系统,包含用户、项目、项目成员和任务四张核心表。Prisma schema 的关键部分如下:

model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? projects ProjectMember[] tasks Task[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Project { id String @id @default(cuid()) name String description String? members ProjectMember[] tasks Task[] createdAt DateTime @default(now()) } model ProjectMember { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) role String @default("member") createdAt DateTime @default(now()) @@unique([projectId, userId]) @@index([userId]) } model Task { id String @id @default(cuid()) title String done Boolean @default(false) dueDate DateTime? projectId String project Project @relation(fields: [projectId], references: [id]) creatorId String creator User @relation(fields: [creatorId], references: [id]) createdAt DateTime @default(now()) @@index([projectId, done]) }

主键我统一用 cuid 而不是自增整数,这是 t3code 里一个很重要的决策。自增主键在单机数据库里确实简单直观,但一旦涉及到做只读副本、迁移数据或者合并多个环境的数据,就可能出现主键冲突;更麻烦的是自增 id 很容易让别人通过遍历接口拿到资源数量,对数据隐私不太友好。cuid 是客户端生成的、无序的字符串 ID,天然规避了这两个问题,代价仅仅是存储空间略大一点,对现代数据库来说完全可以忽略。

ProjectMember 表上的@@unique([projectId, userId])是一个复合唯一约束,保证同一个用户在一个项目里只能有一条成员记录。这是通过数据库层面防止重复加入的底线,不能只靠应用代码判断,因为并发的两个请求可能同时通过应用层检查。Task 表上的@@index([projectId, done])则是针对后台最常见的“按项目筛选任务,再看完成状态”的查询场景。有人可能会问,索引会不会拖慢写入?这种小体量的表基本没有感知,但对频繁查询的字段建立合适的复合索引,收益远大于那点写入开销。

2.2 tRPC 路由设计与 Zod 校验

tRPC 的用法不复杂,难点在划分粒度。t3code 的约定是:不要按数据库表拆 router,而要按页面或者用例拆。比如 Task 相关的操作集中在 taskRouter 里,包括按项目查列表、创建任务、切换状态、删除任务。一个典型的 router 长这样:

// src/server/api/routers/task.ts import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure } from "~/trpc/trpc"; export const taskRouter = createTRPCRouter({ listByProject: protectedProcedure .input(z.object({ projectId: z.string().cuid() })) .query(({ ctx, input }) => { return ctx.db.task.findMany({ where: { projectId: input.projectId }, orderBy: [{ done: "asc" }, { createdAt: "desc" }], }); }), create: protectedProcedure .input( z.object({ projectId: z.string().cuid(), title: z.string().min(1).max(100), dueDate: z.date().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.$transaction(async (tx) => { const task = await tx.task.create({ data: { title: input.title, dueDate: input.dueDate, projectId: input.projectId, creatorId: ctx.session.user.id, }, }); return task; }); }), toggle: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const task = await ctx.db.task.findUnique({ where: { id: input.id } }); if (!task) { throw new TRPCError({ code: "NOT_FOUND", message: "任务不存在" }); } return ctx.db.task.update({ where: { id: input.id }, data: { done: !task.done }, }); }), });

每个 procedure 的入参都用 Zod 做了 schema 校验,这一步不是可有可无的。z.string().cuid()会在请求入口直接拦截非法 id,避免把脏数据带进数据库层;z.string().min(1).max(100)则限定了文本字段的长度,防止用户提交超长文本把页面布局撑坏。tRPC 和 Zod 配合最舒服的一点是:schema 只写一次,服务端入口有运行时校验,客户端的 TypeScript 类型又能自动推导出来,两头都不落空,这也是我坚持零 codegen 路线的底气。

protectedProcedure 是在 trpc.ts 里通过中间件包出来的一种受限 procedure,它会先检查 ctx.session 是否存在,不存在就直接抛 UNAUTHORIZED 错误。这样业务代码里就可以放心写 ctx.session.user.id,不需要在每个方法里手动判断登录状态,也不容易出现漏校验的漏洞。中间件还能继续扩展,比如管理员权限、租户隔离,本质都是对 ctx 做前置处理。

2.3 认证链路:NextAuth 与 tRPC Context

认证方面我直接用 NextAuth,并配合 JWT 会话策略。JWT 模式比起数据库 session 最大的优势是无状态,特别适合跑在 Serverless 环境里的 Next.js 应用,冷启动不会因为查 session 表多一次网络往返。缺点是用户信息变更不会立刻反映到已签发的 token 上,但这个问题在 t3code 里通过“tRPC procedure 按需查库”来兜底,比如需要最新用户资料时直接查 User 表,不依赖 session 里的快照。实战下来的结论是:中小型应用用 JWT 完全够用,没必要为了所谓的实时性引入数据库 session 存储,那会让每次请求都多一次数据库访问。

NextAuth 的核心配置集中在 src/server/auth.ts:

import { type NextAuthOptions } from "next-auth"; import { db } from "~/server/db"; export const authOptions: NextAuthOptions = { session: { strategy: "jwt" }, callbacks: { session({ session, token }) { if (session.user) { session.user.id = token.sub ?? ""; } return session; }, }, };

session.user.id 这里是个容易踩坑的点:默认情况下 NextAuth 的 session 回调里并没有把用户 id 放到 session 对象上,前端拿到的 session 只有 email、name、image,没有 id。而很多业务查询、报表统计、权限校验都依赖 userId,所以必须在回调里手动把 token.sub 映射进去。tRPC 侧则在每次请求时通过 getServerSession 获取当前会话,并连同 Prisma 单例一起塞进 ctx,整个认证链路到这里就完全打通了。

3. 从零搭建 t3code 的实操流程

3.1 脚手架初始化与环境变量准备

如果你要从头复现 t3code,第一步自然是初始化项目:

npx create-t3-app@latest t3code

交互式问答里我推荐按这个组合选:Next.js 用 App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth,其余暂不需要。App Router 不是追新,而是 tRPC 官方对 App Router 支持已经很成熟,服务端组件、客户端组件、路由处理程序都能正常使用,相比 Pages Router 少了很多兼容层。

接着准备环境变量。复制 .env.example 为 .env,重点配置三个值:DATABASE_URL 指向 PostgreSQL 实例;NEXTAUTH_SECRET 是用来签名 JWT 的随机密钥,建议用下面的命令生成 32 字节以上的随机串,不要随便敲一段字符串应付了事:

openssl rand -base64 32

最后执行数据库初始化:

npx prisma migrate dev --name init npx prisma generate

migrate dev 会创建 migration 文件并同步数据库结构,generate 会把类型定义输出到 node_modules 下的 Prisma client。后续每次修改 schema 之后都要再跑一遍 generate,否则 TypeScript 提示不会更新,运行时的 findMany、create 也不会认识新增的字段。这是新手最容易忽略的一步,我第一次就吃过这个亏,改完模型发现页面上一直报类型错误,排查半天才想起来漏了 generate。

3.2 把一个业务功能从模型做到页面

t3code 研发流程的核心价值在于“全链路类型贯通”。以 Task 列表页为例,模型建好之后,先确认 taskRouter 已经写好并合并到根路由:

// src/server/api/root.ts import { createTRPCRouter } from "~/trpc/trpc"; import { taskRouter } from "~/server/api/routers/task"; import { projectRouter } from "~/server/api/routers/project"; export const appRouter = createTRPCRouter({ task: taskRouter, project: projectRouter, }); export type AppRouter = typeof appRouter;

然后写客户端组件,直接从 ~/trpc/react 引入 API hooks:

"use client"; import { api } from "~/trpc/react"; export function TaskList({ projectId }: { projectId: string }) { const utils = api.useUtils(); const { data: tasks, isLoading } = api.task.listByProject.useQuery({ projectId, }); const toggle = api.task.toggle.useMutation({ onSuccess: () => { utils.task.listByProject.invalidate({ projectId }); }, }); if (isLoading) return <div>加载中...</div>; return ( <ul> {tasks?.map((task) => ( <li key={task.id}> <label> <input type="checkbox" checked={task.done} onChange={() => toggle.mutate({ id: task.id })} /> {task.title} </label> </li> ))} </ul> ); }

这里最值得注意的点是数据刷新策略。上面这段用的是“mutation 成功后 invalidate 对应查询”的方案,也就是服务端确认变更后再重新拉取列表数据。这种方案代码简单、不容易出错,适合对实时性要求不高的场景。如果追求极致体验,可以改成 onMutate 里先手动更新本地缓存、onError 里再回滚,但那样要额外处理组件卸载、并发请求覆盖等边界情况,复杂度会大不少。我建议先把 invalidate 方案跑通,等确实遇到界面卡顿、闪烁的问题,再考虑上乐观更新也不迟。

3.3 部署前的检查清单与关键配置

t3code 部署我通常采用 Node 运行时 + PostgreSQL,下面这份清单是多次踩坑之后沉淀出来的,每一条背后基本都对应过一次线上故障:

  • NEXTAUTH_URL 必须设置为线上域名,反向代理、容器编排这类环境最容易漏掉这一项。漏了之后,OAuth 回调地址会不对,登录成功后被踢回环回地址,session 也容易失效。
  • 数据库连接串要带连接池参数,尤其是 Serverless 平台,短时间并发创建连接很容易打满数据库连接数。PostgreSQL 可以在连接串里指定 pgbouncer 模式的连接池,或者直接使用数据库服务商提供的 pooler 地址。
  • tRPC 的 httpBatchLink 默认走相对路径,反代部署时要注意 baseUrl 是否与外部访问域名一致,否则浏览器端会请求到错误路径直接 404。
  • Prisma 的 query engine 在运行时需要系统 libssl,Docker 镜像里要记得安装对应系统依赖,否则容器启动后第一个 Prisma 调用就会报缺库错误。

部署没有一步到位的标准答案,不同平台的细节差异挺大,但把环境变量和连接池这两件事提前理清楚,能省掉一大半线上故障排查时间。

4. 常见问题与排查经验实录

4.1 高频问题速查表

我在 t3code 开发以及后续项目迭代中遇到的典型问题,整理成了一张速查表。这里只收录出现频率最高的六类,便于你遇到同样问题时按图索骥:

现象常见原因解决方案
客户端调用 API hooks 时方法不存在子 router 没有合并到 appRouter检查 root.ts,确认导出对象里是否包含对应 router
修改 Prisma schema 后类型没更新忘记跑 prisma generate每次修改 schema 后执行 npx prisma generate,必要时重启 dev server
开发环境 PrismaClient 报“实例过多”Next.js 热更新导致模块被反复加载用 globalThis 缓存 Prisma 单例
tRPC 请求在部署环境返回 500服务端 hostname 校验不通过或环境变量缺失确认 NEXTAUTH_URL、DATABASE_URL 已配置,查服务端日志定位
NextAuth 回调里 session.user.id 为空没有在 session callback 中映射 token.sub在 authOptions.callbacks.session 里补上赋值逻辑
Tailwind 类名不生效content 配置未覆盖相关目录确认 tailwind.config.ts 的 content 包含 ./src/**/*.{ts,tsx}

4.2 几个值得反复强调的细节坑

第一个坑是 procedure 划分粒度过细。我早期的版本把 task 的 list、create、toggle、delete 全部分开,每个都是独立的小文件,文件多到后面连自己都记不清哪个接口放在哪里。后来改成“按页面用例聚合”的思路,把同一个业务页面相关的操作放在一个 router 里,管理成本和命名负担都降下来了。这看起来只是文件组织问题,但对团队来说,它是代码可读性和交接效率的分水岭。

第二个坑是输入校验写了但没有生效。Zod schema 只有传给 .input 才会在请求入口执行校验,单独定义在外部文件里并不会拦截任何请求。有人喜欢把 schema 抽到公共文件复用,结果 import 回来之后忘了接到 .input 上,客户端的长度校验形同虚设,最后是数据库层报错才被发现。这一条听起来很低级,实操中遇到的人真不少,而且很难靠测试覆盖到。

第三个坑是 tRPC 的 superjson 序列化。t3code 默认开启 superjson,能让 Date、Map 这些类型跨过 HTTP 边界无损传输。但如果你在自定义中间件或者错误格式化里手动 JSON.stringify,Date 就会悄悄变成字符串,客户端拿到的类型提示还是 Date,运行时却不是,这种“类型提示与实际值不一致”的问题非常隐蔽。正确做法是统一走 tRPC 的序列化管线,不要在中间层做任何手动 JSON 转换。

5. 从 t3code 出发还能往哪些方向扩展

t3code 目前只覆盖了单应用模板这一层,实际上它衍生出的几个方向都值得尝试。第一个是测试体系:tRPC 的 HTTP 层可以直接用 supertest 打 e2e 测试,配合 vitest 和 msw 能覆盖大部分接口逻辑;数据库相关测试建议用独立测试库跑真实迁移,而不是去 mock Prisma,mock 出来的读写行为和生产环境差距太大,容易测出“假绿”。第二个是工程化升级:把这套模板塞进 Turborepo monorepo,前端应用、管理后台、共享 UI 组件拆成独立 package,数据库 schema 放在公共包里,多个应用可以共用同一套类型和 migration,改动模型时只需要提交一次。第三个是数据层替换:Prisma 的 query engine 体积和冷启动时间在部分边缘计算场景下不太理想,如果应用跑在 Node 运行时,可以对比 Drizzle ORM 这类更轻量的方案,权衡之后再决定要不要换。

最后说一点我自己真实的体会。模板这东西,最怕的不是功能少,而是违背实际业务的使用习惯。t3code 里的每一项设计,几乎都来源于真实项目里的一次次故障和返工。比起单纯给一份能跑的代码,我更希望这些选型理由和排查经验,能帮你在自己的项目里少走几段弯路。如果真要给个建议,我会说:先克隆下来,跑通一条最细的业务链路,再按你自己的需求去改目录、换模型、调认证,不要为了用模板而用模板。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询