☰
T3 Stack全栈实践:用Next.js、tRPC、Prisma打造类型安全的应用
2026/10/28 15:58:34 网站建设 项目流程

t3code是我最近折腾的一个全栈项目。说白了一点不神秘——它是一个使用T3 Stack(Next.js + tRPC + Prisma + Tailwind CSS)搭建的社区问答类应用,所有核心数据操作从前端到数据库全程类型安全,几乎不存在传统全栈项目里"前端联调靠猜、接口文档靠人"的尴尬。这篇博文我就拿t3code作为实际案例,把从选型、初始化、数据建模、tRPC链路设计到生产部署的整个流程完整拆开讲,包括我踩进去又爬出来的几个坑。

如果你是那种对JavaScript后端一直不太安心、又不想在前后端之间维护一大堆手写类型和接口文档的人,这篇文章应该会对你有用。即便你还没有接触过T3这套组合,跟着走一遍也能明白它为什么被这么多人推荐。文章里所有代码片段都来自t3code的真实实现,我尽量把关键路径上的那段直接贴出来,而不是贴个Hello World应付。

1. t3code的起点:我为什么要做一个"类型安全优先"的全栈项目

先说我做t3code前的背景。过去几年我做过的项目大多是前后端分离:React写前端,Node.js或者别的什么写后端,中间靠一套手写的REST API通信。起初项目小、接口少,这种模式没什么问题。但一旦业务复杂起来,痛点特别明显——后端改了某个字段,前端那个对象还停在三天前的形态;后端结构调整了,前端的类型定义来不及同步,跑起来才发现渲染直接崩。遇到这种问题,日常处理方式就是打开Network面板把返回值拷出来,对着手写一个interface,再祈祷后端别改。

t3code正是对这种状态的一次反击。我想要的很简单:一个数据模型改动,能被编译器直接带到前端的每一个使用点,让我不用翻接口文档,不用维护两份类型定义。T3 Stack的口号就是"typesafe everything",它把数据库Schema、后端API、前端数据消费这三层用一套类型系统串起来。我实际体验下来,确实能做到"数据库字段改个名,前端编译立刻报错",这种感觉一旦习惯了就很难回去。

还有社区问答这个业务方向的选择。这种场景天然适合演示类型安全的收益——用户、问题、回答、点赞、收藏,实体之间有明确的关系,查询条件复杂,联表数据要是靠手写接口文档,维护成本会快速膨胀。如果换成tRPC这种方式,所有查询参数和返回值都被类型约束,编译器会帮我把关,跑偏的概率低很多。所以t3code虽然是一个社区问答应用,但它的核心并不是"做出一个能用的问答网站",而是"用一套类型闭环的架构把这种业务干净地实现出来"。

2. 技术栈逐项拆解:Next.js、tRPC、Prisma、Tailwind各自在t3code里干哪些活

很多第一次听说T3 Stack的人会问,为什么偏偏是这四个组合。我在t3code里的实际使用体验可以挨个说清楚。

2.1 Next.js App Router:路由和API在一棵树里

t3code用的是Next.js 14的App Router模式。在这个模式下,页面、布局、服务端组件、客户端组件都通过文件系统路由组织,对业务开发来说非常直观——app/page.tsx就是首页,app/questions/[id]/page.tsx就是问题详情页,路由参数可以通过props直接拿到。

App Router里最值钱的是Server Component和Server Action。比如t3code的首页要展示问题列表,我可以直接在服务端组件里调用tRPC的server端方法拿数据,然后渲染成HTML返回给浏览器。这省掉了传统前后端分离里"先发HTML再发Ajax请求渲染数据"的来回流程。浏览器拿到的就已经是带数据的页面,对SEO和首屏体验都友好。

不过这里有个容易踩的坑:App Router默认组件都是Server Component,如果某个组件里用了事件处理或者useState,就得在文件顶部显式声明"use client"。t3code早期我就吃过这个亏——在一个服务端组件里直接写了tRPC的useQuery hook,编译能过,但运行时会报错提示hook只能在Client Component里调用。这个问题熟悉了以后不是大事,但新手遇到会比较懵。

2.2 tRPC:让API调用变成"函数调用"的关键

tRPC在整个t3code里是最核心的一环。它的核心思想是把后端路由定义成一个个procedure(可以理解为RPC函数),前端通过类型化的client直接调用,就像一个本地函数一样。你不需要写URL、不需要定义请求体结构、不需要处理响应包装,所有事情都被一个强类型封装盖住了。

