ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

T3 Stack 深度解析:Next.js + tRPC + Prisma 全栈类型安全实战

T3 Stack 深度解析:Next.js + tRPC + Prisma 全栈类型安全实战

如果你经常逛 GitHub,这两年大概率见过一个叫create-t3-app的项目,Star 数涨得飞快,社区里也有人直接叫它 t3code。我第一次看到 T3 Stack 的时候,第一反应是:又一个全栈脚手架?但真正拿它跑通一个从数据库到页面的完整链路之后,我意识到这不是普通的脚手架,而是一整套关于类型安全的工程哲学。今天这篇文章就围绕 t3code 这套组合,把它包含的技术栈、选型背后的逻辑、从零搭建的具体步骤,以及我实际踩过的坑,一次性拆开讲透。

这套方案适合谁?如果你是用 Next.js 写全栈应用、但每次前后端联调都被类型对不上折磨的开发者;或者你刚从 REST API 转向全栈 TypeScript,希望数据库、服务端、客户端三层类型统一;又或者你只是想找一个省心的全栈启动模板,不想在项目初始化时反复纠结技术选型——t3code 都能直接帮你把底子打好。下面我会围绕它逐个拆解,每一层都会讲清楚它能做什么、为什么选它、怎么落在实际项目里。

1. 技术栈全景:t3code 到底包含什么

1.1 名字背后的含义与核心思路

T3 Stack 的名字有两个来源。官方解释是The T3 Stack,T 代表 TypeScript,3 指代三个核心框架:Next.js、Tailwind CSS、tRPC。社区叫 t3code,更多是因为这个组合太常被当成一个整体来讨论,于是干脆用代号指代这套技术路线。它的设计初衷不是创造新技术,而是把已经验证过的成熟工具,按"类型安全优先"的准则组织成一个默认选项。

我在实际使用中最喜欢的一点是:t3code 不是一个"全家桶"。它默认集成的东西不多,但每一件都卡在关键位置上。核心原则是"三条约定":

  • 类型安全贯穿全栈:从数据库到 API 再到 React 组件,类型不再需要手写接口定义
  • 最小化样板代码:能自动推断的类型绝不手写,能靠约定完成的配置绝不单独建文件
  • 一切从业务出发:不需要你为"可能用到的功能"预装插件,需要什么再加什么

这种克制感在脚手架项目里非常难得。很多生成器恨不得把所有热门库都塞进去,结果新项目一半时间在删代码。t3code 反其道而行,初始项目干净到让你觉得"就这么点东西?",等你写几十行代码之后才会意识到,这恰恰是它高效的原因。

1.2 六大核心组件一览

我按自己在项目里的使用频率排个序,加上它们各自解决的核心问题:

组件作用解决的问题
Next.js应用框架、路由、SSR/SSG、API 路由服务端渲染与前端开发统一模型
TypeScript静态类型系统,全栈层面的类型约束三大层类型各自为政、联调易错
tRPC端到端类型安全 API 通信层REST/GraphQL 手动维护类型定义
PrismaORM,数据库 schema 与迁移管理SQL 手写易错、模型与表结构脱节
Tailwind CSS原子化 CSS 框架样式文件碎片化、类名命名困难
NextAuth.js认证体系,支持 OAuth 与凭证登录登录注册/会话管理重复造轮子

有人会问:为什么没有状态管理库(Redux/Zustand)?没有表单库?没有测试框架?这是刻意的。t3code 认为一部分需求可以被 React Server Components 和 URL 状态替代,另一部分应该由你自己按需引入。初期依赖越少,调试范围越小,升级成本也越低。等你真实跑完一个项目,会理解这种"少即是多"的合理性。

2. 专门拆解:每一层为什么这样选择

2.1 Next.js:不止是 React 框架,更是全栈平台

很多人只用过 Next.js 的静态站点模式,但在 t3code 里,Next.js 的角色更接近于一个全栈运行时。它同时充当了前端页面框架和后端 API 宿主。在 App Router 模式下,你可以直接在 Server Component 里访问数据库,也可以按需在 Route Handler 里实现 REST 接口,还能通过 Server Actions 处理表单提交。

我在实际项目里的真实体验是:只要用好了 Server Component 和"use client"的边界,页面的数据获取逻辑会变得异常简单。比如一个用户列表页:

// app/users/page.tsx import { db } from "~/server/db"; export default async function UsersPage() { const users = await db.user.findMany(); return ( <ul> {users.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> ); }

这个组件没有useEffect、没有fetch、没有 loading 态绑定。数据库查询发生在服务器上,数据直接以 props 形式进入 React 组件。这就是 t3code 选 Next.js 的根本原因——它让"取数"从客户端的前端工程变成了服务端的内部调用。

2.2 tRPC:没有 API 层的 API

