在动手写之前,我先把这次的拆解思路交代清楚。标题“t3code”看着像只是个仓库名,但结合社区语境,它基本就是指围绕T3 技术栈(Next.js + tRPC + Tailwind CSS + Prisma)展开的一套代码实践或者说项目脚手架。这类项目最近在前后端全栈圈子里讨论度很高,因为它把“类型安全”从后端一路贯穿到前端,用最少的胶水代码把全栈应用串起来。我这次就以一个“从零搭一个 t3code 全栈项目”的角度,把技术选型逻辑、核心实现细节、真实踩坑记录一次性讲透。
1. 项目整体思路与技术选型拆解
1.1 t3code 到底解决了什么问题
很多团队做全栈项目时,最头疼的其实不是功能本身,而是“前后端数据契约”的维护。传统模式下,后端写好接口文档,前端对着文档手写请求函数,联调时一遍遍对字段,改个字段名就要动两处代码,漏改一处就是线上事故。
t3code 这种项目的核心价值,就是用一套语言(TypeScript)把后端接口、数据库模型、前端调用全部串起来——后端定好 schema,前端在编辑器里就能看到完整的类型提示,接口改了类型,前端编译直接报错。等于把“靠文档约定”升级成“靠编译器兜底”。
我看过不少团队评估要不要上这套技术栈,最后决定用的人基本都提到一件事:写业务代码时那种“大脑不用切换语言模式”的流畅感。你不需要在 TypeScript、GraphQL schema、REST 参数校验之间来回跳,一条链路都是同一种类型系统。
1.2 为什么是 Next.js + tRPC + Prisma 这个组合
先拆开看每个成员:
- Next.js:负责应用框架和路由。现在 App Router 已经是主流,服务端组件和客户端组件混用,配合 RSC 的流式渲染能把首屏性能拉得很高。
- tRPC:负责前后端调用。它不生成 REST 或 GraphQL,而是直接导出一组类型安全的函数,前端像调用本地函数一样调后端。
- Prisma:负责数据库访问。用 schema 文件定义表结构,生成类型安全的 client,迁移和管理都靠命令行。
- Tailwind CSS:负责样式。原子化 CSS 在快速迭代时很爽,省去写一堆冗长类名和组织 CSS 文件的心智负担。
这个组合最妙的一点是全链路类型安全:Prisma 的 model 类型流入 tRPC router 的输入输出类型,再流入前端 React 组件的 props 类型。你在数据库里加一个字段,整个链条的类型都会自动更新,基本不存在“后端改了前端还拿着旧字段”的情况。
对比用 REST + OpenAPI 的方式,虽然也有代码生成工具,但大多需要额外跑脚本、维护生成的客户端代码,或者引入中间层做桥接。t3code 这种方式是“原生类型安全”,少了很多工具链维护成本。
2. 核心功能与数据模型设计实践
2.1 我先定业务边界,再倒推表结构
做 t3code 这类项目,最忌讳一上来就铺功能。我常用的方法是把核心闭环走通,再做加法。比如做一个“项目文档管理”的场景,我最先只做三张表:
- User:用户基础信息,包含 name、email、image。
- Project:项目实体,归属某个用户。
- Doc:文档内容,挂在项目下面,包含 title、content、状态字段。
用 Prisma schema 定义如下:
```prisma model User { id String @id @default(cuid()) name String? email String @unique image String? createdAt DateTime @default(now()) projects Project[] docs Doc[] }
model Project { id String @id @default(cuid()) name String description String? owner User @relation(fields: [ownerId], references: [id]) ownerId String createdAt DateTime @default(now()) docs Doc[] }
model Doc { id String @id @default(cuid()) title String content String? status DocStatus @default(DRAFT) project Project @relation(fields: [projectId], references: [id]) projectId String author User @relation(fields: [authorId], references: [id]) authorId String updatedAt DateTime @updatedAt createdAt DateTime @default(now()) }
enum DocStatus { DRAFT PUBLISHED ARCHIVED } ```
这里有个关键决定:为什么用cuid()而不是自增 id?一是分布式环境下不会撞车,二是不暴露业务数据量。对全栈项目来说,用 cuid 或 uuid 基本是社区共识。
设计表的时候还要把“谁拥有什么”想清楚,因为这直接决定后面 tRPC 的权限校验逻辑。比如 Project 的 ownerId 和 Doc 的 authorId 就是权限判断的锚点。
2.2 类型安全从数据库一路贯穿到组件
t3code 项目的爽点,在把这三层连起来那一刻才真正体现。Prisma 会基于 schema 生成完整的类型定义,tRPC 的 router 里你可以这样写:
```typescript import { initTRPC } from '@trpc/server'; import { z } from 'zod';
const t = initTRPC.context<{ userId: string }>().create();
export const appRouter = t.router({ getProjectDocs: t.procedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { return prisma.doc.findMany({ where: { projectId: input.projectId }, include: { author: true } }); }), createDoc: t.procedure .input(z.object({ projectId: z.string(), title: z.string().min(1).max(100), content: z.string().optional() })) .mutation(async ({ ctx, input }) => { return prisma.doc.create({ data: { projectId: input.projectId, authorId: ctx.userId, title: input.title, content: input.content } }); }) }); ```
前端调用时,不需要手动写任何 fetch 代码:
```typescript import { trpc } from '~/utils/trpc';
function ProjectDocs({ projectId }: { projectId: string }) { const { data: docs, isLoading } = trpc.getProjectDocs.useQuery({ projectId }); const createDoc = trpc.createDoc.useMutation();
return (
{doc.title}
)} <button onClick={() => createDoc.mutate({ projectId, title: '新文档' })}> 新建注意看:useQuery的入参类型、返回值类型、useMutation的入参类型,全部由 tRPC 推导而来。你永远不需要维护一份“接口请求封装文件”。
我第一次用这套时有点不适应,总觉得“没有 axios 封装怪怪的”。用了一周后明白了一个道理:封装请求的核心目的是统一错误处理、补充额外 header、方便维护接口清单。而这些 tRPC 天生就做了,错误处理有统一的ErrorFormatter,header 通过 context 传递,接口清单就是 router 自身。省掉一层反而更干净。
3. 实操过程:从初始化到跑通完整链路
3.1 创建脚手架与目录结构规划
现在创建 t3 项目非常快,直接用官方的脚手架命令:
```bash npm create t3-app@latest my-t3code-app ```
交互式选项里会有很多勾选项,我建议全都要——Next.js、TypeScript、tRPC、Prisma、Tailwind、NextAuth,一套下来基本就是完整骨架。如果你不确定某些项,后面也可以手动装,但脚手架自动做好的集成比手动配省事太多。
生成后的目录结构重点关注这几块:
prisma/schema.prisma:数据模型声明位置。src/server/api/routers/:每个业务域一个 router,比如projectRouter.ts、docRouter.ts。src/server/api/root.ts:根 router,把子 router 合并。src/trpc/server.ts:服务端调用 tRPC 的入口。src/utils/api.ts:前端 React Hooks 的封装。
这个结构其实非常符合领域划分思路。一个业务域对应一个 router 文件,每个 router 内部再拆分 query 和 mutation,逻辑清楚,长期维护不会发展成一个大泥球。
3.2 数据库初始化与第一个接口实操
数据库我平常开发用 SQLite,部署再切 PostgreSQL。Prisma 的好处是改了 provider 就能切,schema 语法几乎一样。
初始化数据库:
```bash npx prisma migrate dev --name init npx prisma generate ```
migrate dev会创建一个真实的迁移 SQL 文件并执行,还会自动重新生成 Prisma Client。这个流程每次改 schema 都要做一遍,千万别手动去改数据库表——那样 Prisma 的 migration 状态会乱掉。
然后写一个最简单的接口验证链路。先建一个 health 检查:
```typescript // src/server/api/routers/healthRouter.ts import { createTRPCRouter, publicProcedure } from '../trpc';
export const healthRouter = createTRPCRouter({ ping: publicProcedure.query(() => ({ message: 'pong', timestamp: Date.now() })) }); ```
把 healthRouter 挂到根 router 后,前端直接调:
```typescript const ping = trpc.health.ping.useQuery(); console.log(ping.data); // { message: 'pong', timestamp: ... } ```
这一步跑通就说明整个架构链路已经没问题——后续的业务逻辑基本就是在这个框架里叠加输入校验、权限、数据库操作而已。
这里我要特别提醒一点:tRPC 的 context 设计是核心中的核心。context 是用来承载每个请求的公共信息的,比如当前登录用户、请求头里的 token。你会经常在 router 里用到ctx.userId,如果一开始没把 context 设好,后面所有接口的权限校验都要返工。
我推荐的 context 实现:用 NextAuth session 作为数据源,在createContext里解析 session,把 userId 存进去。具体看你的项目用不用 NextAuth,但思路是一样的——任何请求进来,先通过中间件或 session 校验,把用户信息塞进 context,后续接口就只管“取用”。
3.3 权限校验与公共逻辑的抽法
全栈项目里权限是个绕不开的坎。tRPC 跟 NestJS 那种框架不同,它没有内置守卫,但我们可以用 middleware 机制达到同样的效果。
tRPC 的 middleware 写法:
```typescript import { initTRPC, TRPCError } from '@trpc/server';
const t = initTRPC.context<{ userId: string | null }>().create();
const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: '请先登录' }); } return next({ ctx: { userId: ctx.userId } }); });
export const authedProcedure = t.procedure.use(isAuthed); ```
之后凡是需要登录权限的接口,就用authedProcedure而不是publicProcedure。比如删除项目这个操作,不仅要登录,还要校验“这个项目是不是当前用户的”,这种就适合在 procedure 内部再查一次:
```typescript deleteProject: authedProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { const project = await prisma.project.findUnique({ where: { id: input.projectId }, select: { ownerId: true } });
if (!project || project.ownerId !== ctx.userId) { throw new TRPCError({ code: 'FORBIDDEN', message: '无权操作此项目' }); } await prisma.project.delete({ where: { id: input.projectId } }); return { success: true };}) ```
这个模式就是“procedure 组合 + 领域内再校验”,比散落在每个页面里的 if-else 好维护得多。只要规则统一,后面加其他角色权限就是多写一个 middleware 的事。
3.4 一个 Webhook 场景的完整落地案例
为了避免内容太抽象,我拿一个真实会遇到的场景走一遍全流程:给外部服务提供一个 webhook,当文档被发布时通知某个回调地址。
第一步,在 schema 里加个字段记录回调信息:
```prisma model WebhookDelivery { id String @id @default(cuid()) docId String status String attempts Int @default(0) response String? createdAt DateTime @default(now()) } ```
第二步,写一个“发布文档并触发 webhook”的 mutation。这里要注意,webhook 请求千万别在 tRPC 请求线程里同步等——外部服务一般都有延迟,慢的话会拖垮你的接口响应。正确的做法是:先落库、再发异步任务。
保持代码简单的做法是直接调用一个 async 函数,不 await 它:
```typescript publishDoc: authedProcedure .input(z.object({ docId: z.string() })) .mutation(async ({ ctx, input }) => { const doc = await prisma.doc.update({ where: { id: input.docId, authorId: ctx.userId }, data: { status: 'PUBLISHED' } });
void triggerWebhook(doc.id); // 不阻塞当前响应 return doc;}); ```
triggerWebhook内部再去创建 WebhookDelivery 记录、发起 HTTP 请求。这样即使外部服务抽风,也不会影响文档发布接口本身的稳定性。当然,生产环境更严谨的做法是接一个队列(如 BullMQ 或 QStash),本地开发用void asyncFunc()完全够用。
第三步,校验入参必须用 zod 做二次校验。tRPC 的 input 类型会在编译期帮你查错,但运行时校验仍然需要 zod。这两者缺一不可——编译期类型和运行时校验不是一回事,前端传过来的数据永远要知道“不可信”这个前提。
一个实用小技巧:zod 的 schema 写的严格一些,比如金额字段用z.number().nonnegative(),字符串字段用.max()限制长度。这样即使恶意用户绕过前端直接调接口,数据也会被拦截在业务逻辑之外。
4. 常见问题与排查技巧实录
4.1 类型不更新的连环坑
这是我第一次用 t3 栈时卡最久的问题。改了 Prisma schema,重新跑了prisma generate,但前端类型还是旧的,编辑器里报红一大片。
排查后发现原因有两个:
- Next.js dev server 缓存。tRPC 的类型基于 Prisma 类型推导,而 Next.js 开发模式下对类型有缓存,需要重启 dev server 才能刷新。
- tsc 增量缓存。TypeScript 开启了增量编译后,旧类型被缓存,需要删掉
.next、node_modules/.cache再跑。
我的建议是:改动 schema 后,按这个顺序执行不会出错:
```bash npx prisma generate rm -rf .next rm -rf node_modules/.cache ```
然后重启npm run dev。别嫌麻烦,这个组合几乎能解决 90% 的类型不更新问题。
另一个相关问题是编辑器无法识别 tRPC 的类型。遇到这种情况,先确认 VSCode 用的是 TS 的 server side 版本,而不是全局版本。可以在项目根目录跑npx tsc --version对比,如果版本不一致,把 VSCode 的 TypeScript 版本切换到项目本地版本即可。
4.2 部署时的冷启动和跨域问题
部署 t3 应用我一般用 Vercel,简单省事。但有两次印象深刻的坑:
第一次是服务端请求数据库的连接数。用 Serverless 部署时,每个请求都会实例化一个新的 PrismaClient,而且数据库连接不会自动复用,经常出现连接池爆满的问题。
解决方案是使用一个全局单例:
```typescript const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') { globalForPrisma.prisma = prisma; } ```
这段代码的原理是:开发环境热更新时避免创建多个 client 实例,生产环境直接复用全局单例。这个模式在 Next.js 官方文档里也被推荐过。
第二次是跨域和生产环境的 baseUrl 问题。本地开发时 tRPC 默认走相对路径没问题,但如果前端静态托管域名为 A,接口域名为 B,就要在createTRPCClient里配置url。用 Vercel 部署时,前后端都在同一个域,反而不用特别担心;但如果你把 Next.js 只当作前端壳,接口单独跑在别的服务上,就得认真配置。
配置方式其实很简单,在src/utils/api.ts里指定:
```typescript export const trpc = createTRPCClient ({ links: [ httpBatchLink({ url: process.env.NEXT_PUBLIC_API_URL ?? '/api/trpc', }), ], }); ```
注意NEXT_PUBLIC_前缀的变量是暴露给浏览器的,千万不要在里面写密钥。
4.3 性能优化:避免过度查询和 N+1
tRPC 最方便的地方是查询任意嵌套数据,但这也最容易产生 N+1 问题。例如查询项目列表时,每个项目再查一次它的文档数,代码写起来很自然:
```typescript projects: authedProcedure.query(async () => { const projects = await prisma.project.findMany(); return projects.map(async project => ({ ...project, docCount: await prisma.doc.count({ where: { projectId: project.id } }) })); }); ```
这样写如果项目有 100 个,就会产生 101 条 SQL。Prisma 的include或_count可以一次性聚合:
```typescript projects: authedProcedure.query(async () => { return prisma.project.findMany({ include: { _count: { select: { docs: true } } } }); }); ```
用_count后,SQL 只需一条LEFT JOIN加一个聚合,性能差距在小规模数据上不明显,但数据量一上来立刻见分晓。
另一个优化点是用select而不是include返回整个对象。tRPC 的数据最终要序列化成 JSON 传到前端,多返回的字段就是实打实的带宽消耗。对大字段(比如文档正文)尤其要注意,只返回前端需要的字段。
我习惯在写每个 query 前问自己一个问题:“前端在这个界面上,真的需要这个对象的全部字段吗?”这个习惯帮我省了不少流量,也逼着自己想清楚数据的最小集是什么。
5. 我给新人的一条完整上手路径
5.1 先跑通官方示例,再动手改造
很多新手拿到 t3code 这类项目,第一反应是“我要从零写一个”。但我更建议的反而是:先原样把官方 create-t3-app 的示例跑起来,逐行读代码,再尝试改动某个小功能。比如把示例里 todo 的字段加一个优先级,顺手把 UI 加上。这个过程让你理解框架的“惯用法”,比自己从零摸索高效得多。
等到跑通了,再基于 t3code 的骨架去扩展自己的业务逻辑——加一张表、加一个 router、加一个 mutation,每一步都是小步快跑,不容易把架构搞崩。
我见过不少新人上来就改 schema、改 router、改前端样式,结果类型报错、请求失败、权限混乱,最后归因于框架不好用。其实框架本身很成熟,问题多半出在一口气改太多了。搞技术,尤其是这种类型强关联的全栈架构,最忌讳“大爆炸式改动”。
5.2 什么时候你不需要 t3code
务实地说,不是所有项目都适合这套组合。如果你的项目以内容展示为主,几乎没什么登录和数据库交互,直接用 Next.js 静态生成就够了,引入 tRPC 和 Prisma 反而增加复杂度。
如果团队前端不写 TypeScript,t3code 的价值会大打折扣。TypeScript 是整个链路的核心,没有人用 TypeScript 就少了一半的类型安全收益。别强行推,强推的结果往往是前后端各自妥协,最后还不如 REST 来得直接。
t3code 最适合的画像:中小型全栈应用、SaaS 类产品、内部工具、快速验证的 MVP。这类项目的共同特点是业务变化快、接口频繁调整、团队成员希望少一些机械劳动、多专注业务本身。在这些场景里,类型安全带来的收益是实打实的。
5.3 后续可以怎么扩展
项目跑到能稳定迭代之后,有几个方向特别值得加强:一是引入测试,tRPC 的 router 是纯函数式的,用 Vitest 做单元测试非常顺手;二是服务端缓存,对热点查询做 React Query 的 staleTime 优化;三是接入定时任务或队列,把 webhook 通知、定时清理之类的工作从请求线程里完全剥离开。
我自己现在维护的 t3code 项目,就是从最开始的文档管理扩展成了带评论、通知、团队权限的小型协作工具。整个过程中我没写过一行 axios 代码,没有手动维护过一份接口文档,也没有因为“前端传错字段”反复联调过。这种感觉,只有真正上手之后才能体会。
最后分享两个亲测有效的小技巧
第一个:给 tRPC 的 error 加上统一的格式化和错误码映射。默认的 tRPCError 到了前端是像TRPCClientError: UNAUTHORIZED这样,用户看不懂。你可以在创建 router 时传入errorFormatter,把错误转成{ code: 401, message: '登录已过期' }这种结构,前端拿到后直接提示。前端再配合 React Hook Form 的 setError,可以把后台的校验错误精准显示到对应字段上,体验会好很多。
第二个:善用trpc的useUtils()做乐观更新。比如用户快速点击“发布”按钮时,不要等后端返回再刷新列表,而是先改本地缓存,失败再回滚。tRPC 的 React Query 集成让这在代码里就是几行的事:
```typescript const utils = trpc.useUtils();
createDoc.mutate(input, { onSuccess: () => utils.getProjectDocs.invalidate({ projectId }) }); ```
这样做的好处是界面响应流畅,同时保证最终一致性。处理复杂交互时,这个模式比单纯 loading 转圈舒服太多了。
t3code 这个项目方向,表面看是一个技术脚手架的集合,本质上是“用类型系统对抗复杂性”这套理念的落地。如果你正在纠结前后端协作的效率问题,或者对全栈类型安全感兴趣,不妨拿这个方案搭一个一天能完成的小项目,把整条链路亲手跑通。跑通一次,你对全栈开发的认知会发生明显变化。