在t3code里,用户获取问题列表的代码长这样:

// server端定义 export const questionRouter = router({ list: protectedProcedure .input(z.object({ tag: z.string().optional() })) .query(async ({ ctx, input }) => { return ctx.db.question.findMany({ where: input.tag ? { tags: { some: { name: input.tag } } } : {}, include: { author: true, _count: { select: { answers: true } } }, orderBy: { createdAt: 'desc' }, take: 20, }); }), }); // client端调用 const questions = trpc.question.list.useQuery({ tag: currentTag });

注意前端调用的时候,useQuery会自动拿到返回值的类型,我不需要额外定义QuestionListItem之类的interface。数据库模型里如果加了字段、改了关系,前端这里的类型同步变化,任何失误都会被编译期拦截。

这里我特别想说一个使用细节:tRPC天然和TanStack Query绑定,所以useQuery返回的对象自带data、isLoading、refetch这些状态。你把查询当成函数调用,但底层有完整的缓存、重试、窗口聚焦重新请求等行为。在t3code里,提交一个新回答之后,我可以让问题详情的问题列表失效并重新拉取,代码就是一行utils.question.detail.invalidate(),几乎不需要手动管理状态同步。

2.3 Prisma:数据库层的"类型源头"

t3code的数据层用Prisma,它在整个类型安全链路里承担"源头"角色。Prisma的schema文件定义了所有表和关系,然后通过代码生成产出TypeScript类型。也就是说,tRPC返回的字段类型、前端呈现的数据结构,最终都Directly由这个schema推断而来。

以一个简单的提问功能为例,schema里这样定义:

model User { id String @id @default(cuid()) name String? email String @unique questions Question[] answers Answer[] likes Like[] } model Question { id String @id @default(cuid()) title String content String createdAt DateTime @default(now()) author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] answers Answer[] }

改完schema,执行npx prisma migrate dev,数据库迁移和类型生成一起完成。这相比传统ORM有一个明显优势:你不大可能写出"代码里的字段类型是string,数据库里却是int"这种不一致,因为数据库迁移本身的DDL就是从同一个schema生成的。如果字段改了,迁移脚本和TypeScript类型同步更新,我在t3code里没有遇到过手改数据库表结构后还要回头改一堆代码的情况。

2.4 Tailwind CSS:不是主角,但能少写很多贴样式的时间

Tailwind在t3code里算是"配角",但它让我写界面速度肉眼可见地提升。社区问答的界面并不复杂——列表、卡片、表单、按钮,Tailwind的原子类让我不用维护一堆CSS文件,也不用为某个class命名纠结。比如问题列表卡片我直接写className="rounded-lg border border-gray-200 p-4 hover:shadow-md",效果直观且不容易影响全局样式。

有一点要注意:Tailwind的类名是在构建时扫描源码生成的,如果你把一些类名动态拼出来(比如text-${color}),最终样式不会生效。t3code里做标签着色时我踩过一次,老老实实用完整类名映射表解决。这也是用Tailwind的人迟早会遇到的经典问题,记一下能省不少排查时间。

3. 搭建t3code的完整过程:初始化、数据建模、目录设计

如果你是第一次跑T3项目,我建议直接用官方脚手架,不要在配置上浪费太多时间,t3code当初就是用create-t3-app初始化的。

3.1 初始化与环境检查

安装命令很简单:

pnpm create t3-app@latest t3code

过程中会问你选哪些模块,我勾选的是Next.js、tRPC、Prisma、Tailwind、Auth.js。官方还支持Drizzle和别的数据库选项,但用Prisma对新手更友好,这在我的场景里足够了。

初始化完成后,项目里已经提前配好了tsconfig路径别名、prisma schema、tRPC的基础路由结构。打开src/server/api/root.ts,能看到两个router:postRouter(示例用)和createTRPCRouter的根router,还有src/trpc/server.ts、src/trpc/react.tsx这些基础设施文件。你不需要手搭tRPC的Provider和server端创建逻辑,脚手架已经处理完了。

有个环境问题值得提醒:t3code我是用pnpm跑的,如果你机器上node版本比较旧,比如低于18,安装时可能会遇到依赖报错。T3这套东西基本要求Node 18以上,最好直接用最新的LTS版本。如果之前装过旧版node,建议先升级再继续。

3.2 数据模型设计:从业务实体到Prisma schema

