t3code 是我最近一个月在跑的一个全栈项目代号。起因特别简单:我实在受够了接口文档和前端类型对不上的日子。后端加一个字段、改一个返回值,前端往往要等到联调时才发现;而等到运行时崩了再把错误抛到控制台,这种体验谁经历谁知道。所以我决定用一套能从头到尾保持类型一致的技术栈,做一个小而完整的应用。于是就有了 t3code:Next.js + TypeScript + Tailwind CSS + tRPC + Prisma + NextAuth。这篇文章把整个项目的选型思考、数据链路、踩坑过程和上线经验一次性写清楚。不管你是被全栈的类型问题折磨过,还是正准备入坑 T3 Stack,应该都能从里面找到点有用的东西。
1. 为什么偏偏是 T3 Stack:不是赶时髦,是类型断层太疼了
1.1 大多数全栈项目真正的痛点不是"功能多",而是"类型断层"
很多团队全栈开发效率上不去的核心原因,不是代码写得不够快,而是前后端之间的"类型断层"反复制造摩擦。我见过太多项目是这样的节奏:后端定义了一套 DTO,前端为了调用接口手写一份 interface,再在 Postman 或者 Swagger 里维护一份文档。三份东西各写各的,没有人能保证它们永远同步。
只要后端某天把一个字段从nullable改成必填,或者把返回结构从data.user挪到data.profile.user,前端大概率不会第一时间知道。等知道的时机,通常是页面白屏、控制台飘红、或者接口返回TypeError: Cannot read properties of undefined。这种问题本质上不是某个人的粗心,而是架构层面缺少一条"类型自动同步"的链路。
T3 Stack 里最核心的一句话是"type-safe everything"。它要解决的,就是这条断层:让类型定义在数据库、服务端、前端三者之间原样流动,中间不经过人手复制。数据库里的字段是什么类型,Prisma 的模型就是什么类型;Prisma 的模型是什么类型,tRPC 的 Router 返回值就是什么类型;Router 返回什么类型,前端useQuery拿到的数据就是什么类型。整条链路没有任何一个环节需要你"再写一遍类型"。
1.2 三条技术路线的对比:为什么我最终留下 tRPC
在正式定 T3 Stack 之前,我其实把当前主流的全栈方案都过了一遍。这里做一张表,方便还没选型的人直接参考:
| 方案 | 类型内聚程度 | 开发速度 | 学习成本 | 适合场景 |
|---|---|---|---|---|
| REST + axios + 手写 TS 类型 | 低,靠人工同步 | 前后端并行开发时还可以,联调阶段慢 | 低 | 已有稳定 API 规范的大中团队 |
| GraphQL + codegen | 高,但需要维护 schema 和生成产物 | codegen 配置和 schema 设计有额外成本 | 中高 | 数据聚合复杂、多端共用的大项目 |
| tRPC | 极高,Router 定义即前端类型 | 单仓全栈开发最快,无 codegen 步骤 | 中 | 全栈 TypeScript 项目、单仓共享逻辑 |
GraphQL 的 codegen 确实能做到类型同步,但代价是你要维护 GraphQL schema、生成 hooks、把生成产物提交到 git 或者构建时重新生成。对这一类项目来说这些成本不算什么,但 t3code 这个体量,我不想为了类型同步额外维护一层工具链。tRPC 的做法更"原始"也更快:没有 schema 语言,没有 codegen 步骤,你在服务端写一个函数,这个函数的输入输出类型就自动出现在前端调用处。
提示:如果你的项目未来一定要给第三方开放公开 API,tRPC 不适合做对外接口面。它更适合内部前后端之间的调用,对外还是建议单独提供 OpenAPI 描述。
1.3 t3code 到底做了个什么东西
t3code 不是一个 hello world 教程项目。它带用户系统、内容发布、点赞互动和个人页,基本覆盖了一个社区型应用从数据建模到上线的完整闭环。当时我刻意让项目范围大一点,因为只有功能达到一定复杂度,才能检验技术栈的"承重能力":有没有鉴权?有没有受保护的写操作?有没有列表查询和缓存失效?有没有多环境部署?
这些需求如果在一个只有两三页的 demo 里,根本暴露不出来。等到你数据库模型超过三张表、路由超过五个、前端页面需要复用同一条查询的时候,很多问题才会浮出水面。t3code 就是拿一个"中型应用"的复杂度,来测试一套"全栈类型安全"的方案。
2. 初始化项目:create-t3-app 的每个选项都值得认真回答
2.1 一行命令跑起来,但交互式选项别闭眼回车
T3 Stack 官方提供 create-t3-app 脚手架,初始化非常快:
npx create-t3-app@latest t3code执行之后会有几个交互式选项,很多人会直接一路回车默认。我的建议是,第一次初始化的时候,每一项都花两分钟搞明白它到底干什么,因为选了什么进入项目,之后很长一段时间都会影响你的目录结构和代码组织方式。
选项大致是这些:Next.js 页面模式(App Router 还是 Pages Router)、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth.js、ESLint。我的最终选择是:App Router + TypeScript + Tailwind CSS + tRPC + Prisma + NextAuth.js + ESLint,除了可选附加项之外基本全开。理由很简单:这些都是 T3 Stack 的"标准装",如果你连标准装都没体验过就急着裁配置,后面查问题反而分不清是框架的问题还是你自己改出来的问题。
2.2 生成的目录结构,藏着一条重要的分层逻辑
初始化完成后,你拿到的是一个分得非常清晰的目录结构。我把它展开,方便参考:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── ... │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ └── post.ts │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── client.ts │ │ ├── react.ts │ │ └── server.ts │ └── ...关键分层的逻辑在src/server和src/trpc这两个目录:所有涉及数据库、鉴权、服务端逻辑的代码都放在server下,所有被前端引用、用于连接 tRPC 的客户端代码都放在trpc下。这样做的目的是从物理上防止服务端代码被误打进浏览器 bundle,尤其是db.ts里的数据库连接。你把src/server/db里的内容直接 import 到一个 Client Component 里,等构建报错或者数据库凭证意外暴露的时候,后悔都来不及。
2.3 哪些选项我建议保持默认,哪些可以按需求关掉
根据我实际使用的经验,这些选项的取舍可以这样看:
- Docker 相关选项:如果你的部署目标不是 Docker 容器,建议初始阶段先不选。T3 Stack 的一大目标平台是 serverless,先按 serverless 跑通再补容器配置,逻辑上更顺。
- ESLint:不要关,它已经成为现代 TypeScript 项目的默认分界线。
- Prettier:create-t3-app 默认集成的是 ESLint 部分配置,Prettier 如果你平时已经在用,可以直接补上。这个纯粹是个人习惯。
- Tailwind CSS:哪怕你还不太熟,也建议保留。项目里的样式体系依赖它,后面想改成纯 CSS 也行,但先用默认最省事。
注意:create-t3-app 生成的默认配置是"能跑、类型安全、结构清晰"三者平衡的结果,在你完全理解之前,不要大改配置。我见过有人第一天就把 Tailwind 换成 CSS Modules,结果后面看官方文档照着示例代码都填不进去,白白多花一天时间。
3. 从数据库到页面:一条请求在 t3code 里到底经历了什么
3.1 先设计三张表:User、Post、Like
既然要做一个带互动的社区应用,我先把 Prisma schema 定下来。三张表:用户、内容、点赞。手写一下核心模型:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? posts Post[] likes Like[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) likes Like[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Like { id String @id @default(cuid()) userId String postId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) post Post @relation(fields: [postId], references: [id], onDelete: Cascade) @@unique([userId, postId]) @@index([postId]) }有个细节值得留意:Like模型上加了@@unique([userId, postId]),这是数据库层面的约束,防止同一个用户对同一条内容重复点赞。如果数据库层面不做这个约束,只在前端禁用按钮,并发情况下很容易出现重复数据。 t3code 里的接口默认是服务端受保护的,所以这种"最终防线"必须写在数据库里。开发阶段同步表结构用npx prisma db push,生产环境部署建议用npx prisma migrate deploy,前者不适合多环境。
3.2 tRPC Router:受保护接口长什么样
数据模型定好之后,就是 tRPC Router。这里先解释一个概念:tRPC 里的procedure相当于"接口",protectedProcedure则是在调用后端逻辑之前先走一遍鉴权中间件。没有登录的用户,在请求还没进入业务代码之前就会被拦下来。
下面这个例子是src/server/api/routers/post.ts里的一条核心逻辑——创建内容。代码本身不长,但每步都在解决一个具体问题:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "@/server/api/trpc"; export const postRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1).max(5000), }), ) .mutation(async ({ ctx, input }) => { const authorId = ctx.session.user.id; const post = await ctx.db.post.create({ data: { title: input.title, content: input.content, authorId, }, }); return post; }), all: publicProcedure.query(async ({ ctx }) => { const posts = await ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { author: { select: { id: true, name: true, image: true } }, _count: { select: { likes: true } }, }, }); return posts; }), });这个create接口做了三件事:第一,用 Zod 在入口校验title和content,非法输入直接返回错误;第二,protectedProcedure确保ctx.session.user一定存在,不存在就抛未登录;第三,把内容写入数据库并返回刚创建的这一条。这三个阶段严格串行,每一层都在减小下一层要应对的"意外输入"。
3.3 前端调用:类型是怎么一路推导过来的
t3code 的前端调用部分,我认为是整套方案里体验最"魔法"的地方。你在页面上写一个useQuery,它返回的数据会被自动注入服务端 Router 的类型。我经常在编辑器里悬停鼠标看类型,确实是从router.post.all的定义里推导过来的,毫无中间损耗。
以"发布内容"为例,前端一个 Client Component 大概长这样:
"use client"; import { api } from "@/trpc/react"; export function PostCreator() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: async () => { await utils.post.all.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createPost.mutate({ title: String(formData.get("title")), content: String(formData.get("content")), }); }} > <input name="title" placeholder="标题" /> <textarea name="content" placeholder="正文" /> <button type="submit" disabled={createPost.isLoading}> 发布 </button> </form> ); }注意createPost.mutate接收的参数类型,就是服务端.input()里 Zod schema 推导出来的类型。如果我在这里传一个没有title的对象,编辑器直接报错,根本轮不到运行时报 400。页面里列出数据也同样简单:
const { data: posts } = api.post.all.useQuery();由于all是publicProcedure,前端不需要登录态也能调用。返回的posts自动带有author.name、_count.likes等字段类型,前端不需要再维护任何一份"DTO 副本"。
3.4 数据流的时间线:发布一次内容,完整走一遍
为了方便理解,我用文字模拟一遍"用户点发布"之后发生的完整经过:
- 表单提交,
createPost.mutate被执行。 - React Query 发起一个 POST 请求,地址是
/api/trpc/post.create。 - Next.js 的 Route Handler 收到请求,转发给 tRPC 内部处理。
protectedProcedure中间件先检查 session,拿不到当前用户就抛UNAUTHORIZED。- Zod 校验
input的title和content,类型不合法就抛BAD_REQUEST。 ctx.db.post.create把数据写入数据库。- 新内容作为 JSON 返回给前端。
onSuccess触发utils.post.all.invalidate()。- React Query 自动重新请求
post.all,列表更新。
因为类型从第 1 步到第 9 步始终是同一个源头,所以第 2 步就算手滑传错参数,也会在编译期而不是线上被拦住。这套链路跑通之后,我心里最大的感受是:以后写后端接口的时候,不用再给前端单独发一份字段变更通知了,类型变更本身就是通知。
4. 我在 t3code 上踩过的三个坑,排查过程比答案更值钱
任何框架都有文档里不太会写、但实际开发必踩的坑。T3 Stack 也一样。我不打算只给结论,我把三个坑的完整排查链路写出来,方便各位后面遇到相似问题的时候有迹可循。
4.1 坑一:ctx.session.user 在类型上不存在,但运行时明明有值
现象很典型:代码运行一切正常,登录用户的信息能正常打印出来,但 TypeScript 一直报错Property 'user' does not exist on type 'Session'。这种情况如果直接用 any 糊弄过去,后面写受保护接口的时候全都会碰上这个类型问题。
排查步骤:
- 先检查
src/server/auth.ts里的authOptions,确认 session 配置的写法没有明显异常。 - 再看
src/server/api/trpc.ts里 createTRPCContext 如何构造 session,发现 session 已经通过getServerSession注入到 context。 - 最后把目光放到
src/types/next-auth.d.ts(或同类声明文件),问题就出在这里:NextAuth 的类型声明里,Session 的user字段默认只包含name、email、image,如果你在 session callback 里额外加了id,TypeScript 根本不知道有这个字段。
根因是模块增强缺失。解决方法是补上类型声明:
import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }如果你还需要在 token 里自定义字段,还要同步声明JWT接口。这个坑的本质是:NextAuth 给你的类型默认值,和你实际注入的数据已经不一致了,必须手动通过模块增强消除偏差。
4.2 坑二:发布内容之后,列表死活不刷新
这个坑的症状是:useMutation的 onSuccess 明明触发了,控制台也打印了成功日志,但页面上的列表数据不变。要刷新一下页面才能看到新内容。
排查步骤:
- 先确认
onSuccess里是不是没有写invalidate——一开始确实没写。 - 在
onSuccess里补上utils.post.all.invalidate()之后,数据还是不变。 - 继续往下查,发现页面列表组件用的是
api.post.all.useQuery(),但 mutation 里的utils是从另一个模块 import 的实例,两个实例对应的 queryKey 前缀不一致,导致缓存失效没有作用到同一个 key 上。
根因是React Query 的缓存 key 没有对齐。tRPC 的 React 集成实际上是把每个 router 的路径映射成了稳定的 key,但如果你在代码里自己 new 了一个 client 实例,或者从多个入口引用了不同的utils,就可能出现 invalidate 失效的情况。解决方法是保证前端所有 tRPC 调用都使用同一份实例,也就是项目里src/trpc/react.ts默认导出的那个api。
注意:invalidate 之后,React Query 不是立刻把旧数据清空,而是发一个新的请求并做后台重新验证。如果你在
staleTime上设置了很长的缓存时间,列表就会"看上去没刷新"。这在调试的时候特别容易让人误判。
4.3 坑三:新环境部署时,Prisma Client 一直报找不到
现象是本地跑得好好的,代码推上去构建也没报错,但一到运行时接口就抛PrismaClientInitializationError或者提示@prisma/client没有生成。
排查步骤:
- 检查
node_modules/.prisma/client目录是否存在,发现构建缓存里确实没有。 - 回顾部署平台(比如某 serverless 平台)的构建流程:它默认只跑
npm install+npm run build,而npm install不会自动执行prisma generate。 - 查看 package.json 的 build 脚本,里面确实没有生成 Prisma Client 的步骤。
根因是Prisma Client 没有在构建阶段重新生成。Prisma 的 Client 是代码生成器产物,不同平台的二进制文件也可能不同,它不会随npm install自动生成完整版本,需要主动执行一次。解决方法是给 package.json 加一个 postinstall 脚本:
{ "scripts": { "postinstall": "prisma generate" } }这样每次依赖安装完成后都会自动生成 Client。如果你的部署环境支持构建钩子,也可以在 build 前显式执行npx prisma generate。不要依赖本地生成的产物被提交到 git,因为它的平台相关文件一定会出问题。
5. 上线部署:环境变量、数据库迁移和会话是三个绕不开的现实问题
5.1 DATABASE_URL 和环境信息分开管理,迁移顺序别搞反
t3code 这类项目在开发环境通常直接用本地 PostgreSQL,或者用一个免费的云端数据库;生产环境又是另一套连接串。环境信息分开管理是第一原则:.env不进 git,.env.production只在部署平台的环境变量配置里填。
数据库变更的顺序我建议严格遵守:先跑迁移,再启动新版应用。如果你先把新代码部署上去,而数据库里还缺表,应用一启动接口就会报错。在 CI/CD 里,常见的做法是构建结束后、启动服务前执行npx prisma migrate deploy。它只负责把 pending 迁移应用到数据库,不会修改 schema 描述,相对安全。同时要注意:migrate deploy需要真实的数据库连接,不能放在纯构建阶段就指望它成功。
5.2 NextAuth 在 serverless 环境下的会话策略要注意
NextAuth 的 session 默认有两种策略:database 和 jwt。在 serverless 环境,我强烈建议使用 jwt 策略。原因很简单:serverless 函数的实例可能在多次请求之间被销毁,无法保证 session 在你本地的内存里持续存在。jwt 策略把 session 信息加密存在客户端的 cookie 里,每个请求都自带,服务端只需要用密钥验证。
对应地,环境变量也要正确配置:新版 NextAuth 使用AUTH_SECRET,老版本常见的是NEXTAUTH_SECRET,具体以官方文档为准。这个密钥必须是一个足够随机的字符串,生产环境不能用开发环境的默认值。另外,运行时环境变量和构建时环境变量要分清楚,像AUTH_SECRET、DATABASE_URL这类需要在运行时读取的变量,必须在部署平台的运行时配置里存在,只写在构建配置里,冷启动时会找不到。
5.3 构建阶段我还有几个小习惯
部署前我会额外检查三件事:
next build是否在构建阶段做了类型检查和 ESLint 检查。默认会做,但如果 CI 里为了提速把--no-lint之类的参数加上了,类型错误就会在构建时被跳过。- Tailwind 的 production 构建会 purge 未使用的样式,如果你动态拼接类名,部分样式可能在发布后被裁掉。这个不是新问题,但每次部署我都能看到有新人踩。
- 静态资源有没有被
next/image正确处理。放在public目录的图片能直接访问,但是由数据库字段动态引用的图片,要确认域名是否加入了remotePatterns配置。
这些都不是 T3 Stack 特有的问题,但正所谓"部署才是项目真正的开始",每一条都会在生产环境给你上眼药。
6. 如果重写一遍 t3code,我会优先改这三件事
项目跑通之后回头复盘,我觉得有三个方面如果能重来,我会在第一天就规划进去。
6.1 把权限逻辑从业务代码里抽出来,统一放到中间件层
t3code 里有些接口是登录才能用的,有些是公开的。最开始我的写法是每个路由里各自判断一下,后来发现重复代码越来越多。更好的做法是在trpc.ts里定义好protectedProcedure之后,把"是否登录""是否为资源所有者"这类通用判断做成中间件链,让路由函数只关心自己的业务逻辑。比如"只能改自己的内容"这种规则,写成中间件比在每个 mutation 里复制粘贴判断要可维护得多。
6.2 从第一天就接上错误监控
t3code 在上线之前,我一直自信地认为"类型都对了,运行还能有什么错"。但实际上出去跑之后,接口超时、第三方服务异常、数据库连接池打满,这些都不是类型系统能兜住的。Sentry 这类工具最迟在联调阶段就应该接上,不是等上线出了事故才想起查日志。接完之后你会发现,tRPC 的TRPCError抛出去之后,前端的错误反馈和后端的日志能一一对应,排查效率完全是两个级别。
6.3 客户端数据变换用 useQuery 的 select,而不是写一堆 useMemo
t3code 刚开始我在页面里做了大量useMemo对列表数据进行排序、过滤、取交集。后来发现这些操作完全可以放进useQuery的select里,让 React Query 在数据层直接处理,并且因为 select 的结果会被缓存,避免每次渲染都重新计算。代码会干净不少。顺带说一句,如果你是新手,先把useQuery的基础用法跑通,再去玩 select、staleTime 这些进阶配置,不然你很容易把缓存语义搞混。
我个人在实际操作中最受用的一点是:每写完一个 tRPC router,先npx tsc --noEmit扫一遍全量类型。这个习惯成本极低,但能把绝大部分前后端类型不同步的问题拦在提交之前。类型安全不是一出代码就自动发生的,它需要你从一开始就信任这条链路,并且及时给声明文件"打补丁"。拿 t3code 来说,这一个月我最大的收获不是学会了某个新框架,而是体验到了"改动一个后端模型,前端即刻感知"的踏实感。如果你也受够了接口联调和类型文档的折磨,不妨从 create-t3-app 开始,走一遍完整的 T3 Stack,我相信你也会有类似的感受。