资讯详情 T3技术栈实战:用tRPC+Prisma构建端到端类型安全的全栈模板
📅 2026/10/28 4:30:22
1. t3code的由来为什么我要把全栈工程模板化1.1 起这个代号的真正动机我手头维护过不下十个业务项目几乎每个都经历过同一种痛苦后端接口文档是一份前端调用另一份后端改个字段名前端半天还不知情等跑到线上才发现类型对不上最后靠着一层层 console.log 把问题捞出来。说白了全栈开发的效率瓶颈根本不在写代码而在两端之间的信息同步。t3code 这个代号其实就是我给自己的一套全栈工程实践起的名字以 T3 技术栈TypeScript Tailwind CSS tRPC为底座从零搭建一个标准化的全栈项目模板。它不是什么商业产品就是一个把类型安全贯彻到前后端每一层的完整示例用来解决我在日常开发里反复踩到的坑API 层类型失联、迁移脚本缺失、前端拿到后端数据后还得手动写类型断言。为什么非要花力气做这件事因为只要做一次后续每个项目都能直接复用这套骨架。新同事入职跑一遍 t3code 的 start 流程就能清楚看到一个业务功能从数据库字段一路走到页面按钮的完整链路。这也是我写这篇文章的原因——把完整的初始化、选型、建模、接口编写、前端调用和部署踩坑过程记录下来给那些想用 T3 技术栈但不知从何下手的人一份可以照抄的作业。1.2 T3这套组合为什么能成立很多人第一次见 T3 这个词会以为是什么新框架其实它不是。它只是一组工具的组合策略。T3 的核心原则是渐进式采用——需要什么才加什么而不是一上来就堆一堆抽象层。TypeScript负责语言层面的类型让变量、函数、数据结构在编译期就互相约束Tailwind CSS负责样式省去繁琐的 CSS 类名管理tRPC负责 API 层让前端调用后端函数时直接获得类型推断连 REST 路由和 GraphQL SDL 都不用写我最初最怀疑的其实是 tRPC。因为习惯了 REST 和 GraphQL你很难相信不定义路由协议直接调函数这种方案在生产环境能扛住。但等我真在项目里把它跑起来尤其是当后端改一个 Zod Schema、前端保存文件后立刻出现红色波浪线报错的时候我才意识到——这才是类型安全该有的样子不是靠文档约定而是靠编译器强制。这套组合还有一个容易被人忽略的优点对新手极其友好。因为每一层都没有复杂的配置魔法你可以顺着一条清晰的调用链读代码。哪怕你不了解 tRPC看到一个trpc.task.getAll.useQuery()也能猜出这是在拉取任务列表。t3code 的定位就是要把这条调用链完整展现出来。2. 初始化选型create-t3-app 的每一步在选什么2.1 交互式命令每一选项的取舍t3code 的第一步是运行create-t3-app。很多人会直接一路回车这其实是偷懒因为每个选项背后都对应着一套工程依赖选了之后想再拆除是很麻烦的事。我建议动手前先把交互选项理解清楚。npm create t3-applatest t3code创建过程中会依次问你几个问题选项说明我的选择TypeScript是否启用 TS必选这就是 T3 的核心Tailwind CSS样式方案必选写页面效率高tRPCAPI 层方案必选t3code 的招牌NextAuth.js认证方案看项目实际需求Prisma数据库 ORM必选配 PostgreSQLApp RouterNext.js 路由模式必选新项目别用 Pages Router统一包管理器npm / pnpm / yarn / bun我用 pnpm安装快、磁盘占用小在 t3code 里我暂时没有启用 NextAuth.js。原因很简单认证逻辑一旦引入会造成很多背景知识负担比如 Session 刷新、Cookie 策略、CSRF这些跟我要展示的核心链路数据库到页面不是同一条主线先砍掉。等模板跑通后再按create-t3-app的官网文档把认证加回来也不难。2.2 拿到的骨架长什么样初始化完成后目录结构大致是这样t3code/ ├── prisma/ │ └── schema.prisma # 数据库模型定义 ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts # tRPC HTTP 入口 │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts # 根 Router聚合所有子 Router │ │ │ ├── trpc.ts # tRPC 上下文和初始化逻辑 │ │ │ └── routers/ # 按业务拆分的子 Router │ │ ├── db.ts # Prisma 客户端实例 │ │ └── auth.ts # 认证相关未启用时仍是占位 │ └── trpc/ │ ├── server.ts # 服务端 tRPC 调用封装 │ └── client.ts # 前端 tRPC 客户端 ├── tailwind.config.ts ├── tsconfig.json └── next.config.mjs这里有一个细节新手经常困惑src/trpc/server.ts和src/trpc/client.ts都是给 React 组件用的 API为什么还分两份因为在 Next.js App Router 模式下组件分两种。Server Component可以在服务端直接调用数据库和 tRPC 函数不需要经过 HTTPClient Component运行在浏览器里必须通过 HTTP 走一遍完整的createTRPCClient。server.ts是给前者用的createTRPCProxyClientclient.ts是给后者用的createTRPCReact两者底层 API 几乎一样但背后机制完全不同。我见过的多数报错都是把 Server Component 里才能用的 util 塞进了 Client Component。3. tRPC 加 Zod端到端类型安全的实现细节3.1 路由抽象的拆分逻辑tRPC 的价值核心是过程调用Procedure你可以把它理解成一个同时包含输入校验、业务逻辑和输出类型定义的函数。所有 Procedure 按域拆到不同 Router 里最后汇聚到root.ts// src/server/api/root.ts import { taskRouter } from ./routers/task; import { postRouter } from ./routers/post; import { createCallerFactory } from ./trpc; export const appRouter createTRPCRouter({ task: taskRouter, post: postRouter, }); export type AppRouter typeof appRouter;AppRouter是整条类型安全链路的起点。它把整个路由器导出的类型暴露给前端前端拿到后在编辑器的自动补全里就能直接看到task.getAll、post.create这些可调用函数。拆 Router 的原则我一般按资源边界而不是按页面边界。比如任务模块里无论有多少页面用到任务数据都往task一个 Router 里塞不要搞出homePage这种 Router那会让接口和业务实体脱节后面维护想重构都难。3.2 Zod Schema 在 t3code 里的双重角色Zod 的 Schema 在 t3code 里不只是校验器它同时承担了输入类型定义和输出类型约束双重角色。// src/server/api/routers/task.ts import { z } from zod; const createTaskSchema z.object({ title: z.string().min(1, 任务标题不能为空).max(100), priority: z.enum([low, medium, high]).default(medium), dueDate: z.date().optional(), }); export const taskRouter createTRPCRouter({ create: protectedProcedure .input(createTaskSchema) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { title: input.title, priority: input.priority, dueDate: input.dueDate, }, }); }), });input(createTaskSchema)这行做完三件事运行时校验请求体错误数据直接拦在服务端入口编译期推断出input字段的 TypeScript 类型生成前端 client 调用时的入参类型前端写错字段名立刻爆红Zod 还有一个非常实用的派生能力从 Schema 反推类型。你可以直接用z.infer声明前端表单的状态类型两个地方的东西永远同步。import type { inferRouterOutputs } from trpc/server; type Task inferRouterOutputsAppRouter[task][create];这段代码的意思是让 TypeScript 自己从服务端的task.create返回值推导出类型Task。前端不需要再定义一个Taskinterface也不需要从后端拷贝一份模型去维护。我是真心建议团队里把前端单独声明 API 返回结构这种做法禁掉所有类型从服务端唯一来源派生。3.3 前端拿到的是函数不是 URL在 t3code 的前端页面里调用后端就像调用一个本地异步函数use client; import { api } from ~/trpc/react; export function TaskList() { const { data: tasks, isLoading } api.task.getAll.useQuery(); const deleteTask api.task.delete.useMutation({ onSuccess: () { // 成功后刷新列表 }, }); if (isLoading) return div加载中.../div; return ( ul {tasks?.map((task) ( li key{task.id} span{task.title}/span button onClick{() deleteTask.mutate({ id: task.id })}删除/button /li ))} /ul ); }关键在于useQuery和useMutation的返回都是强类型的。你不需要写任何 URL 拼接、请求拦截器或错误码约定后端加字段前端组件里自动多出来后端删除字段前端用到它的地方全部编译失败。这就是把运行期才发现的问题提前到了编辑期。从使用感受来说这有点像你在用一套庞大的内部 SDK只不过 SDK 的代码是你自己后端写的。刚开始可能不习惯但一旦习惯了你很难再回到手写fetch(/api/task/delete)然后自己维护返回类型那条路上去。4. 数据层落地Prisma 建模、迁移与填充数据4.1 设计 t3code 的数据模型t3code 需要一个能体现关联查询的业务模型但又不能太复杂。我选了经典的任务与标签多对多关系// prisma/schema.prisma generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model Task { id String id default(cuid()) title String priority Priority default(medium) done Boolean default(false) dueDate DateTime? createdAt DateTime default(now()) updatedAt DateTime updatedAt tags TaskTag[] } model Tag { id String id default(cuid()) name String unique tasks TaskTag[] } model TaskTag { task Task relation(fields: [taskId], references: [id], onDelete: Cascade) tag Tag relation(fields: [tagId], references: [id], onDelete: Cascade) taskId String tagId String id([taskId, tagId]) } enum Priority { low medium high }为什么主键用cuid()而不是自增整数因为 t3code 面向的是现代 Web 应用场景——分布式部署、前端预生成 ID、避免暴露数据量。自增 ID 在实际项目里既容易被遍历又在多库同步时产生冲突CUID 是成本最低的替代方案。TaskTag这个关联表带onDelete: Cascade目的是让删除任务时自动清理关联数据。这个细节很容易被忽略不加的话会经常出现删任务报了外键约束错误的诡异问题。4.2 迁移与 Seed 的实操顺序定义完 Schema接下来要做的就是生成数据库结构和初始数据。顺序千万别搞反。# 1. 生成 Prisma Client npx prisma generate # 2. 创建数据库迁移 npx prisma migrate dev --name init # 3. 写入初始数据 npx prisma db seed需要提前在package.json里配好 seed 命令{ prisma: { seed: tsx prisma/seed.ts } }prisma/seed.ts里可以用极短代码插入初始任务import { PrismaClient } from prisma/client; const prisma new PrismaClient(); async function main() { const work await prisma.tag.upsert({ where: { name: work }, update: {}, create: { name: work }, }); await prisma.task.create({ data: { title: 初始化 t3code 数据库, priority: high, tags: { create: [{ tagId: work.id }] }, }, }); } main() .then(() prisma.$disconnect()) .catch((error) { console.error(error); process.exit(1); });Upsert 写法的好处是幂等跑十次 seed 也不会重复插入同名 Tag。这类细节我建议一上来就写对因为团队换人后重建开发环境会非常依赖这套 seed 脚本。4.3 在 tRPC 中安全访问 Prismat3code 的 Prisma 实例是放在src/server/db.ts里的import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这段全局单例缓存代码是社区的标准做法。原因是 Next.js 在开发模式的热更新时会反复重新加载模块如果不做全局缓存每次热更新都会创建一个新的 PrismaClient 实例最终把数据库连接池撑爆。真正在产品环境运行 t3code 时还有一个非常容易被忽视的问题在 Server Action 或服务端组件里直接 new PrismaClient 是危险的。分段架构下Prisma 实例只能被注入到 tRPC 的上下文里由createTRPCContext统一管理这样才方便将来做连接池复用和请求级隔离。所有业务数据访问都应经由 tRPC 的ctx.db而不是在组件里 importdb直接查。5. 最小可用的任务模块从数据库到按钮的完整链路5.1 一块看看这一条调用链路要覆盖哪些节点前面讲了很多抽象概念到了这一步就该实操了。t3code 里的任务模块我按节点链路来设计每个节点都能独立验证Prisma 模型任务表、标签表、关联表Zod 输入 Schema定义创建/更新任务时前端能传什么tRPC ProceduregetAll、create、toggleDone、deleteReact 页面展示任务列表、新增任务输入框、切换完成状态类型验证故意写错一个字段名看编辑器是否立刻报错这种链路思维是 t3code 最想教给大家的东西。很多人写全栈项目页面一套、接口一套、数据库一套各自都能跑但最后一个环节连起来就会崩。把链路拆成节点每个节点独立验证问题定位会快得多。5.2 服务端路由的完整实现// src/server/api/routers/task.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; const toggleDoneSchema z.object({ id: z.string(), done: z.boolean(), }); export const taskRouter createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) { return ctx.db.task.findMany({ include: { tags: { include: { tag: true } } }, orderBy: { createdAt: desc }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1).max(100), priority: z.enum([low, medium, high]).default(medium), })) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: input }); }), toggleDone: publicProcedure .input(toggleDoneSchema) .mutation(async ({ ctx, input }) { const task await ctx.db.task.update({ where: { id: input.id }, data: { done: input.done }, }); return task; }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { await ctx.db.task.delete({ where: { id: input.id } }); return { success: true }; }), });这里的publicProcedure是在src/server/api/trpc.ts里基于initTRPC.contextcreateTRPCContext()创建的。正式项目里应该把默认的 publicProcedure 全部换成protectedProcedure用中间件校验登录态。t3code 为了把主线讲清楚先全部暴露成 public——但必须在注释里写明白这是教学需要不是安全建议。5.3 前端页面把链路闭合接下来是任务管理页面。我把它拆成三个组件各管一块方便复用和调试use client; import { useState } from react; import { api } from ~/trpc/react; export function TaskCreator() { const [title, setTitle] useState(); const utils api.useUtils(); const createTask api.task.create.useMutation({ onSuccess: () { setTitle(); void utils.task.getAll.invalidate(); }, }); return ( form onSubmit{(e) { e.preventDefault(); createTask.mutate({ title, priority: medium }); }} input value{title} onChange{(e) setTitle(e.target.value)} placeholder输入任务标题 / button typesubmit disabled{!title.trim()} 添加 /button /form ); }关键在useUtils().task.getAll.invalidate()这行。tRPC 在底层用 React Query 管理数据缓存invalidate的意思是把已缓存的数据标记为失效并重新拉取。也就是说新增任务成功后任务列表会自动刷新完全不用手动 setState 拼接数组。这和手写 fetch 再自己更新 state 的体验完全是两个时代。任务列表组件除了渲染还处理加载态和空态use client; import { api } from ~/trpc/react; export function TaskList() { const { data: tasks, isLoading, refetch } api.task.getAll.useQuery(); const toggleDone api.task.toggleDone.useMutation({ onSuccess: () refetch(), }); const deleteTask api.task.delete.useMutation({ onSuccess: () refetch(), }); if (isLoading) return p任务加载中.../p; if (!tasks?.length) return p还没有任务先建一条吧/p; return ( ul classNamespace-y-2 {tasks.map((task) ( li key{task.id} classNameflex items-center gap-2 rounded border p-2 input typecheckbox checked{task.done} onChange{(e) toggleDone.mutate({ id: task.id, done: e.target.checked })} / span className{task.done ? line-through : }{task.title}/span button onClick{() deleteTask.mutate({ id: task.id })}删除/button /li ))} /ul ); }到这里一条从数据库表Task延伸到页面button的完整链路就闭合了。新增、删除、勾选完成状态全部在强类型约束下工作不需要手工写请求封装更不需要 API 文档。6. 部署与维护里我真实踩过的坑6.1 环境变量与生产构建的坑t3code 要上线第一关就是环境变量。create-t3-app自带.env.example但人就是会栽在最简单的事情上。我有一次在生产环境部署界面一直显示数据库连接失败排查了半天发现是.env.production里DATABASE_URL的密码忘记 URL 编码——密码里有符号导致连接串被解析错了。建议所有密码、密钥类的环境变量用随机生成器重造一次别用带特殊字符的旧密码去凑环境变量。另外一定要养成部署前跑npm run build的好习惯T3 的构建过程本身会检查 import 错误和类型错误构建不过就别浪费时间上线。6.2 热更新、缓存和改了没生效的经典幻觉开发模式下t3code 最容易骗你的地方是改了 Prisma Schema没有重新prisma generate导致ctx.db.task上找不到新字段名。这是新手报错率最高的一类问题。正确的操作顺序是先npx prisma migrate dev再npx prisma generate。前者更新数据库后者更新 TypeScript 类型。任何一步没做都可能出现我明明写了这个字段代码却报错说不存在的尴尬。生产环境的缓存坑也值得一提。Next.js App Router 默认有静态渲染和缓存机制如果 t3code 跑到生产环境发现页面没有实时更新可能在两点上出问题页面被静态渲染成了恒定内容动态数据没有提出来数据请求被缓存命中invalidate没有按预期生效解法是给动态页面显式声明export const dynamic force-dynamic或者在数据查询的fetch/query里配置cache: no-store。这里我不展开全部机制但记住一条App Router 的缓存策略是渐进增强的默认静态你需要显式标记动态。6.3 t3code 后续我能想到的扩展方向t3code 做完基础版后还有几个方向可以直接往里加我根据自己的经验列一下优先级接入 NextAuth.js把publicProcedure全部换成protectedProcedure加 Session 判断接入文件上传R2/S3 加 tRPCmutation预签名 URL把上传逻辑也纳入类型安全体系引入 Playwright 做端到端测试链路已经很清晰正好适合写 E2E 用例补充审计日志用一个 tRPC 中间件统一记录每次 mutation 的操作者、时间和数据快照其中第四点是我在实践中特别后悔没早做的一件事。业务跑久了总会有人问你这单数据谁改的、什么时候改的。有了 tRPC 这一层统一入口中间件里加日志比在各处手动打点要干净得多。代码量很小收益却很实在。最后再分享一个小经验如果你决定在自己团队里推行 T3 技术栈模板不要一上来就追求大而全。先把最核心的一条业务链路跑通让团队里最熟悉前后端的两个人能完全解释每一行类型从哪来、到哪去再逐步加认证、上传这些附加能力。t3code 这版模板之所以能一直用下去恰恰因为它保持了最小可用的克制——每一层解决一个明确问题没有多余抽象。我在实际项目中反复体会到技术栈是否能持续运转拼的不是功能多而是当有人深夜收到告警、需要定位问题时能不能顺着一条清晰链路迅速找到症结所在。