社区问答的核心实体是用户、问题、回答、标签、点赞。我先把关系摸清楚:

  • 一个用户可以有多个问题,也可以有多个回答
  • 一个问题可以有多个标签,一个标签也可以挂在多个问题上,多对多
  • 用户可以对问题或者回答点赞,为避免重复点赞,这里用复合唯一约束

于是Prisma schema比刚才的例子稍微丰富了一点,加上了Answer和Tag模型,以及Like表的复合唯一约束:

model Like { id String @id @default(cuid()) userId String questionId String? answerId String? createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) question Question? @relation(fields: [questionId], references: [id]) answer Answer? @relation(fields: [answerId], references: [id]) @@unique([userId, questionId]) @@unique([userId, answerId]) }

这里有个设计细节我特别想讲:@@unique([userId, questionId])这类复合唯一约束,是保证按钮连点两次不会生成两条点赞记录的最简单方案,数据库层面就把重复卡死了,不用在业务代码里先查一次再插入。

其实做这个建模时我也犹豫过要不要把Like单独拆一张表。拆表的好处是统计点赞数方便,坏处是查询多一次关联。t3code里我最终选择拆表,因为社区问答应用里"某个用户是否赞过这个问题"这个查询出现得太频繁了。一张独立的Like表配合复合索引,可以在问题详情页一次查出当前用户点赞的所有目标ID,体验上更顺。

3.3 目录与路由组织

前后端同仓时,目录划分直接影响你找代码的速度。t3code的目录大致是:

  • src/server/api/routers/:放业务router,比如question.ts、answer.ts、user.ts
  • src/server/db.ts:导出Prisma client单例
  • src/trpc/:tRPC server端和client端的初始化代码
  • src/app/:Next.js的页面路由,里面按功能分目录
  • src/components/:通用UI组件

业务路由和页面路由一一对应但不是强制一致。比如/questions/[id]这个页面里会调用question.byId这个procedure,它们各自放在自己的目录里,但关系通过命名能一眼看出来。项目大了以后这个结构不会乱,算是我比较满意的部分。

4. tRPC核心链路设计:查询、变更、权限控制和缓存失效

tRPC是t3code的神经中枢,这一节是整篇文章的重点,完整走过一条tRPC链路你会非常清楚这套东西为什么舒服。

4.1 从定义router到前端消费,一条完整链路

以"回答问题"这个操作为例。先定义answer router:

// src/server/api/routers/answer.ts export const answerRouter = router({ create: protectedProcedure .input( z.object({ questionId: z.string().min(1), content: z.string().min(10, "内容太短,至少10个字"), }) ) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; const answer = await ctx.db.answer.create({ data: { content: input.content, questionId: input.questionId, authorId: userId, }, }); return answer; }), });

protectedProcedure是t3code里我从脚手架继承来的中间件封装,作用是检查session里有没有用户,没有就抛UNAUTHORIZED错误。这块使用了Auth.js(NextAuth)的session,所以每个请求的上下文里都能直接拿到当前用户。

前端调用时:

// 在client component里 const utils = trpc.useUtils(); const createAnswer = trpc.answer.create.useMutation({ onSuccess: () => { utils.question.byId.invalidate(); }, }); async function handleSubmit(content: string) { await createAnswer.mutateAsync({ questionId, content }); }

我提交答案之后主动让问题详情重新拉取,新的回答马上出现在列表里。因为TanStack Query的缓存和失效机制是集成好的,这一步效率很高,不会出现页面要手动刷新才显示新内容的情况。

4.2 查询参数使用zod校验

tRPC对输入参数的校验用的是zod。这不是可选项而是强烈建议——只要你的procedure接收外部输入,就一定会有脏数据或者恶意请求。tRPC官方默认也要求你不裸用procedure,而是最少用publicProcedure或protectedProcedure,这两个实际上都会跑输入校验中间件。

我在t3code里用zod做了两类校验:一类是格式校验,比如内容长度、ID是否为空;另一类是权限语义校验,在mutation里验证"要删除的回答是不是当前用户创建的":

delete: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const answer = await ctx.db.answer.findUnique({ where: { id: input.id } }); if (!answer || answer.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "无权删除" }); } await ctx.db.answer.delete({ where: { id: input.id } }); return { success: true }; }),

这里有个容易忽略的细节:校验输入和校验权限是两个层次。很多人的第一版代码只做输入校验,权限判断丢在页面上——页面隐藏按钮不等于接口安全。t3code里所有写操作的服务端都判断了当前用户身份,前端按钮隐藏只是体验优化,真正的安全性在router层。