tRPC 是 t3code 的灵魂。我第一次看到它时不太理解:没有 schema 定义、没有代码生成、没有路由前缀,那前后端怎么对接?后来明白了,tRPC 的通信协议建立在 TypeScript 的类型系统之上,类型本身就是契约。

简单说,你在服务端定义了一个查询:

// server/api/routers/user.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const userRouter = createTRPCRouter({ byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { const user = await db.user.findUnique({ where: { id: input.id }, }); return user; }), });

然后在客户端这样调用:

// app/users/[id]/page.tsx "use client"; import { api } from "~/trpc/react"; export default function UserDetail({ params }: { params: { id: string } }) { const [user] = api.user.byId.useSuspenseQuery({ id: params.id }); return <div>{user?.name}</div>; }

注意,user的类型是User | null,这个类型是从 Prisma 模型一路推断到客户端的。中间不需要手写 DTO、不需要 OpenAPI 定义、不需要生成器。如果后端逻辑改了返回结构,前端会直接编译报错。这比任何文档都及时,比任何契约测试都严格。

那为什么不用 GraphQL?GraphQL 同样有类型安全,但需要一个 Schema 定义、一堆 resolver、还有客户端代码生成,维护成本并不低。tRPC 的做法更符合人脑直觉:你只是在调用一个"远程函数",编译器帮你把参数和返回值类型都检查好了。

2.3 Prisma:数据库模型即类型源头

数据库层是整个类型链路的起点。Prisma 做的事情是:你写一个schema.prisma文件描述数据模型,它生成 TypeScript 类型并作为 ORM 使用。

// prisma/schema.prisma model User { id String @id @default(cuid()) name String? email String @unique posts Post[] createdAt DateTime @default(now()) }

我特别喜欢 Prisma 的一点是它的迁移流程。改模型之后运行npx prisma migrate dev,它会生成 SQL 迁移文件、执行到数据库、同时重新生成客户端类型。这三步串下来,数据库结构、ORM 模型、TypeScript 类型永远是同步的。我在项目初期频繁调整表结构,这一套流程帮我避免了很多"数据库和代码不同步"的低级问题。

Prisma Studio 也是救命的工具。跑npx prisma studio之后,浏览器里能像看表格一样直接查看和编辑数据库内容,排查数据问题时不用再去命令行敲 SQL,这对前后端联调阶段的效率提升非常明显。

2.4 Tailwind CSS:原子化样式与组件开发

Tailwind 在 t3code 里的角色,比"一个 CSS 框架"要深一层。它不只是提供工具类,而是参与构建组件体系。你在一个 Button 组件里看到的可能是数十个原子类,但这恰恰让样式跟随组件走,不再有"去全局 CSS 文件里找这个按钮样式"的烦恼。

实用技巧:我用 Tailwind 时很少在 JSX 里堆出一长串类名,而是配合clsx和tailwind-merge做组件级的类名组合。t3code 的模板初始化就会装好cn()工具函数,这个细节非常贴心。它解决了一个我过去经常踩的坑:条件渲染类名和覆盖样式冲突。

import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }

2.5 NextAuth.js:认证的快速接入

认证是全栈项目的必备环节,也是最容易出安全问题的环节。t3code 默认集成 NextAuth.js/Auth.js,Prettier 初始化时就会生成对应的 API 路由和配置骨架。

用过 OAuth 的人都知道配置一个 GitHub/Google 登录的真正成本不在"点按钮",而在回调地址、Session 存储、token 校验这些环节。NextAuth 把这一层整体抽象掉了。在 t3code 中,Session 数据默认挂在trpc上下文里,路由处理时可以直接拿到ctx.session.user来做权限判断。

我的建议是:即使你的项目暂时不需要第三方登录,也保留默认的 Credentials 方案。因为后续加权限控制时,认证系统的存在会大幅降低改造难度。Session 结构也能帮你把用户信息与业务数据的关联设计得更加规范。

2.6 TypeScript:贯穿所有层的类型胶水

整套 t3code 的"类型安全感",最终靠的是 TypeScript 在各层之间充当胶水。Prisma 生成的模型类型给 tRPC 用,tRPC 推断出的类型给 React Hook 用,NextAuth 的 Session 类型注入到 tRPC 上下文里,Tailwind 的类名由 TS 做条件拼接。每一层都在用类型系统表达自己的边界和契约。

这里有一个使用原则:尽量让类型自动推断,不要手动定义重复类型。我看到初学者经常做一件事——先写一个Userinterface,又在 Prisma schema 里定义 User,数据库表也叫 user。三个来源一旦不同步,就是一场灾难。t3code 的做法是"单一事实来源":数据库 schema 是唯一权威,其余全部infer出来。

3. 从零搭建 t3code:完整实操记录

3.1 环境准备与初始化命令

