资讯详情 t3code实践:Next.js + tRPC + Prisma构建全栈类型安全应用
📅 2026/10/26 8:51:28
说起t3code这个项目缘起不算光鲜——去年在一个老业务系统里前后端联调因为API类型对不上线上炸了三次。后端把created_at改成了createdAt前端漏改一处整个列表页静默降级用户看到的还是旧数据但埋点统计全错了。排查到凌晨两点组长撂下一句话下次谁再提REST联调先回去把类型安全搞清楚。那之后我开始认真调研类型安全优先的全栈方案最终落到了T3 Stack上并以此为基础搭了t3code这个验证工程。t3code不是一个正儿八经的产品它更像一份白鼠实验报告——用一套典型的CRUD业务用户、文章、评论、标签来验证Next.js tRPC Prisma Tailwind这套组合到底能不能扛住真实开发坑在哪里值不值得搬进团队。这篇文章就是我从零搭建t3code的完整记录包括每个组件的分工逻辑、关键代码细节、以及我踩过的几个让人抓狂的坑。1. t3code这个项目到底在验证什么1.1 被REST联调虐过之后我想要什么样的技术栈先说结论我想要的不是更好用的框架而是一套能强迫前后端在编码阶段就对齐契约的开发范式。传统REST axios的开发流程问题出在中间地带——后端返回什么结构前端拿到什么结构这两个事实之间隔着文档、网络、运行时转换。即使你用Swagger生成了类型定义一旦某个字段被标记为optional连锁反应就是前端所有用到该字段的地方都要做防御性判断久而久之类型退化成摆设整个项目回到了JavaScript不设防的原始状态。T3 Stack吸引我的地方在于它把类型安全的链条从数据库一路延伸到了组件props。数据库表结构定了Prisma生成类型Prisma类型定了tRPC的router输出自动推导tRPC的输出定了前端调用的返回值天然带类型。中间不需要手写一份接口定义不需要维护DTO映射更没有文档和代码不一致这回事。1.2 t3code的选型全景与技术边界t3code采用的标准T3技术栈每个成员角色如下组件承担的职责替代方案我选择它的核心理由Next.js 14App Router应用骨架、路由、服务端渲染Remix、Nuxt生态成熟API Routes与tRPC配合顺滑TypeScript全链路静态类型无类型安全的前提tRPCAPI层替代REST/GraphQLGraphQL、OpenAPI零代码生成类型自动推导PrismaORM与数据库迁移Drizzle、TypeORM类型生成完善schema即文档Tailwind CSS样式方案CSS Modules、Sass原子类在组件迭代期效率高这套组合有一个明确的取舍它适合以数据库为中心的B端应用、内部工具、个人全栈项目。如果你的场景是面向大量第三方开放API、或者需要长期维护一个公共接口版本tRPC的内部RPC属性反而是个限制。t3code的定位就是验证前者。2. T3 Stack四个核心组件的分工与配合逻辑2.1 Next.js不只是一个React框架而是整个应用骨架在t3code里Next.js的角色远超React框架这四个字。App Router的路由嵌套直接影响数据获取模式——比如用户详情页我可以在服务端组件里直接调用tRPC的server caller把数据在服务端取好再渲染前端组件拿到的已经是干净数据。这种模式最大的好处是服务端组件里没有网络请求不需要处理loading态数据在到达浏览器之前就是完整的。我用一个生活类比传统前端组件的useEffect请求数据相当于你每次做饭前都要现去菜市场买食材服务端组件里的server caller相当于前一天晚上已经按菜单备好菜了第二天直接下锅。后者少了一整条依赖网络往返的链路首屏体验和代码复杂度都显著改善。当然代价是对Next.js的渲染模型要有足够理解——哪些代码跑在客户端use client哪些跑在服务端边界画错就是水合hydration报错或者线上白屏。这点我在第5章的踩坑环节会详细展开。2.2 TypeScript与tRPC类型安全的双向奔赴tRPC的设计哲学很简单不要把类型安全停留在编译器能查到错这个层面而是把类型当作应用的主语言。传统的前后端联调是A方写代码、B方猜类型tRPC则相反——后端router定义好之后前端代码里import的api.xxx天然就携带返回类型编辑器里鼠标悬停就能看到完整的类型签名。t3code里我定义了一个用户查询router大概是这样的结构// t3code/src/server/api/routers/user.ts import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const userRouter createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) { return ctx.db.user.findUnique({ where: { id: input.id }, include: { posts: true }, }); }), });这段代码里有几个关键设计选择input用Zod做了运行时验证而不只是TypeScript的编译期验证——这保证了即使有调用方绕过前端直接打API非法参数也会被拦在业务逻辑之前返回类型完全由Prisma的查询结果推导我不需要手写interface UserResponse这种东西publicProcedure意味着无需登录态在t3code里还有protectedProcedure会自动校验session校验失败的请求直接抛错不会进入数据库查询环节。然后前端调用时类型长这样// t3code/src/app/users/[id]/page.tsx import { api } from ~/trpc/react; export default function UserPage({ params }: { params: { id: string } }) { const { data: user, isLoading } api.user.getById.useQuery({ id: params.id }); if (isLoading) return div加载中.../div; return div{user?.name}/div; }注意这里user的类型是后端findUnique返回的类型包含posts数组。如果哪天我在后端把posts改成了postCount编译阶段前端就会报错而不是上线后才发现。2.3 Tailwind CSS样式层的实用主义Tailwind在T3 Stack里经常被忽略但它对开发效率的影响其实很大。t3code里我维护了一套自定义的design token颜色、圆角、阴影在tailwind.config.ts里扩展了主题// tailwind.config.ts import type { Config } from tailwindcss; export default { content: [./src/**/*.{ts,tsx}], theme: { extend: { colors: { brand: { 500: #6366f1, 600: #4f46e5, }, }, }, }, plugins: [], } satisfies Config;一个经常被忽视的点Tailwind tRPC的组合让我在做服务端渲染时不需要额外处理样式闪烁问题——因为Tailwind样式是静态CSS不依赖客户端运行时计算水合前后不会出现样式错位的现象。这在传统CSS-in-JS方案里是要花时间处理的。3. 从零搭建t3code完整实操链路与代码细节3.1 初始化项目与目录结构设计T3 Stack提供了官方脚手架create-t3-app这一步虽然简单但有几个选项需要想清楚npm create t3-applatest在交互式问答里我勾选了Next.jsApp Router、TypeScript、tRPC、Prisma、Tailwind、Zod。没有勾选NextAuth.js——因为t3code暂时用不上登录体系每次加一个依赖都会增加认知负担初期能减则减。生成后的目录结构关键路径如下t3code/ ├── prisma/ │ └── schema.prisma # 数据库模型定义 ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts # tRPC HTTP 端点 │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── users/[id]/page.tsx │ │ └── posts/ │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts # 合并所有子router │ │ │ ├── trpc.ts # tRPC实例初始化 │ │ │ └── routers/ │ │ │ ├── post.ts │ │ │ ├── user.ts │ │ │ └── comment.ts │ │ └── db.ts # Prisma Client 单例 │ ├── trpc/ │ │ ├── react.tsx # 前端 tRPC hooks │ │ └── server.ts # 服务端 caller 封装 │ └── styles/这里有一个关键实践Prisma Client的实例只初始化一次存到globalThis上避免在开发环境热更新时创建过多的数据库连接。这是create-t3-app默认模板里就有的设计我一开始没意识到为什么直到有一天我在自定义文件里直接new PrismaClient()然后发现开发环境下数据库连接数爆炸。原因是Next.js在dev模式下会频繁重新加载模块每次加载都new一个Client连接数自然堆积。正确做法是复用它// src/server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }3.2 数据库建模与Prisma schema设计t3code的业务模型有四个User、Post、Comment、Tag。多对多关系Post与Tag用中间表解决一对多关系User与Post直接在子表加外键。// prisma/schema.prisma generator client { provider prisma-client-js } datasource db { provider sqlite // 开发期用SQLite生产换PostgreSQL url env(DATABASE_URL) } model User { id String id default(cuid()) email String unique name String? posts Post[] comments Comment[] createdAt DateTime default(now()) } model Post { id String id default(cuid()) title String content String authorId String author User relation(fields: [authorId], references: [id]) comments Comment[] tags TagOnPost[] published Boolean default(false) createdAt DateTime default(now()) } model Comment { id String id default(cuid()) body String postId String post Post relation(fields: [postId], references: [id]) authorId String author User relation(fields: [authorId], references: [id]) createdAt DateTime default(now()) } model Tag { id String id default(cuid()) name String unique posts TagOnPost[] } model TagOnPost { postId String tagId String post Post relation(fields: [postId], references: [id], onDelete: Cascade) tag Tag relation(fields: [tagId], references: [id], onDelete: Cascade) id([postId, tagId]) }我特意加了onDelete: Cascade这样删除Post会自动清理关联的TagOnPost记录避免手工处理孤儿数据。这个决定来自一次教训初期我没配Cascade测试时删了一篇文章结果关联表里留下了一堆tagId指向不存在的postId列表接口join的时候直接返回异常数据。3.3 tRPC Router的定义与调用链tRPC的设计里router是接口定义 实现的合体。t3code的root.ts是这样组织子router的// src/server/api/root.ts import { userRouter } from ./routers/user; import { postRouter } from ./routers/post; import { commentRouter } from ./routers/comment; import { createTRPCRouter } from ~/server/api/trpc; export const appRouter createTRPCRouter({ user: userRouter, post: postRouter, comment: commentRouter, }); export type AppRouter typeof appRouter;核心的trpc.ts里有一个初始化实例的过程这里面包含了一个我非常看重的点——上下文context// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import superjson from superjson; import { ZodError } from zod; import { db } from ~/server/db; export const createTRPCContext async (opts: { headers: Headers }) { return { db, headers: opts.headers, }; }; const t initTRPC.contexttypeof createTRPCContext().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;这个createTRPCContext是一个函数式依赖注入的入口。比如以后要加登录态我只需要在context里加上session字段然后定义protectedProcedure publicProcedure.use(({ ctx, next }) { if (!ctx.session) throw new TRPCError({ code: UNAUTHORIZED }); return next({ ctx: { ...ctx, session: ctx.session } }); })所有需要登录的接口就自动统一鉴权了。这个模式比在每个query里手动判断登录状态要干净得多也是tRPC的杀手锏之一。3.4 前端页面与API的无缝对接App Router下我有一个混合模式首屏数据用服务端组件直接调server caller用户交互点赞、新增评论用客户端组件的hooks。服务端调用封装在trpc/server.ts里// src/trpc/server.ts import { createTRPCContext } from ~/server/api/trpc; import { appRouter } from ~/server/api/root; import { createCallerFactory } from trpc/server; const createCaller createCallerFactory(appRouter); export async function getServerCaller() { const ctx await createTRPCContext({ headers: new Headers() }); return createCaller(ctx); }然后在页面里// src/app/page.tsx import { getServerCaller } from ~/trpc/server; export default async function HomePage() { const caller await getServerCaller(); const recentPosts await caller.post.listRecent({ limit: 10 }); return ( main {recentPosts.map((post) ( ArticleCard key{post.id} post{post} / ))} /main ); }上面这段代码没有use client标记是纯服务端组件。recentPosts的类型由post.listRecent的返回类型推导而来ArticleCard组件的props类型直接跟它对齐。如果我改了查询字段编译期就能发现所有不匹配的地方。这种写法还有个额外的好处因为服务端组件里没有fetchNext.js不会把它标记为动态渲染如果页面内容是静态可预取的构建时会自动生成静态HTML性能和开发体验两头都占了。4. 类型安全链路从数据库到UI的端到端体验4.1 类型是如何穿越边界的很多第一次接触tRPC的人会疑惑前后端代码都打包在同一个Next.js应用里为什么类型能跨进程传递答案是——tRPC的类型传递发生在编译期而非运行时。在t3code里前后端共享一个AppRouter类型通过import type { AppRouter } from ~/server/api/root前端创建的api对象是基于这个类型的泛型实例。也就是说前端拿到的api.post.create这个函数它的参数类型和返回类型在编辑器里就已经被推断为后端路由的实现类型了。运行时tRPC通过HTTP传输JSON数据superjson负责序列化特殊类型比如Date、Map、BigInt保证createdAt这类字段在被Prisma从数据库读出来后、经过序列化、到达前端依然能被还原成Date对象而不是字符串。我一开始以为superjson是个可有可无的配置直到删掉它跑了一次测试——所有Date字段全都变成了ISO字符串前端new Date(post.createdAt)直接编译报错这才明白transformer的作用。T3模板默认为你考虑到了这个边界不要轻易去掉。4.2 实际开发中的类型推导体验一次真实改造为了验证类型安全到底好不好用我在t3code里做了一次危险操作——把Post列表接口的返回结构从包含完整author对象改成只包含authorId和authorName两个字段。改动后端// routers/post.ts listRecent: publicProcedure .input(z.object({ limit: z.number().optional() })) .query(async ({ ctx, input }) { const posts await ctx.db.post.findMany({ take: input.limit ?? 10, include: { author: { select: { id: true, name: true } }, }, }); return posts.map((post) ({ id: post.id, title: post.title, author: { authorId: post.author.id, authorName: post.author.name, }, createdAt: post.createdAt, })); }),然后我打开前端对应的组件// components/ArticleCard.tsx type ArticleCardProps { post: { id: string; title: string; author: { authorId: string; authorName: string | null }; createdAt: Date; }; };由于结构变了TypeScript编译器立刻在ArticleCard.tsx里标红了两处——原来引用post.author.email的地方全部报错。整个修复过程就是跟着错误提示走不需要翻接口文档不需要打开浏览器的Network面板更不用担心改了这里漏了那边。这种体验让我意识到类型安全不是写代码时的束缚而是改代码时的安全带。它在多数时候不干预你但在你做出破坏性改动时它把你从静默上线的边缘拉回来。4.3 类型安全的边界哪些场景它覆盖不到我也想说清楚tRPC的边界它不是一个银弹。在t3code里我发现了三个它覆盖不到的盲区第一数据库层本身的约束。Prisma生成的类型不会告诉你这个字段在数据库里有没有唯一索引也不会阻止你插入一条重复的email。这个要靠schema里的unique注解和数据库自身的错误处理。第二运行时环境变量的存在性。tRPC类型推导不了环境变量是否拼写正确。create-t3-app内置了一个env.mjs校验方案在启动时用Zod校验所有process.env如果缺了DATABASE_URL会在启动阶段直接抛错而不是等到第一次查库才报错。这个设计我倒觉得每个Next.js项目都该学。第三跨应用边界。tRPC类型的传递前提是前后端在同一个npm仓库里。一旦t3code被拆成独立的前端仓库和后端服务tRPC的零成本类型传递就失效了那时候需要引入tRPC-Client包或者回到OpenAPI方案。所以T3 Stack天然适合monorepo或单仓库全栈不适合分布式微服务架构。5. 踩坑实录几个让我折腾大半天的真实问题5.1 tRPC的Context类型谜团为什么我的protectedProcedure拿不到session这是t3code开发早期最让我抓狂的问题。我在context里加了session字段然后定义了一个protectedProcedure结果业务代码里ctx.session永远是undefined。排查链路是这样的一开始我怀疑是session赋值逻辑的问题打印后发现createTRPCContext里session明明存在。然后我怀疑是protectedProcedure的泛型推断把ctx类型打出来看发现它的类型定义里根本没有session这个字段。最终定位到原因——我在initTRPC.contextTContext()里的泛型传错了。T3创建的项目里context类型是通过typeof createTRPCContext推断的但如果你像我一样在另一个文件里重新定义了TContext接口并手动传入一旦接口字段没对应上运行时仍然会透传真实的context对象但编辑器里的类型提示就丢了。这个坑的核心教训是tRPC的context类型最好始终保持单一来源Single Source of Truth不要手动复制接口定义。正确做法是直接引用typeof createTRPCContext作为泛型参数。5.2 服务端组件里拿不到headerscreateServerCaller的上下文陷阱在App Router里服务端组件是可以随便读取headers()的但我在getServerCaller()里调用createTRPCContext({ headers: new Headers() })时传的是一个空Headers。这会导致什么问题如果某个router逻辑里需要读取x-forwarded-for之类的请求头来做日志或限流拿到的永远是空值。我当时写了一个获取客户端IP的中间件本地测试怎么都对部署到线上后所有IP都变成了::1——因为服务端组件在渲染时确实不在一个传统请求上下文里它需要显式传入headers。正确做法是使用Next.js的headers()函数// src/trpc/server.ts import { headers } from next/headers; export async function getServerCaller() { const ctx await createTRPCContext({ headers: await headers() }); return createCaller(ctx); }这才让header传递恢复正常。这个坑特别隐蔽因为它不会报错只是数据缺失而且只在真机部署后才暴露。5.3 Next.js缓存与tRPC query的过期数据问题App Router默认对静态渲染的页面做缓存这会引发一个诡异现象t3code里我发布了一条新Post首页列表在刷新后依然看不到它。原因分两层跨页面跳转时Next.js的Router Cache会缓存之前的RSC payload导致短时间内的导航不会触发重新渲染其次我一度把首页标记为force-static整个页面在构建时就固定了HTML。解决方案有两个层面一是页面级别的动态渲染在page.tsx里加export const dynamic force-dynamic让每此请求都实时读取数据库二是路由层的缓存刷新用Next.js的revalidatePath(/)在创建Post成功后主动失效缓存// actions/createPost.ts服务端action use server; import { revalidatePath } from next/cache; export async function createPost(input: { title: string; content: string }) { // 数据库写入逻辑... revalidatePath(/); }如果想保留静态渲染性能也可以改用ISR设置revalidate 60让页面每分钟自动重新生成一次。到底用哪种方案要看你业务对数据新鲜度的要求。t3code因为是内容型应用我选择了force-dynamic保证实时性如果换成博客首页这种可以接受短时间延迟的场景ISR是更好的取舍。5.4 Prisma在Serverless环境的连接数陷阱我在初期把t3code的数据库从SQLite切到PostgreSQL因为部署环境是Serverless结果很快就遇到了连接池耗尽的问题。Serverless环境每个请求都是独立实例如果没有连接池代理每个实例都建立新连接数据库很快扛不住。一个可行的方案是使用Prisma Accelerate或者在本地方案中用pg-bouncer。但本地开发时我选择了一个更简单的路径——让Prisma Client复用同一个连接并且设置合理的连接池上限datasource db { provider postgresql url env(DATABASE_URL) directUrl env(DIRECT_DATABASE_URL) }配合src/server/db.ts里的global单例模式至少保证本地开发不会频繁建连。这个问题的完整解法需要结合部署平台来选择——如果你是Vercel建议直接用Prisma官方文档推荐的连接池配置如果你是自己用Node服务跑传统的长连接方案就够。6. 关于t3code后续演进与我的个人体会项目上线的第一周我让团队里一位习惯写JavaScript而不太熟悉TypeScript的同事基于t3code的模板做一个简单的内部报表页面。他用了大概半天上手最开始的反馈是这些类型报错太烦了第二天变成了我改代码不怕了编译器帮我兜底。这个反馈基本印证了我最初选型T3 Stack的判断。类型安全最大的价值不在你写新代码时省了多少事而在于你改旧代码时敢不敢动手——老项目里最容易滋生不动就不会错的消极心态因为没人能保证改了A不会炸B。t3code这套链路把这种不确定性降低了改字段名编译器替你检查所有调用方改参数结构编辑器里直接飘红删掉一个筛选条件类型系统告诉你哪里还在依赖它。后续我计划往t3code里加两个东西一个是接入NextAuth.js把protectedProcedure真正用起来验证带登录态的完整流程另一个是把测试补上用vitestmsw对tRPC router做单元测试看看类型安全的体系里测试该以什么方式组织。最后一个我个人强烈建议任何想用T3 Stack的人提前做的事先花半天时间读一遍create-t3-app生成的全部模板代码特别是trpc.ts、db.ts、env.mjs这三个文件。它们看起来不起眼但里面全是老手踩过坑之后的结晶——比如globalPrisma单例、Zod环境变量校验、errorFormatter统一处理Zod错误。能理解这些设计动机你在自己项目里就不容易走偏。t3code这个项目本身代码不算多但把这套设计逻辑吃透比多写一万行业务代码都值。