搞全栈这几年,我最大的精神内耗来源一直是那一层前后端之间的"类型断层"。接口文档写得再清楚,联调时照样出幺蛾子:字段名拼错、类型不匹配、字段类型改了前端毫不知情。直到我在一个叫 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 接口或者 GraphQL?REST 的好处是大家都熟,缺点是类型得靠手写或者工具生成,Swagger 那套代码生成器用过的都知道,生成的代码丑得没法看,而且一更新就冲突。
GraphQL 在类型安全上确实强,但你得引入一套查询语言的运行时、Schema 定义、resolver 分层,小项目用起来特别憋屈。tRPC 走的是另一条路:没有额外的接口描述语言,没有路由表,你的服务端函数本身就是接口。客户端调用的时候就像调用本地异步函数,类型自动推导。
打个不太严谨的比方:REST 像是你要通过一个翻译跟对方沟通,GraphQL 像是给对方寄了一整套说明书,而 tRPC 是直接把对方的电话给你,你们俩说同一种语言。
1.3 反面意见:t3code这类项目什么时候不该用T3
说句公道话,T3 不是万能的。如果你的项目是给第三方开放 API,那 tRPC 明显不合适,因为外部调用者不可能接你的 TypeScript 类型。如果你的团队前后端是分离的两个仓库、两拨人,T3 这套"同仓库、同语言"的优势也发挥不出来。
我在 t3code 里的场景是典型的个人全栈应用:自己写业务逻辑、自己写页面、不需要对外提供 API,那 T3 就是最优解之一。选型这事儿没有银弹,你得搞清楚自己的约束条件,再决定要不要上这套组合。
2. 从空目录到能跑的t3code:create-t3-app完整实操记录
2.1 环境准备与脚手架初始化
在动手之前,先把环境理清楚。t3code 用的是 create-t3-app 脚手架,要求 Node.js 版本在 18 以上,我用的 20 LTS。包管理器建议直接上 pnpm,T3 官方默认推荐的就是 pnpm,原因很简单——它处理依赖提升和幽灵依赖的问题比 npm 严格,后续跑 tRPC 和 Prisma 的二进制依赖会少很多莫名的坑。
初始化命令很简单:
pnpm create t3-app@latest t3code跑起来之后会出现一个交互式界面,让你选择要包含哪些功能模块。这一步很多人直接无脑回车,其实每个选项都值得过一遍脑子。
2.2 交互式选项逐一说明
脚手架的选项大概是下面这几个,我把当时的选择和建议一起列出来:
| 选项 | 我的选择 | 说明 |
|---|---|---|
| Next.js 版本 | App Router | 新项目默认 App Router,Pages 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_SECRET=openssl rand -base64 32 生成的值 DATABASE_URL=postgresql://... 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.$queryRaw`SELECT 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。四个小时以内你就能拥有一个类型安全的全栈骨架,后面往上叠业务逻辑,手感和速度都会好很多。