我建议在 Node.js 18+(实测 Node 20 更稳)和 pnpm 环境下操作。npm 也可以跑,但 T3 Stack 的官方文档和社区都更偏向 pnpm,因为它的依赖链接方式和严格 node_modules 结构对 monorepo 和大型项目更友好。

初始化命令:

pnpm create t3-app@latest my-t3-app

交互式界面会问你要不要 TypeScript、Tailwind、tRPC、Prisma、NextAuth、ESLint、Prettier。新手可以全选,但如果你明确用不到某个模块,比如暂时不做认证,就不选 NextAuth——这正是 t3code 的定制化优势。全选情况下默认就是完整的 t3code 项目骨架。

架构约定要留意:安装完成后的项目会有一个src目录,内部包括server和trpc两个核心文件夹。模板特意把"服务端逻辑"和"类型链接口"分开,让代码组织从第一天起就遵守依赖方向:页面可以依赖 tRPC,tRPC 可以依赖 server,server 依赖 db,但不能反过来。

3.2 目录结构与职责边界

我对照实际项目列一下关键目录,这些是 t3code 初始化的标准结构:

路径职责
src/appNext.js App Router 页面与路由
src/server/db.tsPrisma Client 实例
src/server/auth.tsNextAuth 配置与 Session 类型
src/server/api/trpc.tstRPC 初始化、上下文、基础 procedure
src/server/api/routers/具体的 tRPC 路由(user、post 等)
src/trpc/server.tsServer Component 中调用的 tRPC 客户端
src/trpc/react.tsClient Component 中的 React Hooks 封装
prisma/schema.prisma数据库模型定义

重点理解trpc.ts和react.ts的区别。server.ts里的caller是给 Server Component 做同步调用用的,没有网络请求;react.ts里的是给浏览器端useQuery/useMutation用的,走 HTTP。二者底层共享同一套路由注册表,因此类型完全一致,但运行机制完全不同。很多初用 t3code 的人在这块会迷糊,记住一句话就行:服务端调用不经过 HTTP,客户端调用才经过 HTTP。

3.3 配置数据库并跑通第一个查询

初始化完成后,最重要的第一步是配置数据库连接。默认.env里有一个DATABASE_URL,本地开发我建议直接用 SQLite 起步,把表结构先跑通再切 PostgreSQL,这样环境最轻。

# .env DATABASE_URL="file:./db.sqlite"

然后修改prisma/schema.prisma的 provider 为sqlite:

datasource db { provider = "sqlite" url = env("DATABASE_URL") }

同步数据库并生成客户端类型:

npx prisma migrate dev --name init npx prisma generate

此时你已经有了一张空表。把示例数据插进去,可以用 Prisma Studio 手工添加,也可以写一个 seed 脚本。我更推荐后者,因为团队协作时每个人都能一键还原相同数据:

# package.json "prisma": { "seed": "tsx prisma/seed.ts" }
// prisma/seed.ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { await prisma.user.create({ data: { name: "t3code 玩家", email: "dev@example.com", }, }); } main().catch(console.error);

跑pnpm prisma db seed,数据库就有了第一条记录。此时在routers/user.ts里写一个getAll查询并挂到根路由,一个"数据库 → tRPC → 页面"的最小闭环就跑通了。

3.4 用 tRPC 实现完整的"列表 + 详情 + 创建"

这一步是 t3code 最核心的实战部分。先说列表接口。在server/api/routers/user.ts里补充:

getAll: publicProcedure.query(async () => { return db.user.findMany({ orderBy: { createdAt: "desc" }, }); }),

然后新建一个post路由来承载业务模型。我以简化的"文章发布"为例:

// server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(({ ctx }) => { return ctx.db.post.findMany({ include: { author: true }, orderBy: { createdAt: "desc" }, }); }), 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: ctx.session?.user?.id ?? "anonymous", }, }); }), });

注意ctx.db不是 extra 配置,而是 t3code 模板默认把 Prisma Client 注入进了 tRPC 上下文,这样每个 procedure 内部都能直接访问数据库。创建功能在按钮点击时执行:

const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => utils.post.list.invalidate(), }); createPost.mutate({ title: "标题", content: "正文" });

useUtils的invalidate是 t3code 社区公认最好用的功能:服务端数据变了之后,你不需要手动刷新页面,只需要让对应查询失效,tRPC 会自动重新拉取最新数据。这比"提交成功后手动改本地 state"的思路要省心得多。

3.5 认证接入与受保护路由

接入 GitHub OAuth 时,去 GitHub Developer Settings 创建 OAuth App,回调地址填:

http://localhost:3000/api/auth/callback/github

然后在.env里填两个值:

GITHUB_CLIENT_ID=你的_Client_ID GITHUB_CLIENT_SECRET=你的_Client_Secret

