搞全栈这几年我最大的精神内耗来源一直是那一层前后端之间的类型断层。接口文档写得再清楚联调时照样出幺蛾子字段名拼错、类型不匹配、字段类型改了前端毫不知情。直到我在一个叫 t3code 的个人项目里完整跑通了 T3 技术栈才第一次体验到什么叫写接口跟调用本地函数一样踏实。这篇文章准备把我从初始化脚手架、设计 Prisma 数据模型、写 tRPC 路由、接入 NextAuth 认证再到最终部署上线的完整过程原原本本记录下来包括那些让我调了一整个下午的坑和完整排查思路。如果你正犹豫要不要入坑 T3、或者想找一个能直接照着抄的全栈项目骨架这篇应该能帮你省下不少弯路。1. t3code的选型逻辑拆解这套组合到底治的是什么病1.1 全栈开发里最容易被忽视的类型割裂先说个所有全栈开发者都懂的场景。后端用 NestJS 或者 Express 写好了接口返回一个用户对象里面有id、email、profile这些字段。前端拿着 Swagger 或者 Apifox 导出的类型定义在自己项目里再维护一份 interface。问题就藏在维护这两个字上——后端字段一改前端那份类型定义基本没人会同步更新于是你会在运行时才发现问题。t3code 这个项目之所以选 T3核心就是为了治这个类型割裂的毛病。T3 的完整组合是 Next.js 做前端框架、TypeScript 做类型系统、Tailwind CSS 做样式加上 tRPC、Prisma 和 NextAuth.js 这几个扩展库。它们凑在一起的逻辑不是谁流行就用谁而是每一步都指向同一个目标让类型从数据库到浏览器不走样。Prisma 负责把数据库表结构变成 TypeScript 类型tRPC 负责把服务端函数的输入输出类型直接导出到前端Next.js 负责把前后端放在同一个代码库里。你在服务端改一个字段前端编译立刻报错这种改动即反馈的闭环用习惯了就再也回不去传统的前后端分离模式。1.2 t3code为什么没有选 REST 或 GraphQL我一开始也纠结过既然要做全栈为什么不直接用 REST 接口或者 GraphQLREST 的好处是大家都熟缺点是类型得靠手写或者工具生成Swagger 那套代码生成器用过的都知道生成的代码丑得没法看而且一更新就冲突。GraphQL 在类型安全上确实强但你得引入一套查询语言的运行时、Schema 定义、resolver 分层小项目用起来特别憋屈。tRPC 走的是另一条路没有额外的接口描述语言没有路由表你的服务端函数本身就是接口。客户端调用的时候就像调用本地异步函数类型自动推导。打个不太严谨的比方REST 像是你要通过一个翻译跟对方沟通GraphQL 像是给对方寄了一整套说明书而 tRPC 是直接把对方的电话给你你们俩说同一种语言。1.3 反面意见t3code这类项目什么时候不该用T3说句公道话T3 不是万能的。如果你的项目是给第三方开放 API那 tRPC 明显不合适因为外部调用者不可能接你的 TypeScript 类型。如果你的团队前后端是分离的两个仓库、两拨人T3 这套同仓库、同语言的优势也发挥不出来。我在 t3code 里的场景是典型的个人全栈应用自己写业务逻辑、自己写页面、不需要对外提供 API那 T3 就是最优解之一。选型这事儿没有银弹你得搞清楚自己的约束条件再决定要不要上这套组合。2. 从空目录到能跑的t3codecreate-t3-app完整实操记录2.1 环境准备与脚手架初始化在动手之前先把环境理清楚。t3code 用的是 create-t3-app 脚手架要求 Node.js 版本在 18 以上我用的 20 LTS。包管理器建议直接上 pnpmT3 官方默认推荐的就是 pnpm原因很简单——它处理依赖提升和幽灵依赖的问题比 npm 严格后续跑 tRPC 和 Prisma 的二进制依赖会少很多莫名的坑。初始化命令很简单pnpm create t3-applatest t3code跑起来之后会出现一个交互式界面让你选择要包含哪些功能模块。这一步很多人直接无脑回车其实每个选项都值得过一遍脑子。2.2 交互式选项逐一说明脚手架的选项大概是下面这几个我把当时的选择和建议一起列出来选项我的选择说明Next.js 版本App Router新项目默认 App RouterPages Router 除非有兼容性需求否则别选TypeScript必选这是 T3 的地基不选就没意义了Tailwind CSS选样式方案不想要可以去掉但它能省不少写 css 的时间tRPC选核心不选就退化成普通的 Next.js 项目了Prisma选ORM负责数据库访问和类型生成NextAuth.js选认证方案做用户体系基本绕不开导入路径别名选指向src写代码少敲很多../../初始化完成之后脚手架会自动安装依赖并生成一个.env文件模板。这里有一个非常容易踩的坑脚手架生成的.env里DATABASE_URL是占位符你得先配好才能真正跑起来。2.3 初始化完成后的目录结构解读装完之后看一眼目录结构T3 的脚手架已经帮你把分层逻辑铺好了t3code/ ├── prisma/ │ └── schema.prisma # 数据模型定义 ├── src/ │ ├── app/ # 页面和路由App Router │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts # tRPC 根路由 │ │ │ └── routers/ # 各业务路由 │ │ ├── auth.ts # NextAuth 配置 │ │ └── db.ts # Prisma Client 单例 │ └── trpc/ │ ├── client.ts # 前端 tRPC 客户端 │ ├── server.ts # 服务端 tRPC 创建逻辑 │ └── react.ts # React 绑定useQuery/useMutation ├── .env # 环境变量 ├── next.config.mjs ├── tailwind.config.ts └── tsconfig.json这个结构的信息量很大。server目录专门放服务端逻辑trpc目录放 tRPC 的客户端和服务端胶水代码。脚手架之所以要把db.ts单独抽出来是因为 Prisma Client 在开发模式下的热重载会有多实例问题官方推荐用 global 缓存做单例。这个细节后面会展开说。3. 打通前后端数据链路从Prisma模型到tRPC路由的完整过程3.1 先设计数据模型用户和帖子的关系一个全栈项目总得有业务数据。t3code 的示例业务是简单的博客用户发帖子帖子属于用户。先打开prisma/schema.prisma定义模型model User { id String id default(cuid()) name String? email String unique posts Post[] createdAt DateTime default(now()) } model Post { id String id default(cuid()) title String content String published Boolean default(false) author User relation(fields: [authorId], references: [id]) authorId String createdAt DateTime default(now()) updatedAt DateTime updatedAt }有几个设计取舍值得说明。主键用的是cuid()而不是自增整数因为对外暴露 ID 时自增 ID 会泄露数据量而且分布式环境下面自增主键会有冲突风险。updatedAt配合updatedAt指令Prisma 会在每次更新时自动维护省得手写。3.2 迁移数据库并生成客户端模型定义好之后执行两条命令pnpm prisma migrate dev --name init pnpm prisma generatemigrate dev会创建开发环境的 SQLite 或 PostgreSQL 数据库并执行迁移。这里有一点要注意开发环境默认建议用 SQLite生产环境换 PostgreSQL。原因是 SQLite 零配置本地跑起来最快但线上并发写多了就不行了所以部署时要切库。generate会根据schema.prisma生成类型安全的 Prisma Client。这一步做完ctx.prisma.user.findMany()这类调用的返回类型就自动注入了字段拼错当场编译报错。3.3 创建第一个tRPC路由并理解类型推导原理有了 Prisma 之后下一步是创建 tRPC 路由。在src/server/api/routers/post.ts里写import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ list: publicProcedure.query(({ ctx }) { return ctx.prisma.post.findMany({ orderBy: { createdAt: desc }, include: { author: true }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1, 标题不能为空), content: z.string().min(1, 内容不能为空), }) ) .mutation(async ({ ctx, input }) { return ctx.prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session?.user.id ?? anonymous, }, }); }), });然后把这个路由挂到根路由上import { createTRPCRouter } from ~/server/api/trpc; import { postRouter } from ./routers/post; export const appRouter createTRPCRouter({ post: postRouter, }); export type AppRouter typeof appRouter;这段代码里藏着一个关键点export type AppRouter typeof appRouter。这行导出的不是运行时对象而是从根路由推断出的完整类型。前端导入这个类型后tRPC 就能给 React hooks 提供完整的输入输出类型。你加一个 procedure前端立刻有对应的类型提示这就是改一个字段全项目报错的来历。3.4 前端组件消费useQuery和useMutation的使用类型推导落地到前端体验是这样的。在 RSC 或客户端组件里use client; import { api } from ~/trpc/react; export function PostList() { const { data: posts, isLoading } api.post.list.useQuery(); if (isLoading) return p加载中.../p; return ( ul {posts?.map((post) ( li key{post.id} h2{post.title}/h2 p{post.content}/p span{post.author.name ?? 匿名}/span /li ))} /ul ); }第一次跑通这个链路的时候我确实有点不真实感。没有手写 fetch没有接口 URL 拼接没有类型断言api.post.list直接用。参数若传错类型IDE 里直接标红。这让联调环节几乎消失了因为前端和服务端用的是同一种类型语言。4. 认证接入细节NextAuth和tRPC中间件怎么配合4.1 NextAuth初始化与Prisma适配器t3code 需要用户登录才能发帖所以认证是绕不开的一环。NextAuth 在 T3 里的配置有两层一层是初始化配置另一层是 Prisma 适配器。先装依赖pnpm add next-auth auth/prisma-adapter然后在src/server/auth.ts里配置import { PrismaAdapter } from auth/prisma-adapter; import { db } from ~/server/db; import NextAuth from next-auth; import GitHub from next-auth/providers/github; export const { handlers, auth, signIn, signOut } NextAuth({ adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID ?? , clientSecret: process.env.GITHUB_CLIENT_SECRET ?? , }), ], callbacks: { session: ({ session, user }) ({ ...session, user: { id: user.id, name: user.name, email: user.email, }, }), }, });这里的适配器起什么作用next-auth自带的默认 Session 策略是基于 JWT 的不查数据库。但当你需要拿用户 ID 去关联帖子、判断角色权限时JWT 不够用。Prisma Adapter 会把 Session、Account、User 这些表建到数据库里登录后服务端能直接通过数据库查会话。4.2 用受保护procedure实现登录校验tRPC 的中间件机制很适合做鉴权。脚手架默认生成的trpc.ts里有publicProcedure和protectedProcedure两个入口。protected 的定义长这样const isAuthed createTRPCMiddleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { user: ctx.session.user, }, }); }); export const protectedProcedure createTRPCProcedure() .use(isAuthed);有了protectedProcedure就能把发帖改成必须登录才能调用create: protectedProcedure .input(z.object({ title: z.string(), content: z.string() })) .mutation(async ({ ctx, input }) { return ctx.prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.user.id, // 直接拿用户ID }, }); });这一步比传统 REST 方案清爽在哪传统方案里你得在 Controller 里重复写从 token 解析用户 ID的逻辑每写一个接口复制一遍。tRPC 用中间件一次性解决后续新接口只要换protectedProcedure就自动带上了鉴权。4.3 前后端获取会话的差异T3 里的会话获取有两套方式容易搞混。服务端 RSC 里用auth()直接取import { auth } from ~/server/auth; export default async function Page() { const session await auth(); return div{session?.user?.name ?? 未登录}/div; }客户端组件里则用useSession()use client; import { useSession } from next-auth/react; export function UserBadge() { const { data: session, status } useSession(); // status 可以是 loading | authenticated | unauthenticated }注意客户端要用useSession前提是SessionProvider已经包在了组件树外面。脚手架默认在RootLayout里包好了你不需要额外处理。真踩过的坑是服务端组件里直接用useSession结果一直拿到 null因为 RSC 没有 React Context 机制只能用auth()。5. 我在t3code开发过程中踩过的坑和完整排查链路5.1 坑Prisma Client未生成导致的类型报错现象克隆完代码pnpm dev一跑编辑器里所有ctx.prisma的调用都报Property prisma does not exist on type。第一次遇到时我以为是 tRPC 上下文配置写错了查了十分钟。排查过程先看src/server/db.ts有没有导出 db 实例没问题。再看trpc.ts里有没有把db塞进 context也没问题。最后点开报错文件跳转发现prisma/client的.d.ts指向的是一个空目录。这才反应过来Prisma Client 的代码是prisma generate生成的而克隆项目后还没执行过这条命令。根因node_modules/prisma/client里默认不带任何生成代码必须由generate根据schema.prisma生成。如果schema.prisma改了但没重新 generate类型也停留在上一次的状态。解决在 package.json 的postinstall脚本里加上prisma generate这样团队成员 pull 代码后pnpm install会自动生成不用每个人手动执行{ scripts: { postinstall: prisma generate } }5.2 坑tRPC类型更新不及时前端一直看到旧的procedure现象在postRouter里新增了一个deleteprocedure前端api.post.delete一直都是 undefined刷新页面也没用。当时一度以为是 tRPC 版本兼容问题。排查过程仔细想了一下AppRouter的类型是从typeof appRouter推断出来的而appRouter又是从postRouter展开的。理论上改postRouter就应该触发前端类型更新。问题出现在编译缓存上——Next.js 的.next目录缓存了旧的类型定义。根因tRPC 的类型提示依赖增量编译产物而 dev server 长期不重启时tsc的增量缓存没有把 router 的变化传播出去。解决杀掉 dev server 重新跑一次pnpm dev类型马上就出来了。如果还不行就删.next目录再跑rm -rf .next pnpm dev这个坑之后我养成了一个习惯每次改完 tRPC 路由的输入输出类型如果前端没同步先重启 dev server 再查代码别浪费时间在业务逻辑上找原因。5.3 坑生产构建时环境变量缺失导致NextAuth直接崩现象本地跑得好好的pnpm build一执行就报MissingSecret错误构建直接失败。这个问题放在了上线前一天晚上遇到还是挺刺激的。排查过程错误信息指向 next-auth 的AUTH_SECRET。本地开发时 NextAuth 会自动生成一个临时 secret所以不配也能跑。但生产环境必须显式提供否则 NextAuth 拒绝启动。根因我在.env.local里加了NEXTAUTH_SECRET但 Next.js 在生产构建时读取环境变量的时机和环境不同。新版 next-auth 更推荐使用统一的AUTH_SECRET命名并且 RSC 场景下必须保证构建时能读到该变量。解决在.env.production里重新配置AUTH_SECRETopenssl rand -base64 32 生成的值 DATABASE_URLpostgresql://... GITHUB_CLIENT_ID... GITHUB_CLIENT_SECRET...另外提醒一句.env文件不要提交到 Git但.env.example一定要提交否则后来者根本不知道要配哪些变量。5.4 坑RSC与tRPC的边界问题现象在 App Router 下我用 tRPC 的服务端调用方法api.post.list()放到一个 RSC 里直连数据库结果跑出Error: tRPC failed to invoke。排查过程翻查 tRPC 文档后发现T3 脚手架提供的src/trpc/server.ts是专为 RSC 设计的调用器它可以跳过 HTTP 层直接调用 procedure。但是它的类型和客户端 hooks 是不一样的标准做法是import { api } from ~/trpc/server; export default async function ServerPage() { const posts await api.post.list(); // ... }而客户端组件只能用useQuery。两者不能混用。我就是把useQuery的写法搬到了 RSC 里自然崩。根因RSC 没有 React Query 的运行时环境不能使用 hooks而服务端调用器实际上是用一个临时的 server context 在服务端直接执行 procedure。理解了这条分界线之后报错就很好定位了。解决约定好规则页面骨架用 RSC api.xxx直查交互组件一律use client useQuery。界面首屏用 RSC 拿数据交互更新用 hooks这套组合在体验上非常顺。6. 部署上线阶段要盯住的几个细节6.1 数据库从SQLite切换到PostgreSQL开发时用 SQLite 图省事上线不能再省。DATABASE_URL换成 PostgreSQL 的连接串然后重新生成迁移pnpm prisma migrate deploy注意migrate dev只能用在开发环境生产环境要用migrate deploy它只执行迁移文件里的变更不会交互式地对照 schema 做 diff。我见过有人把migrate dev放到 CI 里跑结果因为需要交互确认直接挂掉所以这两个命令一定要分清楚。6.2 部署平台的选型和构建配置t3code 部署选择的是 Vercel因为 Next.js 和 Vercel 的配合是最省心的。但 Vercel 构建环境有一个特点构建时跑的命令和你本地不完全一样。我加了一个构建脚本pnpm prisma generate pnpm prisma migrate deploy pnpm next build这个脚本里prisma generate必须在next build之前因为 Next.js 构建期间要带上 Prisma Client 的类型。而migrate deploy要在拿到数据库连接串之后才能做所以 Vercel 的环境变量面板里DATABASE_URL一定要先配上。6.3 监控与日志个人项目也别裸奔。我给 t3code 加了 Sentry 做前端错误监控后端 tRPC 的 errorFormatter 统一接上日志上报。还有一组很实用的健康检查health: publicProcedure.query(async ({ ctx }) { await ctx.prisma.$queryRawSELECT 1; return { status: ok, time: Date.now() }; })部署完第一件事就是刷一下健康检查接口确认数据库连接没问题再继续接业务。7. 回看这个t3code项目有几点实际操作体会项目跑完再回头看T3 这套技术栈最值钱的地方不在于某个单独的技术而在于它们之间的类型契约被完完整整地串联了起来。数据库的模型变更、后端 procedure 的输入输出变化都能在前端编译时暴露出来。这种体验就像是把以前靠文档和自觉维持的接口规范变成了一道编译器替你守着的红线。开发过程中最爽的瞬间是改完schema.prisma里的一个字段执行migrate然后发现前端所有用到这个字段的页面集体标红逐个修过去绝不漏一个。这种编译器教你做事的确定性在复杂业务项目里能省下大把大把的联调时间。当然这套组合也有它的脾气RSC 和客户端组件的边界要时刻记在脑子里Prisma 的 generate 步骤不能省环境变量的管理要比传统项目更谨慎。但只要你把这些约束当成纪律T3 带来的效率提升是实打实的。如果你也想搭一个自己的全栈小项目我建议直接按 t3code 这个路径走一遍create-t3-app 初始化Prisma 建两个模型tRPC 各写一个 query 和一个 mutation再套上 NextAuth 的 protectedProcedure。四个小时以内你就能拥有一个类型安全的全栈骨架后面往上叠业务逻辑手感和速度都会好很多。