1. t3code 是什么:一个现代全栈项目的技术选型始末
t3code 这个名字,最初只是我仓库里的一个项目代号——当时想做一个结构紧凑、前后端共享类型定义的完整应用,顺手就把 T3 Stack 的 "T3" 和代码的 "code" 拼在了一起。但做着做着我发现,这个代号背后其实藏着一整套值得认真聊聊的选型思路。如果你最近在关注全栈开发,大概率听过 T3 Stack 这个词,它指的是 Next.js + TypeScript + Tailwind CSS + tRPC 这套组合,再按需搭配 Prisma 和 NextAuth。本文不是 T3 Stack 的官方文档复述,而是我把 t3code 这个项目从零搭到上线、再在真实流量下打磨的完整记录。
先交代一下项目的实际需求:一个带用户登录、数据管理后台和公开页面的中等复杂度 Web 应用,数据模型大概有用户、项目、评论三类,需要权限区分管理员和普通用户,对前后端联调效率和类型安全有比较高的要求。技术选型时我对比过几条路线:经典的前后端分离(React SPA + NestJS/Java 后端)、Next.js 全栈但 API 层用 REST、以及 T3 Stack 这套以 tRPC 为核心的类型安全方案。最终选了后者,原因会在下面展开。这个项目适合正在犹豫全栈技术选型的开发者参考,也适合已经用了 T3 Stack 但想在工程细节上做得更扎实的人——里面有一部分内容是我在产品环境踩过坑之后才真正理解的。
1.1 项目背景:为什么最终选择了 T3 Stack
最初的纠结很现实:团队里有熟悉 Node.js 后端的人,也有偏前端的同学,如果走前后端分离,API 文档、类型同步、环境切换这些事会占掉不少沟通成本。虽然可以用 OpenAPI/Swagger 生成类型,但那套流程每次改动都要重新生成、重新检查,总有类型和实际接口对不上的尴尬时刻。
T3 Stack 最吸引我的一点,是它把类型安全做到了端到端。前端调用接口时的输入输出类型,直接由后端 router 定义推导出来,不需要额外维护一份 API 类型文件。这不是省几行代码的事,而是整个协作模式变了:后端改返回结构,前端 TypeScript 编译立刻报错,问题在开发阶段就暴露,而不是上线后靠用户反馈抓瞎。
另一个选择理由是部署形态。项目是典型的小团队应用,不希望为它单独维护一台后端服务器。Next.js 的 API Routes 和 tRPC 可以整套跑在 serverless 平台上,数据库走 Prisma 连接,鉴权交给 NextAuth,所有代码在一个仓库里,从提交到上线非常顺。如果未来某个模块需要独立扩容,再拆出去也不会伤筋动骨。
1.2 技术栈概览与选型理由
t3code 的最终技术栈如下:
| 技术 | 承担的角色 | 选型理由 |
|---|---|---|
| Next.js 14 (App Router) | 应用框架 / 页面渲染 / API 入口 | 支持 SSR、ISR,一套代码同时解决页面和接口 |
| TypeScript | 全链路类型约束 | 前后端共享类型,编译期兜底 |
| tRPC | 端到端类型安全 RPC 层 | 消灭手写 API 类型,router 即类型来源 |
| Prisma | ORM / 数据建模 / 迁移 | Schema 声明式建模,类型生成与 TS 无缝对接 |
| NextAuth.js | 身份认证与 Session | 与 Next.js 集成度高,Provider 生态成熟 |
| Tailwind CSS | 样式方案 | 开发效率高,不引入额外样式架构负担 |
这套组合里,tRPC 是最不常见也最值得解释的一环。可能有人会问:REST 用得好好的,为什么要引入 RPC 风格?我的体会是,REST 的语义化路径在面向外部开放 API 时仍然非常好,但内部前后端联调场景下,它的成本反而很高——你要为每个资源设计 URL、方法、状态码,还要维护类型定义。而 tRPC 的思路很直接:后端定义 procedure,前端直接调用,中间没有 URL 拼装,没有手动类型声明,参数校验用 Zod,类型从函数签名里自动长出来。后面第 3 节会详细展示它怎么用。
2. 从零初始化 t3code:脚手架、目录结构与工程化细节
t3code 的初始化用的是 create-t3-app,这是 T3 Stack 社区维护的脚手架工具,会自动把 Next.js、TypeScript、Tailwind、tRPC、Prisma、NextAuth 组合成一个可直接运行的项目骨架。这一步本身不难,真正决定项目后续质量的,是初始化时的选项取舍和工程化规范。
2.1 create-t3-app 初始化与选项取舍
初始化命令很简单:
pnpm create t3-app@latest t3code交互界面会问你要启用哪些模块,我当时的勾选是:
- Next.js(必选)
- TypeScript(必选)
- Tailwind CSS(必选)
- tRPC(核心,必选)
- Prisma(选,因为数据模型明确)
- NextAuth(选,因为需要登录体系)
有两个选项我最初犹豫过,这里重点说。第一是"App Router vs Pages Router":脚手架默认推荐 App Router,实际用下来,App Router 的 Server Component 和 tRPC 的 server caller 配合得很好,但也带来了一些中间件和鉴权上的新坑(后面会单独讲)。第二是 ESLint 和 Prettier 的初始化配置,建议直接采纳默认,T3 团队的默认规则在实践里相当够用,没必要一开始就动手改,等项目跑起来再按团队习惯微调更稳妥。
2.2 目录结构与依赖管理的实际经验
初始化之后的目录结构是理解 t3code 的基础,有几个位置很关键:
src/ ├── pages/api/trpc/[trpc].ts # tRPC 的 HTTP 入口 ├── server/ │ ├── api/ │ │ ├── routers/ # 子 router 定义 │ │ └── root.ts # 根 router 合并 │ ├── db.ts # Prisma 客户端实例 │ ├── auth.ts # NextAuth 配置 │ └── api/trpc.ts # tRPC 上下文生成 └── utils/ ├── api.ts # 前端 tRPC 客户端封装 └── trpc.ts # 类型导出(供前端引用)这套结构的核心逻辑是:由 src/server 统一承载后端能力,前端通过 utils/api.ts 里导出的类型化客户端访问,所有类型都从 server 侧推导,所以后端是类型源头。刚开始接手的同事经常在这迷路,我一般建议先看 root.ts——它是所有路由的汇合点,看懂了根 router,就掌握了整个后端能力地图。
依赖管理方面,我推荐 pnpm 而不是 npm/yarn。T3 生态对 pnpm 的 workspace 支持一直很积极,安装速度快,依赖隔离也更干净。安装完之后建议立刻在 package.json 里确认几个脚本:
{ "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "postinstall": "prisma generate" } }这里postinstall非常重要,prisma generate会在装完依赖后自动生成类型,否则新克隆仓库的人一跑 tsc 就会看到一堆 Prisma 类型缺失的报错,而且不提示是 generate 的问题,排查很烦。
2.3 一个容易忽略的坑:环境变量与类型推断
t3code 在初始化阶段踩过的第一个实打实的坑,是环境变量。Prisma 需要DATABASE_URL,NextAuth 需要NEXTAUTH_SECRET和NEXTAUTH_URL,tRPC 本身不需要额外配置,但它的上下文里如果有数据库连接和 session,就会依赖前面这些变量。
脚手架默认会在根目录生成.env,但它不会把你的.env加入 gitignore 的提示。我见过不止一次有人把数据库连接串直接 push 到仓库里,后面改密码时全部失效,还暴露了线上库地址。所以第一件事就是确认.gitignore里有.env*,然后建立一个.env.example作为模板提交,里面用占位符写明每个变量的用途。这一个习惯,后面帮了我大忙。
另一个细节是,Next.js 只在服务端能直接读process.env.DATABASE_URL这类服务端变量。如果你在客户端组件里调用了含 Prisma 的 tRPC procedure,正确做法是让 tRPC 的 context 在服务端把数据准备好,前端永远不要直接接触数据库环境变量。T3 脚手架的默认封装已经遵循了这个原则,但如果你自己改过 tRPC 客户端的封装位置(有人图方便把它放到了组件文件里),就容易踩到NEXT_PUBLIC_前缀的坑。记住一条:只有以 NEXT_PUBLIC_ 开头的变量才会打进浏览器 bundle。
3. tRPC 端到端类型安全的落地实践
tRPC 是 t3code 里最核心也最影响开发体验的部分,单独拿一章来讲,值得。
3.1 从 REST 思维切换到 tRPC 心智模型
之前写过不少 REST 接口,刚切到 tRPC 时最不适应的点在于:REST 的第一反应是"这条资源长什么样、用什么 HTTP 方法操作它",而 tRPC 的第一反应是"我要调用哪个函数、传什么参数、拿什么结果"。用生活类比的话,REST 像你去邮局寄信——信封上写地址(URL)、填邮编(参数)、等邮递员按流程送;tRPC 更像你直接给对方打视频电话,问完就答,整个通信链路的格式限制被压缩到了最小。
当然,tRPC 不是没有约束,它只是把约定从"传输层"移到了"类型层"。参数要用 Zod 校验,procedure 有明确类型定义,输入输出通过 TypeScript 静态检查保证匹配。对我这种偏后端的开发者来说,最大的好处是不用再写 API 文档了——router 定义本身就是最权威的文档,前端同事在编辑器里就能看到完整的参数类型和返回类型。
3.2 实际编写第一个 router:类型推导为什么能工作
一个最简 router 的示例:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const projectRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { return ctx.db.project.findUnique({ where: { id: input.id }, include: { owner: true, comments: true }, }); }), });对应前端调用方式:
const project = trpc.project.getById.useQuery({ id: "xxx-uuid" });注意到没有?前端拿到的 project 对象,类型就是后端 Prisma 查询返回的完整类型,包括 owner 和 comments 的嵌套结构。如果哪天后端把include.comments去掉了,前端使用project.comments的代码会在编译期直接报错。这比我之前写过的任何接口文档都可靠,因为文档会过期,类型定义不会。
tRPC 能完成这个魔法,核心是 TypeScript 的泛型推导。createTRPCRouter接收的 router 定义会被推断成一个类型对象,trpc.project.getById这个客户端对象其实就是那个类型对象的运行时映射,所以你调用的每个 method 都天然带上了输入输出的类型信息。
3.3 服务端调用与客户端调用的边界
t3code 里有个典型场景:首页需要展示公开项目列表,要求 SEO 友好,所以必须在服务端渲染(SSR)。如果用客户端useQuery,首屏会有 loading 状态,体验差而且搜索引擎看不到内容。解决办法是用 tRPC 的 server caller:
import { createCaller } from "~/server/api/root"; const caller = createCaller(await createTRPCContext({ headers: headers() })); const projects = await caller.project.listPublic();这段代码放在 Next.js 的 Server Component 里,直接在服务端调用 tRPC procedure,返回值是 Promise 解析后的真实数据。这比单独写一套"服务端数据获取"逻辑省事多了,而且类型完全一致。这里有几个坑值得注意:
createTRPCContext在服务端组件里需要传headers()给 NextAuth 读取 session,否则鉴权状态的获取会失效。- Server Component 里调用
caller.xxx时,注意只能调用query类型的 procedure,mutations 应该在客户端交互时触发。 - 如果你把
caller定义在客户端组件文件里,Next.js 会直接报错说不允许在客户端调用服务端方法。所以 caller 相关代码一定要放在 server-only 作用域内,可以在 files 顶部加import "server-only";来强制约束。
3.4 type-only 导入的隐藏价值
在 t3code 的老版本里,前端会从~/utils/api导入一堆客户端封装。T3 脚手架往往会生成一个类型入口:
import type { AppRouter } from "~/server/api/root";这个import type不是可有可无的写法,它保证类型信息在编译后完全消失,不会把 server 端代码打进客户端 bundle。之前团队有人改成import { AppRouter }忘了写type,结果构建产物体积涨了不少,还被安全扫描工具警告服务端代码暴露。所以看到类似代码,一定要保留type关键字。
4. 数据库与鉴权:Prisma + NextAuth 集成的关键细节
t3code 的数据层没有绕弯子,直接选了 Prisma。它在 TypeScript 生态里的类型体验是最好的,Schema 文件改动后执行prisma generate,全项目立刻拿到新的类型。鉴权这块则用 NextAuth,因为它和 Next.js 的集成可以说是原生的——但在真实项目里,集成本身也暗藏了几个需要认真处理的细节。
4.1 Prisma schema 设计与迁移流程
我的 schema 里最核心的三个模型大致是:
model User { id String @id @default(cuid()) email String @unique name String? projects Project[] createdAt DateTime @default(now()) } model Project { id String @id @default(cuid()) title String ownerId String owner User @relation(fields: [ownerId], references: [id]) comments Comment[] visibility Visibility @default(PUBLIC) } enum Visibility { PUBLIC PRIVATE } model Comment { id String @id @default(cuid()) body String projectId String Project Project @relation(fields: [projectId], references: [id]) authorId String createdAt DateTime @default(now()) }设计阶段要注意的几点:
- 主键用
cuid()而不是自增 ID,避免遍历用户 ID 带来的信息泄露,分布式生成也更安全。 - 多对多关系(比如项目与标签)记得通过中间模型显式定义,别图省事用 JSON 数组,后面查性能会很痛苦。
- 字段命名统一 camelCase,Prisma 会自动映射到数据库的 snake_case 列,全项目风格一致,省心。
迁移流程我坚持用prisma migrate dev做开发环境的演进,每个变更生成独立的迁移文件并提交到仓库。上线前用prisma migrate deploy执行线上迁移。这比db push更可控——db push适合快速改原型,但它不生成迁移记录,多人协作时会乱。
4.2 NextAuth 接入的完整链路
NextAuth 的接入,简单说是三件事:配置 providers、告诉它怎么从数据库查用户、把用户信息扩展进 session。
export const authOptions: NextAuthOptions = { providers: [ GitHubProvider({ clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET }) ], callbacks: { async session({ session, token }) { if (session.user) { session.user.id = token.sub!; } return session; }, async signIn({ user }) { // 这里可以把 OAuth 返回的用户信息 upsert 到数据库 return true; }, }, };这里有个容易忽略的坑:NextAuth 的 session 类型默认只有name、email、image,没有id。如果你想在 session 里拿用户 ID,必须给types/next-auth.d.ts写类型扩展。不写这个扩展,session.user.id会编译报错,或者上运行时直接 undefined,然后你排查半天发现是类型没声明。T3 脚手架通常自带这个文件,但我见过不少人删掉它导致后续项目里反复出错。
还有一个安全建议:回调里别把敏感字段塞进 session。session 是会被浏览器拿到的(如果用 JWT 策略,甚至会被编码进 cookie),所以只放id和必要展示信息就够了。
4.3 权限控制的一个典型问题:中间件 vs tRPC 守卫
t3code 里的私有项目只有管理员可见。很多人第一反应是用 Next.js 中间件在路由层面拦截,但实际操作下来,我强烈建议把权限判断放到 tRPC 的 procedure 层。
原因有两个。第一,中间件只能拦 URL 路径,但 tRPC 的入口只有一个/api/trpc/*,你没法从路径区分这次调用是否需要权限,必须解析 body 里的 procedure 名,既繁琐又脆。第二,数据访问权限应当紧贴数据访问逻辑。如果你在中间件拦了页面,但某些数据是通过 server caller 在服务端组件里调的,中间件根本不会生效。
正确做法是定义受保护的 procedure:
export const protectedProcedure = publicProcedure.use( ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session } }); } );然后这样声明:
getPrivateProject: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { id: input.id } }); if (!project || project.visibility !== "PRIVATE") { throw new TRPCError({ code: "NOT_FOUND" }); } if (project.ownerId !== ctx.session.user.id && ctx.session.user.role !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } return project; }),这样权限逻辑和查询逻辑放在一起,任何人都能一眼看出"这个数据的访问条件是什么"。中间件里我只保留了最简单的登录态检查,用来挡住未登录用户进入某些页面,真正的数据安全,都在 procedure 层完成。
5. 部署与性能:t3code 进入生产环境后的排查记录
项目进入生产环境之后,问题完全不是开发阶段能预料的。这里记录两件我印象最深的事,一件是构建期的问题,一件是性能排查的完整链路。
5.1 部署环境选择与构建优化
t3code 最终部署在 Vercel 上,原因很简单:Next.js 的原生支持最完整,预览部署和回滚都很方便,团队不需要专门维护服务器。如果你需要自托管,也可以选择 Node 服务器 + Docker,但代价是你得自己处理 SSL、日志、进程守护、数据库连接池的复用策略,这些在 serverless 平台上是开箱即用的。
构建阶段最容易踩的坑,是prisma generate在 CI 环境的执行顺序。如果你的构建命令只有next build而没有在postinstall里跑prisma generate,构建镜像里可能没有最新的 Prisma Client,运行时会出现PrismaClient is not configured to run之类的问题。解决办法一是确保 postinstall 存在,二是在 Dockerfile/CI 步骤里显式执行npx prisma generate。
另外一个容易被忽略的是NEXTAUTH_SECRET。如果忘了设置,本地开发可能能跑(NextAuth 会回退到不稳定密钥),但部署到多实例环境后,session 会因为密钥不一致而随机失效,用户反馈就会是"登录之后时不时掉线"。所以部署前一定要生成一个固定密钥,并且只在服务端环境变量里配置。
5.2 一个在生产环境啃了很久的问题:数据库连接与缓存
上线后第一周,t3code 收到一个性能告警:首页的数据库 P95 查询耗时有 2.3 秒,虽然页面没有彻底崩溃,但体验明显慢。初步排查怀疑是数据库索引缺失,检查后发现不是,最后定位到两个真实原因。
第一个原因是 serverless 环境里的 Prisma Client 实例反复创建。每次请求进来都 new 一个 PrismaClient,等于每次都要新建数据库连接,连接池被快速耗尽。代码改成模块级的单例模式后,连接复用立刻生效:
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;第二个原因是首页的公开项目列表在每次 SSR 时都全量实时查询。数据本身变化不频繁,但查询路径包含多个嵌套关联(项目带作者、带评论数),每次 SSR 都重新聚合,计算量不小。我给这个查询加了一层基于时间的缓存,通过 React 的unstable_cache(或类似的数据缓存机制)让它在 60 秒内复用首次渲染结果。
注意:加缓存之前一定要想清楚数据的最终一致性要求。t3code 首页列表接受最长 60 秒的延迟更新,所以这种缓存是安全的;如果你的场景要求用户创建后立即出现在列表里,就不能用这种粗粒度的缓存,或者需要在 mutation 后主动刷新缓存。
5.3 性能瓶颈定位实录:从列表页慢到查询改写
这里把一次完整的排查链路分享出来,方便你复现思路。现象来自用户反馈:"打开项目列表页要转圈 3 秒左右,其他页面都正常。"
第一步,先确认是数据获取慢还是渲染慢。打开浏览器 DevTools 的 Network 面板,看到/api/trpc/project.listPublic这个请求等待(TTFB)约 2 秒。这基本定位到服务端查询或序列化问题。
第二步,在服务端给 tRPC procedure 加执行时间日志:
console.time(`listPublic-${input.page}`); const result = await caller.project.listPublic(); console.timeEnd(`listPublic-${input.page}`);部署后看日志,发现耗时集中在 Prisma 查询阶段,说明问题在 SQL 层面。
第三步,打开 Prisma 的查询日志(log: ["query"]或使用DEBUG=prisma:*环境变量),看到实际 SQL 后发现两个问题:1. 列表查询没有分页,一次查全部;2. 对每条项目又各自查了一次评论数(N+1 查询)。SQL 改写思路是:
- 先用
take: 20和skip: 0强制分页,避免数据量增长后无限膨胀; - 对评论数用
_count聚合在include里一次查出,而不是循环查comments.count():
listPublic: publicProcedure .input(z.object({ page: z.number().default(0) })) .query(async ({ ctx, input }) => { const items = await ctx.db.project.findMany({ take: 20, skip: input.page * 20, where: { visibility: "PUBLIC" }, include: { owner: { select: { name: true, avatar: true } }, _count: { select: { comments: true } }, }, }); return items; }),改完后同样的接口 TTFB 降到 300ms 以内。这次排查最大的收获其实不是"改了几行查询",而是让我养成了一个习惯:性能问题的第一步永远是测量,不是猜。没有日志和 SQL 观测,一切优化都可能是瞎打。
6. t3code 后续演进与我在这个项目上的经验沉淀
项目的代码稳定之后,我开始考虑它下一步怎么走,以及这套组合在未来项目里是否还值得沿用。
6.1 从功能开发向复用能力演进的几个抓手
t3code 在功能完备之后,我想做三件事提升它的可复用性。第一,把 UI 组件从"页面内联"抽到一个独立的@t3code/ui模块,让多个路由共享视觉风格和交互逻辑。第二,给 tRPC procedures 建立一套统一的错误码约定,比如用ZOD_ERROR、NOT_FOUND、FORBIDDEN区分错误类别,前端 catch 错误后统一提示。第三,把权限规则从前端的"隐性判断"改成后端返回的"能力列表",比如 session 里带permissions: string[],前端根据它控制按钮显隐,后端 procedure 根据它兜底校验。这套做法最大的好处是,以后无论什么客户端(Web、管理后台、移动端)接入,权限模型都不需要重新设计。
6.2 我眼中的 T3 风格:适合谁、不适合谁
用完整套组合,我越来越清楚它的适用边界。如果你的项目是:
- 内部工具或中后台系统(需要登录、需要表单、需要快速迭代)
- 中小规模 MVVM/单体 Web 应用(一个应用承担主要业务)
- 团队本来就熟悉 TypeScript,不想维护两份类型
那 T3 Stack 会给你非常高的效率。反过来,如果你要开放 API 给第三方开发者,或者有独立的 iOS/Android/小程序客户端与 Web 共存,那 tRPC 就不合适——外部消费者需要的是稳定、标准化的 REST/GraphQL 接口,而不是内部类型协议。另外,如果你需要极致的首屏性能、要用纯静态站点的边缘渲染,T3 的重量也不一定划算。
6.3 个人体会:开工前要确认的三件事
根据 t3code 的经验,如果有新的项目要用这套技术栈,开工前我会先确认三件事:
- 数据库 schema 是否已经基本清晰。T3 的爽点是改 schema 后类型全链路更新,但如果 schema 天天大变,迁移文件会非常多,同事间合并会痛苦。项目早期花两天把模型定下来,后面收益巨大。
- 鉴权方案是 OAuth 还是账号密码。NextAuth 对 OAuth Provider 支持成熟,但对"手机号 + 密码 + 邮箱验证码"这类传统注册流程,内置支持弱,你得自己写 Credentials Provider 并处理密码哈希、会话管理。这一点建议在开工前就定清楚,中途换鉴权方案伤筋动骨。
- 团队对类型安全的态度。这套栈的价值完全建立在所有人遵守 TypeScript 纪律之上。如果团队习惯"用 any 绕过去",那 tRPC 的端到端类型优势会大打折扣。不是说不能用 any,但你得约定它的使用边界。
我把 t3code 定义成"一个亲手验证现代全栈组合到底能省多少事的实验"。它不是一个宏大工程,但正因为它的规模刚好够覆盖完整闭环,我在它上面得到的体会才最具参考价值。如果你也正准备在下一个项目里尝试 T3 Stack,希望这些记录能帮你少走几个弯路。