4.3 中间件设计:登录态、错误处理、日志

t3code的中间件继承自T3脚手架,protectedProcedure的核心逻辑建立在getServerSession上。如果我要做更细的权限控制,比如管理员才能删除问题,可以在中间件里扩展:

const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.session.user.role !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } return next({ ctx }); });

这比在每一个handler里重复判断角色清爽得多。错误处理我也在中间件层面做了统一封装,tRPC默认会把错误信息返回到前端,但生产环境下有些内部错误信息不该暴露给用户。我在server端加了一层错误拦截,把未捕获的异常转成INTERNAL_SERVER_ERROR,前端再统一用Toast提示"操作失败,稍后重试"。这种全局兜底虽然不复杂,但能让联调体验提升不少。

4.4 React Query缓存与失效策略

前端数据同步是社区问答应用最容易乱的部分。比如用户点了个赞,问题的点赞数要+1,同时按钮要变成已赞状态;如果这个页面其他地方也在展示同一问题的点赞数,全部要保持一致。

t3code的方案是靠TanStack Query的缓存key。tRPC的每个查询会自动生成一个稳定的queryKey,比如question.byId的key就是[["question","byId"],{"input":{...}}]。赞完之后,我执行:

utils.question.byId.invalidate({ input: { id: questionId } });

这条语句会把所有byId且参数匹配的缓存全部标记为过期,并在后台重新请求。页面上所有依赖这个查询的组件会自动更新。相比手动改store里的状态,这种方式代码量少,而且不会出现"这里改了那里没改"的同步遗漏。

5. 生产部署:数据库迁移、环境变量和Serverless注意事项

t3code从"本地能跑"到"线上可用",中间有不少内容不是脚手架能帮你搞定的。

5.1 数据库迁移:本地和生产不要混用命令

开发时我用prisma migrate dev生成迁移并同步本地数据库,但生产环境绝对不能用这个命令,因为它可能有交互式确认和环境敏感操作。正确做法是先把迁移文件提交到代码仓库,生产上用prisma migrate deploy来执行迁移。

t3code的CI流程里我加了一步:每次main分支合并后,自动跑一遍prisma migrate deploy,确保线上schema和代码是同步的。这个步骤非常值得做,否则你部署了新代码但数据库没有新表,一上线接口全挂。

5.2 Serverless部署的注意点:连接数、函数冷启动

t3code部署在Vercel上,Serverless环境有几个坑必须要说。

第一个是数据库连接数。传统服务器常驻进程只需要一个Prisma连接池,但Serverless函数是按请求冷启动的,每个实例都可能新建连接。我用的Postgres数据库连接数上限几十个,一旦流量上来,连接数瞬间被打满,你会看到一堆"Too many connections"报错。

解决办法是连接池。t3code用的是Neon Postgres,它自带Serverless连接池,能够把很多函数实例的连接复用到一个pgbouncer层。如果你不用Neon或Supabase这类Serverless友好数据库,建议至少套一层连接池,而不是直接把Prisma连到数据库实例上。

第二个是冷启动。Serverless函数的冷启动时间直接影响接口延迟。t3code主要接口的响应时间在冷启动时会多出几百毫秒甚至一秒,但考虑到社区问答应用不是高并发低延迟场景,这个代价可以接受。如果后续要做实时性强的功能,应该考虑边缘函数或者常驻Node服务。

第三个是环境变量。Vercel面板里配置的DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL必须和生产环境一致。本地开发时我习惯在.env里写一套,部署前检查一下别把本地库地址传到线上,否则数据会写到你本地数据库,线上什么都没有。早年见过不少项目毁在这上面。

5.3 自定义域名和HTTPS

t3code配置了自定义域名,这一步很简单:Vercel项目设置里添加域名,按提示配置DNS记录,等证书自动签发即可。需要注意的只有一点——如果你用了Auth.js,环境变量里的NEXTAUTH_URL必须改成线上完整地址,比如https://t3code.example.com,否则OAuth登录回调会被判定为无效地址,用户点了Google登录却跳不回来。这个坑非常经典,我每次部署新环境都会先检查这个变量。

6. 我在t3code上踩过的坑:排查过程和最终解法

这一节放三个印象最深的坑,每个都花了不止一晚上才解决。记录一下排查思路,没准你能少走一次弯路。

