资讯动态

t3code 全栈开发实战:Next.js、tRPC、Prisma 类型安全与踩坑指南

发布时间:2026/10/27 16:18:13 来源:尧图企业网站定制
1. 从“t3code”这个名字说起它到底指什么第一次看到“t3code”这个词很多人会下意识地把它当成某个开源库、某个命令行工具或者某个内部项目的代号。我最初也是这么想的翻了半天仓库和文档结果发现它并不是一个能直接npm install或pip install就拿来用的东西。它更像是一个约定俗成的叫法指向的是一类“把 T3 相关技术栈和代码实践串起来”的工作方式。所谓 T3在当下的技术语境里通常指的是以 Next.js、TypeScript、tRPC、Prisma、Tailwind 这一套组合为核心的全栈开发范式。而“t3code”这个说法就是把这套范式落到具体代码组织、类型流转、接口定义上的那一层实践。我之所以愿意花时间聊这个话题是因为这套东西看起来“只是几个库拼在一起”但真正上手之后坑远比想象中多。类型在前后端之间怎么传、tRPC 的 router 怎么拆、Prisma 的 schema 改动如何安全地同步到客户端、Next.js 的 App Router 和 tRPC 的集成边界在哪里——这些问题在官方文档里往往一笔带过但在真实项目里每一个都能卡你半天。这篇文章就是把我自己踩过的坑、验证过的方案、以及那些“文档不会写但你必须知道”的细节完整地摊开来讲。它适合谁看如果你已经写过 React对 TypeScript 不陌生想从零搭一个类型安全的全栈项目或者你已经在用这套栈但总觉得“哪里不对劲”那这篇内容应该能帮你省下不少试错时间。如果你完全没接触过全栈也没关系我会尽量用生活化的类比把关键概念讲清楚保证你能跟上节奏。2. 为什么这套组合值得认真对待类型安全不是口号2.1 传统前后端分离的“类型断层”到底有多痛先说说没有这套东西之前我们是怎么干活的。前端写一个表单提交后端写一个接口接收两边各自定义一遍数据结构。前端定义了一个User类型后端也定义了一个User类型看起来一样但字段名可能差一个字母或者某个字段前端是string后端是number。这种问题在编译期完全发现不了只有跑到浏览器里、点下提交按钮、看到控制台报错的那一刻才会暴露。更麻烦的是接口变更。后端把userName改成username前端不知道上线之后直接白屏。你可能会说“我们有接口文档啊”但文档是死的代码是活的文档更新永远滞后于代码。这种“类型断层”带来的沟通成本和线上事故做过全栈的人应该都深有体会。2.2 tRPC 把类型“焊死”在调用链上tRPC 的核心思路其实很朴素既然前后端都是 TypeScript那为什么不让后端直接把自己的类型“暴露”给前端你在后端定义一个 procedure前端调用的时候参数类型和返回值类型自动推导出来不需要你手写任何 interface。这就像你和同事之间传文件以前是各自抄一份现在是直接共享同一个文件夹改一处两边都变。我实测下来最直观的感受是重构变得大胆了。以前改一个后端字段要全局搜索前端哪里用了它生怕漏掉。现在改完之后TypeScript 编译器会直接把所有受影响的地方标红你顺着报错一个个修就行。这种“编译器帮你兜底”的感觉用过就回不去了。2.3 Prisma 让数据库 schema 成为唯一事实来源Prisma 在这套组合里的角色是把数据库结构变成一份可读、可版本控制的 schema 文件。你不再需要手写 SQL 建表语句也不用担心数据库里的字段和代码里的类型对不上。改 schema、跑 migration、生成 client三步走完数据库和代码就同步了。这里有个细节值得展开Prisma 生成的类型是真正从数据库结构推导出来的不是手写的。也就是说如果你的数据库里某个字段是 nullable 的Prisma 生成的类型里就会体现出来你在写代码的时候就必须处理null的情况。这种“强制你面对现实”的机制虽然一开始会觉得麻烦但长期来看能避免大量空指针问题。2.4 三者叠加之后的化学反应单独看 Next.js、tRPC、Prisma每一个都是好工具。但把它们放在一起产生的效果是叠加的Prisma 定义数据模型tRPC 把数据操作暴露成类型安全的接口Next.js 在服务端和客户端之间无缝调用这些接口。整条链路上类型从数据库一路流到 UI 组件中间没有任何“手写类型”的环节。我画不出图也不打算画但你可以这样理解数据库是水源Prisma 是水管tRPC 是水龙头Next.js 是杯子。水从源头到杯子里全程封闭不会洒出来。这就是这套组合最核心的价值。3. 从零搭起一个 t3code 风格的项目骨架3.1 初始化项目时最容易忽略的三个配置很多人用create-t3-app一键生成项目然后就直接开始写业务代码了。但有几个配置如果一开始不弄好后面会非常难受。第一个是TypeScript 的strict模式。一键生成的项目默认是开启的但有些人觉得报错太多就关掉了。我的建议是千万别关。strict 模式帮你抓到的那些“可能为 undefined”的问题都是真实存在的隐患。关掉它等于把安全气囊拆了开车。第二个是路径别名path alias。默认的/指向src/目录但如果你项目结构复杂一点可能需要额外配置。这个在tsconfig.json里改完之后还要同步改next.config.js或者构建工具的配置否则运行时会找不到模块。我踩过一次本地开发正常构建就报错排查了半天才发现是别名没同步。第三个是环境变量的类型声明。.env文件里的变量在代码里是string | undefined每次用都要判断。更好的做法是写一个env.ts用 zod 做校验和类型收窄这样启动时就能发现缺失的变量而不是等到运行时才报错。// src/env.ts import { z } from zod; const envSchema z.object({ DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), }); export const env envSchema.parse(process.env);这段代码看起来简单但它能帮你避免“部署到服务器才发现少配了一个变量”的尴尬。3.2 目录结构怎么划分才不会被自己绕晕一键生成的项目默认把 tRPC 的 router 放在src/server/api/routers/下面每个业务模块一个文件。这个结构在小项目里没问题但业务一多就会乱。我的做法是按领域而不是按技术层次来分目录。举个例子如果你在做电商不要建controllers/、services/、models/这种目录而是建product/、order/、user/每个目录里放自己的 router、service、类型定义。这样你改一个功能的时候所有相关文件都在一个文件夹里不用在多个目录之间跳来跳去。具体到 tRPC 的 router 组织我习惯用一个root.ts把所有子 router 合并// src/server/api/root.ts import { createTRPCRouter } from ./trpc; import { productRouter } from ./routers/product; import { orderRouter } from ./routers/order; export const appRouter createTRPCRouter({ product: productRouter, order: orderRouter, }); export type AppRouter typeof appRouter;这个AppRouter类型就是前端能“看到”的全部接口定义。前端导入它之后调用api.product.getAll.useQuery()时参数和返回值类型全自动推导。3.3 Prisma schema 的初始设计别急着加字段新手最容易犯的错误是一开始就把所有能想到的字段都加到 schema 里。结果业务一变字段要改migration 一堆数据库里还残留着没用的列。我的经验是先建最小可用的模型跑通流程再逐步加字段。比如做用户系统先只加id、email、name三个字段把注册、登录、查询跑通。等你真正需要“用户头像”的时候再加avatar字段跑一次 migration。这样每次改动都是有针对性的不会出现“这个字段当初为什么加的”这种困惑。另外Prisma 的default和updatedAt要善用。createdAt和updatedAt几乎每个表都需要直接在 schema 里声明Prisma 会自动维护不用你手动写代码去更新。model User { id String id default(cuid()) email String unique name String? createdAt DateTime default(now()) updatedAt DateTime updatedAt }注意name后面的?表示这个字段可以为空。Prisma 生成的类型里name就是string | null你在代码里就必须处理 null 的情况。这就是前面说的“强制面对现实”。4. tRPC 的 router 设计与类型流转实战4.1 query 和 mutation 的边界怎么划tRPC 把接口分成两类query用于读数据mutation用于写数据。这个划分看起来简单但实际用的时候经常有人搞混。比如“搜索用户”是 query 还是 mutation有人觉得搜索带了参数、逻辑复杂就写成 mutation。这是不对的。判断标准很简单这个操作会不会改变服务器状态。搜索不会改变任何数据所以是 query。哪怕参数再多、逻辑再复杂只要不写数据库就是 query。反过来哪怕是“把用户状态从 A 改成 B”这种看起来很小的操作只要写了数据库就是 mutation。为什么这个边界重要因为 tRPC 对 query 和 mutation 的处理策略不同。query 可以被缓存、可以并发、可以在组件挂载时自动触发mutation 则是一次性的、需要手动触发的。如果你把搜索写成 mutation就享受不到缓存和自动重新获取的好处用户体验会差很多。4.2 输入校验zod 不只是“验证一下”tRPC 的 procedure 可以定义input通常用 zod 来做校验。很多人只把它当成“防止用户传错参数”的工具但其实它还有两个更重要的作用。第一它是类型推导的源头。你在input里定义了 zod schema前端调用的时候参数类型就是从这个 schema 推导出来的。如果你不定义 input前端传什么都不会报错类型安全就断了一半。第二它是文档。看一个 procedure 的 input schema就能知道这个接口需要什么参数、每个参数是什么类型、有没有可选字段。比看接口文档快多了。// src/server/api/routers/product.ts import { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; export const productRouter createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx, input }) { return ctx.db.product.findUnique({ where: { id: input.id }, }); }), create: publicProcedure .input( z.object({ name: z.string().min(1).max(100), price: z.number().positive(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.product.create({ data: { name: input.name, price: input.price, }, }); }), });注意z.string().cuid()这个校验它确保传入的 id 是合法的 cuid 格式。如果前端传了一个乱七八糟的字符串tRPC 会在进入业务逻辑之前就拒绝掉不会浪费数据库查询。4.3 中间件把重复逻辑抽出来每个项目都会有一些“每个接口都要做”的事情比如检查用户是否登录、记录请求日志、限流。tRPC 的中间件就是干这个的。我见过有人在每个 procedure 里手动写if (!ctx.session) throw new Error(未登录)重复几十遍。更好的做法是定义一个protectedProcedure在中间件里统一处理。// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; const t initTRPC.contextContext().create(); export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(async ({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user }, }, }); });这样定义之后所有需要登录的接口都用protectedProcedure不需要的用publicProcedure。中间件里还可以做更多事情比如给 ctx 注入当前用户对象这样业务代码里直接ctx.session.user就能拿到不用每次重新查。4.4 错误处理别让 TRPCError 变成“未知错误”tRPC 的错误处理有一套自己的机制。如果你直接throw new Error(xxx)前端收到的是一个通用的错误没有错误码也没有额外信息。更好的做法是用TRPCError指定错误码和消息。throw new TRPCError({ code: NOT_FOUND, message: 商品不存在, });前端可以根据code做不同的处理比如NOT_FOUND显示“找不到”UNAUTHORIZED跳转登录页。这样用户体验会好很多而不是所有错误都弹一个“操作失败”。我踩过的一个坑是在 Prisma 查询里如果找不到记录findUnique返回的是null不是抛错。如果你不判断直接返回null给前端前端可能以为请求成功了但数据是空的。正确的做法是判断一下如果为 null 就抛NOT_FOUND。5. Prisma 与 tRPC 的衔接数据从数据库到 UI 的完整链路5.1 在 ctx 里共享 Prisma client 的正确姿势tRPC 的ctx是每个请求的上下文Prisma client 通常放在这里。但这里有个细节开发环境下不要每次请求都 new 一个 PrismaClient否则热重载的时候会创建大量连接把数据库连接池撑爆。标准做法是用一个全局变量缓存// src/server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;然后在 tRPC 的 context 里引用这个db。这样无论热重载多少次都只有一个 PrismaClient 实例。5.2 查询结果里的关联数据怎么处理Prisma 的include和select是两个非常强大的工具但用不好会导致性能问题。include会把关联表的所有字段都查出来select则允许你精确指定要哪些字段。我的建议是默认用select只在确实需要全部字段时才用include。因为关联表可能有敏感字段比如用户的密码哈希用include会不小心把它们带到前端。const order await ctx.db.order.findUnique({ where: { id: input.id }, select: { id: true, total: true, items: { select: { product: { select: { name: true, price: true }, }, quantity: true, }, }, }, });这样查出来的数据结构非常清晰前端拿到的就是它需要的没有多余字段。5.3 事务什么时候需要怎么写才安全Prisma 的事务用$transaction来写。什么时候需要事务简单说多个写操作必须同时成功或同时失败的时候。比如创建订单要同时写订单表和扣减库存。如果订单写成功了但库存没扣数据就不一致了。这时候就要用事务await ctx.db.$transaction(async (tx) { const order await tx.order.create({ data: orderData }); await tx.product.update({ where: { id: productId }, data: { stock: { decrement: quantity } }, }); return order; });注意事务里的操作要用tx而不是ctx.db否则事务不会生效。这个坑我踩过排查了很久才发现是用了外层的 db 对象。另外事务里的操作要尽量短平快不要在事务里做网络请求或者复杂计算否则会长时间占用数据库连接影响其他请求。5.4 分页offset 还是 cursorPrisma 支持两种分页方式skip/takeoffset 分页和cursor游标分页。小数据量用 offset 没问题但数据量大了之后offset 分页会越来越慢因为数据库要扫描前面所有记录。游标分页的性能更稳定但实现起来稍微复杂一点。我的经验是如果列表数据超过几千条或者有实时性要求就用游标分页。const items await ctx.db.product.findMany({ take: 20, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, });前端拿到最后一条记录的 id下次请求时作为 cursor 传回来。这样每次查询都是常数时间复杂度不会随着数据量增长而变慢。6. 那些文档不会告诉你的踩坑记录6.1 类型推导失效的几种典型场景tRPC 的类型推导大部分时候很靠谱但有几个场景会失效。第一个是循环引用。如果 router A 引用了 router Brouter B 又引用了 router A类型推导会出问题。解决办法是把共享的类型抽到一个独立的文件里两边都从那里导入。第二个是动态 key。如果你用router[someVariable]这种方式动态访问 procedure类型推导会丢失。尽量用静态的写法。第三个是中间件里修改 ctx 类型。如果你在中间件里给 ctx 加了新字段但没有正确声明类型后续 procedure 里访问这个字段会报错。解决办法是在trpc.ts里定义好 Context 类型中间件返回的 ctx 要符合这个类型。6.2 Next.js App Router 下的 tRPC 集成陷阱App Router 和 Pages Router 的 tRPC 集成方式不一样。App Router 下tRPC 的 handler 要放在app/api/trpc/[trpc]/route.ts里而且要用新的fetchRequestHandler。我遇到的一个坑是Server Component 里不能直接用 tRPC 的 hooks。因为 hooks 是客户端的东西Server Component 里要用createCaller直接调用 router。// 在 Server Component 里 import { appRouter } from /server/api/root; import { createCallerFactory } from /server/api/trpc; const createCaller createCallerFactory(appRouter); const caller createCaller({ db, session: null }); const products await caller.product.getAll();这样虽然能跑但要注意createCaller不会走 HTTP 请求是直接函数调用所以中间件里的逻辑比如鉴权需要你自己在调用前处理好。6.3 构建时的类型检查为什么比开发时严格开发环境下 TypeScript 是增量检查的只检查改动的文件。但构建时是全量检查所有类型问题都会暴露出来。我遇到过好几次“本地开发正常构建失败”的情况都是因为某些类型问题在增量检查时被跳过了。解决办法是在提交代码前跑一次tsc --noEmit模拟构建时的全量检查。虽然慢一点但能提前发现问题比等到 CI 挂了再修要好。6.4 数据库迁移在生产环境的注意事项Prisma 的 migration 在开发环境很顺手但生产环境要小心。prisma migrate dev会检测 schema 变化并生成 migration 文件但它在生产环境不应该用。生产环境应该用prisma migrate deploy它只执行已有的 migration 文件不会尝试修改 schema。另外删除字段或修改字段类型的 migration 是不可逆的。执行之前一定要备份数据库。我见过有人直接在生产环境跑了一个删除字段的 migration结果数据丢了只能从备份恢复。还有一个细节如果 migration 执行失败Prisma 会留下一个“失败的 migration”记录后续的 migration 都会被阻塞。这时候需要手动去数据库里删除那条失败记录或者用prisma migrate resolve命令处理。7. 性能与体验的进阶优化思路7.1 请求瀑布流怎么避免tRPC 的 hooks 默认是并行请求的但如果你在组件里嵌套使用可能会形成瀑布流父组件请求完了子组件才开始请求。解决办法是用prefetch或者把请求提升到父组件。在 Next.js 的 Server Component 里可以用Promise.all并行发起多个请求const [products, categories] await Promise.all([ caller.product.getAll(), caller.category.getAll(), ]);这样两个请求同时发出总耗时取决于最慢的那个而不是两个相加。7.2 乐观更新让用户感觉“秒响应”tRPC 配合 React Query 可以做乐观更新。比如用户点赞不用等服务器返回直接先把 UI 改了如果请求失败再回滚。const utils api.useUtils(); const likeMutation api.post.like.useMutation({ onMutate: async (newLike) { await utils.post.getById.cancel(); const previous utils.post.getById.getData(); utils.post.getById.setData(undefined, (old) { if (!old) return old; return { ...old, likes: old.likes 1 }; }); return { previous }; }, onError: (err, newLike, context) { utils.post.getById.setData(undefined, context?.previous); }, onSettled: () { utils.post.getById.invalidate(); }, });这段代码看起来有点长但逻辑很清晰先取消正在进行的请求保存旧数据乐观地更新 UI如果出错就回滚最后无论成功失败都重新获取一次确保数据一致。7.3 缓存失效策略什么时候该 invalidateReact Query 的缓存默认是 5 分钟。但有些数据变化频繁5 分钟太长了有些数据几乎不变5 分钟又太短。我的做法是对每个 query 单独设置staleTime。比如用户列表可以设 30 秒商品分类可以设 10 分钟。mutation 成功之后只 invalidate 受影响的 query不要全部刷新。const createProduct api.product.create.useMutation({ onSuccess: () { utils.product.getAll.invalidate(); }, });这样创建商品之后只有商品列表会重新获取其他无关的缓存不受影响。7.4 打包体积别把 Prisma 带到客户端Prisma 的 client 是服务端的东西绝对不能出现在客户端 bundle 里。如果你不小心在客户端组件里导入了 Prisma 相关的代码打包体积会暴涨而且会报错。检查方法构建之后看.next/analyze或者用next/bundle-analyzer分析如果看到 Prisma 相关的包出现在客户端 chunk 里就说明有问题。解决办法是确保所有 Prisma 操作都在server/目录下客户端只通过 tRPC 调用。8. 我在这套栈上积累的几条实战心得第一条类型安全不是目的是手段。不要为了“类型完美”而写出过度复杂的类型体操。有时候一个简单的as断言比写一堆泛型更实际。类型是为了帮你发现错误不是为了炫技。第二条Prisma schema 的改动要小步快跑。每次只改一个字段跑一次 migration验证没问题再改下一个。一次性改一堆字段出了问题很难定位是哪个改动导致的。第三条tRPC 的 router 不要太大。一个 router 文件超过 300 行就该考虑拆分了。按业务领域拆不要按技术层次拆。第四条开发环境一定要开 Prisma 的 query log。这样你能看到每次请求实际执行的 SQL发现 N1 查询或者全表扫描。很多性能问题在开发阶段就能发现不用等到上线。第五条不要跳过测试。tRPC 的 procedure 是纯函数很容易写单元测试。用createCaller直接调用不需要启动 HTTP 服务。我习惯给每个 router 写几个关键路径的测试改代码的时候心里有底。最后再分享一个小技巧如果你在团队里推广这套栈不要一上来就讲“类型安全”“全栈”这些大词。直接演示一个场景后端改一个字段前端立刻报错然后顺着报错修完全程不用沟通。这种直观的体验比任何技术宣讲都有效。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