刚把手头这个代号为 T3Code 的全栈项目收尾,趁着热乎劲把整个设计思路、踩坑过程和能直接复用的配置都记下来。T3Code 这个名字很容易理解:T3 沿用了社区里 T3 Stack 的缩写,Code 则表明整套工程的核心是类型安全的编码实践。它不是某个单一的函数库,也不是一个脚手架模板,而是一套以 TypeScript 为主线、把前后端串联在同一个类型系统中的完整项目范式。简单来说,就是在 Next.js 的框架下,用 tRPC 去掉手写 API 层的胶水代码,用 Prisma 管数据库建模,再让 Tailwind 把样式问题收敛到原子类里。全程下来最大的体感是:类型从数据库一路贯通到 UI 组件,中间几乎不需要人为维护接口文档。
这篇文章适合两类人看:一类是正在学全栈、但被 Express + Axios + 手写 DTO 那一套折腾得身心俱疲的开发者;另一类是已经在用 Next.js 做业务、想试试 tRPC 但不知道怎么落地的小团队。我会把核心的设计判断、每一步操作背后的理由、以及我在实际项目里踩过的坑都交代清楚,你可以直接照着这套思路去搭自己的项目。
1. 项目整体设计与技术选型思路
1.1 T3Code 到底想解决什么问题
在动手之前,我先想明白了一个事情:这个项目想解决的并不是“怎么把数据存进数据库”这种基础问题,而是前后端协作时的信任成本问题。传统开发模式下,后端定义接口、前端根据文档调用,一旦字段命名不一致、类型对不上、请求路径写错,最容易在联调阶段炸锅。T3Code 的核心目标,就是让 TypeScript 的编译器在开发阶段就把这些错误拦下来,而不是等运行时才暴露。
所以整个项目不是为了炫技,而是为了验证一条链路:数据库表结构定义好之后,前端组件里到底能不能自动获得完整的类型提示?后端的某个字段一旦改名,前端代码是不是立刻报错?实测下来的结论是:能,而且非常彻底。Prisma 负责把数据库表变成 TypeScript 类型,tRPC 把服务端路由变成类型安全的过程调用,Next.js 的 App Router 又在服务端组件里直接消费这些类型。这一套组合拳打下来,我几乎不需要再打开浏览器里的 Network 面板去核对接口返回的字段了。
1.2 为什么锁定 T3 Stack:TypeScript 与三层架构的逻辑
选型的时候其实犹豫过几个方向:纯 Express 写 API 加 React 前端、用 NestJS 做后端、或者直接用 Next.js 的 Route Handler 手写 REST API。最终锁定 T3 Stack,核心原因只有一个:它把类型安全当作第一公民,而不是事后补充。
T3 Stack 的组合逻辑其实可以拆成三层看。最底层是数据模型层,用 Prisma 定义数据库 Schema,表结构就是类型来源;中间是服务端逻辑层,用 tRPC 写查询和变更,参数与返回值都从 Prisma 的类型派生出来;最上层是展示与交互层,用 Next.js 的 Server Component 和 Client Component 配合,Tailwind 负责样式。每一层的信息传递都不需要手写中间类型定义,编译器会自动完成推导。
有人可能会问,直接用 Next.js 的 Route Handler 写 REST API 不也挺好吗?好处是标准化,所有客户端都能调,但代价是每个接口都要手动写请求和响应的类型定义。这个在接口少的时候无所谓,接口一旦超过二十个,类型维护的成本就开始指数上升。tRPC 的思路是用 TS 自身的类型系统代替协议描述,整个开发过程就像调用本地函数,但实际上执行在服务端。
1.3 每一层的角色拆解:Next.js / Tailwind / tRPC / Prisma
- Next.js:应用的地基。我用的是 App Router 模式,服务端组件默认在 Node 环境跑,适合直接访问数据库和调用服务端资源;客户端组件负责交互逻辑,比如表单输入、按钮点击后发起变更。RSC 的延迟加载和缓存策略也变相减少了前端请求数量。
- Tailwind CSS:样式方案。T3Code 里没有单独写 CSS 文件,组件类名直接拼原子类。选 Tailwind 不是因为“大家都在用”,而是因为它配合组件化开发能显著减少上下文切换——我不用在 JSX 和 CSS 文件之间来回跳。碰到复杂布局就抽成小组件,类名虽长但隔离性很好。
- tRPC:前后端的粘合剂。它是整个项目类型安全的核心。客户端通过泛型推断拿到服务端路由的输入输出类型,改一个字段立刻能发现哪个页面在依赖它。
- Prisma:数据库访问层。它生成的 client 类型是精确到字段级别的,且默认排除掉密码等敏感字段的暴露,配合迁移命令,建表和改表都比较可控。
我刻意没选 GraphQL 和 Redux,理由也简单:GraphQL 的 schema 和 resolver 对于这个量级的项目是额外负担,Redux 则在全栈类型安全里帮不上忙,反而引入了一堆样板代码。T3Code 想传递的理念是“让框架替你搞定通用问题,把精力留给业务”。
2. 核心功能模块拆分与设计要点
2.1 数据模型设计:以典型的业务表为例
T3Code 里我设计了一套简单的业务模型,对象是“任务清单”加“标签系统”,这套模型能覆盖绝大多数 CRUD 应用的常见场景。Prisma Schema 里两个核心模型如下:
model Task { id String @id @default(cuid()) title String content String? status TaskStatus @default(TODO) authorId String author User @relation(fields: [authorId], references: [id]) tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique tasks Task[] }这里有两个设计细节值得展开。第一,所有 ID 我都用 CUID 而不用自增数字,原因是这类全栈项目后续大概率要支持分布式部署或离线同步,CUID 在客户端和服务端都能安全生成,不需要查完自增值再回填。第二,状态字段我用枚举TaskStatus而不是字符串,Prisma 会把它映射成数据库枚举,也直接给 TS 提供字面量联合类型,前端判断状态时就不会出现拼错字符串的低级错误。
实际写业务时,我建议把content这类用户输入的长文本字段设置成可空(String?),因为列表页往往只需要标题和状态,详情页才需要正文。这看起来是小事,但直接影响数据库查询性能,也影响前端组件对可选字段的处理逻辑。
2.2 tRPC 路由与类型安全:定义到消费的全链路类型推导
tRPC 在 T3Code 里的组织方式并不是把所有接口平铺在一个文件里,而是按照领域模型拆成子路由。根路由使用router合并子路由,这样每个模块的变更都比较独立。
export const appRouter = router({ task: taskRouter, tag: tagRouter, }); export type AppRouter = typeof appRouter;子路由内部充分利用了 Prisma 生成的类型。一个简单的查询流程如下:
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const taskRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ tagId: z.string().optional() })) .query(async ({ ctx, input }) => { return ctx.db.task.findMany({ where: { authorId: ctx.session.user.id, ...(input.tagId ? { tags: { some: { id: input.tagId } } } : {}), }, include: { tags: true }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional(), tagIds: z.array(z.string()).default([]), })) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { connect: input.tagIds.map((id) => ({ id })) }, }, }); }), });这里最值钱的一点是:前端调用trpc.task.list.useQuery({ tagId: "xxx" })时,入参的校验规则和返回数据的类型全部由后端推导而来。如果我在前端传了不存在的筛选条件,比如tagid,TypeScript 会在编译阶段直接报错,根本轮不到浏览器发请求。zod 在这里起到的是运行时校验兜底的作用,因为请求边界上总有不可信数据进来。
2.3 认证与会话:NextAuth 的接入方式
全栈项目里认证模块往往被轻视,很多脚手架让你自己接 JWT,结果两边各写一套逻辑。T3Code 里直接用 NextAuth.js 的 Credentials 和 OAuth 双通道方案。
关键配置放在src/server/auth.ts,NextAuth 的 session 策略我这里明确选strategy: "jwt",理由是这个项目部署在 Serverless 环境,无状态 JWT 模式不用额外维护 session 存储,冷启动也更快。JWT 里的用户信息通过callbacks.session同步到前端的 session 对象:
callbacks: { session({ session, token }) { session.user.id = token.sub!; return session; }, jwt({ token, user }) { if (user) token.sub = user.id; return token; }, }这里有个很关键的技巧:Prisma 查询到用户后不要直接整个丢进 session。数据库里的 User 表通常有 passwordHash 之类的敏感字段,直接把整个对象塞进 session 是常见的安全漏洞。我只把id、name、email这类必要字段放进 session,所有受保护路由只从ctx.session.user.id取当前用户的主键值,再去查数据库拿完整数据。
3. 从零初始化到跑通:T3Code 实操全记录
3.1 初始化 create-t3-app 与目录结构解析
T3Code 的使用体验很大程度上取决于初始化的参数选择。官方脚手架create-t3-app提供了交互式选项,我这次选择的关键选项如下:
pnpm create t3-app@latest t3code在交互环节我选了 Next.js、Tailwind、Prisma、tRPC、NextAuth,这五个组件正好覆盖全栈所有环节。TypeScript 是默认带上且不可取消的。初始化完成后,目录结构里几个重点需要注意:
src/server/api/root.ts:聚合所有子路由,是 tRPC 的服务端入口。src/server/db.ts:PrismaClient 单例,避免开发环境热更新导致连接泄漏。src/trpc/server.ts与src/trpc/react.tsx:服务端调用与客户端调用的封装,前者供 Server Component 直接调用,后者生成供 Client Component 使用的带 react-query 能力的 hooks。src/server/auth.ts:NextAuth 配置,脚手架默认给了一个 Discord OAuth 示例,直接运行会报错,需要改成自己可用的 provider。
这种分层的目录结构看着繁琐,但层与层之间的职责非常清楚。我在实际项目里没有改动这个基础骨架,只往里填业务代码。
3.2 环境变量与配置文件:容易忽略的几个点
T3Code 里最容易踩坑的是环境变量。脚手架会生成.env.example,但很多字段一开始并没有值。做一个全栈项目,至少需要准备以下环境变量:
# 数据库连接字符串 DATABASE_URL="postgresql://user:password@localhost:5432/t3code" # NextAuth 密钥,生产环境必须改用强随机串 NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32" NEXTAUTH_URL="http://localhost:3000" # 如果用了 GitHub OAuth GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET=""这里公开分享一个我自己的教训:NEXTAUTH_SECRET一开始我用了随便写的字符串,本地跑没问题,但部署到 Vercel 后 session 校验偶尔会失效。原因是 Vercel 环境里若有多个实例,JWT 签名密钥必须一致。这个值建议直接用生成命令产出一次然后同时配置到所有环境。
Prisma 的schema.prisma里,generator client默认输出到 node_modules,如果部署时跑了独立脚本步骤,要注意 build 阶段会重新生成 client。在本地产物和线上产物不一致时,最容易出现运行时找不到@prisma/client的错误。
3.3 实现第一个 CRUD 流程:从 Prisma Schema 到 tRPC 查询
我以任务清单为例,把第一个完整流程拆成几步。第一步先写 Schema,第二步跑迁移,第三步写 tRPC 路由,第四步在页面调用。这套顺序是固定的,最好不要颠倒,因为 tRPC 的输入输出类型强依赖 Prisma 生成的类型。
迁移命令:
pnpm prisma migrate dev --name init迁移成功后,node_modules/.prisma/client会生成与 Schema 严格对应的类型。这时写 tRPC 路由就能直接用ctx.db.task,而不需要自己定义 Task 接口——这种“从数据库到路由自动生成类型”的体验,和以前手写 interface 再复制粘贴相比,效率提升非常直观。
页面层调用示例:
const { data: tasks, refetch } = api.task.list.useQuery(undefined); const createTask = api.task.create.useMutation({ onSuccess: () => refetch(), }); // 表单提交: await createTask.mutateAsync({ title: inputValue, tagIds: selectedTags, });注意这里useQuery和useMutation的泛型完全由api对象自动推导,我不需要再写任何 interface。前端拿到的tasks数组里的每一项都带有id、title、status、tags等字段,且status被收窄为枚举类型。
3.4 部署上线:Vercel 与数据库迁移注意事项
T3Code 的部署我优先推荐 Vercel,因为它和 Next.js 的搭配最顺滑。真正需要额外处理的不是前端,而是数据库迁移策略。在 Vercel 环境里,常见的做法是构建阶段执行prisma migrate deploy,而不是dev模式的迁移。
我在package.json的脚本里配置成:
{ "scripts": { "db:deploy": "prisma migrate deploy", "build": "prisma generate && next build" } }这里解释一下为什么不能用prisma migrate dev:migrate dev是为开发环境设计的,它可能尝试重置数据库或创建 shadow database,在生产环境执行风险极高。migrate deploy只负责应用已有的迁移文件,安全且幂等。构建时反复执行prisma generate是必要的,因为线上安装依赖时不会自动保留本地生成的 client 文件。
部署完后要立刻改环境变量里的NEXTAUTH_URL为线上域名,否则 OAuth 跳转回调地址会不一致。这是每个自托管 NextAuth 项目上线时几乎必踩的坑。
4. 常见问题与排查技巧实录
4.1 类型推导失效:先查泛型和 provider 是否对齐
T3Code 里最让人头大的问题,不是功能跑不通,而是类型推导突然“失灵”。症状一般是:前端api.task.list.useQuery里的返回数据变成了any,调用字段不报错,但也没有提示。排查顺序建议这样走:
- 确认根 router 的导出是否正常:
export type AppRouter = typeof appRouter; - 确认客户端创建时用的
createTRPCReact<AppRouter>()里的泛型是否与后端一致。 - 确认没有在
router合并时遗漏子路由。 - 最后检查 tsconfig 是否因为路径别名配置问题导致类型加载失败。
我见过最普遍的原因是有人把src/trpc/react.tsx里的createTRPCReact泛型传成了某个具体子路由类型,前端就只能拿到那一个子路由的方法,其余全变 any。
4.2 Prisma 迁移卡住:锁表和 shadow database
开发过程中修改 Schema 是家常便饭,但prisma migrate dev偶尔会卡住。我遇到的情况是本地数据库里有一张表被 Navicat 之类的工具手动锁住了,迁移进程等待锁释放,表现就是长时间不返回。排查方法:
SELECT * FROM pg_stat_activity WHERE state = 'active';找到长时间 idle in transaction 的连接后可以手动终止。另一个常见问题是shadow database权限不足,Postgres 用户如果没有创建新数据库的权限,migrate dev会一直报错。解决方案是在本地用户上授予 CREATEDB 权限,或者在schema.prisma里手动指定shadowDatabaseUrl。
4.3 服务端组件调用 tRPC 的注意事项
Next.js App Router 里,服务端组件可以直接调用 tRPC 的serverClient,不需要经过 HTTP 层。但有一个坑是:Server Component 里不能直接调用需要 session 的protectedProcedure如果该组件是静态渲染。因为静态渲染时没有请求上下文,拿不到 headers 里的 cookie,session 会为空。
我处理的办法是:把整块动态内容包到export const dynamic = "force-dynamic"的页面里,或者在根布局层面把 session 读取塞到服务端,再通过createCaller传入上下文。这是 T3 生态里比较进阶的用法,但能有效避免“服务端组件白屏但控制台无报错”的诡异现象。
4.4 后续扩展:Monorepo 与多客户端
T3Code 这套模式天然适合从单体走向 Monorepo。如果后续要加移动端或其他前端应用,可以把prisma和tRPC router抽成独立 package,通过 workspace 共享,移动端仍然可以借助 tRPC 的客户端适配层获得类型安全。我实测下来,Vercel + Turborepo 的组合最顺手,改动一个数据库字段,所有消费该类型的应用在编译期都会出现高亮提示,这种“改一处、全链路感知”的体验在全栈开发里非常有价值。
最后分享一点个人体会。T3Code 这类项目真正的门槛不在于某个框架 API 记不记得住,而在于你是否愿意从“接口思维”切换到“类型思维”。刚开始用 tRPC 时,我总觉得少点什么,后来才意识到,少的是以前手写类型定义时的那种重复劳动和出错机会。当你改了数据库字段,整个应用从服务端到客户端所有依赖它的地方瞬间标红时,你自然会明白这种开发方式的价值。如果读了这篇文章后你决定用这套组合重构自己的项目,建议一次只迁移一个模块,先把列表页跑通,再逐步扩大范围,你会比一开始就全量重构稳妥得多。