t3code,光看这个名字容易被误导成什么代码训练营或者题库项目,我最初也这么以为。真正接触之后才明白,它本质上是一套围绕现代全栈 Web 开发的技术实践集合:以 Next.js 为骨架,用 TypeScript 从数据库一路贯穿到前端页面,搭配 tRPC、Prisma、Tailwind CSS、NextAuth 这几个核心库,把一套完整应用从数据模型到 UI 渲染的代码链路全部打通。它解决的最大痛点,就是告别传统前后端分离项目里"接口文档一份、后端类型一份、前端类型一份,改个字段名要同步三处"的窘境。适合什么人?适合被类型割裂坑过的全栈开发者,适合想快速搭一个带数据库和登录注册的现代应用、又不想在胶水代码上耗时间的团队,也适合刚接触 Next.js 生态、想看看真实项目该怎么组织代码结构的新手。
这篇文章不打算写什么高深理论,我就从 t3code 这类项目的设计与实现出发,把选型思路、目录结构、实操流程、常见坑全部梳理一遍,最后附上我自己的体会,希望能给正在折腾这套技术栈的人一些参考。
1. 整体设计与选型思路:为什么这套技术栈值得抄作业
1.1 全栈项目的最大成本,其实是"类型割裂"
很多人觉得写全栈应用慢,是把时间花在写代码上了,其实不是。真正的慢发生在联调和排查阶段:后端接口返回的字段叫userName,前端代码里读的是name;数据库改了一个字段类型,REST API 返回的 JSON 没变,但前端拿到手才发现类型不对。这类低级问题在传统前后端分离项目里每天都在发生,而定位一次字段对不齐的问题,少则几分钟,多则半天。
T3 Stack 的核心逻辑,就是让 TypeScript 从数据库那一层一直贯穿到浏览器里的 React 组件。Prisma 通过schema.prisma文件生成类型安全的数据库客户端,tRPC 在定义路由时自动推断出输入输出的类型,这些类型又通过api.post.list.useQuery()这样的 hook 直接传导到前端组件里。整个过程不需要手写接口文档,不需要在前后端各维护一份 model 定义,更不需要在请求层手动标注泛型。你改一个 Prisma model 字段,前端调用处会立刻出现编译错误,问题在开发阶段就暴露了,而不是等部署上线后由用户来发现。
我见过很多团队一开始觉得"多写几个类型没什么",等到项目中期接口二三十个、每个接口的请求响应结构越来越复杂的时候,才意识到类型割裂带来的维护成本有多可怕。t3code 这种方案等于从架构层面把这个问题消灭了,而不是靠团队成员自觉去同步文档。
1.2 五个核心包的职责边界,一点不多一点不少
T3 Stack 的选型组合看起来简单,但每个包都不多余,它们恰好覆盖了全栈应用的五个关键环节。
Next.js 负责渲染层和应用路由,它同时支持服务端渲染、静态生成和客户端渲染,是 React 生态里目前最主流、社区资源最多的框架。Tailwind CSS 负责样式层,它的原子类方式比手写 CSS Modules 或 styled-components 在团队协作时更容易保持一致,尤其是配合@apply和组件抽离,后期改设计风格的成本低很多。Prisma 负责数据层,它把数据库表映射成类型安全的查询客户端,写查询的时候有完整补全,几乎不会写错字段名。tRPC 负责接口层,它的名字直译就是 TypeScript RPC,让客户端直接调用服务端函数,好像在调本地函数一样,但背后走的是 HTTP。NextAuth 负责身份认证层,处理登录、会话、OAuth 等,几乎成了 Next.js 生态里的事实标准。
有人问为什么不用 GraphQL,GraphQL 本身也是很好的方案,但它的代价是 schema 和 resolver 依然要手写,还要额外考虑缓存策略、codegen、复杂查询的权限控制。对中小型项目来说,GraphQL 的引入成本明显高于收益。也有人问为什么不用 Redux 管理状态,在 Next.js 这种以服务端渲染为主的应用里,大部分状态根本不需要放在客户端全局 store 里,tRPC 加 React Query 已经能处理服务端状态,客户端 UI 状态靠useState或者 URL 参数就够了。
1.3 t3code 背后的设计理念:约定优于配置
我特别喜欢这套技术栈的一个原因是它有明确的约定,不用你费心去设计项目结构。业务路由放在src/server/api/routers/里,公共 procedure 配置写在src/server/api/trpc.ts,数据库实例放在src/server/db.ts,一切都有模板可循。这种"约定优于配置"的思路,对团队协作尤其重要——新成员看一眼目录结构就知道该把代码写在哪里,不用每次开会讨论架构。
另一个理念是保持最小集。t3code 没有塞一堆用不上的中间件、工具函数和状态管理库,所有依赖都是当前阶段确确实实需要的。真到后期需要加日志、加错误上报、加 Redis 缓存的时候,再按需引入,不会污染初始代码。这跟我见过的一些"全家桶"模板完全不同,那些模板看着高大上,实际上一大半代码根本不知道是干嘛用的,删除又怕破坏其他功能,非常尴尬。
2. 核心结构解析:读懂 t3code 的目录和关键配置
2.1 项目目录结构:一个真实全栈应用的骨架
用脚手架初始化出来的 t3code 项目,目录并不复杂,但每一层都有它存在的意义。我先把最核心的几个路径列出来:
prisma/schema.prisma:数据库模型定义,所有表的 schema 都在这里src/pages/api/trpc/[trpc].ts:tRPC 的 HTTP 入口,Next.js API 路由src/server/api/trpc.ts:公共 procedure 和 context 类型定义src/server/api/root.ts:汇总所有业务 router 的根 routersrc/server/db.ts:Prisma 客户端实例src/server/auth.ts:NextAuth 配置src/utils/api.ts:前端调用的 tRPC 客户端封装
第一次看这个结构的人可能会疑惑,为什么数据层和业务逻辑都放在src/server而不是src/lib,原因很简单:这些代码只允许在服务端运行,严禁出现在浏览器 bundle 里。tRPC 的 context 里包含着数据库连接和 session 对象,如果被前端代码意外引用,等于把数据库凭据暴露给用户,这是绝对不允许的。Next.js 里src/server目录天然有这个保护作用,比靠团队成员自觉靠谱得多。
我见过不少项目把所有代码堆在src/lib里,服务端和客户端混在一起,最后出现"数据库连接字符串被打进浏览器包"的事故。所以我的建议简单直接:所有涉及数据库、密钥、服务端 session 的代码,都放在src/server下,从目录上做硬隔离。
2.2 Prisma 模型设计:关系数据库建模的本质
数据模型设计是全栈应用的地基,t3code 的schema.prisma里默认有 User 模型,它展示了最典型的用户表设计:id 用 cuid 而不是自增 int,email 做唯一索引,profile 和 post 各自建立关联关系。
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } 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 }为什么 id 用 cuid 而不是自增 int?我最早也习惯用自增 id,后来在真实项目里发现几个问题:一是自增 id 容易暴露业务数据量,比如订单表用自增 id,竞争对手通过下单后 id 的变化就能估算出订单量;二是分布式环境下生成自增 id 需要额外协调,cuid 可以在应用层直接生成,性能更好;三是 id 本身没有语义,用自增还是用随机字符串对查询性能影响不大,但安全性差别很大。
Post 和 User 之间的关联关系是这个 schema 里最有教学价值的地方。author User @relation(...)配合authorId String,Prisma 会自动建立外键关系,之后查询时可以用 include 把作者信息一次性取出来,不需要手写 join。
2.3 tRPC Router 组织:接口层如何做到类型安全
t3code 的接口层划分非常清晰,每个业务模块一个 router,然后在 root 里汇总。拿常见的博客场景举例:
// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(50).default(10) })) .query(async ({ ctx }) => { return ctx.db.post.findMany({ take: ctx.input.limit, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional() })) .mutation(async ({ ctx }) => { return ctx.db.post.create({ data: { title: ctx.input.title, content: ctx.input.content, authorId: ctx.session.user.id, }, }); }), });每个 procedure 有三个关键要素:访问控制(public 还是 protected)、输入校验(zod schema)、业务逻辑。zod 在这里不只是做类型校验,它同时承担了运行时校验的任务。前端传入的数据在进入 Prisma 之前就经过了一层验证,不符合规则的数据直接被拦下来,这比在业务代码里手写 if 判断要优雅得多,也安全得多。
protectedProcedure 是 t3code 这类项目里最有用的抽象。它从 context 里读取 session,如果用户未登录就直接抛出 UNAUTHORIZED 错误,已登录用户则可以在 procedure 内部安全地访问ctx.session.user。这个机制让每个接口的权限逻辑变得一目了然:publicProcedure 开放给所有人,protectedProcedure 只允许登录用户调用。
2.4 NextAuth 认证集成:session 如何注入 context
认证是全栈应用最绕不开的部分。t3code 把 NextAuth 的配置集中在src/server/auth.ts,核心点有两个:一是配置 OAuth 或 Credentials 登录方式,二是通过 callbacks 把用户 id 写入 session。很多初学者在第一步就栽了跟头,因为 NextAuth 的默认 session 里只有 name、email、image,没有用户数据库里的 id,导致在 tRPC 里存外键时拿不到当前用户标识。
我实际的解决方案是在callbacks.jwt和callbacks.session两个钩子里做一层映射:
callbacks: { async jwt({ token }) { // 首次登录时把数据库用户 id 写入 token return token; }, async session({ session, token }) { if (session.user) { session.user.id = token.sub ?? ""; } return session; }, }然后在src/server/api/trpc.ts里,用getServerSession从请求中解析出 session,再传给 context:
export const createTRPCContext = async ({ req, res }: CreateNextContextOptions) => { const session = await getServerSession(req, res, authOptions); return { db, session }; };整个链路是这样的:请求进来 -> NextAuth 解析 cookie -> 拿到 session -> 注入 tRPC context -> protectedProcedure 校验 session -> 业务代码使用 session 里的用户信息。类型从getServerSession到ctx.session一路是明确的,不会有"这个对象到底有没有 user"的悬念。很多模板项目会忽略 session 类型的补充,导致在前端组件里拿session.user.id时 TypeScript 报错,t3code 把这一步默认做好了,省了不少事。
3. 实操过程:从空目录到第一个完整功能
3.1 初始化项目:create-t3-app 交互式配置
第一步是用脚手架创建项目,一行命令搞定:
npm create t3-app@latest运行后会出现交互式选择器,让你勾选需要的模块。我建议直接全选 NextAuth、Prisma、Tailwind CSS、tRPC,因为这就是 T3 Stack 的完整形态。如果你暂时不想接认证,可以先不勾 NextAuth,后期手动加也不复杂,但既然都用了脚手架,一步到位是最省事的。项目名我就用t3code,创建完成后进入目录,先跑一遍npm run dev确认基础环境没问题,再继续后面的改动。
这里提醒一句:脚手架会把初始模板的 git 历史一起初始化,所以创建完项目第一时间git add -A && git commit -m "init",给自己留一个干净的起始点,后面改坏了随时可以回退。
3.2 环境变量配置与数据库迁移
t3code 项目的核心配置都在.env文件里:
DATABASE_URL="file:./db.sqlite" NEXTAUTH_SECRET="这里填一个足够长的随机字符串" NEXTAUTH_URL="http://localhost:3000"如果是本地开发,默认 SQLite 就够了,零配置直接跑。但如果你的目标是真实部署,我建议从一开始就用 PostgreSQL,本地可以用 Docker 起一个实例,避免后期从 SQLite 迁移到 PostgreSQL 时踩类型差异的坑。数据库连接串长这样:
DATABASE_URL="postgresql://user:password@localhost:5432/t3code?schema=public"配好连接串后,执行:
npx prisma migrate dev --name init这个命令会读取schema.prisma里的模型定义,生成对应的 SQL 迁移文件,并且自动在项目里生成 Prisma Client。迁移完成后,如果你打开数据库客户端,会看到 user、post、account、session 这些表已经建好了。prisma migrate dev在开发环境还会触发prisma generate,更新客户端类型,所以每次改完 schema 都必须执行一次,前端类型才会跟着刷新。
3.3 写第一个查询接口并接入页面
接下来我们做一个最简单的功能:在页面上显示文章列表。先在刚才的 postRouter 里加一个list查询,然后在前端页面上使用它。
假设项目已经有src/pages/index.tsx,改造后的代码大致是这样:
import { api } from "~/utils/api"; export default function Home() { const { data: posts, isLoading } = api.post.list.useQuery({ limit: 10 }); if (isLoading) return <div>加载中...</div>; return ( <main className="mx-auto max-w-2xl p-8"> <h1 className="text-2xl font-bold mb-4">最新文章</h1> <ul className="space-y-4"> {posts?.map((post) => ( <li key={post.id} className="border p-4 rounded-lg"> <h2 className="font-semibold">{post.title}</h2> <p className="text-gray-600">{post.content}</p> </li> ))} </ul> </main> ); }api.post.list.useQuery会自动处理请求状态、缓存和重新获取。页面加载时它会调用 tRPC 的 HTTP 端点,服务端执行 Prisma 查询,返回类型安全的数组,前端拿到后直接渲染,整个过程不需要任何手写 fetch。第一次跑通的时候我其实挺震撼的,这种感觉就像后端函数可以直接被前端调用,而所有参数和返回值都有 IDE 补全。
3.4 写第一个变更操作:创建文章并刷新列表
光有查询还不够,我们再加一个创建文章的 mutation。前端需要写一个表单,提交时调用api.post.create.useMutation,成功后让列表自动刷新:
import { api } from "~/utils/api"; export default function CreatePost() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.list.invalidate(); }, }); const onSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const form = new FormData(e.currentTarget); createPost.mutate({ title: String(form.get("title")), content: String(form.get("content")), }); }; return ( <form onSubmit={onSubmit} className="space-y-4"> <input name="title" placeholder="标题" className="border p-2 w-full" /> <textarea name="content" placeholder="内容" className="border p-2 w-full" /> <button type="submit" disabled={createPost.isPending}> 发布 </button> </form> ); }这里最妙的是utils.post.list.invalidate()。发布文章成功后,React Query 会自动把列表标记为过期并重新拉取,页面上的新文章立刻出现,不需要手动刷新或者维护全局状态。如果放在传统方案里,你得写一个"列表请求函数"和一个"创建请求函数",然后在成功回调里手动调列表接口,代码一多就开始混乱。而这里的 invalidation 机制让数据同步变得声明式,代码量少一半,出错的概率也更低。
3.5 部署到生产环境的要点
t3code 项目的部署首选 Vercel,因为 Next.js 和 Vercel 本身就是一家人,配置最少。但在部署前有三个坑一定要提前排掉。
第一,Prisma 的构建问题。在 Vercel 构建时,项目必须能执行prisma generate生成客户端,如果用的是托管数据库还要执行迁移。我习惯在 package.json 的build脚本里加上prisma generate,确保生产环境构建时客户端总是最新的:
"build": "prisma generate && next build"第二,环境变量。生产环境的 DATABASE_URL、NEXTAUTH_SECRET 都要在 Vercel 后台填好。NEXTAUTH_SECRET 特别容易被忽略,如果本地用默认占位符而生产环境没配,登录会一直报错或者每次重启都掉线。
第三,数据库连接串。PostgreSQL 云服务商提供的连接串一般都要加上?sslmode=require,如果不加,Prisma 在加密连接模式下会拒绝连接。这个错误提示又不明显,我第一次部署时在这里卡了快一个小时,排查了半天才发现是少了这个参数。
4. 常见问题与排查技巧实录
4.1 前端类型没有更新,报错信息完全看不懂
这个问题在 tRPC + Prisma 项目里几乎每个人都会遇到。现象是:改了schema.prisma里的字段,也运行了prisma migrate dev,但前端api.xxx.useQuery返回的数据类型还是旧的,访问新字段直接报 TS 错误。
原因很简单:Prisma Client 的类型和 tRPC 的类型都是构建时生成的,如果编辑器或 TypeScript 语言服务还在用旧的缓存,自然就看不到新类型。我的排查步骤是:先关掉所有 TypeScript 服务,删除.next目录,重新运行npx prisma generate,再重启 IDE。如果还不行,运行npx tsc --noEmit看具体报错行。绝大多数情况下,循环"清缓存 + 重新生成 + 重启"就能解决。很多看似诡异的问题,本质都是缓存,不是代码逻辑。
4.2 迁移时被"表结构冲突"卡住
prisma migrate dev偶尔会报一个很吓人的错误:P3005或者其他迁移冲突错误。最常见的原因是你手动在数据库里改过表结构,或者之前应用的某个迁移文件与当前 schema 状态不一致。
我自己的解决办法是:如果还在开发早期,数据库里没有重要数据,干脆直接重置:
npx prisma migrate reset这个命令会删除所有表数据,重新从迁移文件构建数据库结构,代价是开发数据全没了。所以执行之前一定要确认没有需要保留的数据。如果数据库已经有生产数据,那就别用 reset 了,而是要检查迁移文件,搞清楚哪个迁移与当前 schema 对不上,再手动改迁移文件或者新增一个新的迁移来解决冲突。开发阶段我真不太建议保留太多"珍贵"数据,因为 schema 还在频繁变,重置反而是最省时间的。
4.3 getServerSession 一直返回 null
NextAuth 集成到 t3code 之后,最典型的翻车现场就是:登录成功了,cookie 也写了,但 tRPC context 里的session值一直是 null,protectedProcedure 永远报未授权。
排查这个问题的方向主要有几个:第一,确认authOptions被 tRPC context 和 NextAuth API 路由使用的是同一个实例,如果复制粘贴了配置,两边的 secret 或 provider 不一致就完蛋;第二,确认NEXTAUTH_SECRET环境变量存在,NextAuth 在缺失 secret 时会在控制台打印警告,但某些版本直接返回 null;第三,确认你访问的 API 和页面在同一个域名下,cookie 作用域不匹配时服务端拿不到 session。如果是本地调试,建议把 NextAuth 的日志级别调到 debug,在控制台里可以看到 cookie 解密是否成功的详细信息。
4.4 Tailwind 类名不生效
Tailwind 开发体验很好,但偶尔会遇到"类名明明写了却没样式"的情况,基本都出在内容路径配置上。t3code 默认tailwind.config.ts的 content 配置是:
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],如果你在项目里引入了一个放在其他目录的组件文件,或者动态拼接类名,Tailwind 就可能漏掉这些 class 导致样式缺失。另一个常见问题是状态切换时用了动态类名,比如const color = isError ? "text-red-500" : "text-green-500"这种写法没问题,但如果是const color = isError ? "text-" + "red-500" : ...,Tailwind 的扫描器就识别不了。解决方案是类名写全,不要做字符串拼接。
4.5 部署后页面刷新就 401
这个问题的典型场景是:本地开发一切正常,部署到生产环境后,进入受保护页面第一次加载正常,但刷新一下或者直接访问深层路由就 401 了。
我排查后的原因基本集中在 session 策略和 cookie 配置上。生产环境必须保证NEXTAUTH_URL是真实的部署域名,并且 Next.js 可能需要在next.config.js中配置安全的 cookie 选项。在跨地域部署时还要考虑时区差异对 session 过期的影响。另外如果用了多实例部署,session 的默认数据库存储策略要提前规划,否则每个实例只能识别自己的会话。这类问题排查周期一般要一两个小时,但只要把 NextAuth 的 session 策略明确设置好,基本能一次性解决。
5. 从模板到真实业务:几个扩展方向和个人体会
5.1 项目代码组织的演进路线
t3code 只是起点,真实业务比 demo 复杂得多。我的建议是,当业务逐渐变大时,不要把所有代码都塞进 router 里。t3code 的模板结构只约束了接口层,业务逻辑还需要进一步分层。比如我会在src/server/services/下建一个业务服务层,把复杂的验证、计算、多表事务放进去,router 只做参数校验和调用服务,这样接口层保持薄而稳定,业务逻辑可以独立测试。
等代码量再上去,就要考虑模块拆分。不要把几十个 router 全塞进src/server/api/routers/里,按业务域建子目录:post/、user/、comment/,每个目录里放自己的 router 和 service。这个演进不复杂,动手拆的时候就知道了,但一开始不做的话,后面重构成本会越来越高。
权限系统是另一个需要提前考虑的点。t3code 只给了登录用户的区分,真实业务里会有管理员、编辑、普通用户等不同角色。我的处理方式是扩展 context 里的 session 对象,把 role 字段加上,然后定义自己的requireRoleprocedure 工厂,生成带角色校验的 procedure。这个模式跟protectedProcedure是一样的套路,扩展起来并不难。
5.2 性能优化:缓存预热和查询瘦身
用 tRPC + React Query 的项目,性能优化的核心思路是减少不必要的数据传输和请求次数。
api.xxx.useQuery的第二个参数支持配置staleTime,这个参数决定了数据在多久时间内被认为是新鲜的。列表页如果内容更新不频繁,可以把 staleTime 设成 30 秒甚至 5 分钟,避免每次进入页面都重新请求。对于关键页面数据,可以在服务端用prefetch提前取好数据,用户一打开页面内容就在了,不需要先转圈再渲染。
Prisma 查询默认会返回所有标量字段,但你未必全用得到。在 tRPC 的 query 里用select或者include来裁剪返回字段,能显著减少网络传输量。我见过不少项目列表接口一次返回几 KB JSON,里面一大半字段前端根本没用。这个习惯越早养成就越好,等数据量大了再回来优化,改动面就大了。
5.3 我踩过的一些坑,以及最后几句心里话
说几个我做 t3code 这类项目时踩得比较深的坑。一个是过分依赖脚手架,把模板当成框架来用。框架会替你做很多决定,但 t3code 的理念恰恰是让你理解每一层都在干什么。真到项目变大、需要定制的时候,如果你不知道 Prisma context 和 tRPC 的初始化流程是怎么串起来的,就会非常被动。
第二个坑是处理认证时忽略了 session 策略的细节。NextAuth 默认的 session 策略在 serverless 环境下的表现和本地不完全一样,生产环境经常出现"明明登录了,服务端却拿不到用户信息"的情况。我后来养成了一个习惯:所有涉及 nextauth、tRPC、Prisma 的改动,都在本地用 production build 模式跑一遍,并且用真实的数据库去测,而不是只在 dev 模式下面验证。
第三个坑是数据库连接串的坑。我前面提到过?sslmode=require的问题,其实还有连接数和连接超时的坑。Serverless 环境下每个请求都可能导致一个新的数据库连接,如果连接池配置不合理,高峰期会直接打爆数据库的最大连接数。Vercel 上跑 PostgreSQL 的话,我建议查一下你的数据库服务商有没有提供 pgbouncer 或者 serverless 连接模式,如果有就直接用,能省掉很多麻烦。
最后说句心里话:t3code 这类项目最大的价值不是给你一套能跑的代码,而是帮你建立了一种"端到端类型安全"的开发习惯。当你习惯了类型从数据库一路传到前端的顺畅感,再回到手写接口文档、手动维护类型的项目,你会非常不适应。技术选型永远没有银弹,但 T3 Stack 这种务实的组合,确实让我在日常开发里少写了很多重复代码,也少受了很多联调的气。
我个人在整个实践过程中最深的体会是:模板可以帮你省时间,但它不能代替你理解原理。下一次当你遇到一个奇怪的问题,别急着搜索答案,先沿着 data flow 走一遍——从数据库、到服务端函数、到 HTTP 层、到 React Query 缓存、再到组件渲染,你脑子里有这个完整链条,所有问题都能快速定位。希望这篇关于 t3code 的拆解和实操记录,能给你自己的项目带来一点启发。