最近把一个从零到一的全栈项目收尾了,项目代号就叫t3code。这个项目本身不算复杂,但把它完整跑下来的过程中,我对整个 T3 技术栈的理解比之前深了好几个层次——尤其是类型安全到底能给开发流程省多少事、tRPC 和 Prisma 在真实业务里要怎么配合、以及部署到生产环境时那些文档里不会明说但早晚会踩的坑。这篇文章就把整个思路、关键选型、实操过程、踩坑记录全部摊开来说,希望给准备用 Next.js + TypeScript + tRPC + Prisma 这套组合做点东西的朋友,提供一份能直接照着走的参考。
为什么叫 t3code?其实就是 T3 Stack 的代号,核心想解决的是很多独立开发者和小团队反复遇到的问题:前后端类型不通、接口文档永远滞后、数据库设计跟业务对不上、部署环节东拼西凑。t3code 把这几件事用一套严格类型约束的方式串在一起做,做一个中小型全栈应用,从数据库到页面渲染全程类型打通,改一个字段,编辑器里立刻全线标红,这种体验用惯了真的回不去。
适合谁来参考这篇文章?如果你已经会 React 和 JavaScript 基础,想认真做一个落地可用的全栈项目;或者你是一名技术负责人,正在评估团队要不要引入 tRPC 或 T3 这套组合;再或者你对后端不可知论这套理念感兴趣但没实操过——这篇文章都值得你花 10 分钟过一遍。下面的内容全部基于我实际跑通t3code项目的过程,包括每一步的命令、每个选择的理由、每个坑的排查方式,不是抄文档。
1. 整体设计与技术选型的底层思路
1.1 为什么是 T3 而不是“传统前后端分离”
最开始我其实犹豫过:要不要用 Vue + Spring Boot,或者 React + Go,前后端一拆,各干各的。但 t3code 这个项目定位是“一个人能维护的中型业务系统”,这种规模下传统前后端分离的协作优势根本发挥不出来,反而要付出双倍的沟通成本。
T3 的思路核心是“后端不可知论”——不强制你用什么后端,但只要你用了 TypeScript,就能做到前端直接调用后端函数,类型全自动推导,不需要手写 API 层,不需要根据 OpenAPI 生成客户端,更不需要在联调的时候对着接口文档猜字段。t3code 里有一套用户管理、项目档案、日志记录的核心业务,如果按传统方式,光接口定义、DTO 转换、前端类型声明这三层,我预估要多写至少两天的样板代码。用 tRPC 之后,这些全都不存在了。
另一个关键理由是类型安全的端到端贯通。t3code 的数据模型改动很频繁,早期几乎每天调整 Prisma schema。传统方案里改一个后端字段,要同步改接口文档、改前端类型、改联调环境,任何一个环节漏掉,线上就会出隐蔽的运行时错误。但 T3 体系内,改掉 Prisma schema,运行prisma generate,tRPC 的 router 类型会立刻变化,前端调用处在编辑器里直接报错,问题在编译期就被拦截了。
1.2 技术栈拆解:每个组件在 t3code 里扮演什么角色
t3code 最终采用的组合是:
| 技术 | 在项目里的职责 |
|---|---|
| Next.js 14 (App Router) | 页面渲染、路由、服务端组件、API 托管 |
| TypeScript | 全链路类型系统的基础 |
| tRPC | 替代 REST API,提供端到端类型安全调用 |
| Prisma | ORM,负责数据库模型与迁移 |
| Tailwind CSS | 样式方案,快速搭建 UI |
| NextAuth | 身份认证与会话管理 |
这套组合不是随便拼的。Next.js 负责把服务端和客户端整合在一个框架里;tRPC 藏在 Next.js 的 API 路由后面,本身不需要额外服务器;Prisma 管数据库,schema 文件同时是类型来源和迁移依据;NextAuth 提供 OAuth 和 Session,和 tRPC 的 context 对接起来非常顺。每个组件边界清晰,但合在一起又像一个整体,这正是 T3 想要的效果。
我见过有人问“为什么不用 GraphQL”?GraphQL 解决的是“前端需要灵活查询多个资源”的问题,它引入了 schema、resolver、缓存、codegen 一整层复杂度。t3code 这种中小型系统,需求的其实是函数式远程调用 + 类型推导,tRPC 比 GraphQL 轻得多,不需要 codegen,类型直接从 router 定义推导,心智负担小。选型核心逻辑就是一句话:能用类型系统硬约束解决的问题,就不要引入运行时做额外开销。
1.3 目录结构与工程组织
t3code 的目录组织完全遵循 T3 官方脚手架,但我在实际开发中做了几处调整。主体结构如下:
t3code/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── app/ │ │ ├── api/trpc/[trpc]/ │ │ └── (routes)/ │ ├── trpc/ │ │ ├── server.ts │ │ └── client.ts │ └── styles/ ├── .env └── package.json几个容易忽视的点:src/server/api/trpc.ts是 tRPC 的初始化文件,专门负责创建 tRPC 实例、注入 auth 和 db 上下文,不要跟业务 router 混在一起;src/trpc/server.ts是服务端调用 tRPC 的封装,在 Server Component 里直接调用查询时要用它;src/trpc/client.ts给客户端提供useQuery等 hooks。这三层是 tRPC 在 Next.js 里能跑通的关键,很多人上来就把它们合并了,后面加中间件或者按路由拆分时就会很痛苦。
2. 核心细节解析与实操要点
2.1 Prisma Schema 设计:业务先行还是类型先行
t3code 有三张核心表:用户(User)、项目(Project)、操作日志(AuditLog)。最开始我把它们当成三个独立模型写,后来在实际写 router 时发现关联查询特别别扭。如果你也在做类似项目,建议先想清楚业务聚合边界,再落 schema。
我在 t3code 上踩过一个实在的坑:一开始给 Project 表加了status字段,直接用 String 存储,值就是'draft'、'active'、'archived'。开发中没问题,但后来有个路由要根据状态过滤统计,一个手滑把'actived'写进去了,前端类型完全检查不出来,因为它是字符串。排查花了我四十分钟。后来改成 Prisma 的 enum:
enum ProjectStatus { DRAFT ACTIVE ARCHIVED } model Project { id String @id @default(cuid()) name String status ProjectStatus @default(DRAFT) ownerId String owner User @relation(fields: [ownerId], references: [id]) createdAt DateTime @default(now()) }改动之后,任何非法状态在编译期就被 TypeScript 拒了,这类低级错误彻底消失。这是个值得记住的原则:能交给类型系统保证的,就不要依赖人的纪律。
权限方面,我的选择是user和admin两种角色,用 Prisma enum 加在 User 模型上,配合 NextAuth 的 session 在 tRPC middleware 里做鉴权。这块后面细说。
2.2 tRPC Router 组织:按业务域拆,不按操作拆
t3code 早期犯过一个组织错误:把 createProject、updateProject、deleteProject、listProjects 全塞进一个projectRouter,加上 userRouter 和 auditRouter,router 文件越来越胖。其实 tRPC 允许嵌套 router,更合理的做法是按业务域拆分子路由,再用createCaller或router合并。
实际上我在 mid 阶段按“资源 + 动作”重构成这样的模式:
// 文件 src/server/api/routers/projectRouter.ts import { createTRPCRouter, protectedProcedure } from "../trpc"; import { z } from "zod"; export const projectRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ status: z.enum(["DRAFT", "ACTIVE", "ARCHIVED"]).optional() })) .query(async ({ ctx, input }) => { return ctx.db.project.findMany({ where: input.status ? { status: input.status } : {}, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ name: z.string().min(2), status: z.enum(["DRAFT", "ACTIVE", "ARCHIVED"]).default("DRAFT") })) .mutation(async ({ ctx, input }) => { return ctx.db.project.create({ data: { name: input.name, status: input.status, ownerId: ctx.session.user.id }, }); }), });Zod 的 schema 直接作为 tRPC 的 input 校验层,这个设计很妙——校验规则写一次,前端和后端类型同步生效。我在项目里几乎把所有 mutation 的 input 都做了严格 zod 约束,比如name最小长度、email格式、可选字段的 default 值。这样带来的间接好处是:前端表单校验和后端校验用同一套规则,不会出现前端说“填错了”但后端放行,或者反过来。
另外提醒一句:protectedProcedure这种包装方式非常实用,我写了一个全局的 auth 中间件,用protectedProcedure包裹所有需要登录才能访问的接口,没登录直接抛 UNAUTHORIZED 错误。不需要登录的查询(比如公开列表页)再用publicProcedure。这个分层很清晰,也方便以后加角色权限。
2.3 NextAuth 与 tRPC 的会话打通
tRPC 的 context 是每次请求时创建的,标准做法是把 NextAuth 的 session 塞进 tRPC context。t3code 里我在src/server/api/trpc.ts做了这样的处理:
export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, ...opts, }; };然后在 procedure 初始化时,通过use.ctx.session?.user拿当前用户。这里有三个容易踩的坑:
第一,getServerSession 必须在服务端调用,不能在客户端组件里用,否则拿不到 session。t3code 里客户端要获取登录状态,统一通过 tRPC 暴露的sessionquery 来拉,而不是直接 import NextAuth 的方法。
第二,NextAuth 的 session 类型默认不全。默认只包含name、email、image,我在 User 模型里加了 id 和 role,所以要做模块扩展才能取到:
declare module "next-auth" { interface Session { user: { id: string; role: UserRole; } & DefaultSession["user"]; } }不扩展的话,ctx.session.user.id会直接报类型错误,这也是一个“类型系统逼你正确设计”的典型例子。
第三,OAuth 登录后,Prisma 里可能没有对应用户记录。t3code 用的是 GitHub OAuth + Credentials 混合登录,我在 schema 里给 User 的account、session绑定 NextAuth 默认的适配器表结构。用 NextAuth 自带适配器时,注意要填@auth/prisma-adapter,并且跑一次prisma db push或prisma migrate dev把扩展表建出来,否则登录会直接报 P2010 类错误。
3. 实操过程与核心环节实现
3.1 从脚手架到第一个页面:完整命令记录
下面这段是 t3code 实际初始化的完整命令,照着跑就行。用项目代号做应用名:
# 使用 create-t3-app 脚手架 pnpm create t3-app@latest t3code # 交互选择时我勾选了: # - Next.js (App Router) # - TypeScript # - tRPC # - Prisma # - Tailwind CSS # - NextAuth # - 数据库选 SQLite(开发阶段),后续生产切换 PostgreSQL选择过程中有两点建议。数据库我建议开发期先用 SQLite,零配置,跑起来快;但 Prisma schema 里不要写死 SQLite 特有的类型,尽量保持可迁移性。t3code 进入部署阶段后切换到了 PostgreSQL,只改了DATABASE_URL和provider,重跑一次 migrate,迁移文件自动生成,几乎没有额外成本。
脚手架装完后,需要先生成基础表。由于勾选了 NextAuth,prisma schema 里已经带了Account、Session等模型,直接执行:
pnpm prisma migrate dev --name init pnpm prisma generate随后启动开发服务器:
pnpm dev打开http://localhost:3000,确认首页跑通后,整个骨架就活了。t3code 的第一个功能我做了项目列表页,它验证了从数据库到 UI 的整条链:先在 prisma schema 建表、再写 tRPC router、然后在 Server Component 里用api.project.list()直接取数据,全程没有写一个 fetch。
3.2 数据库连接与 Prisma Client 初始化
t3code 中src/server/db.ts直接使用脚手架生成的代码,内部是PrismaClient单例。这背后有个值得一提的细节——Next.js 在开发热重载时,如果每次都new PrismaClient(),会因为连接溢出报警,所以脚手架会在globalThis上做缓存,避免开发模式下重复实例化:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = db; }这个模式不是可选项而是必选项。我在 t3code 开发中有一阵子频繁保存文件,日志里经常出现Error: P1001连接失败,排查半天发现就是因为这个实例被反复创建。如果你用 Prisma 配合 Next.js,直接把上面这段照抄,不要自己 new。
3.3 用 tRPC 打通类型安全的数据流
tRPC 的核心运行机制是一个基于 Next.js API 路由的 endpoint:/api/trpc/*。所有 procedure 请求都打到这个 endpoint 上,通过 URL 里的参数区分是哪个 router、哪个 procedure,以及是 query 还是 mutation。它不需要单独的服务器进程、不需要部署额外的 gateway,看着像“本地调用”,实际走的是 HTTP——这个抽象做得很干净。
在 t3code 中,前端组件用一个自定义 hook 调用后端逻辑:
// 客户端组件内 import { api } from "~/trpc/react"; function ProjectCreateButton() { const utils = api.useUtils(); const createProject = api.project.create.useMutation({ onSuccess: async () => { await utils.project.list.invalidate(); }, }); const onSubmit = (name: string) => { createProject.mutate({ name, status: "ACTIVE" }); }; }onSuccess里调用invalidate()让相关 query 自动重新拉取,这是 tRPC 里我最喜欢的功能之一——它替代了传统方案里“更新完手动刷新列表”或者“事件通知”的繁琐写法。数据变更后,列表、统计、详情页可以统一失效,下一渲染周期自动拿新数据。
需要注意的是,服务端组件里不能用api.useQuery。在 Server Component 里要调用 tRPC,需要用src/trpc/server.ts里导出服务端 caller:
import { api } from "~/trpc/server"; export default async function ProjectListPage() { const projects = await api.project.list({ status: "ACTIVE" }); // 直接渲染 projects }这个 caller 走的是函数调用路径,不经过网络,但类型和客户端已经保持一致了。t3code 里绝大部分列表页用了这种方式,首屏渲染直接出数据,不需要 loading 态。需要交互的按钮区域再单独拆客户端组件,用 hooks 处理。
3.4 Tailwind CSS 在 t3code 里的实战运用
样式这块我没有引入组件库,直接 Tailwind 手写。t3code 的核心页面包括登录页、项目列表、项目详情、用户管理,全部用 Tailwind 类完成。这里给新手三个我实际用出的心得:
第一,先把设计令牌定好,再动手写业务页。在tailwind.config.ts里扩展自定义颜色和字体,这样页面之间不会出现同一个语义颜色在不同页面深浅不一的问题。t3code 定义了brand和surface两组色系,统一了按钮、卡片、背景的视觉层级。
第二,页面结构用 flex/grid 组合,不要一味用绝对定位。列表页用grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4一行搞定响应式,搭配rounded-lg border p-4做卡片,可读性和视觉密度比传统 CSS 手写类高很多。
第三,复杂交互组件用 Tailwind + Headless UI。T3 脚手架可选装headlessui,t3code 的弹窗、下拉菜单用的都是它。它提供无样式交互逻辑组件,Tailwind 负责全部视觉,组合起来非常有生产力,完全不需要引入整套 antd 或 MUI。
4. 常见问题与排查技巧实录
4.1 类型没同步:”找不到 xxx Router 的调用方法”
这是使用 tRPC 后最多人遇到的第一道坎。现象是:前端写api.project.list(),编辑器报错说Property 'list' does not exist on type 'TRPCClientErrorLike'之类,或者干脆提示 router 里没有这个方法。原因几乎都是修改 router 后没有重启 Dev Server,导致 tRPC 类型更新没被 Next.js 的热重载拾取。
tRPC 的类型是从实际 router 函数推导的,而 Next.js 的 fast refresh 对这种跨模块的复杂类型推导支持并不完美。我实测下来最稳妥的做法是:改完 router,手动按Ctrl+R强制刷新页面,或者干脆重启 dev 进程;在 CI 里则确保跑一遍tsc --noEmit,让类型检查在提交前兜底。
如果你遇到类型报警但不知道错在哪个文件,直接看 TypeScript 报错信息中的引用路径,多半指向src/trpc/react.ts中的类型接口。顺着链路往上找,就能定位到是哪个 router 导出出了问题。
4.2 Prisma 迁移冲突与数据库残留
t3code 早期改动 schema 频繁,有一阵子连续执行了几次prisma migrate dev,结果迁移列表出现冲突,状态显示缺少某几个迁移文件。这通常是因为多人协作或者本地仓库拉取时迁移目录不同步。
我的处理方式是:
- 先备份一次当前库数据(本地开发阶段直接用 SQLite 文件复制即可)。
- 执行
prisma migrate reset --force,彻底重置本地库,让所有迁移从头执行。 - 如果仍不一致,检查
prisma/migrations/migration_lock.toml,确保 provider 一致,再删掉本地多余迁移目录重跑migrate dev --name sync。
生产环境切数据库时绝对不要这样做。我在上线的 PostgreSQL 实例上恢复数据后,刻意用了prisma migrate deploy而不是migrate dev。deploy只应用迁移文件、不生成新迁移,适合生产。这条纪律建议牢记:本地随便 reset,生产必须 deploy。
4.3 NextAuth 回调地址与 Session 失效问题
在本地开发时 NextAuth 的 GitHub OAuth 回调地址要配成http://localhost:3000/api/auth/callback/github,这个在 GitHub OAuth App 设置里。部署后要把生产域名也加上,并且.env里的NEXTAUTH_URL要同步更新。我有个朋友部署上线后登录一直 500,排查了两小时,最后发现是环境变量里的NEXTAUTH_URL没改,还在指 localhost,反向代理层转发过去的回调地址对不上。
另一个 Session 问题是:登录后长时间开着页面,点击时突然 401。原因是 JWT session 默认有效期到了。t3code 里我根据不同场景调整了策略——管理后台用数据库 session,有效期拉到 7 天;接口请求则继续用短时效的 JWT。具体在authOptions里配置:
session: { strategy: "database", maxAge: 60 * 60 * 24 * 7, }调整后体验好很多,除非用户主动登出,否则不频繁被踢。注意数据库 session 策略需要在 schema 里有 Session 模型,前面提到用 PrismaAdapter 时已经建过了,所以切过去零额外成本。
4.4 部署后的性能与构建坑
t3code 部署到 Vercel 后,有个页面首屏始终很慢,打开浏览器的 Network 面板发现某个列表接口返回了全量数据——虽然只有几百条,但每次渲染都重复序列化。后来我给列表 query 加了分页参数,在前端用infiniteQuery做“加载更多”。这个改动把首屏体积降了一半以上。
值得强调的另一个坑是:t3code 里任何客户端组件都不要直接 importserver-only依赖。tRPC 的服务端 caller 只能在服务端组件里用,客户端组件只能走api.*.useQueryhooks。我一度在共享组件里误用了服务端调用,构建时报错You're importing a component that needs server-only,排查后把组件拆成了两半:纯 UI 部分放共享,数据部分由父组件通过 props 传入。这类问题在团队协作初期很容易出现,脚手架生成代码里其实有区分,但你写多了自己的文件后要格外小心边界。
4.5 常见问题速查表
把 t3code 开发中积累的典型报错整理成一个速查表,遇到类似问题可以直接对号入座:
| 报错或现象 | 常见原因 | 应对办法 |
|---|---|---|
| tRPC 方法在编辑器里不存在 | 修改 router 后热重载未刷新类型 | 重启 dev server 或手动强刷 |
| P1001 数据库连接超限 | PrismaClient 重复实例化 | 使用 globalThis 单例模式 |
| NextAuth 登录后立刻 401 | NEXTAUTH_URL 未指向正确域名 | 检查 .env 与部署环境变量 |
| P2010 找不到关系表 | 未按 PrismaAdapter 建表 | 运行 migrate 生成 Account/Session 表 |
| 部署构建报 server-only 错误 | 客户端组件误用服务端模块 | 拆组件,数据通过 props 传入 |
| Session 类型缺少 id/role | 未扩展 next-auth 模块声明 | 增加 declare module 补全类型 |
| 列表接口返回慢、页面卡 | 全量数据一次性返回 | 改分页或 infiniteQuery |
| 生产数据库迁移异常 | 使用了 migrate dev | 改用 migrate deploy |
4.6 几个不写进文档但非常实用的细节
第一,日志要有唯一请求标识。tRPC 的中间件里可以给 context 注入一个 requestId,然后统一打进日志,排查看链路时非常管用。t3code 我用的是crypto.randomUUID(),一行代码,后期排障效率翻倍。
第二,环境变量千万别只写一套。.env里只放本地,部署平台单独配置生产变量,不要把线上 DATABASE_URL 写在仓库里。t3code 在 git 里保留了.env.example模板,DATABASE_URL、GITHUB_ID、GITHUB_SECRET、NEXTAUTH_SECRET全部用占位符,新环境克隆下来照着填就能跑。
第三,定期清理 tRPC 的 catch 分支。我早期在每个 mutation 里都写了 try-catch,后来发现其实没必要——tRPC 的异常会在客户端以error.message暴露,统一在 hooks 层面处理即可,写成千篇一律的 try-catch 反而掩盖了错误栈。把边界错误处理收敛到一个公共层,代码会干净很多。
5. t3code 后续扩展与个人实操体会
5.1 这个架构往后还能加什么
如果你要把 t3code 这类 T3 结构往前推一步,我建议按下面这条路线走,顺序很重要。第一步把 Prisma schema 里的数据模型覆盖到完整业务域;第二步利用 tRPC 的 middleware 加文件上传、订阅通知这类横切能力;第三步把 NextAuth 升级成多角色权限体系。每一步都不需要动整体架构,T3 的边界设计让横向扩展非常平稳。
我在 t3code 里其实已经加了两块不属于脚手架默认内容的东西:一个是基于 Prisma 的审计日志表,另一个是用serverOnly标记的定时任务模块。两个都顺利接上了,没有造成任何架构上的倾斜感,这也是我最终信任这套组合的原因——它不锁死你的扩展方式,只是在核心链路上做极致的类型约束。
5.2 关于类型安全的最终心得
最后说点个人体会。t3code 这个项目给我的最大经验,不是某种具体技术,而是“类型系统不是束缚,是脚手架”这句话的真实含义。传统开发里,你觉得类型声明是多写的工作;但在 T3 里,类型是自动生成、自动传播的,你不需要费心维护,它却能在你犯错时第一时间拦住你。t3code 整个开发周期里,我几乎没花时间“修复线上类型不匹配导致的数据问题”,这在以前是不可想象的。
严格来说,T3 并不适合所有人——如果你追求极致的后端性能或者需要大量非 TypeScript 生态的中间件,那它不一定是最优选。但如果你和我一样,想用一套人能完全理解、类型全程贯通、一个人也能快速交付的现代全栈方案,t3code 这套组合值得你认真投入。它在中小型业务场景下,是真的能让人只关注业务本身,而不是跟框架细节搏斗。