前后端各写一套类型定义、接口文档永远比代码慢一拍、后端改个字段名要翻三个文件——这种日子我过了好几年,直到把技术栈固定在 T3 Stack 上,项目命名为 t3code,才真正体会到"类型安全"这四个字不是口号,而是能直接省时间的日常。t3code 不是什么新框架,它是我把 TypeScript、Tailwind CSS、tRPC,外加 Next.js、Prisma、NextAuth 组合起来的一套全栈工程方案。你可以把它理解成一份反复打磨过的脚手架,但它更值钱的地方在于"为什么这么配"的取舍逻辑。这篇文章把我搭这套东西的完整过程拆开讲,从初始化、数据建模、接口层、登录鉴权到部署上线,全程带实际代码和踩坑记录,适合正在做全栈项目、又不想在类型上糊弄自己的人。
1. t3code 到底想解决什么问题
1.1 三个 T 的分工与组合逻辑
T3 Stack 的"3"来自 t3.gg 的 Theo 提出的三个核心件:TypeScript、Tailwind CSS、tRPC。很多人第一次听说时觉得这仨各管一摊——TS 管类型、Tailwind 管样式、tRPC 管接口调用——但放在一起,它们其实是在解决同一个问题的三个侧面:让数据和界面保持同步,让前后端协作不再靠"猜"。
TypeScript 提供的是静态检查能力,它的价值在项目小时不显眼,一旦组件、接口多了,重构时编译器能帮你兜住大部分低级错误。Tailwind 解决的是样式维护成本,原子化 CSS 类让样式跟着组件走,不会出现"改个按钮颜色要找半小时 class"的情况。tRPC 是最关键的一环,它让后端函数像本地函数一样被前端调用,同时类型完全自动推导,不用手写接口定义,也不用像 OpenAPI 那样维护一份独立文档。
在 create-t3-app 的默认组合里,Next.js 承担路由和渲染框架,Prisma 负责数据库访问,NextAuth 处理登录会话。这六个组件叠在一起,形成一条完整的链路:数据库表结构 -> Prisma 类型 -> tRPC 路由 -> React 组件,全部由 TypeScript 编译器串联。
1.2 为什么端到端类型安全不是锦上添花
先讲个我自己的经历。以前用 REST 接口写全栈项目,后端返回一个 JSON,前端要用any或者手动定义 interface 去接。后端把username改成nickname的时候,编译不会有任何反应,只有运行到页面才发现数据没了。运气好点是白屏,运气差点是报错。这种问题在联调阶段特别耗时间,双方各说各话,最后只能靠一起看接口文档来对齐。
t3code 的方案是把类型定义的权利交给编译器。你在 Prisma schema 里定义表结构,Prisma 自动生成类型;你在 tRPC router 里写查询逻辑,返回值的类型自动推导;前端用api.post.list.useQuery()拿到的东西,不需要任何手工声明,就是服务端返回的完整类型。改一个数据库字段,所有用到的页面在编译期就报错,改完一处,其余地方全部同步。
这就像两个人合作搬家具,以前靠口头描述"那个深色的、大概这么长的柜子",现在直接给一张带尺寸的图纸。沟通成本省下来的部分,才是你真正写业务逻辑的时间。
1.3 t3code 适合谁、不适合谁
如果你在做中小型全栈应用,比如个人博客、内容管理后台、SaaS 的 MVP 版本,t3code 这套组合非常合适。它不需要你搭建独立的 BFF 层,不需要维护 GraphQL schema,也不需要额外生成代码。一个仓库里前后端代码共存,改起来一步到位。
但如果你是前后端完全分离的大型团队,前端要对接多个后端服务,或者后端逻辑极其复杂需要独立部署,那 t3code 的方案就不太适用了。tRPC 更适合单体全栈架构,硬拆出去反而会丢失它的最大优势——类型联动。另外,如果你的团队对 TypeScript 不熟,这套方案的学习曲线会比传统 REST 高一些,TS 类型会让一些人一开始不适应。这个要提前衡量。
2. 项目初始化:把 t3code 从零搭起来
2.1 环境准备
动手之前先把环境确认好。t3code 的依赖需要 Node.js 18.18 以上版本,我建议直接用 Node 20 LTS,省去很多版本兼容问题。包管理器推荐 pnpm,create-t3-app 对它是原生支持的,而且 pnpm 的冷启动速度和磁盘占用都比 npm 好一点。
node -v # v20.11.0 <- 至少 18.18 npm install -g pnpm pnpm -v # 10.x2.2 交互式选项怎么选
初始化命令一条:
npm create t3-app@latest t3code它会问你使用哪种路由方式、是否启用 tRPC、Prisma、NextAuth、Tailwind,还会让你选 ESLint 和 Prettier。我的建议是:第一次用就全选默认推荐项。默认方案是经过社区大量验证的组合,如果你想在样式上玩花活,可以后续再加 shadcn/ui,但不要在一开始就去改基础配置。
尤其要注意的是,不要跳过 ESLint 和 Prettier。t3code 的模板里自带了一套规则集,包括@typescript-eslint的严格规则,这些规则会在你写any或者用了未定义变量的时候及时叫停。很多人觉得 lint 烦,实际上在类型系统之外,它是第二道防线。
2.3 初始化后的目录结构
初始化完成后,核心结构大致是这样:
t3code/ ├── prisma/ │ └── schema.prisma # 数据库表结构定义 ├── src/ │ ├── app/ # Next.js 页面与路由 │ │ ├── api/trpc/ # tRPC HTTP 入口 │ │ └── page.tsx │ ├── components/ # React 组件 │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ # 业务路由按模块拆分 │ │ │ ├── root.ts # 路由汇总 │ │ │ └── trpc.ts # tRPC 初始化与中间件 │ │ └── db.ts # Prisma Client 单例 │ └── trpc/ │ ├── server.ts # 服务端调用辅助 │ └── react.tsx # 客户端 hooks 封装 ├── .env # 环境变量 ├── tailwind.config.ts └── tsconfig.json这个结构里最值得留意的点是server/api目录。t3code 把服务端逻辑放在server前缀下,配合 Next.js 的渲染体系,尽量避免把敏感逻辑打进客户端 bundle。比如数据库查询、密钥相关的操作都只能出现在服务端文件里。这是很多人刚开始容易忽略的地方——在客户端组件里 import Prisma Client 会直接报错,这种"目录即边界"的设计其实是在帮你养成安全的习惯。
3. tRPC 核心实现:让类型从服务端流到客户端
3.1 tRPC 不是什么黑魔法
tRPC 的使用体验近似"远程函数调用":前端 import 一个对象,然后直接调用它的方法。它不需要编写 API schema,不需要生成 client SDK,整个链路全靠 TypeScript 的typeof和泛型推导来串联。原理上,它类似一个带类型信息的 RPC 框架,通过 HTTP 传输数据,但类型检查完全发生在编译期。
和 GraphQL 相比,tRPC 省掉了 schema 语言那一层。GraphQL 要先写 SDL(Schema Definition Language),然后编写 resolver,前端再写查询语句,每一层都有心智负担。tRPC 则是"直接定义一个函数,前端就能调用",没有中间描述层。当然这也意味着它缺少 GraphQL 那种灵活的"客户端指定返回字段"的能力,但对于单体全栈应用,这点灵活性基本用不上。
3.2 写第一个 router:query 和 mutation
在 t3code 里,业务代码通常按模块拆分。以发帖功能为例,server/api/routers/post.ts大概是这样的:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, }); }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id }, }); }), create: protectedProcedure .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: ctx.session.user.id, }, }); }), });这里有几个设计要点。publicProcedure是任何人都能调用的接口,protectedProcedure则会先检查登录态,未登录直接抛错。入参校验用 zod,它的类型可以直接从 schema 推导给前端,前端调用时传错参数会立刻在编译期报出来。
3.3 在 React 组件里调用
前端调用同样简洁。拿列表页举例:
"use client"; import { api } from "~/trpc/react"; export default function PostList() { const { data, isLoading, isError } = api.post.list.useQuery(); const createPost = api.post.create.useMutation(); if (isLoading) return <div>加载中...</div>; if (isError) return <div>出错了</div>; return ( <ul> {data?.map((post) => ( <li key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </li> ))} </ul> ); }useQuery返回的状态对象包含data、isLoading、isError、refetch等常用字段,和 TanStack Query 的用法几乎一样,因为 tRPC 的 React 集成本来就是基于 TanStack Query 封装的。
提交新数据时,mutation 成功后记得让列表重新拉取。一般用utils对象里自动生成的 invalidate 方法:
const utils = api.useUtils(); createPost.mutate( { title, content }, { onSuccess: async () => { await utils.post.list.invalidate(); }, }, );这里最容易犯的错是忘记 invalidate,导致新数据不出现,必须刷新页面才看到。我建议所有增删改的 mutation 都写在创建成功后的回调里,养成习惯。
3.4 用中间件实现鉴权
protectedProcedure的实现原理是中间件。在server/api/trpc.ts里,初始化 tRPC 时定义 context,然后基于中间件扩展出需要登录态的过程:
const isAuthed = tRPC.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new Error("未登录"); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure = tRPC.procedure.use(isAuthed);中间件里可以统一做权限校验、日志记录、埋点等等。比如你想加一个"只有管理员能调用"的 procedure,就在isAuthed之后再套一层检查角色的中间件。这种链式结构很灵活,比在每个接口里手写if (!ctx.session.user) throw要干净得多。
4. Prisma 数据建模:从 schema 到真实数据
4.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 author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }一个很容易踩的坑是关系字段命名。注意author和authorId必须同时出现在模型里,前者是 Prisma 用来做关系操作的虚拟字段,后者才是真实数据库列。如果你漏了其中一个,Prisma 会拒绝生成迁移。
4.2 数据库迁移与种子数据
schema 改完之后,执行迁移:
npx prisma migrate dev --name initmigrate dev会做三件事:对比 schema 与数据库差异、生成新的迁移文件、把迁移应用到开发数据库。如果你初始化时选的 SQLite,开箱即用;选 PostgreSQL 的话需要先在.env里填DATABASE_URL。
本地调试时我强烈建议在迁移后跑一次npx prisma studio,它会启动一个网页端的数据库浏览器,方便直接查看和改数据。比命令行写 SQL 直观得多。
需要造测试数据时,可以在prisma/seed.ts里写脚本,然后在package.json里配置"prisma": { "seed": "tsx prisma/seed.ts" }。种子数据的意义在于让前端开发时不用反复手造数据,浪费大量时间。
4.3 类型如何贯穿到查询
Prisma 会依据 schema 自动生成类型文件,tRPC 返回ctx.db.post.findMany()的结果时,返回值类型自动带上Post[]的结构,而前端拿到data后,post.title、post.author这些字段全部有提示。这背后是 TypeScript 的infer机制在工作。
之前提到改字段同步报错,实际操作中就是这样:你在 schema 里把content改成body,然后执行迁移,再回到代码里时,前端用到post.content的地方全部标红。你不用去翻每一个文件,编译器就是最好的 grep 工具。
5. NextAuth 集成:登录会话与类型扩展
5.1 认证方案怎么选
t3code 的登录方案默认是 NextAuth(现在叫 Auth.js)。选它而不是自己写 Session 的主要原因有两个:一是它内置了 GitHub、Google、邮箱等一票 Provider,不用自己实现 OAuth 流程;二是它提供了 Prisma Adapter,会话数据可以直接存到数据库里,和应用的用户表打通。
很多教材里会用 JWT 模式做无状态登录,但在单体全栈项目里,我更推荐数据库会话模式。原因是 JWT 一旦签发,修改用户角色、封禁账号这类操作需要额外处理黑名单,而数据库会话天然支持即时失效。用户量不大时,数据库会话的额外开销完全可以忽略。
5.2 配置 GitHub Provider 与 Prisma Adapter
创建src/auth.ts:
import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { db } from "~/server/db"; export const { handlers, signIn, signOut, auth } = NextAuth({ adapter: PrismaAdapter(db), providers: [GitHub], callbacks: { session: ({ session, user }) => ({ ...session, user: { ...session.user, id: user.id }, }), }, });然后在.env里填三个变量:
GITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=xxx NEXTAUTH_SECRET=随便一串随机字符NEXTAUTH_SECRET在本地可以随便填,但生产环境一定要用强随机字符串,否则会话被伪造的风险很高。生成方式:openssl rand -base64 32。
5.3 扩展 Session 类型:让 ctx.session.user.id 不报错
默认情况下,NextAuth 的session.user只包含name、email、image这几个字段,没有id。如果你想在 tRPC 里使用ctx.session.user.id做查询,必须扩展类型:
// types/next-auth.d.ts import { type DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }不写这个文件的话,ctx.session.user.id会一直报Property 'id' does not exist,很多人第一次接入时会在这个小坑里卡很久。这个类型的扩展是声明式的,编译期生效,不影响运行时。
6. 页面层:Tailwind 布局与服务端渲染取舍
6.1 Tailwind 在这个栈里的角色
Tailwind 的价值在于把样式收敛到类名上,不写独立 CSS 文件,也不会出现命名冲突。配合 t3code 的组合,组件的状态变化和样式在一个文件里完成,逻辑与外观离得近,改动起来很快。
一个常见误区是以为 Tailwind 会生成巨大的 CSS 文件,实际上它默认只扫描源码文件里出现的类名,按需生成。也就是说,你加了新类,构建时会自动包含它;删除代码后,对应的样式也会消失。你不需要手动清理"无用样式"。
6.2 写一个带加载态的帖子列表页
综合前面的内容,一个完整的列表页长这样:
"use client"; import { api } from "~/trpc/react"; export default function HomePage() { const { data, isLoading, isError, refetch } = api.post.list.useQuery(); if (isLoading) { return ( <main className="mx-auto max-w-2xl p-8"> <div className="animate-pulse space-y-4"> <div className="h-24 rounded-lg bg-gray-200" /> <div className="h-24 rounded-lg bg-gray-200" /> </div> </main> ); } if (isError) { return <main className="p-8 text-red-600">数据加载失败,请刷新重试</main>; } return ( <main className="mx-auto max-w-2xl p-8"> <h1 className="mb-6 text-2xl font-bold">最新帖子</h1> <button onClick={() => refetch()} className="mb-4 rounded-md bg-blue-600 px-4 py-2 text-white"> 重新加载 </button> <ul className="space-y-4"> {data?.map((post) => ( <li key={post.id} className="rounded-lg border border-gray-200 p-4"> <h2 className="text-lg font-semibold">{post.title}</h2> <p className="mt-1 text-sm text-gray-600">{post.content}</p> <p className="mt-2 text-xs text-gray-400">{post.author.name}</p> </li> ))} </ul> </main> ); }注意我用了"use client"指令,因为useQuery依赖浏览器端的状态管理。但这不是说页面就不能做 SEO,Next.js 会先服务端渲染一次静态内容,再在浏览器端注水(hydrate)成交互页面。
6.3 什么时候该用服务端渲染
遇到纯展示型页面,比如博客详情页、商品详情页,没必要走客户端拉数据。你可以直接在服务端拿 tRPC 的调用 helper:
import { api } from "~/trpc/server"; export default async function PostPage({ params }: { params: { id: string } }) { const post = await api.post.getById({ id: params.id }); if (!post) return <div>帖子不存在</div>; return ( <main className="mx-auto max-w-2xl p-8"> <h1 className="text-2xl font-bold">{post.title}</h1> <p className="mt-4">{post.content}</p> </main> ); }这种写法省掉了 loading 状态和客户端请求,首屏内容直接由服务端拼装好返回,对用户体验和 SEO 都好。我的习惯是:详情页、首页用服务端渲染,表单提交、动态筛选、需要频繁交互的区域用客户端组件。
7. 部署上线:从本地跑通到生产环境
7.1 环境变量清单
部署前检查环境变量,t3code 涉及的关键变量参考下表:
| 变量 | 作用 | 示例 |
|---|---|---|
DATABASE_URL | 数据库连接串 | postgresql://user:pass@host:5432/db |
NEXTAUTH_SECRET | 会话加密密钥 | openssl rand -base64 32生成 |
NEXTAUTH_URL | 部署站点地址 | https://your-site.com |
GITHUB_CLIENT_ID | GitHub OAuth ID | 在 GitHub 设置页获取 |
GITHUB_CLIENT_SECRET | GitHub OAuth 密钥 | 同上 |
在本地开发时,.env文件就够了;部署平台一般提供自己的环境变量配置面板,记得把.env内容同步过去,并且不要把密钥提交进 Git 仓库。
7.2 Vercel 部署要点
t3code 部署到 Vercel 非常简单:把仓库导入、框架预设选择 Next.js、环境变量填好,点 Deploy 就行。需要注意的一个细节是构建命令里要确保执行了数据库客户端生成,否则可能在构建阶段报PrismaClient failed to initialize。解决方案是在package.json的postinstall脚本里加:
"postinstall": "prisma generate"这个脚本会在依赖安装后自动生成 Prisma Client,避免"本地能跑,线上找不到客户端"的经典问题。
7.3 生产环境的数据库迁移
生产环境的数据库迁移是一个高频问题。prisma migrate dev只适合开发环境,它会在检测到 schema 变化时自动重置数据,生产环境千万不能用。正确做法是先本地生成迁移文件并提交到仓库,部署时手动执行:
npx prisma migrate deploymigrate deploy只应用未执行的迁移文件,不会重置已有数据,是生产环境的标准操作。如果用的是部署平台的后置命令(如 Vercel 的 Post Build Command、Render 的 Pre-Deploy Command),可以把这条命令加进去,让发布流程自动化。
8. 常见问题排查与避坑实录
8.1 tRPC 接口 404
前端调用 tRPC 时报 404,十有八九是 HTTP 入口没有正确注册。检查src/app/api/trpc/[trpc]/route.ts是否存在,且是否正确导出了 handler:
import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; const handler = (req: Request) => fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, createContext: createTRPCContext, }); export { handler as GET, handler as POST };还有一种情况是路由文件放错目录。Next.js 对app目录下的文件夹命名有严格约定,[trpc]这种动态路由必须保持方括号拼写一致,少一个字母都识别不了。
8.2 类型推导丢失:别手抄类型
tRPC 最大的优势是类型自动推导,但如果你在某个环节手动定义了"看起来差不多"的类型,推导链路就断了。比如你嫌RouterOutputs["post"]["list"]每次写太长,想抽一个export type PostList = { title: string; content: string }来代替,短期看着舒服,后续数据库加字段时这个手动类型不会同步更新,问题就来了。
正确做法是用工具类型:
import type { RouterOutputs } from "~/trpc/react"; type PostList = RouterOutputs["post"]["list"];这样新字段加入时,PostList自动跟着变,不需要维护两份定义。
8.3 Prisma 客户端未生成
同步代码后运行时报错Your current Prisma version is not compatible with the generated client,通常是 Prisma Client 没有重新生成。执行:
npx prisma generate然后把上面提到的postinstall脚本配上,以后同步依赖都会自动生成。
8.4 热更新后类型不刷新
用 Next.js 开发服务器时,修改 tRPC router 后前端偶尔不立刻提示类型错误。这是 TS 服务缓存的老问题,大多数时候重启一下 dev server 就恢复了。如果重启还不行,执行npx tsc --noEmit强制整个项目做一次类型检查,看到的具体报错通常能直接定位问题。
再补一个我自己最常用的排查思路:遇到编译报错,先看报错文件属于前端还是后端,然后从头到尾读一遍类型信息,而不是急着把报错的地方改成any。改成any虽然一时痛快,但相当于把 tRPC 整个体系的优势亲手拆掉了。
最后说点个人体会。t3code 这套东西我前后维护了不短时间,最大的感受是:类型安全的收益不是在做完的那一刻体现的,而是在项目跑到第三个月、第五个月,你改一个字段、重构一个模块、新加一个功能时体现的。以前我改完接口要祈祷前端没挂,现在编译器会直接把所有需要改的地方列出来。这种踏实感,是这套技术栈给我最大的回报。如果你正准备从零搭一个全栈项目,我建议别急着挑框架,先按 t3code 的这套链路跑一个带增删改查的小功能,体验一次"数据库字段改动全工程联动"的感觉,你会回来感谢 TypeScript 的。