去年做一个内部工具项目时,我选型调研了一圈,最后还是回到了一套组合拳:TypeScript + Next.js + tRPC + Tailwind,也就是常说的T3 Stack。当时照着社区里比较火的一个开源模板改造,发现这套东西最大的优势不是“快”,而是心智负担低——前端、后端、数据库全链路类型打通之后,改一个字段,编译期就能把上下游的报错全给揪出来。这个项目我内部代号就叫t3code,一路从原型做到上线,踩了不少坑,也沉淀了不少可以复用的套路。这篇就来聊聊t3code从0到1的完整思路、核心实现和排障实录,想入坑全栈TypeScript、或者正纠结技术选型的朋友,可以参考参考。
T3 Stack最打动我的一点是“约定大于配置”,它不逼你用ORM全家桶,也不搞微服务那套重型治理,而是把一套紧凑的、类型安全的组合拳摆在你面前:Next.js负责页面和API路由,tRPC负责端到端类型安全的API调用,Prisma负责数据库访问,NextAuth负责鉴权,Tailwind负责样式。这套组合特别适合中小型产品、内部工具、个人项目,也适合像我这样一个人要撑起前后端的开发者——写代码的时候不需要在多个语言和框架之间来回切换思路,真正做到了“全程一种语言、一套类型”。
1. t3code的项目定位与整体设计思路
1.1 为什么是tRPC而不是REST或GraphQL
动手写t3code之前,我先把“API层到底用什么”这个问题想清楚了。REST方案需要手动维护路由文档、参数校验和类型定义,前后端类型容易脱节;GraphQL方案虽然强大,但引入Schema语言、Resolver拆分、缓存失效这些概念,对小团队来说学习成本和复杂度都不低。tRPC走的是另一条路:不定义额外的Schema语言,直接用 TypeScript 的函数作为API端点,客户端从一个类型安全的对象上直接调用服务端函数,编译期就能验证参数和返回类型。
我做一个类比你就明白了:REST像是两个人通过传真机传输文件,你得先约定好格式、编号、归档规则;GraphQL像是开了一家有自助查询台的图书馆,功能全但得先学分类法;tRPC更像是两个人坐在同一张桌子旁,直接把文件递过去,类型就是文件本身的格式,只要你俩都认识TypeScript,就不会出现“我发的Zip你以为是PDF”这种问题。
t3code利用tRPC的另一个收益是开发效率的跃升。以前写完一个接口,要手写类型定义、搞定联调、排查字段名拼写错误;现在服务端router里加一个procedure,前端立刻在类型上多出对应方法,IDE自动补全直接给出参数提示,不匹配的字段根本保存不了代码。我实测下来,CRUD类的接口开发速度至少提升了40%——这不是夸张,是省掉了“反复切页面看文档、两边对字段”的巨大时间开销。
1.2 技术栈选型背后的妥协与取舍
T3虽然叫“Stack”,其实每层都有替换空间。我在t3code里用的组合是:Next.js 14(App Router)+ TypeScript严格模式 + tRPC 11 + Prisma + SQLite(开发环境)/ PostgreSQL(生产环境)+ NextAuth(Credentials + OAuth混合)+ Tailwind CSS。选这套全是基于实际项目的权衡:
- Next.js的App Router虽然有一定学习曲线,但服务端组件和客户端组件的边界清晰,配合tRPC做服务端调用能减少很多序列化样板代码。如果项目以纯前端交互为主,其实Pages Router上手更快,但t3code有大量需要SEO和后端渲染的页面,所以选了App Router。
- Prisma用声明式Schema管理表结构,迁移工具比较成熟,配合SQLite做本地开发几乎零配置,换到生产库也只需要改连接串和重新迁移,对个人项目非常友好。
- NextAuth(现在叫Auth.js)虽然配置起来有点绕,但它能同时兼容会话和JWT两种模式,和Prisma的适配也做得不错,省了自己写加密、签发、校验那套轮子。
- Tailwind不用多说,写UI快,改样式不污染HTML结构,T3全家桶标配。
有一点必须提醒你:T3 Stack不是为了“酷炫”而生的组合,它更适合从零开始的新项目,或者你有足够精力把老项目的前后端一起重构的场景。如果团队里已经有人非常熟悉Express + React REST那套生态,强行迁移到tRPC反而有阵痛期,不一定划算。
1.3 设计目标拆解:从数据模型到页面交互
t3code在动手之前,我先拆了三个层次的设计目标。第一层是数据模型,我需要一张用户表、一张项目表、一张任务表,以及任务标签的关联表;用户和项目之间是多对多关系,项目和任务是一对多,为了演示也加入了任务评论的自引用关联。第二层是API能力,我需要提供标准的认证、项目管理、任务流转和评论追加能力,但不想为每个实体都写一套MVC式控制器。第三层是页面交互,我要做一个看板式任务管理界面,拖拽列切换状态,还要有全局搜索和历史活动流页面。
这三个层次其实对应了t3code里最核心的三种代码资产:Prisma Schema、tRPC Routers、React组件树。我先在Prisma里定义Schema,然后写一个base router,再为项目、任务、评论各自建子router,最后在页面里通过trpcReact的hooks调用。整个开发流程是“自底向上、由表及里”的:数据模型是一切的地基,RPC层是承重墙,UI只是墙纸。这个顺序反了会非常痛苦——先画好UI却不知道该从服务端取什么形状的数据,等于反复拆墙。
2. t3code核心细节解析:类型安全链路是如何打通的
2.1 类型安全的核心链路:Schema -> Router -> Client
t3code最值得讲的不是某个具体功能,而是那条“类型安全链路”,它串联起了整个应用的数据流。链路从Prisma Schema开始,比如我定义了一个Project模型,Prisma会根据Schema生成完整的TypeScript类型;tRPC服务端在router里用protectedProcedure查询时,返回的数据类型会被自动推导出来;而客户端通过trpc.project.getAll.useQuery()调用时,传入参数的类型、返回值的类型都直接来自服务端推导,全程没有任何手写的interface或类型断言。
这条链路的威力在重构时体现得淋漓尽致。有一次我要给任务表加一个priority字段,把枚举从两个值扩到三个值。我只改了Prisma的enum TaskPriority、示意代码里的迁移文件,以及任务创建和更新的router逻辑,前端调用的地方压根没动——因为类型系统自动把这些改动广播了出去。保存代码的一瞬间,TypeScript编译器直接把我漏改的一个页面标记为错误,精准到行号。试想如果在传统前后端分离的项目里,这种低级错误恐怕要到联调时才能被发现。
为了让这条链路更稳健,我还在t3code里加了一个约束:所有业务逻辑必须经过tRPC router,禁止页面组件直接访问数据库或Prisma Client。这样做的好处是,API层成为唯一的后端入口,权限校验、参数校验、错误处理都集中在同一层,不会出现“这个页面绕过鉴权直接查库”的漏洞。
2.2 鉴权与授权:认证只是第一步,数据隔离才是关键
T3 Stack中NextAuth解决了“你是谁”的问题,但t3code这种多租户场景,“你能看到谁的数据”同样重要。我的处理方案是:在Prisma Schema里给Project加上ownerId外键,在tRPC router的每个查询和变更procedure里,先通过当前session拿到user id,再把它作为查询条件的一部分,比如where: { ownerId: session.user.id }。这样即使某个团队成员的token被泄露,他也没法通过API直接拿到别的用户项目数据——因为后端查询本身就限定了数据范围,而不是靠前端隐藏按钮来实现。
这里要提醒一个t3code开发中很容易忽略的坑:不要只校验“已登录”,还要校验“有权限”。我在初版代码里只做了requireUser认证中间件,结果发现任意登录用户可以通过传一个projectId去修改不属于自己的项目名称。修这个问题的思路很朴素:在updateProject的procedure里先根据session里的userId查一次project,确认project.ownerId === session.user.id,否则抛出UNAUTHORIZED错误。这种“先查再改”的模式虽然多一次查询,但对数据安全来说是值得的。
2.3 数据校验与错误处理的最佳实践
t3code里所有可变操作(create/update/delete)的参数都用zod库做了运行时校验。为什么有了TypeScript还不够?因为TypeScript只在编译期生效,API边界上你面对的是JSON字符串,可能来自任何客户端,不能假设数据“形状正确”。zod能做的事很多,比如保证title至少3个字符、status是合法枚举、dueDate是合法日期格式等等。我习惯把zod schema定义在和router同目录的一个schema.ts文件里,这样既能给服务端用,也能导出给前端做表单初筛。
错误处理的模式我统一为“tRPC errors + HTTP状态码映射”:校验失败返回BAD_REQUEST,未认证返回UNAUTHORIZED,无权限或数据不存在返回FORBIDDEN或NOT_FOUND,服务端异常返回INTERNAL_SERVER_ERROR。前端通过trpc.useUtils()捕获错误并显示对应的提示信息。这样做的好处是错误类型是强类型的,前端可以针对UNAUTHORIZED跳转登录页、针对BAD_REQUEST在表单下展示具体字段错误。加上tRPC内置的errorFormatter,我可以把zod的字段错误集合透传出来,前端表单校验体验基本上能做到和本地校验一样即时。
3. t3code实操记录:从初始化到第一个接口跑通
3.1 环境准备与项目初始化
先交代一下环境:Node.js 20 LTS、npm 10、SQLite(生产用PostgreSQL,本地开发用SQLite最顺手)。T3项目有两种初始化方式:create-t3-app交互式脚手架和手动搭建。t3code用的是create-t3-app,它能一次性把Next.js、tRPC、Prisma、Tailwind、NextAuth的骨架和目录结构装好,省去手工拼装各种配置碎片的时间。
初始化命令行如下:
npx create-t3-app@latest t3code进入交互提示后,有几个选项值得留意:
- Use TypeScript:选Yes,这是T3地基。
- Use Next.js App Router:我选Yes,理由前面说过。
- Use tRPC:选Yes,核心依赖。
- Use Prisma:选Yes。
- Use Tailwind CSS:选Yes。
- Use NextAuth:选Yes。如果想先跑通再接入登录,这里也可以先选No,但后面再集成会麻烦一些。
- Use ESLint / Prettier:全选Yes,保持代码风格一致性。
- Enable React Compiler:如果版本支持,我会开启,能自动优化组件重渲染,减少手动memo的负担。
初始化完成后,项目自带一个能跑的“Hello World”页面和tRPC的健康检查路由。我建议先跑一遍npm run dev确认基础环境没问题,再开始改代码。
3.2 数据库模型设计与迁移
接着是定义数据模型。t3code的核心数据模型长这样(已简化):
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] projects Project[] memberships ProjectMember[] tasks Task[] comments Comment[] } model Project { id String @id @default(cuid()) name String description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) ownerId String members ProjectMember[] tasks Task[] } model ProjectMember { projectId String userId String role String @default("member") user User @relation(fields: [userId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) @@id([projectId, userId]) } model Task { id String @id @default(cuid()) title String description String? status TaskStatus @default(TODO) priority TaskPriority @default(MEDIUM) dueDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String assignee User? @relation(fields: [assigneeId], references: [id]) assigneeId String? comments Comment[] } enum TaskStatus { TODO IN_PROGRESS DONE } enum TaskPriority { LOW MEDIUM HIGH URGENT } model Comment { id String @id @default(cuid()) content String createdAt DateTime @default(now()) author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) taskId String }定义好Schema后,执行迁移并生成客户端代码:
npx prisma migrate dev --name init npx prisma generate注意prisma migrate dev在生成开发库的同时还会生成迁移历史文件,这些文件是项目的一部分,要提交到git里。后续改字段就再跑一次migrate dev --name 描述信息,它会自动对比Schema和数据库的差异,创建新的迁移文件。
3.3 tRPC Router的组织结构与第一个接口实现
t3code的tRPC代码是分层组织的,目录结构如下:
src/server/api/root.ts:汇总所有子routersrc/server/api/routers/project.ts:项目相关接口src/server/api/routers/task.ts:任务相关接口src/server/api/routers/comment.ts:评论相关接口src/server/api/trpc.ts:定义procedure构建器、中间件、上下文类型
根router的写法大致如下:
// src/server/api/root.ts import { router } from "~/server/api/trpc"; import { projectRouter } from "~/server/api/routers/project"; import { taskRouter } from "~/server/api/routers/task"; import { commentRouter } from "~/server/api/routers/comment"; export const appRouter = router({ project: projectRouter, task: taskRouter, comment: commentRouter, }); export type AppRouter = typeof appRouter;第一个接口我通常从“获取当前用户的项目列表”开始,它最能体现tRPC的风格:
// src/server/api/routers/project.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const projectRouter = createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) => { return ctx.db.project.findMany({ where: { ownerId: ctx.session.user.id }, orderBy: { updatedAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ name: z.string().min(1).max(50), description: z.string().max(300).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.project.create({ data: { name: input.name, description: input.description ?? "", owner: { connect: { id: ctx.session.user.id } }, }, }); }), });客户端调用长这样,类型自动推导,不用写任何请求逻辑:
// src/app/dashboard/page.tsx (client component) "use client"; import { api } from "~/trpc/react"; export default function DashboardPage() { const { data: projects, isLoading, error } = api.project.getAll.useQuery(); if (isLoading) return <p>加载中...</p>; if (error) return <p>出错了:{error.message}</p>; return ( <ul> {projects?.map((project) => ( <li key={project.id}>{project.name}</li> ))} </ul> ); }注意这里api是create-t3-app自动生成的带类型上下文的React hooks集合。把组件标记为"use client"是因为要使用hooks,但数据获取依然是在服务端通过tRPC完成,再以序列化JSON传给客户端组件,既保留了类型安全又没有暴露数据库连接细节。
3.4 看板交互与乐观更新
t3code最复杂的交互是看板拖拽。任务卡片需要从一个列表拖到另一个列表,我用了@dnd-kit/core和@dnd-kit/sortable组合。拖拽结束的回调里更新任务状态,同时立即调用tRPC mutation。这里我实现了乐观更新,避免拖拽后出现“先转圈再刷新”的卡顿。
乐观更新的具体做法是:在调用useMutation时,通过onMutate直接修改react-query的缓存,把任务的状态乐观改成目标列;onError再回滚缓存并弹出错误提示;最后onSettled触发重新拉取,保证服务端数据最终一致。这个模式的代码不算复杂,但能显著提升交互质感。
const updateStatus = api.task.updateStatus.useMutation({ onMutate: async ({ id, status }) => { await utils.task.getAll.cancel(); const previous = utils.task.getAll.getData(); utils.task.getAll.setData(undefined, (old) => old?.map((task) => (task.id === id ? { ...task, status } : task)) ); return { previous }; }, onError: (_err, _newTask, context) => { if (context?.previous) { utils.task.getAll.setData(undefined, context.previous); } }, onSettled: () => { void utils.task.getAll.invalidate(); }, });这里有个小细节:utils.task.getAll.cancel()要先执行,防止正在请求中的旧数据覆盖乐观更新的结果;getData/setData直接操作缓存,比通过fetchQuery重新请求要快一个量级。
4. t3code踩坑记:常见问题与排查实录
4.1 类型推导失效:trpc client被当成any的排查
第一个大坑是tRPC客户端类型变成any。现象是:api.project.getAll.useQuery()返回的data类型不再是推导出来的Project数组,而是any,这样类型安全就形同虚设了。排查半天发现是src/trpc/react.ts里的createTRPCReact泛型挂的不对。create-t3-app默认会生成createTRPCReact<AppRouter>(),如果自己手动改过AppRouter的导出方式(比如改成了默认导出),类型推导就会断掉。
解决方案是保持export type AppRouter命名导出,并且在trpc/react.ts里确保引用的是同一个类型。还有一个隐蔽原因:个别文件里出现了ESLint没有报但TS会忽略的any,这时可以运行npx tsc --noEmit查看具体是哪个声明把推断打断了。
来自经验的一个习惯:不要随便在根Component里给useQuery的结果做“as any”断言。一旦你这么写,IDE自动补全都救不回来,而且这个污染会往下传递。遇到类型不对,先查router输出的类型,再查trpc/react的泛型,不要急于断言。
4.2 会话过期与Session类型扩展
NextAuth在t3code里默认用JWT策略(因为没配数据库session表也行)。但项目里的业务代码需要用到session.user.id,而默认的Session类型里只有name/email/image,没有id字段。直接写session.user.id会报TS错误。解决方法是在项目里声明模块补丁,通常新建src/types/next-auth.d.ts:
import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }同时在NextAuth的callbacks.session回调里把token里的id加进去:
callbacks: { session({ session, token }) { if (session.user && token.sub) { session.user.id = token.sub; } return session; }, jwt({ token }) { return token; }, },这个坑比较经典,不扩展Session类型,protectedProcedure里的ctx.session.user.id就没法通过类型检查。顺便提醒:如果你用的是Credentials登录,JWT callback里要自己放一个自定义字段(比如userId),否则token.sub不一定是你用户表的主键。
4.3 Prisma迁移冲突与开发库重建
开发期间经常遇到“迁移后数据库和Schema不一致”的问题,典型场景是:我改了一个字段的可选性,执行prisma migrate dev,warnings提示有数据会丢失,我手抖选了个“reset”。执行重置后本地所有测试数据全部清空,而且迁移历史也被回滚了。虽说不至于灾难,但重建数据很烦。
经验做法是:在非生产环境大胆用reset,但reset前把关键seed数据做成脚本。create-t3-app默认没带seed,我建议自己在package.json里加一条"prisma": { "seed": "tsx src/server/db/seed.ts" },然后写一个幂等的seed脚本(先删除再插入,或者采用upsert),这样任何时候reset都能一键恢复基础数据。另外,如果把migrations文件夹视为只读的、不可手动修改的资产,大部分冲突都可以通过新建一个迁移文件来解决,而不是去改历史迁移。
另外有个实用技巧:当多人在同一分支开发时,经常出现“迁移历史分叉”。这时候可以跑npx prisma migrate resolve --applied 某迁移名把某个迁移标记为已应用,但老实说,小项目直接备份数据重建库更省心。
4.4 路由权限与缓存误区
tRPC的protectedProcedure只认证,不鉴权。我踩过一次坑:在getAll接口里查全表返回所有项目给每个用户,前端根据ownerId来筛选显示。看起来没错,但API本身泄露了其他用户的数据。正确做法是后端就按ctx.session.user.id过滤,前端拿到什么就显示什么。装过这么一次“过桥式安全”后,我对tRPC每个procedure的查询条件都养成了习惯:第一句永远先用当前用户id去限定范围。
另一个缓存误区是React Query的staleTime。我初版把project.getAll的staleTime设成60秒,结果一个用户改名后,另一个设备上的用户看到旧名字60秒。换个维度看,普通数据用默认0秒就够了;如果确实想减少请求,可以针对频繁访问的读接口单独调staleTime,但一旦涉及mutations,记得在onSuccess里invalidate对应query。
4.5 给新手的三条建议
如果你也想照着t3code的思路做一个同类型项目,我有三条不掺水的建议:
- 先把Prisma Schema数据模型画出来,再动页面。项目、任务、评论、成员之间的关系没理清,后面每个接口和页面都会返工。
- 不要给所有查询都套上
useQuery的refetchOnWindowFocus默认行为(tRPC的原生查询继承React Query,默认窗口聚焦会重新请求)。如果你不想频繁刷新,可以改全局QueryClient的refetchOnWindowFocus为false。 - 所有mutation尽量用
zod.input()约束好参数,不要相信任何来自前端的字段。tRPC的强类型并不是防护墙,运行时校验才是最后一道闸门。
5. t3code的扩展空间与个人心得
从t3code这个项目里,能明显感受到一套类型安全的全栈技术栈带来的“静默收益”:大部分类型相关的低级错误在编译期就被拦截,线上bug数量明显变少;跨端联调时间大幅压缩;加字段、改枚举、调权限,都能在一条清晰的链路上完成传播。当然它也有局限,如果项目需要原生App接入、需要暴露给大量第三方开发者、或者团队里TypeScript功底参差不齐,tRPC这套“全程TS”的优势就会打折扣,这时REST或GraphQL可能更合适。
我个人的体会是:T3 Stack不是银弹,但它非常适合中小团队的“全栈单仓库”项目,尤其是产品原型期和内部工具期。t3code的前端体验、类型推导和开发效率,在同规模的传统前后端分离项目里很难找到对手。如果你正好要对一个偏重内部协作、数据关系清晰但交互不复杂的Web应用做技术选型,完全可以参照t3code这套组合,从create-t3-app开始,先跑通一条完整链路,再逐步增加认证、复杂查询和交互。踩过我上面那些坑之后,你会发觉这套技术栈的回报周期,真的比预想中短得多。