6.1 useMutation类型报错:input少了一个字段

现象:trpc.answer.create.useMutation的mutate调用传参时,TypeScript报错说参数缺少questionId。

排查过程:我先确认了router端zod schema确实要求questionId和content两个字段,没问题。然后看调用处,我传的是一个对象,里面明明有questionId和content。后来仔细看类型定义才发现,我在组件里把变量名写成了questionID,多打了一个D。TypeScript只认schema里定义的questionId,大小写不同就报错了。

这个坑不复杂,但它体现了一个常态:类型安全环境下,报错信息越细,说明类型定义越严格。解决方式就是把字段名严格对应schema,一秒钟就能改完。这个报错如果发生在传统项目里,可能要等运行时才能发现,而类型系统在编译阶段就把你拦住了。

6.2 中间件里的session在Server Component里读不到

现象:t3code的首页要显示"当前用户是否已登录",我在Server Component里写了getServerSession(authOptions),返回的却是null,但同一个账号在浏览器里分明是登录状态。

排查过程:最初怀疑是Cookie没传对,但检查后确认浏览器带上了session cookie。后来翻tRPC的server端代码才发现,脚手架初始化的server端调用依赖的是一个单独创建的createTRPCContext,它内部会自己调用getServerSession。我在页面组件里直接调用的getServerSession没有传入正确的cookies()或者headers(),导致上下文没拿到有效的session请求内容。

最终解法:不要在服务端组件里手动去拿session,而是通过tRPC暴露一个session查询,或者直接使用auth()这种App Router自带的方法。t3code里我用了一个封装好的getServerAuthSession函数,在需要服务端判断登录态的地方统一调用它。这个坑的教训是:在App Router里,鉴权上下文创建方式跟Pages Router不完全一样,别把旧习惯直接搬过来。

6.3 Next.js缓存与tRPC数据不同步

现象:t3code部署上线后,修改问题标题,刷新页面仍然看到旧标题。

排查过程:一开始以为是tRPC缓存问题,invalidate好像没生效。后来用curl直接请求页面,发现服务端返回的HTML里就是旧数据,这说明和浏览器缓存无关,是Next.js层面的缓存。App Router默认对静态渲染(如果页面没有动态函数)会做静态优化,也就是在构建时生成一次HTML,后面直接复用。问题详情页如果参数是动态的,应该走动态渲染,但我当时没有显式声明dynamic = 'force-dynamic',导致某些页面被缓存住了。

最终解法:对高度动态的页面(如问题详情、回答列表)在页面顶部加上:

export const dynamic = "force-dynamic";

或者确保页面里使用了cookies()、headers()这类动态函数,让Next.js知道页面不能静态化。这个坑提醒我:类型安全解决的是数据形状的问题,渲染层缓存是另一套问题,两者都要照顾到。

7. 一些额外的心得:t3code这个架构适合什么、不适合什么

做完t3code之后,我对T3这套架构的边界有了更清晰的认识。它非常适合中小规模全栈应用,尤其是业务模型清晰、类型约束价值高的项目——管理后台、内容社区、SaaS应用都算。它也非常适合团队协作,因为前后端类型的强一致会让联调成本大幅降低,新人上手时也不需要先读懂一整本接口文档。

但如果你要做的是对外开放的复杂API平台,或者需要高性能、玩法非常独特的后端逻辑(比如大量流式计算),那Next.js + tRPC这种模式就不是最佳选择,传统后端框架提供的生态和调优手段会更直接。另外,如果你团队里全是后端工程师、对TypeScript和前端不熟,这套全栈统一用TS的理念反而会增加学习成本。技术选型从来没有银弹,t3code刚好是我这类全栈偏前端开发者最顺手的组合。

基于这次实践,我后续如果要继续扩展t3code,会在几个方向上做增强:给回答增加分页加载、做全文搜索、把点赞改成乐观更新。乐观更新这块尤其值得做,因为TanStack Query已经提供了onMutate和onError的回滚机制,t3code现在的点赞体验还是等请求返回才改UI,做乐观更新后手感会顺滑很多。

如果你正在纠结要不要用T3 Stack,我的建议是找一个小项目直接试一遍,把查询链路的类型追踪走通一次,你很快就会对它有判断。t3code的全部代码我都放在GitHub上,涉及的业务结构和实现细节在本地跑一遍就能看到全貌。类型安全这套东西,光看文章不如自己上一次编译器报错的当。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询