开篇先解释一下t3code 是我最近从头到尾搭完的一个基于 T3 Stack 的全栈应用代码库名字里的 T3 指的不是某个框架版本而是这几年在前端圈子里很流行的一套技术组合——Next.js、TypeScript、tRPC、Prisma、Tailwind CSS 再加 NextAuth。很多人刷到 T3 Stack 相关的内容都会觉得“这不就是一堆工具拼在一起吗”但实际把它当成一个完整项目去写的时候你会发现这套组合在设计思路上高度自洽类型安全从数据库一路延伸到浏览器端几乎不需要手工维护接口文档和类型定义。这篇文章就把我从零搭建 t3code 的完整过程、关键原理、代码结构以及踩过的坑一次性讲清楚适合准备上手全栈 TypeScript 开发、或者想从传统 REST 架构迁移到 RPC 模式的同学参考无论你是刚接触 Next.js 还是已经写了一阵子都能从中拿到可以直接落地的经验。1. 项目概述与选型思路1.1 t3code 是什么为什么会有这个名字先把这个名字拆开。T3 Stack 最早由开发者 Theo 提出并推广社区里对 T3 的含义有几种解释最常见的一种是说它代表 TypeScript、Tailwind、tRPC 这三个以 T 开头的核心技术也有说法认为 T3 指的是“The Three”即三件套的组合思想。不管哪种解释核心指向都是一样的用全栈 TypeScript 替换掉传统的前后端语言割裂状态让一份类型定义贯穿整个开发链路。t3code 这个名字就是我给这套实践代码库起的代号它本质上是一个“带生产级目录结构的 T3 全栈项目模板”。和官方脚手架 create-t3-app 生成的空项目不同t3code 里面包含了一个完整的业务 demo——团队任务看板有用户登录、项目分组、任务流转、评论记录这些真实场景同时还沉淀了配置、工具函数、路由设计、错误处理这些工程化细节。这样做的原因是光有一个 hello world 级别的模板你很难判断这套技术栈到底适不适合自己的业务只有把一个带着真实复杂度的业务跑通你才能感受到类型安全带来的收益也才能真正暴露问题。如果你现在正处于技术选型阶段我的建议是别只看文章和视频测评直接拿一个中等复杂度的模块比如带权限的增删改查用 T3 跑一遍对比以前写 REST API 时的工作量。实测下来单纯从“改一个字段需要动几处代码”这个指标看T3 的优势非常明显。1.2 选型对比为什么是 T3 而不是普通 Next.js REST在决定使用 T3 Stack 之前我专门把几种全栈方案放在一起做过对比因为我知道只看类型安全这个点就冲进去很容易忽略它的适用边界。先看最传统的方案Next.js或 Express REST API。这个组合的好处是人人都会生态成熟找一个开源项目几乎都能看到标准写法。但痛点也很明显——后端定义好一个接口前端要手写对应的请求函数、类型定义、错误处理后端改一个字段类型前端可能要跑一遍全局搜索才能把所有用到的位置找干净。业务规模小的时候无所谓一旦接口数量上到几十个这种双端维护的成本会显著吃掉你的开发效率。再看 Next.js GraphQL。GraphQL 的类型系统确实强大但引入它意味着你要维护 schema 文件、学习 resolver 和 directive 的写法还要处理 codegen 生成的代码包。对于内部系统、后台管理这类偏私有化的场景GraphQL 的灵活性反而变成负担因为它把简单问题复杂化了。tRPC 的方案则完全不同接口消费方直接像调用本地函数一样调用后端方法不需要生成 API 路由文档不需要手写请求层代码所有参数和返回值的类型在编译期就已经绑定。你看代码的时候不需要跳转到后端去确认返回结构IDE 提示会直接告诉你一切。这种“Copilot 式”的开发体验在中小型项目里尤其舒服。当然 tRPC 不是万能钥匙。如果项目需要对外提供公开 API让不相关的第三方接入REST OpenAPI 文档依然是更通用的选择。T3 架构里你也可以在同一个 Next.js 应用内同时暴露 tRPC 路由和 REST 路由两边互不干扰。我的原则是内部模块用 tRPC外部接口用 REST/OpenAPI两者不冲突。2. 核心技术点拆解T3 Stack 每一层到底在做什么2.1 Next.js 应用架构App Router 与目录规划t3code 的项目骨架由 Next.js 提供。目前脚手架的默认选择是 App Router也就是基于文件系统的路由方式。相比老版 Pages RouterApp Router 的核心变化是引入了 Server Component 和 Client Component 的边界划分。简单说默认情况下组件都是服务端组件里面可以直接读取数据库、调用内部函数、访问环境变量不会把任何相关代码暴露给浏览器只有明确标注use client的组件才会被打包到前端。这个能力在 T3 架构中非常关键。因为 tRPC 的 server API 可以直接在服务端组件里通过 caller 方式调用不需要走 HTTP 请求。你在服务端组件里取数据本质上就是一次内存中的函数调用既没有网络开销也没有序列化损耗。再看目录结构我的项目是这样规划的src/ app/ # Next.js 路由目录 (dashboard)/ # 需要登录的界面组 projects/ tasks/ api/ # 其他 HTTP 路由 layout.tsx server/ api/ root.ts # tRPC 路由聚合 routers/ # 各个业务子路由 auth.ts # NextAuth 配置 db.ts # Prisma 客户端实例 trpc/ server.ts # 服务端调用器 client.ts # 客户端请求代理这个结构的关键在于把 server 目录下的所有业务代码与前端组件物理隔离从根源上杜绝“服务端代码被意外打包到客户端”的问题。app 目录下只管页面和组件复杂的取数逻辑全部放在 server 里。2.2 TypeScript 严格模式类型即文档的基石t3code 对 TypeScript 的使用不是“能用就行”而是全程开启严格模式。create-t3-app 生成的 tsconfig.json 里会默认开启strict: true这还不够建议继续打开noUncheckedIndexedAccess和exactOptionalPropertyTypes。这些配置看起来只是编译期限制但它对运行时的安全性影响很大。举个例子noUncheckedIndexedAccess开启后访问数组元素arr[i]的类型会变成T | undefined强制你处理越界情况。在写业务代码时你可能会觉得烦但在 tRPC 的输入解析和表单数据处理场景中这种严格性帮我们提前拦截了大量潜在的空值错误。从表达力上讲TypeScript 类型已经不只是文档它成了前后端之间唯一的协议约定。我甚至要求团队成员在代码 review 时把类型变化当作第一关注点因为这往往意味着接口契约变了。2.3 tRPC 的端到端类型安全机制tRPC 的工作方式可以用一句话概括在后端定义函数前端直接调用类型自动同步。它的运行时链路并不神秘本质上是一个轻量级 RPC 框架后端通过procedure定义可调用的方法每个 procedure 可以声明输入校验、中间件和业务逻辑前端通过 React Query 的useQuery/useMutation封装调用。先看服务端定义// server/api/routers/task.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const taskRouter createTRPCRouter({ list: protectedProcedure .input(z.object({ projectId: z.string().uuid() })) .query(async ({ ctx, input }) { return ctx.db.task.findMany({ where: { projectId: input.projectId }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), projectId: z.string().uuid(), priority: z.enum([LOW, MEDIUM, HIGH]), }) ) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { ...input, creatorId: ctx.session.user.id } }); }), });这里最值得注意的设计是 zod 的引入。zod 承担了运行时校验和类型推导双重角色z.object({...})不只是验证数据它推导出的 TypeScript 类型会直接作为input参数的类型。前端调用时你传错一个字段、少传一个必填项编辑器立刻红波浪线。这就是端到端类型安全的来源——输入类型、输出类型都在编译期被完整检查过误用 API 在这里变得很难实现。客户端调用同样简洁// components/TaskList.tsx use client; import { api } from ~/trpc/client; export function TaskList({ projectId }: { projectId: string }) { const { data: tasks, isLoading } api.task.list.useQuery({ projectId }); if (isLoading) return div加载中.../div; return tasks?.map((task) ( div key{task.id}{task.title} - {task.priority}/div )); }注意这个api.task.list.useQuery它背后自动连接了 React Query 的缓存、重新请求和状态管理逻辑。你不必再手动写 loading、error 状态也不需要担心多个组件重复请求同一份数据。如果你还停留在“每个页面自己管请求状态”的写法这层封装能直接释放一大块精力。2.4 Prisma 数据库建模与迁移T3 的数据访问层由 Prisma 负责。Prisma 的核心优势是 schema 驱动的模型定义和类型生成在schema.prisma里写完数据模型运行prisma migrate生成 SQL 迁移文件再运行prisma generate更新 TypeScript 类型。以 t3code 里的任务看板为例我设计了三个核心模型datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) email String unique name String? tasks Task[] } model Project { id String id default(cuid()) name String ownerId String owner User relation(fields: [ownerId], references: [id]) tasks Task[] } model Task { id String id default(cuid()) title String priority Priority default(MEDIUM) status Status default(TODO) projectId String project Project relation(fields: [projectId], references: [id]) creatorId String creator User relation(fields: [creatorId], references: [id]) createdAt DateTime default(now()) } enum Priority { LOW MEDIUM HIGH } enum Status { TODO IN_PROGRESS DONE }这个模型设计里有几个经验值得分享。第一是主键统一用cuid()而不是自增id。自增整数在中小项目里没问题但在分布式环境和数据合并场景下容易撞车cuid 生成的无序字符串密钥则天然适合前端临时创建数据。第二是在 relation 字段上要明确指定relation的关联字段名否则 Prisma 会默认创建两个外键导致前端赋值得多传一个字段。第三是枚举类型尽量用数据库枚举而不是字符串这样在 tRPC 的 input 校验里可以直接用z.enum()对应类型检查更严格。3. 完整实操从零搭建 t3code 项目3.1 初始化与依赖安装搭建过程第一件事是使用官方脚手架生成基础项目。在终端运行npx create-t3-applatest t3code交互式命令行会让你选择要包含的功能模块。我的选择是Next.js、TypeScript、tRPC、Prisma、Tailwind CSS、NextAuth。不需要单独的 ESLint 和 Prettier因为脚手架已经内置了这些配置。如果你是在已有项目里渐进式引入 T3也可以只装 tRPC 相关包但那样配置成本会高不少新手建议直接全选。初始化完成后第一件事是配置环境变量。项目根目录下有一个.env.example文件复制为.env后填写DATABASE_URL。我用的数据库是 PostgreSQL本地如果是 Docker 环境可以直接用一段命令起一个实例docker run --name t3code-postgres -e POSTGRES_USERpostgres -e POSTGRES_PASSWORDpostgres -e POSTGRES_DBt3code -p 5432:5432 -d postgres:16-alpine然后.env里的连接串长这样DATABASE_URLpostgresql://postgres:postgreslocalhost:5432/t3code?schemapublic NEXTAUTH_SECRET随便生成一个足够长的随机字符串 NEXTAUTH_URLhttp://localhost:3000等数据库容器启动后先运行npx prisma migrate dev --name init这会根据默认的 schema 生成第一版迁移并同步到你本地的数据库。再运行npm run dev浏览器打开http://localhost:3000基础模板已经能跑起来了。3.2 数据模型设计与迁移实战我在 3.1 里没有直接用脚手架自带的基础 schema而是先替换成任务看板的三张业务表。具体 schema 已经在 2.4 展示过了这里重点说迁移过程。每次改完 schema.prisma必须按固定流程走一遍npx prisma migrate dev --name add_project_model npx prisma generate第一行命令会生成迁移 SQL 并应用到数据库同时重新生成 Prisma Client。第二行虽然在 migrate 时已经自动执行过但在团队协作时队友拉取代码后只跑npx prisma migrate deploy就行不需要也不应该在别人环境里自动跑migrate dev。migrate dev是会做数据清空的如果涉及破坏性变更生产环境必须用migrate deploy。为了让本地开发有数据可用我写了一个 seed 脚本// prisma/seed.ts import { PrismaClient } from prisma/client; const db new PrismaClient(); async function main() { const user await db.user.upsert({ where: { email: demot3code.dev }, update: {}, create: { email: demot3code.dev, name: Demo User }, }); const project await db.project.create({ data: { name: t3code 开发计划, ownerId: user.id, }, }); await db.task.createMany({ data: [ { title: 搭建项目基建, projectId: project.id, creatorId: user.id }, { title: 完成任务看板, projectId: project.id, creatorId: user.id }, ], }); } main() .then(() console.log(Seed completed)) .catch(console.error);在package.json里加上prisma: { seed: tsx prisma/seed.ts }之后运行npx prisma db seed就能一次灌好数据。关于 seed 有一个坑如果表结构经常调整seed 脚本里的createMany可能因为关联数据不存在而失败所以顺序很重要必须保证user先存在再创建project最后创建task。3.3 实现 tRPC API 的完整链路现在进入核心环节把任务模块的后端接口跑通。首先要在server/api/root.ts里把路由聚合起来import { createTRPCRouter } from ./trpc; import { taskRouter } from ./routers/task; import { projectRouter } from ./routers/project; export const appRouter createTRPCRouter({ task: taskRouter, project: projectRouter, }); export type AppRouter typeof appRouter;这个AppRouter类型是整个类型安全链路的起点。前端trpc/client.ts里的createTRPCClientAppRouter和trpc/react.tsx里的createTRPCReactAppRouter()都会引用它从而保证两端对同一份类型定义工作。回到任务路由我补充两个高价值的接口分别处理“按项目筛选任务”和“更新任务状态”updateStatus: protectedProcedure .input( z.object({ taskId: z.string().cuid(), status: z.enum([TODO, IN_PROGRESS, DONE]), }) ) .mutation(async ({ ctx, input }) { const task await ctx.db.task.findUnique({ where: { id: input.taskId }, select: { creatorId: true }, }); if (!task) throw new TRPCError({ code: NOT_FOUND }); if (task.creatorId ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN }); } return ctx.db.task.update({ where: { id: input.taskId }, data: { status: input.status }, }); }),这个接口看起来简单但里面有几个我踩过的坑值得说清楚。第一是权限校验不能放在前端做必须在 procedure 内部做。很多人只判断“登录用户”却忘了区分“资源的创建者”。我一开始写共享看板时所有项目成员都能改任务状态后来加了一圈“仅创作者或项目所有者可改”的规则才发现如果这种逻辑散落在组件里很快会失控。集中到 tRPC resolver 里是最稳妥的做法。第二是错误类型必须用TRPCError而不是直接throw new Error()。如果你直接抛普通 Error前端收到的错误信息会很不友好而且错误 code 无法统一处理。用TRPCError后前端可以根据error.data.code判断是权限问题、参数问题还是资源不存在从而给出对应文案。第三是select和include的取舍。在查询任务时通常不需要把整个关联用户对象拖出来用select只取需要的字段可以有效降低数据库 IO 和网络传输量。在 Prisma 里include会把关联记录全部加载数据量一上来性能会很难看。3.4 前端页面接入与交互优化后端接口完备后前端接数据就变得相当无脑。这里分享一个实际的“任务看板”页面实现包含列表、新增和状态更新use client; import { api } from ~/trpc/react; import { useRouter } from next/navigation; export function TaskBoard({ projectId }: { projectId: string }) { const router useRouter(); const { data: tasks, isLoading } api.task.list.useQuery({ projectId }); const createTask api.task.create.useMutation({ onSuccess: () { router.refresh(); }, }); const updateStatus api.task.updateStatus.useMutation(); if (isLoading) return div任务加载中.../div; return ( div classNamespace-y-4 button onClick{() createTask.mutate({ title: 新任务, projectId, priority: MEDIUM })} classNamerounded bg-blue-500 px-4 py-2 text-white 添加任务 /button {tasks?.map((task) ( div key{task.id} classNameflex items-center gap-4 rounded border p-4 span{task.title}/span select value{task.status} onChange{(e) updateStatus.mutate({ taskId: task.id, status: e.target.value as TaskStatus })} option valueTODO待处理/option option valueIN_PROGRESS进行中/option option valueDONE已完成/option /select /div ))} /div ); }这里有两个交互层面的优化点。第一是 mutation 成功后的数据刷新。tRPC 集成 React Query 后默认情况下如果你操作的数据正好是某个 useQuery 的缓存 keyonSuccess触发后 React Query 会自动失效缓存并重新请求。但如果你用了router.refresh()它会触发服务端组件重新渲染两条链路都能确保界面更新。实测中我建议两者选其一避免重复请求。第二是乐观更新。在任务状态变更这种高频操作里等待网络返回再更新 UI 会让交互显得迟缓可以用 React Query 的onMutate做乐观更新。更省事的方案是调用api.task.list.setData手动修改缓存立即反映用户操作再通过onError回滚。我通常只在“状态切换”“点赞”这类操作上做乐观更新对于表单提交这类需要确认结果的场景还是等真实响应更稳妥。3.5 NextAuth 鉴权配置与跨端安全T3 里的鉴权默认交给 NextAuth最新版叫 Auth.js。它支持 GitHub、Google 等 OAuth provider也支持 Credentials 账户密码登录。t3code 里我同时配了 GitHub OAuth 和 Credentials但实际项目建议优先 OAuth省去自己维护密码哈希和找回密码的麻烦。关键配置在server/auth.tsimport { getServerSession, type NextAuthOptions } from next-auth; import GitHubProvider from next-auth/providers/github; import { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ./db; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(db), session: { strategy: jwt }, providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], callbacks: { session({ session, token }) { if (session.user token.sub) { session.user.id token.sub; } return session; }, }, }; export const getServerAuthSession () getServerSession(authOptions);这里有一个非常重要的细节默认情况下 PrismaAdapter 使用数据库 session 策略但如果你在 CRUD 里大量使用ctx.session.user.id数据库 session 会频繁查询 session 表性能不够好而且 JWT 策略更适配 Serverless 部署环境。所以配置里显式声明session: { strategy: jwt }把用户标识直接打进 token后续每个请求不需要回数据库反查 session 就能拿到用户身份。tRPC 的 protectedProcedure 是这样关联鉴权的export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); });这个中间件在路由定义的顶层执行任何挂上 protectedProcedure 的接口都要求登录。如果你需要管理员接口可以再包一层use判断ctx.session.user.role权限体系可以逐级扩展。前面 3.1 已经提到要在.env里配置NEXTAUTH_SECRET。这个密钥必须足够长且不在代码仓库里出现否则部署后被别人拿到可以伪造 session token。生产环境我还建议在 Vercel 或云服务器的环境变量面板单独配置GITHUB_CLIENT_ID和GITHUB_CLIENT_SECRET在 OAuth provider 后台把回调地址从http://localhost:3000/api/auth/callback/github换成线上域名。4. 常见问题与排查技巧实录4.1 tRPC 接口报 500 但控制台看不到任何日志这应该是 tRPC 新手遇到最多的一个问题。我在写 t3code 的早期经常出现前端弹出“Internal Server Error”但服务端控制台完全没有堆栈信息。排查后发现根因几乎都出在 resolver 内部当 procedure 抛出的错误不是 TRPCError 时tRPC 会把它包装成通用 500默认不打印详细信息。解决办法分两步第一所有可能失败的路径都主动抛出 TRPCError并给足上下文信息第二在根路由上加一个全局错误处理 middleware。这里是最有效的一段代码export const t createTRPCContext() .middleware(async ({ ctx, next }) { const result await next(); if (!result.ok result.error instanceof Error) { console.error(tRPC error:, result.error); } return result; });next()返回的result.ok并不是指业务逻辑失败而是指中间件链路是否成功。业务里 throw TRPCError 也会走这段逻辑这样所有异常至少会在服务端留下痕迹。4.2 Prisma 生成的客户端类型没有跟上 schema 变更现象通常是这样的你刚改了schema.prisma里的字段名运行完migrate dev然后在编辑器里调用ctx.db.task.update参数提示还是旧的。这是因为 Prisma Client 的类型定义是生成出来的如果你的编辑器没有自动重启 TS Server或在一体化开发环境如 monorepo里引用了缓存类型不会即时刷新。强制解决的方法是npx prisma generate然后重启编辑器或手动重载 TypeScript 服务VSCode 里按 CtrlShiftP 输入 “TypeScript: Restart TS server”。如果问题还在多半是tsconfig.json的 paths 配置把prisma/client指到了缓存目录建议检查node_modules/.prisma/client是否存在。4.3 NextAuth 的 session.user 里取不到邮箱或头像这个问题发生的背景是我用 GitHub OAuth 登录后前端拿到的 session 对象里只包含name和imageemail 字段要么没有要么提示为 null。根因是 NextAuth 默认不会把邮箱和头像之外的字段带进 session。如果你确实需要 email有两个方案。第一方案在 provider 配置里显式申请user:emailscope。GitHub provider 可以加authorization: { params: { scope: read:user user:email } }。第二方案推荐不要依赖第三方返回的 email而是在数据库里以account.userId反查用户记录。t3code 里的做法是 session callback 里拿到token.sub也就是数据库用户主键然后直接用这个主键去 User 表查询业务需要的字段。这个方案的好处是只要数据库用户表结构稳定session 内容始终可控。4.4 useQuery 的 data 为 undefined 时不要急着判空React Query 在首次加载和重新验证数据时data都可能是undefined很多新手会直接写if (!data) return null结果页面刚加载完闪一下或者每次刷新都出现空白。正确做法是区分isLoading和isFetchingisLoading表示还没有拿到任何数据首次isFetching表示正在后台重新取数但已有缓存。业务上更安全的写法是if (isLoading) return Skeleton /; if (!tasks || tasks.length 0) return EmptyState /;这样既不会让页面在每次 background refetch 时重新渲染成空白也能正确处理“接口返回空数组”的情况。4.5 部署到线上后的类型安全防线失效本地开发时类型安全的体验非常爽但部署之后有一个容易忽略的点Prisma Client 的类型是在构建期生成的如果 CI 流程只跑next build而没有先跑prisma generate构建产物会引用旧类型或直接报错。我在 t3code 里把这一步写进了 vercel build scriptbuild: prisma generate next build另外线上数据库的 schema 更新一定不能跑migrate dev它会在非交互式环境里阻塞等待确认。线上用prisma migrate deploy即可。如果你的平台是 Vercel还得注意环境变量DATABASE_URL应该指向生产数据库不要在构建阶段复用本地.env。5. 实战后的个人总结把 t3code 从零写到能在真实业务里使用我对 T3 Stack 这套组合的理解发生了不少变化。最初我以为它只是“类型安全”这一个卖点实际写完才发现它最大的收益是降低了全栈开发的上下文切换成本你不需要在“后端定义接口-生成文档-前端写请求-对字段类型”这几个环节之间反复横跳所有代码都在同一份类型宇宙里改一处能立刻看到另一端的影响范围。这个体验对那种需要快速迭代内部工具、后台管理系统、SaaS 应用的团队来说价值远远大于几个工具包的简单相加。如果你想进一步扩展有几个方向值得尝试一是把 Prisma 换成 Drizzle ORMtRPC 依然可以正常工作针对 SQL 偏好的团队体验更好二是接入 Playwright 对核心链路做端到端测试因为 tRPC 接口走的是 HTTP POST测试工具可以直接模拟登录后调用三是把全局错误处理、日志上报、OpenTelemetry 追踪整合进 middleware生产环境排障会轻松很多。在后续迭代上我给 t3code 规划了工作流状态机、项目成员权限和操作审计日志三个模块等这些模块做出来整个模板的项目复杂度和参考价值会比现在高一大截。最后分享一个很小但很实用的习惯因为 tRPC 的 input 校验依赖 zod我在所有路由的 input schema 里都加上了.catch()或者safeParse的辅助函数并把校验错误信息统一返回。这个做法的好处是前端拿到错误时可以直接展示 zod 生成的友好提示不用再自己拼字符串。刚开始可能觉得多写了几行但长期维护时每个字段的报错都是可预期的这也是把 t3code 当成长期基础设施来维护时我从经验里沉淀下来的一个值得坚持的细节。