开篇先解释一下:t3code 是我最近从头到尾搭完的一个基于 T3 Stack 的全栈应用代码库,名字里的 T3 指的不是某个框架版本,而是这几年在前端圈子里很流行的一套技术组合——Next.js、TypeScript、tRPC、Prisma、Tailwind CSS 再加 NextAuth。很多人刷到 T3 Stack 相关的内容都会觉得“这不就是一堆工具拼在一起吗”,但实际把它当成一个完整项目去写的时候,你会发现这套组合在设计思路上高度自洽:类型安全从数据库一路延伸到浏览器端,几乎不需要手工维护接口文档和类型定义。这篇文章就把我从零搭建 t3code 的完整过程、关键原理、代码结构以及踩过的坑一次性讲清楚,适合准备上手全栈 TypeScript 开发、或者想从传统 REST 架构迁移到 RPC 模式的同学参考,无论你是刚接触 Next.js 还是已经写了一阵子,都能从中拿到可以直接落地的经验。
1. 项目概述与选型思路
1.1 t3code 是什么,为什么会有这个名字
先把这个名字拆开。T3 Stack 最早由开发者 Theo 提出并推广,社区里对 T3 的含义有几种解释,最常见的一种是说它代表 TypeScript、Tailwind、tRPC 这三个以 T 开头的核心技术,也有说法认为 T3 指的是“The Three”,即三件套的组合思想。不管哪种解释,核心指向都是一样的:用全栈 TypeScript 替换掉传统的前后端语言割裂状态,让一份类型定义贯穿整个开发链路。
t3code 这个名字就是我给这套实践代码库起的代号,它本质上是一个“带生产级目录结构的 T3 全栈项目模板”。和官方脚手架 create-t3-app 生成的空项目不同,t3code 里面包含了一个完整的业务 demo——团队任务看板,有用户登录、项目分组、任务流转、评论记录这些真实场景,同时还沉淀了配置、工具函数、路由设计、错误处理这些工程化细节。这样做的原因是:光有一个 hello world 级别的模板,你很难判断这套技术栈到底适不适合自己的业务;只有把一个带着真实复杂度的业务跑通,你才能感受到类型安全带来的收益,也才能真正暴露问题。
如果你现在正处于技术选型阶段,我的建议是别只看文章和视频测评,直接拿一个中等复杂度的模块(比如带权限的增删改查)用 T3 跑一遍,对比以前写 REST API 时的工作量。实测下来,单纯从“改一个字段需要动几处代码”这个指标看,T3 的优势非常明显。
1.2 选型对比:为什么是 T3 而不是普通 Next.js + REST
在决定使用 T3 Stack 之前,我专门把几种全栈方案放在一起做过对比,因为我知道只看类型安全这个点就冲进去,很容易忽略它的适用边界。
先看最传统的方案:Next.js(或 Express)+ REST API。这个组合的好处是人人都会,生态成熟,找一个开源项目几乎都能看到标准写法。但痛点也很明显——后端定义好一个接口,前端要手写对应的请求函数、类型定义、错误处理;后端改一个字段类型,前端可能要跑一遍全局搜索才能把所有用到的位置找干净。业务规模小的时候无所谓,一旦接口数量上到几十个,这种双端维护的成本会显著吃掉你的开发效率。
再看 Next.js + GraphQL。GraphQL 的类型系统确实强大,但引入它意味着你要维护 schema 文件、学习 resolver 和 directive 的写法,还要处理 codegen 生成的代码包。对于内部系统、后台管理这类偏私有化的场景,GraphQL 的灵活性反而变成负担,因为它把简单问题复杂化了。
tRPC 的方案则完全不同:接口消费方直接像调用本地函数一样调用后端方法,不需要生成 API 路由文档,不需要手写请求层代码,所有参数和返回值的类型在编译期就已经绑定。你看代码的时候不需要跳转到后端去确认返回结构,IDE 提示会直接告诉你一切。这种“Copilot 式”的开发体验在中小型项目里尤其舒服。
当然 tRPC 不是万能钥匙。如果项目需要对外提供公开 API,让不相关的第三方接入,REST + OpenAPI 文档依然是更通用的选择。T3 架构里你也可以在同一个 Next.js 应用内同时暴露 tRPC 路由和 REST 路由,两边互不干扰。我的原则是:内部模块用 tRPC,外部接口用 REST/OpenAPI,两者不冲突。
2. 核心技术点拆解:T3 Stack 每一层到底在做什么
2.1 Next.js 应用架构:App Router 与目录规划
t3code 的项目骨架由 Next.js 提供。目前脚手架的默认选择是 App Router,也就是基于文件系统的路由方式。相比老版 Pages Router,App Router 的核心变化是引入了 Server Component 和 Client Component 的边界划分。简单说:默认情况下组件都是服务端组件,里面可以直接读取数据库、调用内部函数、访问环境变量,不会把任何相关代码暴露给浏览器;只有明确标注"use client"的组件才会被打包到前端。
这个能力在 T3 架构中非常关键。因为 tRPC 的 server API 可以直接在服务端组件里通过 caller 方式调用,不需要走 HTTP 请求。你在服务端组件里取数据,本质上就是一次内存中的函数调用,既没有网络开销,也没有序列化损耗。再看目录结构,我的项目是这样规划的:
src/ app/ # Next.js 路由目录 (dashboard)/ # 需要登录的界面组 projects/ tasks/ api/ # 其他 HTTP 路由 layout.tsx server/ api/ root.ts # tRPC 路由聚合 routers/ # 各个业务子路由 auth.ts # NextAuth 配置 db.ts # Prisma 客户端实例 trpc/ server.ts # 服务端调用器 client.ts # 客户端请求代理这个结构的关键在于把 server 目录下的所有业务代码与前端组件物理隔离,从根源上杜绝“服务端代码被意外打包到客户端”的问题。app 目录下只管页面和组件,复杂的取数逻辑全部放在 server 里。
2.2 TypeScript 严格模式:类型即文档的基石
t3code 对 TypeScript 的使用不是“能用就行”,而是全程开启严格模式。create-t3-app 生成的 tsconfig.json 里会默认开启strict: true,这还不够,建议继续打开noUncheckedIndexedAccess和exactOptionalPropertyTypes。这些配置看起来只是编译期限制,但它对运行时的安全性影响很大。
举个例子,noUncheckedIndexedAccess开启后,访问数组元素arr[i]的类型会变成T | undefined,强制你处理越界情况。在写业务代码时你可能会觉得烦,但在 tRPC 的输入解析和表单数据处理场景中,这种严格性帮我们提前拦截了大量潜在的空值错误。从表达力上讲,TypeScript 类型已经不只是文档,它成了前后端之间唯一的协议约定。我甚至要求团队成员在代码 review 时把类型变化当作第一关注点,因为这往往意味着接口契约变了。
2.3 tRPC 的端到端类型安全机制
tRPC 的工作方式可以用一句话概括:在后端定义函数,前端直接调用,类型自动同步。它的运行时链路并不神秘,本质上是一个轻量级 RPC 框架:后端通过procedure定义可调用的方法,每个 procedure 可以声明输入校验、中间件和业务逻辑;前端通过 React Query 的useQuery/useMutation封装调用。
先看服务端定义:
// server/api/routers/task.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const taskRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ projectId: z.string().uuid() })) .query(async ({ ctx, input }) => { return ctx.db.task.findMany({ where: { projectId: input.projectId }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), projectId: z.string().uuid(), priority: z.enum(["LOW", "MEDIUM", "HIGH"]), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { ...input, creatorId: ctx.session.user.id } }); }), });这里最值得注意的设计是 zod 的引入。zod 承担了运行时校验和类型推导双重角色:z.object({...})不只是验证数据,它推导出的 TypeScript 类型会直接作为input参数的类型。前端调用时,你传错一个字段、少传一个必填项,编辑器立刻红波浪线。这就是端到端类型安全的来源——输入类型、输出类型都在编译期被完整检查过,误用 API 在这里变得很难实现。
客户端调用同样简洁:
// components/TaskList.tsx "use client"; import { api } from "~/trpc/client"; export function TaskList({ projectId }: { projectId: string }) { const { data: tasks, isLoading } = api.task.list.useQuery({ projectId }); if (isLoading) return <div>加载中...</div>; return tasks?.map((task) => ( <div key={task.id}>{task.title} - {task.priority}</div> )); }注意这个api.task.list.useQuery,它背后自动连接了 React Query 的缓存、重新请求和状态管理逻辑。你不必再手动写 loading、error 状态,也不需要担心多个组件重复请求同一份数据。如果你还停留在“每个页面自己管请求状态”的写法,这层封装能直接释放一大块精力。
2.4 Prisma 数据库建模与迁移
T3 的数据访问层由 Prisma 负责。Prisma 的核心优势是 schema 驱动的模型定义和类型生成:在schema.prisma里写完数据模型,运行prisma migrate生成 SQL 迁移文件,再运行prisma generate更新 TypeScript 类型。
以 t3code 里的任务看板为例,我设计了三个核心模型:
datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? tasks Task[] } model Project { id String @id @default(cuid()) name String ownerId String owner User @relation(fields: [ownerId], references: [id]) tasks Task[] } model Task { id String @id @default(cuid()) title String priority Priority @default(MEDIUM) status Status @default(TODO) projectId String project Project @relation(fields: [projectId], references: [id]) creatorId String creator User @relation(fields: [creatorId], references: [id]) createdAt DateTime @default(now()) } enum Priority { LOW MEDIUM HIGH } enum Status { TODO IN_PROGRESS DONE }这个模型设计里有几个经验值得分享。第一是主键统一用cuid()而不是自增id。自增整数在中小项目里没问题,但在分布式环境和数据合并场景下容易撞车,cuid 生成的无序字符串密钥则天然适合前端临时创建数据。第二是在 relation 字段上要明确指定@relation的关联字段名,否则 Prisma 会默认创建两个外键,导致前端赋值得多传一个字段。第三是枚举类型尽量用数据库枚举而不是字符串,这样在 tRPC 的 input 校验里可以直接用z.enum()对应,类型检查更严格。
3. 完整实操:从零搭建 t3code 项目
3.1 初始化与依赖安装
搭建过程第一件事是使用官方脚手架生成基础项目。在终端运行:
npx create-t3-app@latest t3code交互式命令行会让你选择要包含的功能模块。我的选择是:Next.js、TypeScript、tRPC、Prisma、Tailwind CSS、NextAuth。不需要单独的 ESLint 和 Prettier,因为脚手架已经内置了这些配置。如果你是在已有项目里渐进式引入 T3,也可以只装 tRPC 相关包,但那样配置成本会高不少,新手建议直接全选。
初始化完成后,第一件事是配置环境变量。项目根目录下有一个.env.example文件,复制为.env后填写DATABASE_URL。我用的数据库是 PostgreSQL,本地如果是 Docker 环境,可以直接用一段命令起一个实例:
docker run --name t3code-postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=t3code -p 5432:5432 -d postgres:16-alpine然后.env里的连接串长这样:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="随便生成一个足够长的随机字符串" NEXTAUTH_URL="http://localhost:3000"等数据库容器启动后,先运行npx prisma migrate dev --name init,这会根据默认的 schema 生成第一版迁移并同步到你本地的数据库。再运行npm run dev,浏览器打开http://localhost:3000,基础模板已经能跑起来了。
3.2 数据模型设计与迁移实战
我在 3.1 里没有直接用脚手架自带的基础 schema,而是先替换成任务看板的三张业务表。具体 schema 已经在 2.4 展示过了,这里重点说迁移过程。
每次改完 schema.prisma,必须按固定流程走一遍:
npx prisma migrate dev --name add_project_model npx prisma generate第一行命令会生成迁移 SQL 并应用到数据库,同时重新生成 Prisma Client。第二行虽然在 migrate 时已经自动执行过,但在团队协作时,队友拉取代码后只跑npx prisma migrate deploy就行,不需要也不应该在别人环境里自动跑migrate dev。migrate dev是会做数据清空的(如果涉及破坏性变更),生产环境必须用migrate deploy。
为了让本地开发有数据可用,我写了一个 seed 脚本:
// prisma/seed.ts import { PrismaClient } from "@prisma/client"; const db = new PrismaClient(); async function main() { const user = await db.user.upsert({ where: { email: "demo@t3code.dev" }, update: {}, create: { email: "demo@t3code.dev", name: "Demo User" }, }); const project = await db.project.create({ data: { name: "t3code 开发计划", ownerId: user.id, }, }); await db.task.createMany({ data: [ { title: "搭建项目基建", projectId: project.id, creatorId: user.id }, { title: "完成任务看板", projectId: project.id, creatorId: user.id }, ], }); } main() .then(() => console.log("Seed completed")) .catch(console.error);在package.json里加上"prisma": { "seed": "tsx prisma/seed.ts" },之后运行npx prisma db seed就能一次灌好数据。关于 seed 有一个坑:如果表结构经常调整,seed 脚本里的createMany可能因为关联数据不存在而失败,所以顺序很重要,必须保证user先存在,再创建project,最后创建task。
3.3 实现 tRPC API 的完整链路
现在进入核心环节,把任务模块的后端接口跑通。首先要在server/api/root.ts里把路由聚合起来:
import { createTRPCRouter } from "./trpc"; import { taskRouter } from "./routers/task"; import { projectRouter } from "./routers/project"; export const appRouter = createTRPCRouter({ task: taskRouter, project: projectRouter, }); export type AppRouter = typeof appRouter;这个AppRouter类型是整个类型安全链路的起点。前端trpc/client.ts里的createTRPCClient<AppRouter>和trpc/react.tsx里的createTRPCReact<AppRouter>()都会引用它,从而保证两端对同一份类型定义工作。
回到任务路由,我补充两个高价值的接口,分别处理“按项目筛选任务”和“更新任务状态”:
updateStatus: protectedProcedure .input( z.object({ taskId: z.string().cuid(), status: z.enum(["TODO", "IN_PROGRESS", "DONE"]), }) ) .mutation(async ({ ctx, input }) => { const task = await ctx.db.task.findUnique({ where: { id: input.taskId }, select: { creatorId: true }, }); if (!task) throw new TRPCError({ code: "NOT_FOUND" }); if (task.creatorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } return ctx.db.task.update({ where: { id: input.taskId }, data: { status: input.status }, }); }),这个接口看起来简单,但里面有几个我踩过的坑值得说清楚。
第一是权限校验不能放在前端做,必须在 procedure 内部做。很多人只判断“登录用户”,却忘了区分“资源的创建者”。我一开始写共享看板时,所有项目成员都能改任务状态,后来加了一圈“仅创作者或项目所有者可改”的规则,才发现如果这种逻辑散落在组件里,很快会失控。集中到 tRPC resolver 里是最稳妥的做法。
第二是错误类型必须用TRPCError,而不是直接throw new Error()。如果你直接抛普通 Error,前端收到的错误信息会很不友好,而且错误 code 无法统一处理。用TRPCError后,前端可以根据error.data.code判断是权限问题、参数问题还是资源不存在,从而给出对应文案。
第三是select和include的取舍。在查询任务时通常不需要把整个关联用户对象拖出来,用select只取需要的字段,可以有效降低数据库 IO 和网络传输量。在 Prisma 里include会把关联记录全部加载,数据量一上来性能会很难看。
3.4 前端页面接入与交互优化
后端接口完备后,前端接数据就变得相当无脑。这里分享一个实际的“任务看板”页面实现,包含列表、新增和状态更新:
"use client"; import { api } from "~/trpc/react"; import { useRouter } from "next/navigation"; export function TaskBoard({ projectId }: { projectId: string }) { const router = useRouter(); const { data: tasks, isLoading } = api.task.list.useQuery({ projectId }); const createTask = api.task.create.useMutation({ onSuccess: () => { router.refresh(); }, }); const updateStatus = api.task.updateStatus.useMutation(); if (isLoading) return <div>任务加载中...</div>; return ( <div className="space-y-4"> <button onClick={() => createTask.mutate({ title: "新任务", projectId, priority: "MEDIUM" })} className="rounded bg-blue-500 px-4 py-2 text-white" > 添加任务 </button> {tasks?.map((task) => ( <div key={task.id} className="flex items-center gap-4 rounded border p-4"> <span>{task.title}</span> <select value={task.status} onChange={(e) => updateStatus.mutate({ taskId: task.id, status: e.target.value as TaskStatus })} > <option value="TODO">待处理</option> <option value="IN_PROGRESS">进行中</option> <option value="DONE">已完成</option> </select> </div> ))} </div> ); }这里有两个交互层面的优化点。第一是 mutation 成功后的数据刷新。tRPC 集成 React Query 后,默认情况下如果你操作的数据正好是某个 useQuery 的缓存 key,onSuccess触发后 React Query 会自动失效缓存并重新请求。但如果你用了router.refresh(),它会触发服务端组件重新渲染,两条链路都能确保界面更新。实测中我建议两者选其一,避免重复请求。
第二是乐观更新。在任务状态变更这种高频操作里,等待网络返回再更新 UI 会让交互显得迟缓,可以用 React Query 的onMutate做乐观更新。更省事的方案是调用api.task.list.setData手动修改缓存,立即反映用户操作,再通过onError回滚。我通常只在“状态切换”“点赞”这类操作上做乐观更新,对于表单提交这类需要确认结果的场景,还是等真实响应更稳妥。
3.5 NextAuth 鉴权配置与跨端安全
T3 里的鉴权默认交给 NextAuth(最新版叫 Auth.js)。它支持 GitHub、Google 等 OAuth provider,也支持 Credentials 账户密码登录。t3code 里我同时配了 GitHub OAuth 和 Credentials,但实际项目建议优先 OAuth,省去自己维护密码哈希和找回密码的麻烦。
关键配置在server/auth.ts:
import { getServerSession, type NextAuthOptions } from "next-auth"; import GitHubProvider from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "./db"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db), session: { strategy: "jwt" }, providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], callbacks: { session({ session, token }) { if (session.user && token.sub) { session.user.id = token.sub; } return session; }, }, }; export const getServerAuthSession = () => getServerSession(authOptions);这里有一个非常重要的细节:默认情况下 PrismaAdapter 使用数据库 session 策略,但如果你在 CRUD 里大量使用ctx.session.user.id,数据库 session 会频繁查询 session 表,性能不够好,而且 JWT 策略更适配 Serverless 部署环境。所以配置里显式声明session: { strategy: "jwt" },把用户标识直接打进 token,后续每个请求不需要回数据库反查 session 就能拿到用户身份。
tRPC 的 protectedProcedure 是这样关联鉴权的:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session } }); });这个中间件在路由定义的顶层执行,任何挂上 protectedProcedure 的接口都要求登录。如果你需要管理员接口,可以再包一层use判断ctx.session.user.role,权限体系可以逐级扩展。
前面 3.1 已经提到要在.env里配置NEXTAUTH_SECRET。这个密钥必须足够长且不在代码仓库里出现,否则部署后被别人拿到,可以伪造 session token。生产环境我还建议在 Vercel 或云服务器的环境变量面板单独配置GITHUB_CLIENT_ID和GITHUB_CLIENT_SECRET,在 OAuth provider 后台把回调地址从http://localhost:3000/api/auth/callback/github换成线上域名。
4. 常见问题与排查技巧实录
4.1 tRPC 接口报 500 但控制台看不到任何日志
这应该是 tRPC 新手遇到最多的一个问题。我在写 t3code 的早期,经常出现前端弹出“Internal Server Error”,但服务端控制台完全没有堆栈信息。排查后发现根因几乎都出在 resolver 内部:当 procedure 抛出的错误不是 TRPCError 时,tRPC 会把它包装成通用 500,默认不打印详细信息。
解决办法分两步:第一,所有可能失败的路径都主动抛出 TRPCError,并给足上下文信息;第二,在根路由上加一个全局错误处理 middleware。这里是最有效的一段代码:
export const t = createTRPCContext() .middleware(async ({ ctx, next }) => { const result = await next(); if (!result.ok && result.error instanceof Error) { console.error("tRPC error:", result.error); } return result; });next()返回的result.ok并不是指业务逻辑失败,而是指中间件链路是否成功。业务里 throw TRPCError 也会走这段逻辑,这样所有异常至少会在服务端留下痕迹。
4.2 Prisma 生成的客户端类型没有跟上 schema 变更
现象通常是这样的:你刚改了schema.prisma里的字段名,运行完migrate dev,然后在编辑器里调用ctx.db.task.update,参数提示还是旧的。这是因为 Prisma Client 的类型定义是生成出来的,如果你的编辑器没有自动重启 TS Server,或在一体化开发环境(如 monorepo)里引用了缓存,类型不会即时刷新。
强制解决的方法是:
npx prisma generate然后重启编辑器或手动重载 TypeScript 服务(VSCode 里按 Ctrl+Shift+P 输入 “TypeScript: Restart TS server”)。如果问题还在,多半是tsconfig.json的 paths 配置把@prisma/client指到了缓存目录,建议检查node_modules/.prisma/client是否存在。
4.3 NextAuth 的 session.user 里取不到邮箱或头像
这个问题发生的背景是我用 GitHub OAuth 登录后,前端拿到的 session 对象里只包含name和image,email 字段要么没有,要么提示为 null。根因是 NextAuth 默认不会把邮箱和头像之外的字段带进 session。如果你确实需要 email,有两个方案。
第一方案:在 provider 配置里显式申请user:emailscope。GitHub provider 可以加authorization: { params: { scope: "read:user user:email" } }。第二方案(推荐):不要依赖第三方返回的 email,而是在数据库里以account.userId反查用户记录。t3code 里的做法是 session callback 里拿到token.sub(也就是数据库用户主键),然后直接用这个主键去 User 表查询业务需要的字段。这个方案的好处是只要数据库用户表结构稳定,session 内容始终可控。
4.4 useQuery 的 data 为 undefined 时不要急着判空
React Query 在首次加载和重新验证数据时,data都可能是undefined,很多新手会直接写if (!data) return null,结果页面刚加载完闪一下,或者每次刷新都出现空白。正确做法是区分isLoading和isFetching:isLoading表示还没有拿到任何数据(首次),isFetching表示正在后台重新取数但已有缓存。业务上更安全的写法是:
if (isLoading) return <Skeleton />; if (!tasks || tasks.length === 0) return <EmptyState />;这样既不会让页面在每次 background refetch 时重新渲染成空白,也能正确处理“接口返回空数组”的情况。
4.5 部署到线上后的类型安全防线失效
本地开发时类型安全的体验非常爽,但部署之后有一个容易忽略的点:Prisma Client 的类型是在构建期生成的,如果 CI 流程只跑next build而没有先跑prisma generate,构建产物会引用旧类型或直接报错。我在 t3code 里把这一步写进了 vercel build script:
"build": "prisma generate && next build"另外,线上数据库的 schema 更新一定不能跑migrate dev,它会在非交互式环境里阻塞等待确认。线上用prisma migrate deploy即可。如果你的平台是 Vercel,还得注意环境变量DATABASE_URL应该指向生产数据库,不要在构建阶段复用本地.env。
5. 实战后的个人总结
把 t3code 从零写到能在真实业务里使用,我对 T3 Stack 这套组合的理解发生了不少变化。最初我以为它只是“类型安全”这一个卖点,实际写完才发现它最大的收益是降低了全栈开发的上下文切换成本:你不需要在“后端定义接口-生成文档-前端写请求-对字段类型”这几个环节之间反复横跳,所有代码都在同一份类型宇宙里,改一处能立刻看到另一端的影响范围。这个体验对那种需要快速迭代内部工具、后台管理系统、SaaS 应用的团队来说,价值远远大于几个工具包的简单相加。
如果你想进一步扩展,有几个方向值得尝试:一是把 Prisma 换成 Drizzle ORM,tRPC 依然可以正常工作,针对 SQL 偏好的团队体验更好;二是接入 Playwright 对核心链路做端到端测试,因为 tRPC 接口走的是 HTTP POST,测试工具可以直接模拟登录后调用;三是把全局错误处理、日志上报、OpenTelemetry 追踪整合进 middleware,生产环境排障会轻松很多。在后续迭代上,我给 t3code 规划了工作流状态机、项目成员权限和操作审计日志三个模块,等这些模块做出来,整个模板的项目复杂度和参考价值会比现在高一大截。
最后分享一个很小但很实用的习惯:因为 tRPC 的 input 校验依赖 zod,我在所有路由的 input schema 里都加上了.catch()或者safeParse的辅助函数,并把校验错误信息统一返回。这个做法的好处是,前端拿到错误时可以直接展示 zod 生成的友好提示,不用再自己拼字符串。刚开始可能觉得多写了几行,但长期维护时每个字段的报错都是可预期的,这也是把 t3code 当成长期基础设施来维护时,我从经验里沉淀下来的一个值得坚持的细节。