t3code 这个名字乍一看确实有点抽象。但如果你这几年一直混在 Next.js 和 TypeScript 的圈子里看到 t3 前缀大概率会想到那个火得一塌糊涂的 T3 Stack——Next.js、TypeScript、tRPC、Prisma、Tailwind 再加上 NextAuth 的组合。t3code 其实就是我基于这套全栈范式从零搭建的一个完整代码实践项目目的很单纯把类型安全从数据库一直贯穿到浏览器端把前后端胶水代码降到最低同时验证一下这套组合拳在真实业务场景下到底好不好用。这篇文章我不打算写成一个教程式的菜谱而是想把我在搭建 t3code 过程中的真实思考、技术选型的权衡、踩过的坑以及最后沉淀下来的可复用经验系统性地整理出来。如果你正准备入手 T3 Stack或者已经在用但被类型问题、tRPC 路由设计、Prisma 迁移这些细节折磨过这篇文章应该能帮你省下不少时间。1. 项目整体设计与思路拆解1.1 为什么我把宝押在 T3 Stack 上先说点实在的。过去做全栈项目最常见的痛点就是前后端各维护一套类型定义。后端改了接口返回结构前端 Axios 那边的 response type 还是老样子运气好点等运行时报错运气不好直接线上出 bug。TypeScript 的好处大家都知道可如果类型只停留在前端代码里那它本质上只是个稍微严格一点的“语法糖”。t3code 的核心思路就是把 TypeScript 的能力真正延伸到数据链路的每一环。T3 Stack 里最值钱的就是 tRPC——它是一个让你不用写 REST 路由、不用手写 API 文档、不用搞 GraphQL schema直接在前端“调用”后端函数的方法。前端写trpc.post.list.useQuery()后端配一个postRouter两个地方的类型是自动对齐的。数据库字段变了Prisma 的类型变了前端代码立刻就在编译期报错。那种“改了一个字段全链路都跟着变色”的感觉开发过一次就回不去了。我会选择这套组合作为 t3code 的基础还有一个考虑是它的“约定优于配置”。create-t3-app 官方脚手架已经帮你把目录结构、ESLint、tsconfig、NextAuth 接缝都搭好了我只需要关注业务逻辑本身。对于一个想快速验证想法、又不想在基建上耗费大量精力的项目来说这几乎是当下的最优解之一。1.2 t3code 这套项目到底解决了什么问题单纯说“类型安全”可能还是有点虚。我用自己的实际场景举个例子t3code 是一个包含用户系统、文章发布、点赞收藏、评论互动的内容社区类应用。在传统写法里我需要做这些事情后端写 Express 或 NestJS 路由定义 DTO 做参数校验前端根据后端文档手写 interface 或 type 做请求响应标注前端写 API 封装层每个接口一个函数维护 React Query 的 queryKey、接口路径、入参类型三处全都要对上。在 t3code 这种 tRPC 架构下上述四步直接合并成了两步在server/api/routers里写一个业务函数然后在前端组件里直接引入一个api对象调用。类型系统会自动把入参和出参从后端带到前端。queryKey 的维护也由 React Query 的useQuery封装自动完成你根本不需要关心它内部的 key 是怎么拼的。另外隐藏价值在于代码量大幅减少。我自己粗算过t3code 的业务代码量大概比传统的 REST 手写类型方案少 30%~40%。少了接口封装层、少了类型定义文件、少了文档同步成本这部分时间省下来去打磨功能收益是很直接的。1.3 技术选型的完整清单t3code 的核心技术栈如下我顺便标注了每个技术在这里承担的角色技术作用选型理由Next.js 14App RouterReact 框架、服务端渲染、路由社区生态最大、和 Vercel 部署无缝衔接TypeScript全链路静态类型语言类型安全的根基tRPC v11端到端类型安全的 API 层消灭手写 API 层前后端类型共用PrismaORM、数据库模型定义schema 即类型来源迁移工具成熟Tailwind CSS样式方案原子类样式开发效率高团队无 CSS 内耗NextAuth.js认证方案支持 OAuth 和 Credentials和 Next.js 集成好React Hook Form Zod表单处理和校验Zod 的 schema 可以同时服务入参校验和 UI 校验MySQL或 Postgres数据存储t3code 默认用 Postgres部署时可无缝切到 Docker这个组合不是银弹但对于“开发速度快、类型安全、部署省心”这三个诉求它确实很能打。2. 核心细节解析与实操要点2.1 目录结构是项目的骨架别小看它t3code 的目录结构沿用了 create-t3-app 的官方布局但它不是拍脑袋定的每个目录的使命都很清楚src/pages或src/app页面组件和路由层尽量保持薄只做数据转发和 UI 渲染。src/server/api/routers按业务域拆分的 tRPC 路由比如postRouter、userRouter、commentRouter。src/server/db.tsPrisma Client 单例导出所有后端逻辑都从这里拿数据库实例。src/trpc/server.ts服务端调用 tRPC 的封装供 Server Component 或 Server Action 使用。src/trpc/react.tsx前端调用的 React Hooks 封装里面配置了 tRPC Client。src/validation或直接并进 router 文件放 Zod schema定义入参规则。我在这里特别想强调一个细节tRPC router 千万不要按文件类型来分要按业务域来分。我看到过有人把getUser、login、register一股脑塞进authRouter然后又把文章相关的全塞进postRouter虽然后果不算严重但随着项目变大路由收敛逻辑会很混乱。正确做法是拆成“用户”“帖子”“评论”“通知”等聚合根再在根部用createTRPCRouter({ user: userRouter, post: postRouter })做一层合并调用的时候路径会变得很有语义感trpc.post.list()、trpc.comment.create()。2.2 Prisma schema 是类型安全的源头在 t3code 里Prisma schema 不仅仅是一个数据库建模工具它是一切数据库类型生成的源头。你用model Post { id String id default(cuid()) ... }定义了模型Prisma Client 会自动生成完整的 CRUD 操作和对应的 TypeScript 类型后端在用到 Post 表数据时字段名拼错立刻就是红波浪线。这里有几个实操上容易踩坑的点第一ID 字段用 cuid 还是自增我建议现代项目直接用 CUID 或者 UUID。自增 ID 虽然看着友好但在分布式环境和数据迁移场景下不够稳而且前端日志里暴露自增 ID 会被人猜到数据量属于安全隐患。第二关系字段怎么命名如果文章表要关联作者应该在 Post 模型里写author User relation(fields: [authorId], references: [id])。Prisma 的关系逻辑是“字段定义在主表外键也物理存在主表”理解不了的话数据库表建出来会懵。第三千万记得跑迁移命令。每次改动 schema下一步就是npx prisma migrate dev它会自动生成迁移 SQL 并同步 Client 类型。很多人只改了 schema 忘了跑迁移然后发现前端类型没更新报一些莫名其妙的错误。这不是 tRPC 的 bug是你 Prisma Client 是旧版。2.3 tRPC router 的设计模式我是从 REST 和 GraphQL 的路子走过来的刚开始写 tRPC 时候很容易带着“控制器”的思维。后来我总结了一套比较顺手的模式分享出来供参考每个业务资源都有三个层级list、byId、create、update、delete。这不是死规定而是我验证过的一个“能覆盖 80% 场景”的最小公约数。比如帖子模块先写出这五个基础 procedure再根据需求补充like、favorite之类的动作型接口结构就很清晰。入参校验全部走 Zod。我第一版偷懒想着入门槛不高直接在 procedure 里解构input再用结果是后端拿到的参数是个unknown还要手动做类型断言。正确写法是import { z } from zod; const createPostInput z.object({ title: z.string().min(1).max(100), content: z.string().min(10), }); export const postRouter createTRPCRouter({ create: protectedProcedure .input(createPostInput) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });注意这里用了protectedProcedure它是我基于createTRPCRouter封装的一个带登录校验的 procedure。没有它的话Session 检查要在每个 mutation 里手写一遍容易漏而且漏掉的后果是接口没带登录态也能调用安全问题极大。尽量让 mutation 直接返回完整实体。我见过一些优秀实践但更常见的是很多人在创建成功后只返回一个{ id }前端还要再发一条 detail 请求。t3code 的做法是直接在 mutation 里返回prisma.post.create()的完整结果前端拿到后可以做本地缓存更新少一次网络往返。2.4 认证方案的集成细节t3code 的登录系统走了 NextAuth.js 的 Credentials GitHub OAuth 双通道。这里我要提醒一个关键经验用 Credentials Provider 的时候一定要在 session callback 里手动把用户 ID 塞回去。NextAuth 默认的 session 里只有user.name和user.email但业务逻辑里要拿数据库记录更常用的是用户 ID。所以我在authOptions里加了这样一段callbacks: { session({ session, token }) { if (session.user) { session.user.id token.sub as string; } return session; }, jwt({ token, user }) { if (user) { token.sub user.id; } return token; }, },这里有两个陷阱。第一token.sub是 JWT 的保留字段用它来存储用户 ID 是社区认可的惯例但一定搞清楚它到底是什么值——默认情况下它是 NextAuth 内部为用户生成的 ID并不等于数据库主键 ID你要自己映射。第二Session 类型默认没有id字段需要在src/types/next-auth.d.ts里做模板扩展否则前端session.user.id一直报 TS 错误。这个我见过无数人在社区里问其实不是 NextAuth 不支持而是类型扩展没写。2.5 Tailwind CSS 和 UI 状态样式层面t3code 用的是 Tailwind CSS。说实话样式方案每个人喜好不同但 Tailwind 和 T3 生态配合得确实好因为 create-t3-app 已经接好 PostCSS 配置和content路径不需要额外花时间。t3code 里我还集成了clsx来处理条件类名表单状态用 React Hook Form 管理Zod 既做后端校验也做前端表单校验一份 schema 双端复用省下来的重复校验代码是真不少。前端交互方面我全部用 React QuerytRPC 的 React 封装基于它。缓存更新有两个常用技巧可以直接抄作业用useUtils()拿utils实例在 mutation 成功后调用utils.post.list.invalidate()让列表自动刷新或者用setData乐观更新比如点赞行为先改 UI 再调接口失败时回滚。t3code 里的点赞功能就是这么做的体验很顺。3. 实操过程与核心环节实现3.1 项目初始化一步步来初始化 t3code 的时候我走的是标准的 create-t3-app 流程npm create t3-applatest t3code脚手架会弹出几个选项问你要不要装 Prisma、Tailwind、tRPC、NextAuth 等。第一次玩的话建议全选先感受一下完整链路。选完它会自动安装依赖并把基础目录搭好。这个阶段你要做的第一件事就是npx prisma migrate dev --name init把默认可能存在的示例模型同步到数据库里。这里要提前准备好数据库连接串写在.env的DATABASE_URL里。我用的是本机 Postgres本地跑起来配合docker compose up -d postgres很省心。有些朋友用 SQLite 来做起步我个人不太推荐在生产方向的项目里这样做。因为 SQLite 的 Prisma 类型和 Postgres 还是有一些差异比如枚举、JSON 字段的用法迁移太晚的话后面切库成本很高。开发可以轻量但请从第一行代码就面向目标环境。3.2 数据模型设计与迁移实战我以 t3code 的“文章发布”模块为例数据模型设计的关键代码如下model User { id String id default(cuid()) name String? email String unique emailVerified DateTime? image String? createdAt DateTime default(now()) updatedAt DateTime updatedAt posts Post[] comments Comment[] } model Post { id String id default(cuid()) title String content String published Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt author User relation(fields: [authorId], references: [id]) authorId String comments Comment[] } model Comment { id String id default(cuid()) content String createdAt DateTime default(now()) post Post relation(fields: [postId], references: [id]) postId String author User relation(fields: [authorId], references: [id]) authorId String }这个设计里author和authorId必须同时定义Prisma 才会在数据库层生成外键。每次模型有修改就执行一次npx prisma migrate dev --name add_comment_modelPrisma 会提示你是否要重置数据开发环境直接确认即可。它会生成 SQL 迁移文件并自动重新生成 Client 类型前端 TS 类型在这一步就同步更新了。3.3 从 tRPC 接口到页面渲染的完整链路我以“文章详情页”为例带你走一遍 t3code 里数据流通的完整过程。后端先定义了一个查询逻辑import { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; export const postRouter createTRPCRouter({ list: publicProcedure .input(z.object({ take: z.number().default(10), cursor: z.string().optional() })) .query(async ({ ctx }) { const posts await ctx.db.post.findMany({ take: 11, orderBy: { createdAt: desc }, include: { author: { select: { name: true, image: true } } }, }); let nextCursor: string | undefined; if (posts.length 10) { const nextItem posts.pop(); nextCursor nextItem!.id; } return { posts, nextCursor }; }), });这里我用了一个游标分页而不是传统page参数。原因是游标分页在数据顺序变化时更稳定支持无限滚动体验更顺滑且不会因为插入新数据导致某一页内容重复或遗漏。前端调用代码在页面组件里是这样的const { data, fetchNextPage, hasNextPage } api.post.list.useInfiniteQuery( { take: 10 }, { getNextPageParam: (lastPage) lastPage.nextCursor } );tRPC 的useInfiniteQuery把 React Query 的分页能力包了进去前端只需要告诉它“下一页怎么取”滚动加载就自动完成了。光标分页的nextCursor字段类型也是严格对齐后端返回的写错了编译器会直接告诉你。3.4 登录功能和权限控制t3code 的登录页面调的是 NextAuth 的signIn。按钮点击触发逻辑大体如下import { signIn, signOut, useSession } from next-auth/react; function LoginButton() { const { data: session } useSession(); if (session?.user) { return button onClick{() signOut()}退出/button; } return button onClick{() signIn(github)}使用 GitHub 登录/button; }实现了登录之后紧接着就是权限控制。tRPC 的protectedProcedure实现其实很简单就是用 middleware 判断ctx.session是否存在const isAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); }); export const protectedProcedure t.procedure.use(isAuthed);前端如果没登录就调protectedProceduretRPC 会返回 HTTP 401前端useQuery会以错误状态呈现再配合路由守卫跳转登录页。整个过程不需要自己写任何 Session 校验逻辑非常省心。3.5 部署流程与运行环境t3code 部署在 Vercel 上数据库部署在 Neon 的边缘 Postgres。Next.js 应用在 Vercel 上是纯 Serverless 环境Prisma 需要在 Serverless 场景下做一些特殊处理其中最重要的两点用accelerate或者直接开启连接池扩展否则每次冷启动都新建数据库连接性能会很惨在src/server/db.ts里加上连接池配置和更长的连接超时时间。我的db.ts长这样import { PrismaClient } from prisma/client; import { env } from env; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: env.NODE_ENV development ? [query, error, warn] : [error], }); if (env.NODE_ENV ! production) globalForPrisma.prisma db;这个写法利用了全局缓存避免开发环境热重载时创建多个 PrismaClient 实例是一个社区标准模式的微调版。部署后第一次访问冷启动会慢一秒左右属于正常现象别急着报 bug。4. 常见问题与排查技巧实录4.1 一个典型的 tRPC 类型报错场景开发 t3code 社区模块的时候我遇到一个最折腾的问题前端trpc.comment.create.useMutation()调用后TS 报错说 propertyiddoes not exist on typenever。排查思路是这样的第一反应是 Zod schema 类型定义有问题检查了 input 定义发现其实没问题再检查返回类型发现 mutation 函数里ctx.db.comment.create()返回的不是 Promise而是被我无意间在 create 参数里传了一个undefined字段问题根源是数据模型Comment里的authorId字段类型是String但我在代码里写成authorId: session?.user?.id这样类型就推断成string | undefinedPrisma 的输入类型要求是stringTS 整个类型体操就跑飞了并推断成never。这种问题在传统前后端分离项目中很难被发现因为前端根本不知道后端要的到底是string还是undefined。但在 t3code 里类型系统直接把问题焊死了你没法绕过。经验总结tRPC 的报错信息有时候看起来像玄学但 90% 的根源是后端函数本身的类型不对先检查 context 的session.user.id是否可能为 undefined再检查 Prisma 的必填字段是否都有值。4.2 Prisma 迁移冲突多人协作开发 t3code 时比较常见的问题是我和同事同时在分支上改了 schema并且各自生成了迁移文件合并代码后就出现多个迁移文件指向同一个数据库状态prisma migrate dev会报“migration not applied”之类的错误。这种场景的解法行业内相对统一的做法是删掉自己分支上后续的迁移文件只保留最先合并到主分支的那一份。如果已经执行过migrate dev则先prisma migrate reset重置本地库再重新执行最新的一个迁移。无所谓开发环境的数据库数据不重要重要的是迁移文件的历史是线性、无冲突的。4.3 NextAuth 回调里数据类型对不上在集成 GitHub 登录时我一度遇到了“登录成功但 session 查询结果一直是 null”的怪问题。后来排查发现是 NextAuth 的jwtcallback 里user参数只有在首次登录时才有值后续请求它都是undefined我没做兼容处理导致token.sub被覆盖成undefined最终 session 拿不到用户信息。修复方法是jwt({ token, user }) { if (user) { token.sub user.id; } return token; }只有当user存在时才赋值。这个坑极其隐蔽因为生产环境首次登录一切正常第二次才会暴露 session 消失调试起来非常费神。建议大家在自测的时候一定把“重新登录”“清除 cookie”“二次访问”这三个路径都过一遍。4.4 构建时环境变量缺失Vercel 部署的时候发生过一次惨案本地构建都正常部署到 Vercel 之后构建阶段直接报DATABASE_URL is not defined。原因是我只在 Vercel 项目设置里配了生产环境变量但没配 “Preview” 环境的变量而构建跑的是 Preview。这个问题说穿了很简单但足够让人浪费一个下午。部署前请务必检查三件套Production、Preview、Development 三个环境的环境变量是否都已经设置好。4.5 常用问题速查表症状大概率原因解决办法前端类型始终不更新Prisma Client 未重新生成执行npx prisma generatetRPC 报UNAUTHORIZED调用了 protectedProcedure 但未登录检查 NextAuth 会话状态和 cookienext build报类型错误App Router 页面里直接用trpc服务端调用但没包withTRPC用src/trpc/server.ts里的api调用查询列表无限滚动不触发getNextPageParam返回了undefined而非 null修改分页逻辑确保 hasNextPage 判断正确Prisma 查询SyntaxErrorDATABASE_URL 里含有特殊字符未转义复制连接串时检查#等字符Tailwind 类名不生效新文件路径未包含在content配置中检查tailwind.config.ts的 content glob5. 提升开发效率的几个实操心得5.1 用 Zod 一把梭到底在 t3code 里Zod 不只是后端校验工具。我把所有表单的 schema 统一抽到src/validation目录然后前后端共用。React Hook Form 用zodResolver接入返回的错误信息可以直接丢给 UI 提示区后端 tRPC 入参校验也是同一套 schema。一份 schema两个端同步验证改动字段时不怕漏。5.2 开发环境调试 tRPC 请求的独门技巧在浏览器 DevTools 的 Network 面板里tRPC 请求看起来是一条 POST 请求请求体里是一大段类似 JSON 的内容但它内部是 tRPC 编码过的。用起来不太直观。我的习惯是在浏览器 Console 里执行fetch(/api/trpc/post.list?batch1, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ json: { take: 10 }, meta: {} }) })这样可以直连 tRPC 端点测试接口不受前端 React Query 缓存影响。另外打开 Network 面板后勾选 Fetch/XHR 过滤能看到每一条 tRPC procedure 请求的 payload 和返回用来定位 bug 特别快。5.3 别在服务端组件里随便用 useQueryt3code 用 App Router一开始我糊里糊涂就在一个 Server Component 里写了api.post.list.useQuery()。结果编译器直接报错提示 hooks 只能在 Client Component 里使用。这里有两个选择把这个组件标记为use client用 tRPC 的 React 封装走客户端数据流或者保留服务端组件用src/trpc/server.ts导出的api直接await api.post.list()拉数据传给子组件。两种方式我都实践过经验是首屏数据优先用服务端直取因为可以配合 React 的流式渲染和 SEO交互频繁的动态数据用客户端 useQuery因为可以做乐观更新和缓存。5.4 测试策略到底怎么写t3code 项目的测试我分了两层。第一层是用 Vitest 对 Zod schema 和工具函数做单元测试跑得快、不依赖数据库。第二层是用 Playwright 做几条端到端主路径测试注册、登录、发帖、点赞保证核心链路不出问题。真实的业务项目不建议给每个 tRPC procedure 都做单体测试成本太高收益太低。tRPC 的 procedure 薄得像纸逻辑都在 Prisma 层和业务函数层所以优先测这些需要复杂判断的纯函数就行。6. 最后再聊点实在的t3code 这个项目做到现在我最深的感受是T3 Stack 它不是一套“新语言”它只是把 TypeScript 的潜力真正挖到了全栈的底层。传统开发里前后端类型断层是常态t3code 这个项目用 tRPC 打通了这层壁垒。你不需要写 REST 文档不需要手维护 DTO不需要在接口变更时到处搜索调用点——类型系统就是最好的文档和维护者。对我个人而言踩过的坑也很多但回头看都是值得的。它让我有更多精力去关注产品本身的逻辑而不是在接口一致性上反复填坑。如果你正打算用 T3 Stack 搭建自己的全栈项目我建议你直接创建一个最小可用版本把用户、列表、详情、创建这几条主链路走通再回头对比一下原有方案的代码量和开发体验。你会很快感受到所谓的“类型安全”不是一句口号而是一种能切切实实帮你兜底的开发方式。后续如果想在里面加 AI 生成、文件上传、SSE 推送这类能力T3 生态也都有成熟的扩展点玩法还很多。