“t3code”这个代号,是我给最近一个全栈项目起的内部名字。它没有多玄乎——就是基于 T3 Stack(TypeScript、Tailwind CSS、tRPC 三个同样以 T 开头的工具,整体跑在 Next.js 上)搭的一套完整应用。如果你最近看过不少前端模板或者开源项目,多半会撞见 create-t3-app 这个脚手架,t3code 就是在它之上长出来的产物:一个从数据库到界面全程类型安全的小型业务系统。
我选的这个方向是一个团队内部用的轻量任务与知识库工具,包含登录、任务创建、列表查询、标签筛选和基础权限。听起来不复杂,但它把所有现代全栈开发的要害都碰了一遍:服务端渲染、API 层抽象、ORM 建模、认证集成、前端数据缓存。所以它很适合两类人看:一类是前端转全栈的同学,想知道 Next.js 加 tRPC 到底怎么把前后端串起来;另一类是已经在写 REST API、但对类型安全越来越不满的开发者,想看看另一种做法长什么样。
1. 内容整体设计与思路拆解
1.1 先搞清楚“T3”到底是什么意思
写 T3 Stack 的文章不少,但很多人其实是先听说过脚手架,才回头理解它的名字。T3 不是一个框架,而是一套组合建议:TypeScript 提供全链路类型系统,Tailwind CSS 负责样式,tRPC 负责远程调用,而 Next.js 是承载它们的宿主框架。除此之外,这套组合还会带上 Prisma 和 NextAuth,分别处理数据库操作和用户认证。
这些工具看起来是“各干各的”,实际上它们有一个共同的基因——类型安全。Next.js 本身没有强制前后端共享类型,但当你把 tRPC 放进去之后,从数据库查询出来的数据,到服务端路由函数的返回值,再到前端组件的 props,类型是一路连贯的。用生活化的方式理解:以前做前后端分离,好比两家公司靠传真对接,一方的表格改了版式,另一方还在按旧版式填数据,经常对不上账。而 T3 这套玩法,是让两边坐进同一个办公室、用同一套表格,后端改一个字段名,前端立刻在编译期收到提醒。t3code 之所以能把开发效率提上来,靠的正是这种“同表同步”。
这套组合还有一个隐藏优势:它默认是“约定大于配置”的。create-t3-app 初始化出来的项目已经把 tRPC 和 Next.js 的 API 路由打通,把 Prisma 和数据库连接配好,把 NextAuth 的 session 类型处理器挂上。你不需要像早年搭 SSR 项目那样四处找胶水代码,把精力省下来,全花在业务逻辑上。
1.2 我为什么放弃传统 REST,改用 tRPC
在 t3code 之前,我做过的几个全栈项目都是标准的“Next.js + REST API”。前端写 fetch,后端写路由,中间再写一层 API 文档,或者靠 Postman 手动维护。这套模式不是不能用,但有一个很现实的问题:接口一旦多起来,类型维护成本会指数级上升。改一个响应字段,后端改完还不够,前端 type 定义要改,接口文档要改,联调时还要反复确认“这次部署上去了没有”。更难受的是,这类错误大多在运行时才暴露,用户打开页面看到 undefined,你才知道哪个字段漏了。
tRPC 完全换了思路。它的核心做法是把后端路由封装成一个个 procedure(过程),前端直接 import 这些 procedure 来调用。你不需要拼接 URL、不需要手写 fetch 封装、不需要定义重复的 interface,TypeScript 会从后端函数的返回类型里自动推断出前端拿到的数据类型。也就是说,tRPC 不是帮你把 API 文档写更好,而是直接消灭了“文档与代码不一致”这种问题。
我把两种方式做了一张对比表,方便你看清取舍:
| 对比项 | REST + 手写类型 | tRPC |
|---|---|---|
| 调用方式 | HTTP 动词 + URL | 直接调用后端函数 |
| 类型一致性 | 手动维护,容易漂移 | 编译器自动推断 |
| 接口文档 | 需要额外工具或手写 | 类型即文档 |
| 缓存与状态管理 | 自己封装 SWR/React Query | 内置 React Query 集成 |
| 调试体验 | 看 Network 面板 | 直接断点进后端函数 |
| 适用规模 | 对外公开 API、跨团队复用 | 前后端同仓库的应用 |
需要说明的是,tRPC 并不是要取代 REST。如果你的项目需要开放给第三方开发者,REST 或 GraphQL 仍然更合适。但像 t3code 这种前后端同仓库、由同一个团队维护的产品,tRPC 的收益非常直观:少写一层胶水,多一份安心。
1.3 t3code 的功能规划
t3code 的定位不是技术 demo,而是一个能真实跑起来的内部工具。我给它规划了五个核心模块:
- 账号登录:通过 NextAuth 接入 GitHub OAuth,识别用户身份。
- 任务管理:创建任务、修改状态、编辑标题和描述。
- 标签系统:给任务打标签,按标签筛选列表。
- 个人视图:只显示当前用户创建或负责的任务。
- 基础统计:在首页看到任务总数、待办数量、完成率。
之所以选这个场景,是因为它麻雀虽小、五脏俱全。你既能看到 tRPC 怎么做简单的 CRUD,又能看到它怎么做跨表查询、怎么做权限校验、怎么结合 React Query 做数据缓存。每一步踩的坑,都是实际业务里一定会遇到的。
2. 核心细节解析与实操要点
2.1 create-t3-app 生成的项目到底长什么样
t3code 是直接用 create-t3-app 初始化的,命令是 pnpm create t3-app@latest。初始化时它会问你选哪些模块,我的选择是全部都要:Next.js、tRPC、Prisma、NextAuth、Tailwind,ESLint 保留,Playwright 不装。原因是这套脚手架的价值就在于“集成好的样板”,去掉任何一个模块都要自己手工接回来,性价比不高。
初始化后,项目结构大致是这样的:
prisma/ schema.prisma # 数据库模型定义 src/ pages/ api/ trpc/ [trpc].ts # tRPC 的 HTTP 入口 _app.tsx # 全局 App 组件 index.tsx # 首页 server/ api/ routers/ post.ts # 示例 router(后改成 task.ts) root.ts # 根 router,聚合所有子 router trpc.ts # tRPC 实例、上下文、中间件定义 auth.ts # NextAuth 配置 db.ts # Prisma Client 实例 styles/ globals.css # Tailwind 入口 utils/ api.ts # 前端调用 tRPC 的封装第一次看到这个结构,不少人会疑惑:为什么 API 路由只有一个 [trpc].ts?这就是 tRPC 的设计哲学——它把所有接口都收敛到一个统一入口,通过 procedure 名称来区分具体路由。你不需要为每个接口新建一个文件,后端逻辑按业务模块拆成一个个 router,挂载到根 router 上即可。
2.2 数据模型设计:从任务表开始
类型安全的根基在数据库模型。我用 Prisma 定义了三张表:User、Task、Tag,以及一张多对多关联表。之所以不用简单的字符串数组存标签,是因为标签需要被筛选和统计,用关联表查询效率更高,语义也更清晰。
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? tasks Task[] @relation("CreatorTasks") assignedTasks Task[] @relation("AssigneeTasks") accounts Account[] sessions Session[] } model Task { id String @id @default(cuid()) title String description String? status String @default("TODO") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt creatorId String creator User @relation("CreatorTasks", fields: [creatorId], references: [id]) assigneeId String? assignee User? @relation("AssigneeTasks", fields: [assigneeId], references: [id]) tags TaskTag[] } model Tag { id String @id @default(cuid()) name String @unique tasks TaskTag[] } model TaskTag { taskId String tagId String task Task @relation(fields: [taskId], references: [id]) tag Tag @relation(fields: [tagId], references: [id]) @@id([taskId, tagId]) }几个字段选择的理由,我说一下:
- id 用 cuid() 而不是自增数字,是为了避免把业务规模暴露给外部。自增 id 一眼就能看出“你平台上有多少条数据”,cuid 没有这个问题,且生成不需要等待数据库自增,分布式的场景也友好。
- updatedAt 让 Prisma 自动维护,每次更新记录时不用手动 set,这对审计场景很有用。
- status 我暂时用 String 而不是枚举,是因为任务状态可能在产品迭代中变多(待办、进行中、已完成、已归档),用字符串过渡比较灵活。如果你确定状态数量固定,用 Prisma 的 enum 更好。
提示:设计 schema 之前,一定要先想清楚查询模式。比如“按标签筛选任务”,如果标签只是字符串数组,数据库层面就很难索引。我一开始图省事用了字符串数组,后来加筛选功能时发现查起来很别扭,才拆出了关联表。
2.3 类型安全是怎么“流”到前端的
t3code 最让人舒服的一点,是类型不需要手动“搬运”。它经历了四层:
- Prisma 根据 schema.prisma 生成完整的数据库类型,包括查询结果、输入参数、关联关系。
- tRPC 的 procedure 在服务端定义好输入校验和返回结构。如果输入需要校验,我用 zod 定义一个 schema,tRPC 会在运行时自动解析。
- 前端 utils/api.ts 导出的 api 对象,会根据服务端根 router 的类型自动生成客户端调用方法。这个过程是在 Next.js 编译时完成的。
- 组件里 useQuery 的返回数据,自动带上后端返回类型的结构,IDE 里能看到所有字段,不存在的字段会直接报错。
也就是说,你在 Prisma 里改一个字段名,接下来 tRPC router 的返回类型会变,前端 useQuery 的类型会变,组件里用到的旧字段会被 TypeScript 标红。所有错误在开发阶段就暴露了,而不是等到线上用户反馈才发现。
这段链路有个细节值得注意:tRPC 利用的是 TypeScript 的类型推断,而不是运行时反射。所以你不需要启动什么代码生成服务,只要确保 dev server 在运行、类型能正常编译,前端拿到的类型就一定和后端一致。这也是为什么 t3code 里我几乎没有写过任何重复的 interface。
3. 实操过程与核心环节实现
3.1 环境准备与项目初始化
在开始之前,我建议把环境统一一下:Node.js 版本不低于 18,包管理器用 pnpm。pnpm 对 monorepo 和依赖隔离做得更好,实测在 Next.js 项目里安装速度比 npm 快不少,占用的磁盘也小。
初始化命令很简单:
pnpm create t3-app@latest t3code cd t3code pnpm install交互式选项里,我建议全选,除非你明确知道自己不需要某些模块。初始化完成后,第一件事是把 Prisma 的数据库地址配好。我本地用的是 PostgreSQL,直接在 .env 里修改 DATABASE_URL:
DATABASE_URL="postgresql://user:password@localhost:5432/t3code?schema=public"然后执行数据库迁移:
pnpm prisma migrate dev --name init这一步会生成 migration 文件,并把表结构同步到数据库。有些人会跳过 migrate 直接 db push,如果项目还没上线,db push 更省事;但一旦有多个环境,我强烈建议用 migrate 管理版本。
3.2 写第一个 tRPC 接口并接上前端
t3code 的第一个接口是“创建任务”。在 src/server/api/routers 下新建 task.ts:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const taskRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), description: z.string().optional(), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, description: input.description, creatorId: ctx.session.user.id, tags: input.tags ? { create: input.tags.map((name) => ({ tag: { connectOrCreate: { where: { name }, create: { name }, }, }, })), } : undefined, }, include: { tags: { include: { tag: true } } }, }); }), });注意这里用了 protectedProcedure 而不是 publicProcedure,说明这个接口必须登录才能调用。ctx.session.user.id 是从 NextAuth 会话里取出来的当前用户 ID,不需要前端传当前用户,也就避免了“伪造他人身份创建任务”的问题。
把 taskRouter 挂到根 router:
// src/server/api/routers/root.ts import { taskRouter } from "./task"; export const appRouter = createTRPCRouter({ task: taskRouter, });然后到前端页面里调用。首页加一个表单,提交时用 tRPC 的 useMutation:
import { api } from "~/utils/api"; const createTask = api.task.create.useMutation({ onSuccess: () => { // 创建成功后刷新任务列表 utils.task.getAll.invalidate(); }, }); function handleSubmit(data: { title: string; description?: string }) { createTask.mutate(data); }这个调用方式看起来就像调用本地函数,但它实际执行的是网络请求。React Query 会自动处理 loading、error、success 状态,useMutation 返回的 mutate 方法在调用时会触发对应状态更新,你不需要自己写请求状态管理。
3.3 加上认证和权限
t3code 的登录用的是 NextAuth,接入 GitHub OAuth。配置写在 src/server/auth.ts:
import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const authOptions = { adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], callbacks: { session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, }; export default NextAuth(authOptions);这里有一个关键配置:session 回调里把 user.id 塞进 session.user。因为 PrismaAdapter 默认不把数据库 id 暴露给前端 session,如果不加这个 callback,你在 tRPC 的 protectedProcedure 里拿不到当前用户 ID。
权限控制的核心在 tRPC 的中间件。create-t3-app 默认生成了一段 protectedProcedure 的定义,本质是一个中间件:如果 session 里没有用户,直接抛 UNAUTHORIZED 错误;有用户就把用户信息注入 ctx。
// src/server/api/trpc.ts export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这样每个需要登录的接口只要用 protectedProcedure,就自动完成了身份验证。相比 REST 里每个控制器手动去查 session,这种声明式写法省事很多。
3.4 部署到 Vercel
t3code 的部署目标我选的是 Vercel,因为 Next.js 是它亲儿子,环境变量配置和自动部署都顺畅。在 Vercel 项目设置里需要填三个变量:
- DATABASE_URL:线上数据库的连接串。
- NEXTAUTH_SECRET:用于加密 session 的随机字符串,生产环境必须设置,否则登录模块会提示错误。
- NEXTAUTH_URL:线上域名,比如 https://t3code.vercel.app。
- GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET:OAuth 应用凭证。
部署前,在本地执行 pnpm prisma migrate deploy 把线上数据库的迁移补上。注意不要跑到 Vercel 构建时自动跑 migrate,因为构建环境不一定能访问到生产数据库的网络。最省心的做法是部署前手动跑一次迁移,或者写一个部署前脚本显式执行。
Vercel 部署完成后,再把 GitHub OAuth 的回调地址改成线上域名,否则登录会跳转失败。这套流程我第一次部署时踩了坑,后面章节会细说。
4. 常见问题与排查技巧实录
4.1 前端类型总是报错,但代码看起来没问题
这是 t3code 项目里最容易被新手当成“灵异事件”的问题。现象是:后端 router 明明加了一个新方法,后端代码也没报错,但前端调用 api.task.xxx 时,TypeScript 报“属性不存在”。
原因通常是 dev server 没有重新生成类型。tRPC 的前端类型由服务端 router 类型推导而来,但这个推导结果在 Next.js 的编译缓存里是有时效的。改了 router 文件后,如果 dev server 的热更新没有触发完整重编译,前端会用旧的类型缓存。
我的排查方法固定三步:
- 先保存所有文件,确认后端代码没有编译错误。
- 执行 tsc --noEmit,看是否真的存在类型错误。如果这里过了,说明代码本身没问题。
- 重启 dev server,让 Next.js 重新编译整个项目。
注意:VSCode 里的 TypeScript 语言服务和 Next.js 的编译是两个体系。有时候 VSCode 还显示红色波浪线,但终端里 tsc 已经通过,这种时候优先信终端结果,或者重启 TS Server(命令面板里输入 Restart TypeScript Server)。
4.2 部署后登录失败或者跳回首页
Vercel 部署之后,点登录按钮跳转到 GitHub,授权完又跳回首页,但页面没有显示登录状态。这个问题九成出在环境变量上。
最容易被忽略的是 NEXTAUTH_SECRET。本地开发时不配这个变量 NextAuth 会警告但还能跑,生产环境直接拒绝工作。生成一个强随机字符串:
openssl rand -base64 32然后填到 Vercel 的环境变量里,重新部署。
第二个常见原因是没有把 GitHub OAuth 的 callback 地址改成线上域名。GitHub 的 OAuth App 设置里,Homepage URL 填 Vercel 分配的域名,Authorization callback URL 填 https://你的域名/api/auth/callback/github。如果这一项不对,GitHub 会拒绝回调。
第三,NEXTAUTH_URL 必须和生产域名一致,不要留 localhost。本地开发时它会自动推断,但线上就不一定了,显式配置最稳妥。
4.3 列表查询慢:N+1 查询问题
t3code 的任务列表里,每条任务要显示创建人名字、标签列表。我一开始在 tRPC router 里写了两次查询:先查 Task,再循环查每个 Task 的关联 Tag,结果页面一打开,数据库要执行几十条查询,接口响应肉眼可见地慢。
Prisma 的老手一眼就知道这是 N+1 问题。解决办法是用 include 一次性把关联数据取出来:
getAll: protectedProcedure.query(({ ctx }) => { return ctx.db.task.findMany({ where: { creatorId: ctx.session.user.id }, include: { creator: { select: { name: true, image: true } }, tags: { include: { tag: true } }, }, orderBy: { createdAt: "desc" }, }); });include 在 Prisma 里会生成 JOIN 查询,而不是多次往返数据库。这里的取舍是:如果关联层级太深(比如多级嵌套),查询语句会变得复杂,甚至出现笛卡尔积。我一般只 include 两层,再多就拆接口或在应用层做聚合。
另外,React Query 的缓存也是优化重点。t3code 的列表数据其实不需要每次进入页面都重新请求,我给 useQuery 配了 staleTime:
const { data } = api.task.getAll.useQuery(undefined, { staleTime: 30_000, });这样 30 秒内的重复访问直接走缓存,不会打数据库。如果任务更新频繁,可以在 mutation 成功后调用 invalidate 强制刷新。
4.4 tRPC 输入校验失败,报 400 错误
t3code 里所有 mutation 的输入都用了 zod 校验。如果前端传的字段格式不对,tRPC 会直接返回 400,错误信息是英文的 zod 错误。新手容易在这卡壳:明明填了标题,为什么报错?
一个常见原因是 zod 的字符串校验默认是严格类型。如果你从前端传的 title 是 undefined,但 zod 要求是必填,就会校验失败。我在初始化表单时用空字符串而不是 undefined 默认值,就避开了这个坑。
还有一些情况是字段名对不上。比如 zod schema 里写的是 tags,前端字段名写成了 tagList,tRPC 会忽略多余字段,只按 schema 解析。这种问题用 TypeScript 很难完全拦住,因为 addTag 和 tagList 都是 undefined,类型上不会报错。我自己的习惯是:每个 mutation 的输入 schema 写完后,去前端调用处确认字段名一致,或者直接右键跳转到类型定义看一眼。
5. 一些经验分享
t3code 从初始化到第一版可用,我前后花了一个周末。现在回头看,最值的不是掌握了某个工具,而是把“类型安全”从口号变成了工作习惯。
有几个具体的心得,我想单独拿出来说。
第一,不要一上来就铺太大。create-t3-app 默认模板里自带了一个 post 示例,很多人上手会先急着改成自己的业务模型,然后东加一个字段西加一个接口,最后被各种类型报错劝退。我的做法是先把示例 post 完整跑通,理解 router、procedure、客户端调用、invalidates 这一整条链路,再去改自己的 schema。先学会走路,再跑起来。
第二,遇到类型问题,优先怀疑“缓存”和“重启”。我不是说代码一定没错,而是在 tRPC + Next.js 这种强类型项目里,大量报错其实源自类型没刷新。先重启 dev server,再 Ctrl+Shift+P 重启 TS Server,最后再查业务代码。按这个顺序排查,大部分问题 5 分钟内解决。
第三,写 procedure 时保持“一个函数只做一件事”。t3code 早期我写过一个 getAllAndStats 的接口,既返回列表又返回统计数字,前端用起来是方便,但后端每次查询都多算一遍聚合,性能不划算。后来拆成两个接口,统计用单独的 useQuery,staleTime 拉长到 60 秒,列表保持高频刷新,两边互不拖累。
第四,NextAuth 的 session 类型要自己补充。模板里默认的 session.user 只有 name、email、image,没有 id。如果你在 procedure 里用 ctx.session.user.id,TS 会报错。按我前面写的,在 auth.ts 的 callbacks 里把 id 塞进 session,这样类型和运行时都安全。
如果你打算复刻 t3code,我的建议是从任务管理这个最典型的 CRUD 场景入手,它足够简单,但又有认证、关联表、权限、缓存这些真实业务的骨干。把这套链路跑通之后,再换成任何业务模型,都只是替换 Prisma schema 和 router 里的逻辑而已。
最后补一个小技巧:数据库模型里凡是涉及多对多的关系,命名时尽量用“实体 + 实体”(Tag、TaskTag),少用含义模糊的中间表名。t3code 的 TaskTag 虽然看着冗余,但在 tRPC 的 include 里语义非常明确:task.tags 下面挂的是 tag,不会产生歧义。等你想在这个关联表上扩展业务字段,比如“打标签的人是谁、什么时候打的”,直接往 TaskTag 上加列就行,不需要改已有代码。这种命名上的小选择,会在项目变大后省下大量改查询的时间。