T3全栈开发:TypeScript、Prisma、tRPC实现端到端类型安全
“t3code”这个标签最近一年在半路转全栈的开发者群里面被反复提及。它指向的并不是某个单一框架而是社区里那套最流行的全栈TypeScript实践——由create-t3-app脚手架引导的TypeScript、Tailwind CSS、tRPC外加Next.js和Prisma的组合。这篇文章我会把这套组合从“为什么要统一类型安全”讲起一路讲到实际建一个带数据库和接口的小功能要写哪些代码、怎么跑通、怎么部署最后把我踩过的坑一并交代出来。适合两类人看一是已经会写React但一直靠Mock数据联调、想从零搭一套完整后端的前端工程师二是想控制团队技术栈复杂度、又习惯TypeScript的独立开发者。1. 内容整体设计与思路拆解1.1 T3这个名字到底是怎么来的T3最早指的是“The Three Tiers”来自社区开发者Theo Browne发起的create-t3-app项目。最初的意思是三层核心TypeScript、Tailwind CSS、tRPC——三个以T开头的技术组合在一起构成全栈应用的基础。不过今天你在网上看到大量用t3code做标签的项目实际构成早已扩展成了五件套Next.js负责页面和路由TypeScript负责语言层Tailwind CSS负责样式Prisma负责数据库访问tRPC负责前后端接口通信。create-t3-app这个脚手架允许你在初始化时按需勾选选完以后生成的项目直接就是可运行状态不需要你再折腾Webpack、Babel、ESLint和Prettier那一堆配置。很多刚接触的人会问为什么非要把这五个东西绑在一起单独用Vite搭React、用Express写接口、用axios调后端不也行吗行但你会陷入“类型割裂”的困境——前端定义一套接口类型后端定义一套数据模型中间全靠手工同步。改一个字段名前端调用方根本不知道运行到线上才发现数据对不上。t3code的核心价值就是消灭这类问题。1.2 “端到端类型安全”解决的是什么痛点端到端类型安全这个词听起来很玄其实说白了就是从数据库表结构到后端接口定义再到前端页面里拿到的数据TypeScript的类型全部是自动推导出来的你不需要在任何一层手工重复声明一遍。我给你打个比方。以前的前后端联调就像装修时你买了一个插座卖插头给你的商家只给你一个大概的规格表你得自己拿尺子量、拿眼睛估结果插上去才发现接触不良需要拿胶布缠两圈才能用。胶布就是前端Mock、类型断言、运行时的数据清洗这些补救手段。而T3这套做法相当于从设计阶段就把插座和插头的尺寸钉死在一起插不进去就是编译期报错根本轮不到上线。这套组合里每个环节都在为这个目标服务Prisma读数据库schema生成后端类型tRPC根据后端procedure自动推导出前端调用参数和返回值的类型Next.js作为连接层把两边的类型串在同一个TypeScript工程里。所以你在页面里写posts.title如果数据库里根本没有这个字段编辑器会当场画红线。这种感觉用习惯了以后再回去写手写双份API类型的项目会非常难受。1.3 设计哲学简单优先而不是全家桶优先create-t3-app的设计原则里有一条很关键认为简单优于复杂保持务实。这也是为什么它不把Redux、Zustand、React Query这些状态管理库默认塞进项目里。在早期的React开发里全局状态管理几乎是必需品因为组件之间传数据太麻烦。但在App Router和Server Components的加持下大部分页面根本不需要客户端全局状态——数据直接在服务端组件里取取完渲染成HTML发给浏览器。我见过很多团队一上全栈项目就先装一堆库Redux Toolkit、react-router、axios拦截器、ESLint插件装完发现80%用不上还增加了概念负担。T3的理念是你需要哪块装哪块脚手架把你选的东西完整配置好不选的就干干净净不出现。这个“克制”的气质是它在开发者社区里口碑好的重要原因。2. 核心细节解析与实操要点2.1 Next.js同构应用的底座Next.js在这个架构里承担的是“壳”的角色。它提供App Router把页面文件系统路由、服务端组件、客户端组件、路由处理程序这些概念统一在一起。你不需要单独部署一个Node服务加一个前端静态站一个Next.js应用就能处理SSR、SSG和客户端交互。实操中最重要的边界概念是“use client”。在App Router里默认情况下组件都是服务端组件里面可以直接写数据库查询但没有任何交互能力不能用useState、useEffect和事件监听。如果你需要写一个按钮、一个表单、一个下拉框就在文件顶部加一行use client把它声明成客户端组件。服务端组件负责取数和首屏渲染客户端组件负责交互这是T3项目里最基本的组件划分原则。很多新手在这里会犯一个错误把所有组件都加上“use client”图省事。结果服务端取数能力被浪费了所有数据请求变成客户端发起首屏性能变差还多了一层加载状态要处理。正确的做法是页面主体用服务端组件取数把表单、按钮这类交互部分抽成小的客户端组件通过props把数据传下去。2.2 tRPC把“写接口”这件事简化到极致tRPC是整套架构里最反直觉、也最上头的部分。传统开发流程里你要先在后端定义路由再在另一个文件里维护接口文档再去前端写请求封装。tRPC的做法是直接在后端写一个procedure——一个带类型的过程前端调用它就跟调用本地函数一样。在create-t3-app生成的代码里你会看到这样几个核心概念router是路由集合procedure是最小的API单元query用于读数据mutation用于写数据。定义的时候不需要写URL、不需要写HTTP方法tRPC会自动把类型信息通过TypeScript的声明合并带到前端。为什么它能做到因为前后端代码在同一个TypeScript仓库里。tRPC利用TypeScript“从一个表达式推导出另一个表达式类型”的能力让前端import后端的类型定义所以前端调用api.post.list.query()的时候参数类型和返回值类型都是直接从后端推导出来的。这相当于把API文档变成了编译器的一部分。不过要提醒一句tRPC适合前后端同仓的项目如果你的前端项目是独立的仓库或者有多套不同的客户端比如iOS和Android那它的优势会大打折扣。这种情况下不如老老实实用OpenAPI生成客户端代码。2.3 Prisma数据库模型就是类型的唯一来源Prisma是一个ORM但它的体验和传统ORM差别很大。你用schema.prisma文件定义表结构然后运行npx prisma migrate dev它会自动生成迁移SQL、建表、同时生成TypeScript类型客户端。重点在于之后你写数据库查询时所有的表名、字段名、关联关系都是强类型的写错了直接编译报错。我第一次用的时候最惊艳的是它的关联查询。普通ORM里你要先查Post再查Comment手动拼接Prisma里直接这样写db.post.findMany({ include: { comments: true }, });返回的每篇post下面就已经带好了comments数组类型自动匹配。省掉的不仅是代码量更是一堆容易出错的字段映射逻辑。选择数据库时要留意一件事本地开发直接用SQLite最快零配置、文件即库但生产环境建议换成PostgreSQL因为Serverless平台上的实例文件不持久后面我还会详细讲。2.4 Tailwind CSS与TypeScript的默认配置Tailwind CSS在这里承担所有样式工作。它是原子化CSS框架类名描述样式不需要你写一堆语义化的CSS类名和样式覆盖。配合shadcn/ui这类基于Tailwind的组件库开发速度非常快。create-t3-app默认帮你接好了Tailwind的Prettier插件它会自动帮你排序类名比如flex items-center justify-between这种顺序不用自己手动整理。这个细节看着小实际团队协作时价值很大至少不会再因为类名顺序打架。TypeScript那边脚手架默认开了严格模式还包括了noUncheckedIndexedAccess。这个选项会把数组索引访问的返回值类型变成“T | undefined”逼你处理可能为空的边界情况。刚开始你会觉得烦但用久了你会发现它确实能挡掉不少空指针错误。3. 实操过程与核心环节实现3.1 从create-t3-app初始化项目实操部分我带你走一遍完整的流程功能很简单一个帖子列表可以查看帖子详情并发表评论。先用脚手架创建项目npx create-t3-applatest my-t3-app期间会让你勾选模块。我的建议是Next.js必选Tailwind CSS必选tRPC必选Prisma必选NextAuth先不选后续需要登录再加避免一开始被认证逻辑干扰数据库本地先用SQLite初始化完成以后项目会自动安装依赖并创建.env文件。模板默认会生成一个用户示例表和一整套示例代码可以直接运行npm run dev访问首页先把环境跑通再动刀改造。3.2 定义数据模型并完成第一次迁移打开prisma/schema.prisma把示例内容替换成我们需要的模型model Post { id String id default(cuid()) title String content String comments Comment[] createdAt DateTime default(now()) } model Comment { id String id default(cuid()) content String post Post relation(fields: [postId], references: [id]) postId String createdAt DateTime default(now()) }这里我用了cuid而不是自增id。原因是全栈应用以后大概率会做数据同步或者分库用字符串ID比自增整数更好处理而且cuid在客户端就可以生成不需要查一次数据库取下一个ID。然后执行迁移npx prisma migrate dev --name init这一步会创建迁移文件、执行建表SQL、再自动生成Prisma Client类型。以后每次改schema就重复这个命令。你会发现类型变更立即生效不需要手动去改任何一处的接口类型。3.3 用tRPC实现查询和写入接下来定义两个API查询帖子列表、创建新帖子。这里的代码基于create-t3-app当前生成的结构具体文件名以模板为准但概念是一致的。先写帖子路由文件import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; import { db } from ~/server/db; export const postRouter createTRPCRouter({ list: publicProcedure.query(async () { return db.post.findMany({ orderBy: { createdAt: desc }, include: { comments: true }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1, 标题不能为空), content: z.string().min(1, 内容不能为空), }) ) .mutation(async ({ input }) { return db.post.create({ data: input }); }), });然后在根路由注册import { createTRPCRouter } from ~/server/api/trpc; import { postRouter } from ~/server/api/routers/post; export const appRouter createTRPCRouter({ post: postRouter, }); export type AppRouter typeof appRouter;这里有两个细节要说明。第一zod负责入参校验前端传过来的数据会先经过这里的规则检查不合法的请求根本进不了数据库层。第二publicProcedure表示当前接口不需要登录后面要加权限只需要换成受保护的procedure中间件接口业务代码不用改动。3.4 在服务端组件中直接取数Next.js的App Router允许服务端组件直接调用tRPC的调用器不需要走HTTP请求。你可以在页面文件里这样写import { api } from ~/trpc/server; export default async function HomePage() { const posts await api.post.list.query(); return ( div classNamespace-y-6 p-8 h1 classNametext-2xl font-bold帖子列表/h1 {posts.map((post) ( article key{post.id} h2 classNametext-lg font-semibold{post.title}/h2 p{post.content}/p span评论数{post.comments.length}/span /article ))} /div ); }这个过程没有useEffect、没有loading状态、没有fetch调用。数据在服务端直接查完渲染成HTML再发给浏览器。用户看到首屏的时候数据已经在页面里了不存在闪烁或二次加载。如果你需要在客户端组件里发起请求就用tRPC的React hooks版本use client; import { api } from ~/trpc/react; function CreatePostForm() { const utils api.useUtils(); const createPost api.post.create.useMutation({ onSuccess: () { utils.post.list.invalidate(); }, }); // 表单提交逻辑 }invalidate是tRPC和React Query联动提供的函数意思是把之前的列表缓存标记为过期数据会自动重新拉取。这一步就替代了手写“提交成功后刷新列表”的逻辑。3.5 切换数据库与部署上线部署到Vercel的时候项目会被当成Serverless函数运行。这意味着本地SQLite文件不能用了因为Serverless环境每次执行函数都是全新的文件系统你在数据库里写入的数据可能会丢。解决办法很简单生产环境用PostgreSQL。推荐用托管服务比如Vercel Postgres、Neon或Supabase它们的免费额度足够个人项目跑得很舒服。拿到数据库连接串以后改一下DATABASE_URL环境变量然后执行npx prisma migrate deploy这个命令和migrate dev的区别在于dev会检查schema状态并可能重置开发数据deploy只执行未运行的迁移文件适合生产环境。部署后记得在Vercel后台把环境变量配好重新部署一次就行。4. 常见问题与排查技巧实录4.1 类型全部飘红但代码逻辑没问题这是新手最常见的困惑刚跑完migrate编辑器里一片红色错误提示某些属性不存在。通常原因是Prisma Client类型没有生成成功或者生成之后TypeScript的语言服务没有刷新。处理顺序是npx prisma generate然后在编辑器里执行重启TypeScript服务器。VSCode按CtrlShiftP输入Restart TS Server。如果项目正在运行最好也重启一下npm run dev因为tRPC的类型推导在路由新增后有时不会自动热更新。4.2 tRPC客户端不认识新添加的路由你在后端新加了一个procedure但前端调用时报“找不到该属性”。这大概率不是代码问题而是类型更新没被IDE捕捉到。tRPC的类型传递依赖TypeScript的声明合并新增router后需要重新跑一遍类型检查。先确认appRouter里有没有注册新路由再确认编辑器的TypeScript版本用的是项目本地版本。在VSCode右下角可以看到当前TS版本点击选择Use Workspace Version避免用了全局旧版导致类型判断错误。4.3 Serverless环境里SQLite数据“蒸发”如果你把本地调试好的SQLite项目直接部署到Vercel第一次访问可能正常第二次数据就丢了。原因是Serverless运行时不保证文件持久化函数实例随时可能被销毁重建写入prisma/dev.db的文件只是临时存在。这个问题的判断信号很典型本地一切正常线上数据一重启就没。解决方案就是前面说的换成PostgreSQL并把数据库连接放在环境变量里不提交到代码仓库。4.4 NextAuth登录态类型对不上如果你加了NextAuth做认证会发现session里的用户信息经常报类型错误。这是因为NextAuth默认的类型只包含name、email、image这几个字段你想在session里存userId或角色需要自己扩展类型声明。在项目里建一个类型声明文件用declare module扩展Session和User接口。这个操作在create-t3-app的文档里有样例包括如何在tRPC的上下文类型里读取session。类型扩展完以后中间件里就能安全地访问session.user.id了。4.5 Tailwind类名覆盖率不足导致样式丢失T3模板默认生成的Tailwind配置会扫描代码目录但如果你自定义了特殊文件路径比如把组件放在了项目根目录之外新加的类名不会被编译进去。检查tailwind.config.ts的content数组确保包含你实际用到的文件路径。另外注意动态拼类名千万别用字符串模板比如bg-${color}Tailwind的静态扫描器无法生成这类动态类。正确做法是写全量类名到对象里通过条件判断取用这样才能被编译时识别。4.6 常见问题速查表症状可能原因处理建议类型报错但代码没问题Prisma Client过旧或TS缓存执行prisma generate重启TS服务新路由不识别appRouter未注册或IDE缓存检查根路由重启开发服务器线上数据丢失SQLite不持久更换PostgreSQL并配置环境变量session字段报错NextAuth类型未扩展增加类型声明文件并扩展Session样式不生效content配置遗漏目录修正tailwind.config.tsmutation后页面不刷新未调用invalidate在onSuccess中触发对应query失效5. 一些值得推荐的团队约定我在实际项目中用T3做全栈开发半年多积累了三个值得坚持的约定分享给大家参考。第一数据库访问只在tRPC router的服务端代码里做千万不要在客户端组件里直接new PrismaClient。客户端代码一旦被打进浏览器数据库连接串等于直接暴露任何人打开控制台就能看到。严格遵循“页面只能通过tRPC接口取数”这条线安全性和可维护性都会好很多。第二mutation成功后的数据更新统一用invalidate触发而不是手动修改本地缓存。手动改数据容易出现视图和数据库不一致的竞态比如评论发出去后列表刷新但计数没变。用invalidate让React Query帮你重新拉取数据虽然多一次请求但正确性优先。第三权限校验尽量收敛到中间件里完成。所有需要登录的接口共享同一个protectedProcedure里面统一判断session是否存在、用户角色是否匹配。不要让每个router各自做权限判断否则权限逻辑会散落得到处都是后续审计起来很难受。最后再分享一个小细节我在项目的package.json里加了一个db:reset脚本内容是prisma migrate reset --force用于开发环境快速清库重建。每次要改模型结构、又不在乎保留开发数据的时候一条命令就能回到干净状态比手动删库再迁移要省心得多。这个脚本建议只在开发分支使用生产环境千万别碰。t3code这套技术栈说到底是一次“把类型安全推到极致”的实践。它不适合所有项目但如果你正在做全栈TypeScript开发花一个下午完整跑通一遍流程你会看到那种“前后端不再互相猜”的开发体验有多痛快。