此时在页面上加一个登录按钮:

import { signIn, signOut, useSession } from "next-auth/react"; export default function AuthButton() { const { data: session } = useSession(); if (!session) { return <button onClick={() => signIn("github")}>通过 GitHub 登录</button>; } return ( <div> <span>已登录:{session.user?.name}</span> <button onClick={() => signOut()}>退出</button> </div> ); }

受保护路由的关键写在 tRPC procedure 上。把公共 procedure 替换为受保护的:

protectedProcedure: publicProcedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new Error("未登录"); } return next({ ctx: { session: ctx.session } }); }),

业务路由中这样用:

me: protectedProcedure.query(({ ctx }) => { return ctx.db.user.findUnique({ where: { id: ctx.session.user.id }, }); }),

有了这一层,你就等于给操作数据库的每一个入口都装了"门禁"。哪怕前端忘写了登录判断,后端也会拦截并抛出异常。

4. 实战中常见的问题与排查方法

4.1 tRPC 类型不刷新

症状:改了后端路由返回值,前端useQuery的data类型还是旧的。原因基本都是Next.js 开发服务器的 HMR 缓存没有完全重建 tRPC 的类型上下文。最有效的处理:杀掉 dev server,删掉.next目录,重新pnpm dev。如果改了 Prisma schema,还需要先npx prisma generate再重启。这套流程我几乎每周都要跑一遍,建议直接加一个脚本:

"scripts": { "dev:clean": "rm -rf .next && pnpm dev" }

4.2 Prisma 连接超时或表不存在

SQLite 版本如果出现Table does not exist,优先检查是否执行过migrate dev。PostgreSQL 则常见于DATABASE_URL格式错误,需要写成:

DATABASE_URL="postgresql://postgres:password@localhost:5432/mydb?schema=public"

还有一个我踩过的坑:改了 schema 后忘了prisma generate,导致@prisma/client里的类型还带着旧字段,编译报错提示字段不存在。建议在 package.json 的postinstall里加一条prisma generate,这样每次安装依赖后都会自动同步。

4.3 部署时环境变量不生效

我用 Vercel 部署时遇到过几次奇怪问题:本地能登录,线上提示 Google/GitHub 回调失败。最后发现是把AUTH_SECRET只写在本地.env,线上环境没有同步。t3code 的 NextAuth 依赖这个密钥来做 Session 加密签名,漏了它,所有 OAuth 流程都会中断。

排查建议:把线上环境变量和本地完全对照一遍,尤其注意AUTH_SECRET、AUTH_URL/NEXTAUTH_URL、各 OAuth 的CLIENT_ID和CLIENT_SECRET。每次改完环境变量,必须重新部署才会生效。

4.4 部署后样式或者页面 404

Tailwind 在构建期扫描的是src/**/*.{ts,tsx},如果你把自定义组件放在了src之外,样式会丢。另一个常见问题来自 App Router:app下的页面文件如果忘记默认导出组件,页面会直接 404。这类问题在本地 dev 模式下往往不报错,只有next build后才暴露,所以改动路由后必须跑一次构建自检。

4.5 NextAuth Session 拿不到用户信息

开发初期我在 tRPC 上下文里想读取session.user.email,但一直拿到undefined。后来发现问题出在数据库里 User 模型缺少email字段,而 NextAuth 的 GitHub Provider 返回的 profile 需要映射。在server/auth.ts里加一段 profile 字段映射即可:

providers: [ GitHubProvider({ profile(profile) { return { id: profile.id.toString(), name: profile.name ?? profile.login, email: profile.email, image: profile.avatar_url, }; }, }), ],

官方默认只做最简映射,如果你需要额外字段,就得自己掌握这个过程。推荐一拿到 t3code 模板就打开auth.ts看清楚 Schema 对齐方式,不要等到登录流程写完了再回头补。

5. 我的个人使用体会

从第一次接触 t3code 到现在,我用它写过内部工具、个人博客、小型 SaaS 原型,整体最大感受是:心智负担很低。过去写全栈,我要同时记着 REST API 的 URL 列表、请求参数名、响应结构,还得维护 DTO 类型。用 t3code 后,这些信息全部封装在类型系统里,编辑器自动补全就告诉我该怎么写。

最后分享一个我在多个项目里验证过的建议:把 t3code 当成起点而不是终点。它给的默认配置非常克制,业务代码会随着需求逐渐增加。不要一开始就给它引入太多外围架构,先把"数据库 → tRPC → 页面"这条链路跑顺,等规模上来了,再按需接入 message queue、cache、事件总线这些重型组件。很多项目复杂度失控,不是因为选错了框架,而是因为在不需要抽象的阶段过早引入了抽象。t3code 让我相信,类型安全是比"多写一层封装"更可靠的防线。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进