如果你最近在全栈开发圈里逛,大概率见过“T3”这个缩写——就是 t3.gg 那位社区大佬推的技术组合,通常由 Next.js、TypeScript、tRPC、Tailwind CSS 组成,再按需加入 Prisma 和 NextAuth。我拿这套组合做了一个自己的项目,名字就叫 t3code,初衷很简单:想给自己搭一套带认证、带数据库、带完整类型推导的全栈起点模板,以后做小工具、中后台甚至外包项目,可以直接拿来开局。结果从脚手架、数据建模、鉴权到部署,一路踩了无数坑。这篇文章把整个过程和背后的取舍逻辑写下来,适合两类人看:一是听说过 T3 Stack 但不知道值不值得上手的人,二是已经跑通了官方 demo、想在真实项目里把它用扎实的人。
1. t3code 到底是什么:从一个“名字误会”到我为什么需要它
1.1 先澄清:T3 不是三种技术的缩写那么死板
很多人第一次看到 T3 Stack,会下意识把它拆成 TypeScript、Tailwind、tRPC——因为它们都带 T。这个解释基本靠谱,但更准确的来源是 t3.gg 这个社区代号,项目作者是 Theo。他最开始其实是半开玩笑地做了个create-t3-app脚手架,想告诉大家“不要再纠结技术选型了,用这套组合写全栈很爽”。
所以,T3 Stack 实际上是一个约定俗成的组合拳:
| 组成 | 负责什么 | 为什么是它 |
|---|---|---|
| Next.js | 前端框架 + SSR + API 层 | React 生态成熟,前后端一个项目维护 |
| TypeScript | 全链路类型语言 | 为 tRPC 端到端类型安全打基础 |
| tRPC | 前后端接口调用 | 免去手写 API 类型和请求代码 |
| Tailwind CSS | 样式方案 | 原子化类名,写界面速度快 |
| Prisma | ORM 和数据建模 | Schema 直观,Migration 方便 |
| NextAuth | 第三方登录与会话 | 和 Next.js 配合最自然 |
其中 Prisma 和 NextAuth 是可选项。官方推荐“按需加入”,这一点很重要——很多人上手就把所有模块全勾上,结果项目被自己根本用不到的东西拖住。
1.2 我的 t3code 具体想解决什么问题
我给这个项目取名t3code,目标很明确:做一个“能直接拿去接需求”的起始仓库,而不是又一个官方 demo。我平时会接一些中小型项目,比如内部工具、内容管理后台、个人博客系统,这些项目的共同点是:都有用户登录、都有数据表、都有增删改查界面。如果每个项目都从零搭一遍鉴权和 CRUD,纯属浪费时间。
t3code 项目的核心约定:
- 用户认证开箱即用,GitHub 和邮箱登录都配好
- 数据层用 Prisma,预置 User、Account、Session 等基础模型,并留好扩展位
- 所有业务接口通过 tRPC 暴露,前端调用时自动获得类型提示
- 页面基础布局、暗色模式、通用列表页都先搭好骨架
- 部署配置写在文档里,包括环境变量清单和常见报错
这个定位决定了后面所有技术决策的方向:宁可少做炫技,也要保证每一块都能被读者或客户直接复现。
1.3 技术选型时我做过什么对比
在定稿之前,我其实在几个组合之间犹豫过:
第一个候选是Next.js + REST API + react-query。这是最稳妥的路线,但问题在于前后端类型要维护两份,改一个接口字段要同步更新接口文档、类型定义和前端调用,烦得很。
第二个候选是Vite + Express + 前后端分离。这种方案的部署更灵活,但多一个项目、多一套部署流程,对小规模项目来说是负担。
第三个候选是tRPC。它最吸引我的地方在于:接口就是函数调用,类型从数据库模型一路推到前端按钮,中间没有任何代码生成步骤。改动数据模型后,前端所有相关页面直接变成红色报错,这种“改错地方立刻知道”的体验,是 REST 给不了的。
所以 t3code 最终落在 T3 Stack 上。它不见得是每个场景的最优解,但在“一个代码库、全类型安全、快速交付”这个目标下,它是效率最均衡的选择。
2. 脚手架实操:create-t3-app 没有替你解决所有麻烦
2.1 创建项目时的模块取舍
创建命令很简单:
npm create t3-app@latest t3code交互式命令行会问你要启用哪些模块。我最终的选择是:Next.js、TypeScript、tRPC、Tailwind、Prisma、NextAuth,没用 Drizzle,也没用 eslint 之外的额外检查。取舍逻辑如下:
- 我不是不喜欢 Drizzle,但 Prisma 的
schema.prisma+ migration 流程对团队协作更友好,而且很多客户的后端同事只看 SQL,Prisma 生成的迁移文件更直观。 - ESLint 只保留基础的
next/core-web-vitals和typescript规则,不额外上 strict 全家桶。前端项目 lint 规则太多会降低交付速度。 - 没有启用
src/目录之外的额外选项,保持官方默认结构,方便新手查文档。
2.2 版本兼容性:容易被忽略的 Node 和包管理器
这里有个非常实际的坑:create-t3-app在不同时间生成的模板,依赖的 Next.js 版本差异很大。我用的时候默认装的是 Next.js 15,同时要求 Node.js 18.18 以上。
如果你同时装了很多 Node 环境,建议先用下面命令确认当前版本:
node -v corepack enable pnpm --version我建议用 pnpm 而不是 npm,原因有两个:
- pnpm 的依赖安装速度更快,磁盘占用更少,特别是 Next.js 这种依赖树庞大的项目
- pnpm 的严格依赖隔离能暴露隐式依赖问题,反而帮你提前发现代码里“根本没写依赖却直接用”的情况
2.3 生成后的目录结构,哪些文件是你真正要改的
生成完的模板结构很清晰,但新手容易迷路。我认为最主要关注这几个文件:
src/ ├── app/ # Next.js App Router 页面 ├── server/ │ ├── api/ │ │ ├── routers/ # 你的业务接口都放这里 │ │ ├── root.ts # 合并所有 router 的入口 │ │ └── trpc.ts # tRPC 上下文和中间件定义 │ ├── auth.ts # NextAuth 配置 │ └── db.ts # Prisma 客户端实例 ├── trpc/ │ ├── server.ts # 服务端 tRPC 类型 │ └── client.ts # 客户端 React Query 封装 └── env.js # 环境变量校验env.js是很多人容易忽略的点。它用 zod 做运行时校验,如果环境变量缺失或类型不对,启动时会直接报错。这其实是个好设计,但也会让首次启动直接卡住——比如没配好DATABASE_URL和NEXTAUTH_SECRET,连next dev都起不来。不要觉得这是 bug,这是模板在帮你尽早发现配置问题。
2.4 第一个真正的开发坑:Prisma generate 发生在什么时候
跑通pnpm dev之前,必须确保数据库连接没问题。我的步骤是:
# 1. 复制环境变量模板 cp .env.example .env # 2. 修改 DATABASE_URL 指向本地 PostgreSQL # 3. 执行迁移 pnpm prisma migrate dev # 4. 生成 Prisma Client pnpm prisma generate # 5. 启动 pnpm dev我第一次没执行prisma generate就直接启动,结果db.ts里导入的PrismaClient一直报模块不存在。原因是新版 Prisma Client 默认输出到node_modules/.prisma/client,必须执行 generate 才会生成。另外,如果你的数据库还没有创建对应的 database,prisma migrate dev会直接报连接错误,不会自动建库。我的建议是先用createdb t3code把库建好,再跑 migrate。
3. tRPC 这把“双刃剑”:端到端类型安全的爽与痛
3.1 先说清楚 tRPC 的原理
很多第一次接触 tRPC 的人会问:它跟 REST 有什么本质区别?没有 URL、没有 GET/POST,前端怎么知道调哪个接口?
tRPC 的核心思路是:过程调用(Procedure Call)。你在服务端定义一个函数,客户端通过导入类型拿到这个函数对应的调用方式,然后像调用本地函数一样调用它。整个链路没有手写 HTTP 请求,也没有 GraphQL 那样的 schema 解析层。
看一个最简单定义:
// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 20, }); }), create: publicProcedure .input(z.object({ title: z.string(), content: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: input }); }), });前端这样调用:
const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => utils.post.list.invalidate(), }); createPost.mutate({ title: "你好", content: "t3code" });看到这样的调用,你就会明白为什么说“类型安全”是 tRPC 最大的卖点:title和content的类型、必填性、返回数据的结构,全部由服务端定义自动推导到前端。数据库改字段,前端立刻红一片,这是最直接的好处。
3.2 我在 t3code 里的实际做法:把路由拆成“资源”
项目规模一大,把所有接口堆在一个root.ts里会很难维护。我按领域拆 router:
// root.ts import { createTRPCRouter } from "../trpc"; import { postRouter } from "./post"; import { userRouter } from "./user"; import { projectRouter } from "./project"; export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, project: projectRouter, }); export type AppRouter = typeof appRouter;每个 router 内部再拆 query 和 mutation:
query:一切读操作,包括列表、详情、下拉选项mutation:一切写操作,包括创建、更新、删除、批量操作
这里有一个经验:写操作不要偷懒只写一个update。比如“发布文章”和“保存草稿”在业务上是两个语义,在代码里也应该是两个 mutation,哪怕底层都是更新同一张表。这样做的原因是,前端可以做更精准的缓存失效和错误处理,而不是每次刷新整张列表。
3.3 权限控制:publicProcedure 和 protectedProcedure
t3code 的核心需求之一是认证,所以绝大部分业务接口必须是“登录才能调用”。模板自带的protectedProcedure正好解决这个问题:
import { createTRPCRouter, protectedProcedure } from "../trpc"; export const projectRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ name: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.project.create({ data: { name: input.name, ownerId: ctx.session.user.id, }, }); }), });protectedProcedure会在没有合法 session 时直接抛错,前端收到的是未认证错误,根本不会执行数据库操作。这里面藏着 tRPC 的一个重要特性:校验在服务端执行,不是在中间件里靠 if 判断。所以不要在调用处再去检查登录状态,那既绕又容易漏。
3.4 我踩过的类型错误:input 类型没有显式导出
有一个非常隐蔽的坑:如果你在某个 mutation 里定义了复杂的 input schema,并且想在另一个文件里复用这个类型,直接import { $input }是拿不到的。tRPC 的类型推导是在内部完成的,外部能拿到的是整个 procedure 的类型。需要单独导出时,要显式定义一个类型:
const createProjectSchema = z.object({ name: z.string().min(1), description: z.string().optional(), }); export type CreateProjectInput = z.infer<typeof createProjectSchema>;这样前端某个表单组件里才能做类似useForm<CreateProjectInput>的操作。我在 t3code 里把所有 zod schema 集中放在src/server/schemas/目录,router 和前端都从那里导入,避免类型散落。
3.5 错误处理的细节:httpBatchLink 和 errorFormatter
tRPC 默认用httpBatchLink,它会尝试把多个并发请求合并成一个 HTTP 请求。这很高效,但也有副作用:请求报错时,你可能拿不到直观的 HTTP 状态码,而是一个封装后的 TRPCClientError。
我的做法是在服务端自定义 error formatter,把业务错误清楚地返回:
// trpc.ts export const createTRPCRouter = ({ procedure, ... }) => { // 相关配置,包括 errorFormatter };如果某个操作是表单校验失败、数据冲突或未授权,建议用TRPCError显式抛出:
import { TRPCError } from "@trpc/server"; throw new TRPCError({ code: "UNAUTHORIZED", message: "请先登录", });前端就能针对error.data.code做对应的用户提示,而不是把泛泛的“请求失败”抛给用户。踩过的坑是:一开始我把错误都堆在onError里统一 toast,结果输入校验错误和服务器错误混在一起,调试起来很难受。后来改成按错误码分类处理,清晰多了。
4. 数据建模与鉴权:Prisma + NextAuth 的正确组合姿势
4.1 schema 设计:先把模型画清楚,再谈功能
t3code 最初只有用户表和文章表,后来我加了“项目表”来管理多个独立业务场景。每次加表,我都强迫自己先回答三个问题:
- 这张表的核心实体是什么?它的生命周期是什么?
- 哪些字段是业务字段,哪些是冗余字段?
- 访问权限边界在哪里?
Prisma schema 示例:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] projects Project[] posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Project { id String @id @default(cuid()) name String ownerId String owner User @relation(fields: [ownerId], references: [id]) members 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) projectId String? authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }这里面有两个设计经验值得展开:
第一,Project.members我用的是String[]存用户 ID 列表。这是偷懒的做法,如果做正式系统,应该建ProjectMember中间表,否则查某个用户的所有项目要全表扫描。但对于一个人维护的 t3code 项目,我选择“先跑起来”,等确实有需要再迁移成关联表。这体现了 T3 社区的一个精神:不要过度设计。
第二,User模型里那些accounts、sessions字段是 NextAuth 的 Prisma Adapter 自动关联关系要求的。如果你把@default(cuid())改成别的 ID 生成策略,或者把字段名改了,NextAuth 就会报错。所以这些字段是框架的地基,不能随便动。
4.2 NextAuth 配置中的常见翻车点
t3code 使用了 GitHub 登录,配置过程看似简单,实际坑不少。
首先,环境变量必须配好:
AUTH_GITHUB_ID=xxxx AUTH_GITHUB_SECRET=xxxx AUTH_SECRET=xxxx新版 NextAuth 用的是AUTH_SECRET而不是旧的NEXTAUTH_SECRET,这也是很多老教程过时的原因。生成密钥用:
npx auth secret其次,GitHub OAuth App 的Authorization callback URL要填对:
http://localhost:3000/api/auth/callback/github部署后还要把线上域名加进去。我第一次部署时忘了加线上的 callback URL,上线后点击登录一直跳回首页,控制台也没报错。这个排查了很久才意识到是 OAuth 应用配置的问题,和代码无关。
4.3 让 tRPC 的上下文里始终有 session
t3code 里最关键的衔接就是把 NextAuth 的 session 注入到 tRPC 的ctx。模板里已经写了类似逻辑:
export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await auth(); return { db, session, headers: opts.headers, }; };这样每个 procedure 都能拿到ctx.session.user。但要注意:auth()在服务端组件里调用没问题,如果你在普通 API Route 里也想拿 session,需要明确调用auth()而不是getServerSession,新版 NextAuth v5 的 API 变化比较大。
登录后的用户信息还可以通过 tRPC 暴露给前端,比如导航栏显示头像,我就写了一个getMe的 query:
getMe: protectedProcedure.query(({ ctx }) => { return ctx.session.user; });4.4 路由级保护:middleware 到底该不该用
很多人会想到用middleware.ts去保护所有页面路由。我实测下来的建议是:不要依赖 middleware 做业务权限判断,它的主要用途是简单的“未登录访问受限页面就重定向”。
原因很简单:middleware 在 Edge 环境运行,不能直接用 Prisma,所以没法做精细的数据库权限校验。真正的权限边界必须在 tRPC 的 procedure 里做。middleware 只是提升体验:未登录跳转登录页,已登录访问登录页则返回首页。
t3code 里我保留了一个很薄的 middleware,只做一件事:
// middleware.ts export { auth as middleware } from "@/server/auth"; export const config = { matcher: ["/dashboard/:path*"], };务必注意 matcher 的写法,很多人在这里写错导致整个网站都被重定向。我的经验是:路径模式要精确,不要图省事写/,不然静态资源都被拦截。
5. 部署上线:t3code 从本地到稳定运行的全过程
5.1 环境变量清单:部署前先对着这份表格检查
部署和本地开发最大的区别是环境变量。我整理了一份 checklist,每次部署都对着检查:
| 变量 | 本地 | 线上 | 说明 |
|---|---|---|---|
DATABASE_URL | 本地数据库 | 云数据库或内网地址 | 注意线上是否要求 SSL |
AUTH_SECRET | 开发密钥 | 重新生成 | 用npx auth secret |
AUTH_GITHUB_ID | OAuth App ID | 线上 App ID | 线上 callback URL 要匹配 |
AUTH_GITHUB_SECRET | 对应密钥 | 对应密钥 | 和 ID 是成对的 |
ENV | development | production | Prisma 按此判断日志级别 |
我在本地用 SQLite 其实也行,但生产环境建议直接上 PostgreSQL。原因之一是并发写入和事务处理不同,另一个原因是 Prisma 对 PostgreSQL 的特性支持更完整,比如String[]字段、枚举类型、JSONB 等。
5.2 构建阶段最容易出的问题:Prisma 引擎找不到
我在自建服务器部署时遇到过一个经典问题:容器或裸机环境构建 Next.js 时,Prisma Client 引擎文件没被正确复制,导致运行时一直报PrismaClientInitializationError。
解决方法是检查 Dockerfile 或构建脚本中是否在prisma generate之后执行了next build,并确保构建阶段的node_modules被完整保留。对于 Docker 多阶段构建,推荐用node:20-slim作为运行镜像,同时安装openssl:
RUN apt-get update && apt-get install -y openssl ca-certificates如果使用 Vercel 部署,则不需要担心这个问题,平台会自动处理 Prisma 引擎和连接池。但我建议给数据库加一个连接串参数:
DATABASE_URL=postgresql://user:pass@host:5432/db?connection_limit=5Vercel 的 Serverless Function 会为每个并发实例建立数据库连接,如果连接数被数据库上限卡住,你会看到间歇性超时。限制连接数能有效缓解。
5.3 上线后我实际遇到的三类问题
第一类是 Next.js 的缓存导致页面不更新。App Router 默认会把静态渲染结果缓存很久,改动内容后不清缓存就会觉得“数据不对”。实际上该把动态页面标记为动态渲染:
// app/projects/[slug]/page.tsx export const dynamic = "force-dynamic";第二类是 tRPC 请求在服务端触发 React Query 刷新,导致无限刷新循环。根本原因是我在服务端组件里调用了api.post.list.useQuery(),又没有正确设置ssr: false或缓存策略。tRPC + React Query 在 App Router 下的 SSR 行为要和客户端调用区分开,否则很容易出现“客户端水合时请求又发一次”的问题。
第三类是日志太少,出错时无从查起。我的做法是在所有 mutation 的关键路径加日志,不是用console.log乱打,而是级别分清楚:
console.info(`[t3code] user ${userId} created project ${projectId}`); console.error(`[t3code] delete project failed: ${error.message}`);上线后直接看服务日志就能定位数据操作问题,不需要到处翻数据库。
5.4 健康检查与自动重启
自部署时,我建议至少给进程加一个健康检查脚本:
curl -f http://localhost:3000/api/health || exit 1t3code 里我留了一个极简的健康检查路由,返回数据库连接状态。配合 pm2 的max_memory_restart配置,能保证进程异常时自动恢复。别小看这一步,我遇到过内存泄漏直接宕机、又没有拉起机制的情况,半夜接客户电话的滋味真的不想再来一次。
6. 从 t3code 做出来后,我更想说的几件事
6.1 tRPC 的“类型安全”是有边界的
很多人宣传 T3 Stack 时,会把“端到端类型安全”吹得神乎其神。我的真实体感是:它消除了接口层的类型漂移,但没有消除业务逻辑层的类型漂移。比如表单提交的字符串和数据库里的枚举值,还是需要 zod 做运行时校验。类型安全解决的是“编码时”的错误,运行时错误永远要靠防御式编程兜底。所以我在 t3code 里坚持所有外部输入都过 zod,不在 procedure 里裸用input做逻辑。
6.2 下一个版本我会怎么做
t3code 目前已经稳定用于我自己的两个小项目,但下一个版本我想做三件事:
第一,把Project.members从String[]迁到真正的多对多表,并用事务保证数据一致性。这是从“能跑”到“经得起多人用”的必经一步。
第二,加入软删除。现在删除Post是物理删除,实际操作中我吃过亏,误删数据没法恢复。下一步在每个需要审计的模型上加deletedAt字段,查询时默认过滤。
第三,做一个可视化的路由调试面板。tRPC 没有像 GraphQL Playground 那样的现成调试界面,排查接口问题要靠前端页面触发。我想给 t3code 加一个/api/debug工具页,能够列出所有 tRPC procedure,并允许在浏览器里直接调用带 mock session 的接口。这对团队成员上手项目帮助很大。
6.3 给准备抄作业的人一个最终清单
如果你也想照着 t3code 的思路搭项目,我建议按这个顺序来:
- 先跑通
create-t3-app原版模板,只加 Prisma,不加 NextAuth,熟悉数据模型和 tRPC 基本用法 - 第二步加上 NextAuth,把注册登录跑通,并理解 session 如何进入 tRPC context
- 然后写第一个完整的业务模块:列表页、详情页、创建表单、删除按钮
- 最后才关注部署:先部署到 Vercel 体验 Serverless 的“零运维”,再根据需求迁移到自己的服务器
整个过程不要贪快,每一层都要把报错读透。我在实际使用中最深的体会是:T3 Stack 的学习曲线不在脚手架本身,而在于你愿不愿意花时间理解“类型是怎么流过去的”。一旦把这个想明白,后面所有增删改查都变得很顺,因为数据从数据库到页面按钮,整条链路的形状是清晰可感的。t3code 现在还在持续迭代,如果你也在折腾同一套技术栈,欢迎把你在 tRPC 错误处理或 NextAuth 配置里遇到的奇葩问题翻出来对比一下,你会发现我们踩的坑重叠度相当高。