如果让我用一个词概括这两年全栈开发圈最明显的变化,我的回答是 t3code。这个词本身并不是一个特别复杂的开源库,而是 T3 Stack 这套技术组合在日常开发里的代称——背后是 TypeScript、Tailwind CSS、tRPC 这三样东西组成的一整套全栈写法。我第一次接触它纯属偶然:当时要做一个带后台的小项目,传统 REST API 那套流程把接口定义、类型声明、数据校验、文档维护全都拆开,前后端各写一份,改一个字段就要同步改三个地方。后来按 t3code 的思路把工程重写了一遍,类型从数据库一直通到页面组件,那种"改一处,编译器告诉你所有要改的地方"的体验,确实让人回不去。
这篇文章我打算把 t3code 的完整编码范式拆开讲,从项目初始化、数据建模、tRPC 路由到前端消费,再到我实际项目里踩过的坑。如果你手头正打算写一个全栈应用,又不想在接口层来回折腾,这套东西很适合你照着做一遍。
1. t3code 到底是什么:一个工程师的真实观察
1.1 从三个字母看全貌:TypeScript、Tailwind、tRPC
T3 这个名字最早来自社区里一个约定俗成的组合,三个 T 分别对应 TypeScript、Tailwind CSS 和 tRPC。很多人第一反应是"这不就是个 Todos 教程吗",实际上它是一整套有明确取舍的全栈工程范式,核心只有一句话:让类型安全覆盖从数据库到浏览器按钮的每一条路径。
TypeScript 负责语言层,保证变量、函数参数、返回值的类型在编译期是可检查的;Tailwind 负责界面层,用原子类快速拼出样式;tRPC 负责接口层,把后端的函数直接变成前端可调用的"远程过程",并且类型是自动推导的。再配上 Prisma 做数据库访问、NextAuth 做登录认证,就凑齐了现在社区里最常见的"T3 全家桶"。
有一点值得先说清楚:t3code 不是一个必须原样安装的 npm 包。它更像一套约定,你可以理解成"基于 T3 Stack 的编码行为规范"。真正让你省时间的不是某个魔法函数,而是这套规范约束下的工程结构。
1.2 为什么我把它称为"写在约定里的全栈范式"
传统的前后端分离开发里,最让人头疼的不是写代码,而是维护接口文档。后端写一个/api/posts,前端要自己再定义一遍Post接口,联调的时候两边对不上是家常便饭。t3code 的做法完全不同:后端用 tRPC 定义路由和入参校验,前端直接消费同一套类型推导,接口文档自动生成在编辑器里。
你甚至可以这样理解:在 t3code 工程里,后端路由就是一份活的 API 文档。前端调用api.post.byId.useQuery()时,IDE 会自动弹出参数要求、返回结构,拼错一个字段名都会标红。这种体验很像在同一个项目里调用本地函数,只不过这个函数运行在服务端。
还有一个被很多人忽略的约定是"Zod 做校验"。tRPC 允许你用 Zod schema 声明入参,这带来的好处是双重的:运行时会把非法请求挡在外面,编译期又能从 schema 推导出静态类型。一份代码同时管住了运行时和编译期,这是我用 t3code 之后最明显的效率提升点。
1.3 什么时候不适合用它
t3code 不是银弹。如果你要做的是一个纯前端展示站,没有数据库也没有登录,那直接用 Next.js 静态导出就行,没必要引入 Prisma 和 tRPC。如果你的业务大量依赖外部系统提供的 REST 接口,并且后端不在你控制范围内,t3code 的收益也会打折——你依然要在接入层手动定义一个 DTO。
另外,如果你的团队里没有 TypeScript 基础,或者所有人都在用 Java 思维写全栈,那学习曲线会明显更陡。t3code 适合的团队是:已经接受类型安全理念、愿意为前期约束换后期效率的工程团队。一个人开发小项目,它同样很好用,因为少写的代码量立竿见影。
2. 用 t3code 的姿势初始化一个全栈应用
2.1 初始化 create-t3-app 与三个必选项
官方脚手架create-t3-app到现在已经非常成熟,它能帮你把 Next.js、TypeScript、Tailwind、Prisma、tRPC、NextAuth 一次配好,避免自己搭环境时踩版本坑。初始化命令很简单:
npm create t3-app@latest my-app过程中会有一串交互选项,我的建议是第一次先全选,跑通一次再说。如果你不想引入认证,可以去掉 NextAuth,但 Prisma 和 tRPC 最好都留着,它们是 t3code 的核心。选完之后继续:
cd my-app npm install npm run dev启动后访问http://localhost:3000,你会看到一个带 Tailwind 样式的示例页面。这个模板已经把 tRPC 的 provider、Prisma 的 client、NextAuth 的 session 都接好了,你只需要往里填业务代码。
这里说一个很多人不知道的细节:create-t3-app 生成的项目目录里,src/server/api/root.ts是所有路由的汇总入口。你新增路由后必须把它挂到root.ts上,前端才能调得到。第一次用的人经常忘了这步,结果后端报了 404,排查半天发现只是少挂了一个 router。
2.2 Prisma 数据建模:先写数据库,再写一切
t3code 强调的"类型先于代码",最直接的体现就在 Prisma schema。你先用 schema 描述数据库表结构,然后 Prisma 会帮你生成迁移脚本和类型安全的 Client,后续所有代码都围绕着这套模型展开。
比如我要做一个简单的博客,模型可以这样写:
model User { id String @id @default(cuid()) name String? email String? @unique passwordHash String? posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } 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 }写完 schema 后执行:
npx prisma migrate dev --name init这条命令会做两件事:创建迁移文件,并重新生成@prisma/client的类型。迁移和生成类型是两个动作,容易被合并理解成一个。如果你只改了 schema 没跑迁移,数据库结构不变;如果你跑了迁移但编辑器没刷新,类型提示可能还是旧的。后面我会讲到这个坑的具体表现。
2.3 tRPC 路由:把后端程序变成前端直接可用的函数
在 t3code 里,你很少写传统意义上的 Controller。你只需要定义一个 router,里面按 query 和 mutation 分好处理函数。一个最简路由长这样:
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const postRouter = createTRPCRouter({ byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id }, include: { author: true }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: "test-user-id", }, }); }), });注意到没有?ctx.db就是 Prisma Client,ctx是由 tRPC 上下文注入的对象。这种写法最大的好处是:你不再需要为前端单独维护状态管理里的数据模型,返回什么结构,类型就是什么结构。
在真实项目中,我会把公开查询和需要登录的操作分开,用protectedProcedure保护内部接口。这个后面第 4 节我会展开讲。
2.4 前端组件消费:server component 与 client component 两种路径
t3code 对应的前端框架是 Next.js,它有两种消费 tRPC 的方式。
第一种是 Server Component,适合页面初次渲染时直接拿数据。在 T3 模板里,src/server/api/root.ts导出的api可以直接在服务端组件里调用:
import { api } from "~/trpc/server"; export default async function HomePage() { const posts = await api.post.byId({ id: "某个id" }); return <div>{posts?.title}</div>; }第二种是 Client Component,适合需要交互响应的页面,比如点赞、评论、分页。T3 模板用createTRPCReact生成了类型化的 hooks,你可以这样用:
"use client"; import { api } from "~/trpc/react"; export function PostButton({ postId }: { postId: string }) { const utils = api.useUtils(); const likeMutation = api.post.like.useMutation({ onSuccess() { utils.post.byId.invalidate({ id: postId }); }, }); return ( <button onClick={() => likeMutation.mutate({ id: postId })}> 点赞 </button> ); }invalidate是 t3code 里最常用的缓存刷新手段。它会把指定 query 的缓存标记为失效,下次渲染时自动重新拉取数据。写惯了手写 fetch + setState 的人,刚开始可能不习惯,但用了两次就会觉得离不开。
3. 类型安全的底层原理:你省的远不止"少写几行 API"
3.1 tRPC 的魔法:类型从 Router 到 useQuery 的传递
很多人好奇,tRPC 到底是怎么做到前端调用时后端类型自动出现的。核心机制是 TypeScript 的泛型推导,加上 tRPC 定义的一套类型工具。
你在后端定义postRouter时,tRPC 会把这个 router 的结构完整保存在AppRouter类型里。前端初始化createTRPCReact<AppRouter>()时,这个类型被传给了生成的 hooks。于是api.post.byId.useQuery()的input参数类型就自动变成了{ id: string },返回类型也自动跟踪了后端的query回调。
这就是为什么你在前端拼错了字段名会直接标红,而不是等到运行时接口返回 400 才发现。编译器替你完成了一次"接口联调"。
3.2 Prisma + Zod + tRPC 的校验闭环
t3code 的另一个特点是三层校验闭环:
- Zod schema:在 tRPC 路由入口做运行时校验,拦截非法参数。
- Prisma Client 类型:在做数据库访问时保证字段名和关联关系的正确性。
- TypeScript 编译器:在整个链条上保证静态类型的一致性。
举个具体场景:用户注册时前端传email和password,Zod schema 校验格式;如果没有问题,tRPC 把参数交给处理函数,处理函数再用 Prisma 写入数据库。这三者复用同一份数据定义,你根本不需要写第二遍类型。哪怕将来要加字段,改动也只集中在 schema 和数据库模型,编译器会提醒你所有用到的位置。
3.3 当类型推断派不上用场时的兜底策略
类型推导不是万能的。比如某些接口的数据结构来自外部服务返回,运行时你才能确定字段是否存在;又比如你用了动态对象键,TypeScript 无法完全收敛。t3code 的两个实用工具是inferRouterInputs和inferRouterOutputs:
import type { AppRouter } from "~/server/api/root"; import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; type RouterInput = inferRouterInputs<AppRouter>; type RouterOutput = inferRouterOutputs<AppRouter>;你可以用RouterOutput["post"]["byId"]这种写法,在业务层复用远端返回的类型。当你确实需要"打破类型"的时候,再考虑as断言。我个人经验是:t3code 的类型体系已经覆盖了 90% 的常规操作,剩下 10% 用显式定义兜底就好,没必要为了“彻底的类型洁癖”拖慢进度。
4. 一套真实业务场景:四小时完成一个带登录的评论系统
4.1 数据模型设计
理论讲完,我给你一个可复现的实操案例。假设要做一个极简的评论系统:用户可以登录,在文章下面发表评论,每条评论显示作者和创建时间。这里我们不需要自己做密码加密,直接用 NextAuth 的 GitHub 登录,省掉一大块鉴权逻辑。
数据模型在上一节的基础上加一个 Comment 表:
model Comment { id String @id @default(cuid()) content String post Post @relation(fields: [postId], references: [id]) postId String author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }跑一下npx prisma migrate dev --name add_comment,然后继续。
4.2 认证与受保护路由
create-t3-app 默认集成了 NextAuth,你需要先去 GitHub 创建一个 OAuth App,拿到 Client ID 和 Client Secret,放到.env里。然后检查项目里是否已经生成了src/server/auth.ts,T3 模板已经帮你配好了数据库 session 模型,通常可以直接用。
在 tRPC 里,protectedProcedure就是带登录保护的中间件,它确保ctx.session.user一定存在。我的评论创建接口必须只有登录用户能调用:
import { protectedProcedure, createTRPCRouter } from "~/server/api/trpc"; export const commentRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ postId: z.string(), content: z.string().min(1).max(500), })) .mutation(async ({ ctx, input }) => { return ctx.db.comment.create({ data: { content: input.content, postId: input.postId, authorId: ctx.session.user.id, }, }); }), byPostId: publicProcedure .input(z.object({ postId: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.comment.findMany({ where: { postId: input.postId }, include: { author: true }, orderBy: { createdAt: "desc" }, }); }), });注意protectedProcedure和publicProcedure的区别:前者会检查 session,后者不做登录校验。评论列表是公开的,所以用publicProcedure;发布评论必须登录,所以用protectedProcedure。
4.3 路由挂载与前端页面
新建commentRouter之后,别忘了挂到root.ts:
import { createTRPCRouter } from "~/server/api/trpc"; import { postRouter } from "~/server/api/routers/post"; import { commentRouter } from "~/server/api/routers/comment"; export const appRouter = createTRPCRouter({ post: postRouter, comment: commentRouter, }); export type AppRouter = typeof appRouter;前端页面就是一个标准的 Client Component。我习惯在本页直接渲染评论列表和表单,用useQuery拉数据,用useMutation提交:
"use client"; import { useSession } from "next-auth/react"; import { api } from "~/trpc/react"; export function CommentSection({ postId }: { postId: string }) { const { data: session } = useSession(); const utils = api.useUtils(); const { data: comments } = api.comment.byPostId.useQuery({ postId }); const createComment = api.comment.create.useMutation({ onSuccess() { utils.comment.byPostId.invalidate({ postId }); }, }); async function handleSubmit(formData: FormData) { const content = String(formData.get("content") ?? ""); await createComment.mutateAsync({ postId, content }); } return ( <div> <form action={handleSubmit}> <textarea name="content" placeholder="写下你的评论" /> <button type="submit" disabled={!session}> {session ? "发布评论" : "请先登录"} </button> </form> <ul> {comments?.map((comment) => ( <li key={comment.id}> <strong>{comment.author.name}:</strong> {comment.content} </li> ))} </ul> </div> ); }这样四十分钟不到,登录、查评论、发评论、列表刷新的闭环就通了。剩余时间我用来补页面样式和交互细节,这才是 t3code 让人舒服的地方——业务逻辑不用在 API 层反复横跳。
4.4 本地联调与身份模拟
开发时最方便的身份模拟方式是直接用 NextAuth 的 session。你在.env里配好 GitHub OAuth 后,启动项目访问/api/auth/signin,用 GitHub 账号登录一次,之后所有请求都会自动带上身份。T3 模板还在开发环境里预置了 debug 工具,你可以在页面里打印ctx.session来确认登录状态。
如果你不想真的跳转 OAuth,也可以先给protectedProcedure换成publicProcedure写业务,等联调完再改成受保护。但上线前一定要记得改回来,这是我见过最多的遗漏之一。
5. 踩坑复盘:分布式类型与部署环境的四个教训
5.1 类型生成顺序:Prisma 与 tRPC 的同步问题
t3code 里最典型的"灵异事件"是这样的:你明明写了正确代码,编辑器却一直报错property 'comment' does not exist on type 'PrismaClient'。大概率是 Prisma Client 没有重新生成。
解决办法很简单,改完 schema 后跑:
npx prisma generate但如果你在 CI 里跑构建,要记得把prisma generate放进postinstall脚本,否则服务器上拿到的@prisma/client是旧类型。我在package.json里的写法是:
"scripts": { "postinstall": "prisma generate", "dev": "next dev", "build": "prisma generate && next build" }5.2 中间件与 ctx 的类型扩容
t3code 工程里,createContext负责把数据库和 session 塞进ctx。一开始我会手动把某个 user 塞进 context,后来接入 NextAuth 时忘记同步类型,结果所有protectedProcedure都拿不到ctx.session。这类问题通常不报编译错误,只在运行时出现Cannot read properties of undefined。
解决方式是把CreateContext和 tRPC 的createTRPCContext类型对齐。T3 模板已经有现成的createContext,你只需要保证它把session: await getServerSession(...)返回给 tRPC 即可。自己额外加字段时,记得同时改 Middleware 的类型。
5.3 useQuery 缓存与乐观更新
tRPC 的缓存策略默认是"按输入参数缓存"。同一个api.comment.byPostId.useQuery({ postId: "a" })和{ postId: "b" }会分开缓存,这没问题。但如果你用同一个 key 去查不同条件,比如分页里的第 1 页和第 2 页,且没有在 query key 里带上 page,就会出现数据串页。
正确的做法是把所有筛选参数都放进 input:
api.post.list.useQuery({ page, limit });如果你要乐观更新,建议用onMutate先写缓存、成功后再invalidate。但刚开始用 t3code 时最好别急着做乐观更新,先把invalidate这条链路跑顺,体验稳定之后再加也不迟。
5.4 部署时 Next.js standalone 输出
T3 模板默认使用 Next.js 的 standalone 输出,但这个配置需要你在next.config.ts里主动开启:
const nextConfig: NextConfig = { output: "standalone", };否则部署到 Docker 时,你可能会觉得整个 node_modules 都太大。standalone 只输出最小依赖,启动速度会快很多。另一个部署常见问题是环境变量:DATABASE_URL、NEXTAUTH_SECRET、GITHUB_CLIENT_ID这些必须提前配置好,少了任何一个,服务启动时不会直接报错,但登录或数据库访问会诡异地失败。
5.5 常见错误速查表
| 现象 | 直接原因 | 处理办法 |
|---|---|---|
前端api.xxx不存在 | 路由没挂到root.ts | 在appRouter里注册对应子路由 |
| Prisma 报字段不存在 | 未运行prisma generate | 重新生成并重启 dev server |
protectedProcedure拿不到 user | NextAuth 配置或 session 类型没对齐 | 检查authOptions与createContext |
| 部署后 404 | 缺少环境变量或 standalone 未开启 | 补齐.env并设置output: "standalone" |
| 数据刷新不生效 | 缓存 key 没指定 | 给invalidate传入对应 input |
6. 把它变成日常:维护自己的 t3code 模板库
6.1 把 CRUD 流程固化成代码片段
t3code 真正用得顺手之后,你会发现自己写的代码模式高度重复:每加一个业务实体,基本就是 Prisma 模型、tRPC 路由、前端查询 hooks 三件套。这时候我建议你把这些常用结构固化成 VSCode 代码片段,或者直接做一个自己的 template 分支。
比如评论路由这段代码,我直接存成了 snippet,输入t3router就能自动展开。前端useQuery + useMutation的组合也存了一套。别小看这点工作量,它能让新业务上线时间压缩一半。
6.2 一个脚本完成"迁移-生成-类型检查"
我自己的项目里会额外加几个 npm scripts:
"scripts": { "db:gen": "prisma generate", "db:migrate": "prisma migrate dev", "db:push": "prisma db push", "typecheck": "tsc --noEmit" }改完 schema 后,我习惯按db:migrate、db:gen、typecheck的顺序走一遍。这三步看起来简单,但能挡掉 80% 的类型问题。一次养成习惯之后,我再也没有出现过"本地好好的,部署就崩"的情况。
6.3 还可以往哪个方向扩展
如果你已经掌握了基础链路,下一步值得深挖三个方向:
- 服务端渲染与缓存策略:把 tRPC 的 query 和 Next.js 的缓存机制结合起来,减少数据库压力。
- 复杂的权限体系:在 tRPC middleware 里加角色判断,让受保护接口支持管理员、普通用户、游客三种身份。
- 微前端或多服务接入:把 tRPC 作为内部服务间的通信协议,配合
httpBatchLink做请求合并,减少网络开销。
另外一个我自己很喜欢的扩展是给 tRPC 加日志中间件。你可以在createTRPCRouter外面包一层 middleware,把每次调用的路由名、耗时、用户 ID 打印出来。这在联调阶段定位问题非常高效,比前端断点一点点看 Ajax 请求舒服多了。
回到开头那句话,t3code 不是什么黑科技,它就是把一套"类型安全优先"的约定坚持到底。我最直观的体会是:项目规模越往后滚,这种全链路类型推导带来的返工量减少就越明显。很多人刚接触时嫌它约束多,等经历了一次字段改名后自动高亮所有需改位置的感觉,就知道约束到底值不值了。如果你正准备做一个新项目,我建议你完整按这套流程走一次,别跳过 Prisma 迁移也别绕过 tRPC 的输入校验,跑完再来评价它也不迟。