1. 一句话说清t3code是什么,以及为什么值得动手写一遍
t3code这个项目,我前后花了两周时间才完整落地,最终形态是一个全栈TypeScript应用,技术栈选的是T3 Stack这条标准线:Next.js 14作为页面框架,tRPC负责API层,Prisma管PostgreSQL数据访问,Tailwind处理样式,NextAuth做登录。业务本身是个链接收藏夹,把平时保存的文章、文档、工具站统一收进来,支持搜索和删除,登录后数据跟着账号走。
先说结论:这个项目的业务功能真实不复杂,但把它从零写完,CRUD、数据库建模、鉴权、表单校验、部署上线全都过了一遍。对任何一个想验证T3 Stack这套技术栈的开发者来说,没有比这更合适的练手场景了。如果你已经被“前端改一个字段、后端接口文档没更新、联调时才发现类型对不上”这个过程折磨过,这篇记录应该能让你少走不少弯路。
1.1 为什么偏偏做一个链接收藏工具
说实话,链接收藏这个点子不算新鲜。我自己平时工作里经常要保存一些技术文档、工具站、灵感页面,浏览器书签只能自己看,团队里要分享就得靠聊天记录里翻,时间一长链接就失效了。所以我想做一个轻量的内部工具:登录之后把链接收进去,按时间排好,支持关键词搜索,不需要的人也可以直接删掉。
选它当t3code的载体,核心原因是“麻雀虽小,五脏俱全”:它有用户体系,所以能验证鉴权链路;有列表页,所以能验证查询接口和数据渲染;有新增和删除,所以能验证mutation和缓存失效;有搜索,所以能验证带参数的procedure。如果把业务做得太复杂,比如加上标签体系、点赞评论、多级分享,反而会冲淡T3 Stack本身的看点。我刻意把功能圈得很小,就是为了让每一个技术环节都能看得清清楚楚。
1.2 技术选型对照:T3 Stack vs 传统前后端分离
| 对比维度 | T3 Stack(t3code实际使用) | 传统前后端分离 + REST |
|---|---|---|
| 类型同步 | 服务端定义一次,客户端自动推导,不写interface | 手写d.ts或靠Swagger生成,极易过期 |
| 接口调试 | 页面组件里直接调用,输入输出都有类型提示 | 依赖Postman调试,文档维护靠自觉 |
| 数据校验 | Zod在tRPC边界统一校验,错误对象可结构化读取 | 前端一套校验、后端一套校验,经常对不齐 |
| 数据库访问 | Prisma Schema即类型,查询结果自带模型类型 | 手写SQL或Mapper,字段改动容易漏改所有调用处 |
| 学习成本 | 需要理解router/procedure/context等概念 | 人人都会REST,但API文档设计长期是负担 |
不少朋友会问,为什么不用NestJS加OpenAPI那一套?NestJS本身确实优雅,但你要在一个中小型项目里把Controller、Service、DTO一层层建好,再引入Swagger生成文档,再让前端基于文档生成client代码,中间链路实在太长。T3 Stack的思路是用TypeScript本身的类型系统把这些环节直接抹平——REST的边界还在,但你不再需要手动维护它的描述文件。
1.3 模块拆解与一次完整的数据流
t3code的模块划分很清晰,一共三块:
- 认证模块:NextAuth对接GitHub登录,session中保存用户id
- 链接模块:新增、列表、搜索、删除四个tRPC procedure
- 用户模块:只从session读取当前用户,不做独立的管理页面
数据流走一遍是这样的:用户在表单输入标题和URL,点击提交,React Query的mutation把数据交给tRPC客户端;tRPC客户端序列化请求后发送到服务端,服务端先过Zod校验,再经过protectedProcedure的登录态检查,最后调用Prisma把数据写入PostgreSQL。成功后客户端invalidate对应query,列表自动重新请求。这条链路上的每一个环节——入参类型、出参类型、错误结构——都是TypeScript可推导的,等到后面3.4节你看到代码就会理解“端到端类型安全”不是宣传话术,而是真实的工作方式。
2. T3 Stack的魂:端到端类型安全是怎么打通的
2.1 没有tRPC的时代,我们吃了什么亏
回想一下之前做全栈项目的状态:后端接口和前端类型是两套体系。后端定义好路由,前端要么手动复制字段来写interface,要么接一个代码生成器。手写interface最大的问题不是懒,而是“不同步”:后端加了一个字段、改了一个字段名,前端没人记得去同步,于是所有错误都拖到运行时才暴露。更常见的是字段名拼错,编译期完全不报错,用户点了一下才崩,你还要开着DevTools去网络面板里对JSON。
有人可能会说,那用OpenAPI啊。OpenAPI确实能解决一部分问题,但它在实际项目里往往变成另一份需要维护的文档。只要没人严格把关,它就会滞后于真实代码。tRPC的思路是:直接让API定义本身具备类型信息,前端拿到的类型就是从服务端router自动推导出来的,不存在第二份描述文件,也就不存在“同步”的问题。
2.2 tRPC的请求链路:类型如何从服务端流向客户端
tRPC的套路是这样的。服务端定义一个router,比如:
export const appRouter = router({ link: linkRouter, }); export type AppRouter = typeof appRouter;这个AppRouter就是全应用的类型中心。客户端创建tRPC实例时,把它作为泛型参数传进去:
import type { AppRouter } from "@/server/api/root"; export const api = createTRPCNext<AppRouter>({ config() { return { transformer: superjson }; }, });之后页面上写api.link.all.useQuery(),TypeScript会把all这个procedure的输入输出类型直接推断到组件里,你压根不需要手动定义type LinkItem = ...。原因在于createTRPCNext是泛型函数,AppRouter传入后,TS会根据router的结构生成对应的一组hooks签名。Zod输入的类型、Prisma查询返回的模型类型,全部体现在签名里。这就是t3code里“类型零手写”的底层来源。
2.3 Zod与Prisma:校验层和数据层的分工
在t3code里,Zod管请求边界,Prisma管数据层,两者在服务端联合工作,但类型信息都会透传到client。
具体到每个procedure,输入校验是用Zod的object schema定义的:
create: protectedProcedure .input( z.object({ title: z.string().min(1, "标题不能为空"), url: z.string().url("必须是合法URL"), description: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { ... })前端在使用这个mutation时,variables的类型会被推断为{ title: string; url: string; description?: string },url不合法时TS直接标红,运行时Zod还会再拦一道。而Prisma这边,model Link定义好后,查出来的行对象直接自带类型,不用再自己写DTO转来转去。两层加起来,从用户输入到数据库写入之间,类型和校验是闭合的。
2.4 上下文与鉴权:类型安全的前提条件
tRPC能做好类型推导,离不开context这个概念。每次请求,tRPC都会调用createContext构造一个请求级上下文,通常把session和Prisma client放进去:
export const createContext = (opts: CreateNextContextOptions) => { return { prisma, session: getServerSession(opts), }; };再通过中间件把“需要登录”的procedure单独抽出来:
const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure = t.procedure.use(isAuthed);关键点在于,中间件一旦判定未登录就抛错,而通过后返回的ctx里session.user一定是存在的。所以后面所有用protectedProcedure写的procedure,内部拿ctx.session.user.id时TS都能正确推断,不需要写if (!user)防御代码。这看起来是小细节,实际写起来体验差异非常大。
3. 从零搭建t3code:核心代码与实现细节
3.1 初始化项目与环境变量配置
搭建用的是create-t3-app官方脚手架:
pnpm create t3-app@latest t3code按提示选择Next.js、TypeScript、Tailwind、Prisma、tRPC、NextAuth。初始化完成后,项目里已经自动生成了src/server/api目录、Prisma的schema文件、NextAuth的配置骨架。目录结构很清晰:src/server/api/trpc.ts放tRPC初始化逻辑,src/server/api/root.ts合并所有router,src/server/db.ts创建Prisma client单例。
然后改环境变量,在.env里至少需要配三项:
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/t3code" NEXTAUTH_SECRET="改成随机长字符串" NEXTAUTH_URL="http://localhost:3000"NEXTAUTH_SECRET可以用openssl rand -base64 32生成,生产环境务必换掉默认值;NEXTAUTH_URL本地默认就是localhost:3000,上线时要改成正式域名,否则GitHub登录回调会失败。这两个变量是NextAuth的老坑,几乎每个项目都会踩一次。
3.2 设计数据模型并完成迁移
创建链接收藏工具,核心数据模型就两个:用户和链接。Prisma Schema长这样:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String @unique links Link[] } model Link { id String @id @default(cuid()) title String url String description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id]) }这里有几个选择值得说明。主键用cuid而不是自增id,是因为社交登录场景下用户id可能来自不同系统,字符串id更通用,也避免暴露行数信息;@updatedAt会自动维护更新时间,不需要在代码里手动赋值;Link.userId加上外键关联,Prisma在查询时能自动生成关联对象的类型。
改完Schema后执行:
npx prisma migrate dev --name init这条命令会创建迁移文件、执行SQL并生成新的Prisma Client。t3code里如果勾选了NextAuth,模板会额外生成Account、Session、VerificationToken等模型,这是NextAuth的Adapters机制要求的,建议保留,不要手贱删掉,否则登录时会报“表不存在”的错误。
3.3 配置tRPC基础设施
create-t3-app已经帮我们搭好了tRPC的基础骨架,但值得从头理解一遍。src/server/api/trpc.ts里大致是这样的:
import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; import type { createContext } from "@/server/auth"; const t = initTRPC.context<typeof createContext>().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const createTRPCRouter = t.router; export const publicProcedure = t.procedure;transformer: superjson是为了让Date、Map等复杂类型也能在序列化时保留类型信息,tRPC的前后端要配同一个transformer,不然时间字段会变成字符串。errorFormatter把Zod错误结构化地返回给前端,前端可以直接读取字段错误信息,而不是收到一行难懂的消息。
接着在root.ts里把所有子router合到一起:
export const appRouter = router({ link: linkRouter, auth: authRouter, }); export type AppRouter = typeof appRouter;这个AppRouter就是前面2.2节说的类型中心,所有client端类型都从它推导。
3.4 实现链接增删查的完整闭环
链接相关的业务逻辑全部收敛在linkRouter里,tRPC的router本质上是“不依赖HTTP方法、不依赖URL路径”的procedure集合。t3code里我写了四个procedure:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; export const linkRouter = createTRPCRouter({ all: protectedProcedure.query(async ({ ctx }) => { return ctx.db.link.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), search: protectedProcedure .input(z.object({ keyword: z.string().min(1) })) .query(async ({ ctx, input }) => { return ctx.db.link.findMany({ where: { userId: ctx.session.user.id, title: { contains: input.keyword, mode: "insensitive" }, }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1, "标题不能为空"), url: z.string().url("必须是合法URL"), description: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.link.create({ data: { ...input, userId: ctx.session.user.id }, }); }), delete: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { return ctx.db.link.deleteMany({ where: { id: input.id, userId: ctx.session.user.id }, }); }), });几个细节说一下。查询类的procedure用query,写操作统一用mutation,这是tRPC对请求语义的划分,前端也会据此生成带缓存逻辑的useQuery和不带缓存的useMutation。搜索时用contains加mode: "insensitive"实现大小写无关的字符串匹配,在Postgres里实际生成的是ILIKE语句。删除操作我用的是deleteMany而不是delete,因为delete会直接按id删,完全不校验归属;deleteMany可以同时把id和userId放进where条件,保证只能删自己的链接。这是个安全习惯,建议长期保持。
3.5 前端页面接入:React Query与缓存失效
页面侧接tRPC,说直白点就是在组件里调用hooks。t3code的列表页核心代码很少:
const utils = api.useUtils(); const { data: links, isLoading } = api.link.all.useQuery(); const createLink = api.link.create.useMutation({ onSuccess: () => utils.link.all.invalidate(), }); const deleteLink = api.link.delete.useMutation({ onSuccess: () => utils.link.all.invalidate(), });新增和删除成功后,只需要调用utils.link.all.invalidate(),React Query会立即把link.all缓存标记为过期并重新请求。这比手动维护一个list state再setData要干净得多。你可能注意到了,这里从头到尾没有一个“接口类型定义”的文件——类型全是推导出来的,我改完后端Schema和procedure,前端IDE马上同步感知到,这是T3 Stack最直观的爽点。
登录接NextAuth时,有一个几乎必踩的坑:session里默认没有user.id。t3code需要在authOptions里加一个callback,把token里的sub塞给session.user:
export const authOptions: NextAuthOptions = { providers: [...], session: { strategy: "jwt" }, callbacks: { session({ session, token }) { if (session.user) { session.user.id = token.sub as string; } return session; }, }, };同时记得在类型声明文件里给Session扩展id字段:
declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }否则TS会直接报Property 'id' does not exist on type 'Session['user']',而这个报错只有在你尝试ctx.session.user.id时才出现,很容易让人摸不着头脑。
4. 开发与部署中的坑:问题排查实录
4.1 本地能跑、部署就挂的Prisma问题
t3code在本地跑通只是第一步,部署时最常见的坑几乎都集中在Prisma上。第一是本地数据库和线上数据库不一致,比如本地用SQLite、线上用PostgreSQL,本地没问题的查询线上执行报错;第二是部署环境没有执行prisma generate,导致构建时Prisma的客户端类型缺失;第三是数据库连接串配错,尤其用了托管Postgres时,直接把连接池地址写成迁移地址,迁移命令会连不上。
我自己的做法是:本地和线上统一用PostgreSQL,比如本机装一个,或者直接用托管平台的免费档;迁移文件必须提交到Git仓库,部署前用prisma migrate deploy而不是migrate dev执行变更;构建脚本里确保有prisma generate。这样能避免绝大多数“本地好好的,一部署就崩”的问题。
4.2 tRPC类型不同步和React Query缓存问题
tRPC开发中最烦的“类型不生效”,多半发生在新增procedure之后。IDE不报错也不提示,或者明明改了代码但前端还是调用不到。这种情况一般是dev server的类型缓存问题,先把dev server重启,再跑一次tsc --noEmit,基本都能解决。另一个高频问题是transformer不一致:如果你在客户端config里没加superjson,而服务端加了,Date类型会被序列化成ISO字符串,前端拿到后调用.toLocaleDateString()会直接报错。排查顺序是:先看配置,再看类型,最后看运行时。
React Query那边还有一个容易被忽略的点:mutation成功后如果忘记invalidate,列表是不会自动刷新的。t3code的代码里我特意没用手动setData,而是统一走缓存失效,就是为了让数据流单一化。你如果接手类似项目,看到列表不刷新时,先检查有没有invalidate,而不是去改数据源。
4.3 NextAuth Session拿不到userId的坑
这个问题是T3 Stack新手最常遇到的,表现是:登录成功了,但页面里拿不到user.id,或者在tRPC的protectedProcedure里ctx.session.user.id是undefined。原因在于NextAuth的session默认只包含name、email、image,id字段不在里面。解决方案就是3.5节里写的session callback,把token.sub赋值给session.user.id。
这里有两个容易写错的地方:一是token.id是个不存在的东西,应该用token.sub,这是JWT标准字段;二是如果是Database session策略,session里拿到的user可能来自数据库,但同样要手动关联id。另外,如果用了PrismaAdapter,Schema里的User、Account、Session、VerificationToken模型一个都不能少,少了某个表登录时会报“Table does not exist”,这种错误往往不是代码问题而是模型缺失。
4.4 Edge Runtime与Node API冲突
在Next.js App Router模式下,tRPC的route handler默认运行在Node.js runtime,但如果你在某个页面或中间件里用了Prisma之外的Node原生库,或者不小心把tRPC的handler放到Edge runtime里,就可能遇到“Dynamic server usage”报错或者直接构建失败。解决办法是在对应的route handler或layout顶部显式声明:
export const runtime = "nodejs";为什么不用Edge Runtime?因为Prisma要维护数据库连接池,Edge的冷启动模型和短生命周期并不适合维持长连接。t3code把tRPC的endpoint保留在Node runtime,页面静态渲染部分留在默认环境,两边互不干扰。
4.5 部署前必做的检查清单
部署是项目收尾时最容易慌张的部分。我整理了一份检查清单,t3code上线前逐项过一遍:
| 检查项 | 说明 |
|---|---|
| 环境变量 | DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL是否设置完整且正确 |
| 数据库迁移 | 执行过prisma migrate deploy,迁移全部生效 |
| Prisma Client | 构建前执行过prisma generate,类型完整 |
| 登录回调地址 | GitHub Provider中的Callback URL必须等于${NEXTAUTH_URL}/api/auth/callback/github |
| 正式域名 | NEXTAUTH_URL必须是线上域名,不能留localhost |
| 构建命令 | next build干净通过,没有TS类型错误 |
还有一点容易被忽略:托管数据库的连接池地址和直连地址往往不同,Prisma的迁移命令需要直连地址,而应用运行推荐用连接池地址,建议在环境变量里区分DATABASE_URL和DIRECT_URL,Prisma schema中配置directUrl = env("DIRECT_URL")。这样迁移和运行各自用对地址,能省很多事。
5. 写在后面:几点个人体感
5.1 什么场景适合T3 Stack
t3code做下来我最深的体会是:T3 Stack不是万灵药,它有非常明确的适用边界。适合的场景是“前后端都由同一个TS团队维护的中小型全栈项目”,比如内部工具、管理后台、MVP产品,这类项目没有对外公开API的需求,用tRPC能砍掉大量接口维护成本。不适合的场景是“需要对外提供REST/OpenAPI、或者客户端是第三方非TS生态”,因为tRPC的优秀体验建立在TS客户端和服务端共享类型的前提上,一旦客户端脱离了TS,这套优势就完全不存在了。
5.2 最值得带走的一个习惯
抛开技术栈本身,t3code给我最大的收获是“类型不是写出来的,而是推出来的”这个习惯。接口类型不要维护第二份描述,数据模型不要写重复的DTO,校验规则尽量收敛到边界,剩下的让TypeScript自己推导。你能减少多少重复劳动,就能减少多少不一致的问题。我个人实际做下来最爽的瞬间,是前端代码里写错一个字段名,IDE立刻标红,而不是等到用户反馈一个运行时错误。这种体验,用过一次就回不去了。