
我做全栈开发这几年最深的体会是大部分项目不是死在业务复杂度上而是死在技术选型和胶水代码上。前端要选框架后端要定接口规范样式方案要统一数据库 ORM 得建模等把这些全部折腾明白业务还没开写小半个月就没了。t3code这个项目就是用一套把类型安全贯穿到每一层的方案——T3 Stack也就是 TypeScript、Tailwind CSS 和 tRPC——从零建一个全栈应用的真实实践记录。这篇内容对所有想快速搭建可维护、有类型保障的 Web 应用的人都有参考价值不管你是前端想转全栈还是后端想提升协作体验都能在里面找到可以直接抄的作业。T3 这个缩写最早来自社区一个知名项目Take the TypeScript、Tailwind CSS 和 tRPC 组队后来根据实际需要也会加入 Next.js、Prisma 和 NextAuth。这套组合的核心思想只有一个——尽量让数据流在你的代码里有迹可循而不是靠脑补。下面我会把整个项目的设计思路、核心原理、实操步骤和踩坑记录完整拆开讲内容偏实战看完照着做就能跑通一个全栈应用。1. 整体设计与选型思路1.1 为什么选 T3 Stack 而不是 Next.js Express先说结论如果你要做一个内部工具、管理后台、或任何前后端由同一个团队维护的 Web 应用T3 Stack 是我试验下来成本最低的方案。它砍掉的不是功能而是接口层这一整块胶水代码。传统模式相信大家都不陌生前后端分离前端 React 调 REST API后端 Express 写路由两边各维护一套类型定义。前端改了字段类型后端没同步页面直接挂后端重构了接口前端变量还得一个个对。我见过不少团队的需求评审最后都变成了接口字段对齐大会枯燥且容易出错。T3 Stack 的做法是让前端直接调用后端的函数中间没有 HTTP 接口定义这一层全靠 TypeScript 在编译期做类型推导。这意味着你在前端写.getTodoById(id)IDE 就能自动提示参数类型、返回类型就算字段名写错编辑器直接红线给你标出来根本不用等运行时爆错。我还对比过另一种方案Next.js API Routes SWR。这套也能跑但问题在于你要手动维护请求路径、Method 和参数格式一旦项目膨胀几十个 API endpoint 没人说得清哪个还活着、哪个已经在裸奔。tRPC 这类 RPC 方案本质上是把接口文档消灭在编译期里让类型自己当文档。比较下来选用 T3 Stack 的核心理由就三个类型安全从数据库一路推到浏览器没有断点。减少胶水代码字段改动自动传导不容易埋坑。生态统一Next.js 负责渲染层所有组件都用 React 语法学习成本低。1.2 项目架构从浏览器到数据库的完整链路t3code项目的架构并不复杂我用一句话概括就是Next.js 应用里页面组件通过 tRPC 调用后端过程后端过程用 Prisma 操作 PostgreSQL认证交给 NextAuth。数据请求的链路是这样的浏览器发起请求 → Next.js 服务端执行 tRPC 调用 → tRPC 路由器分发到具体过程 → 过程里调用 Prisma 查询数据库 → 返回数据到组件渲染。关键点在于 tRPC 的调用发生在 Next.js 的服务器端所以浏览器到服务器之前只是普通的 HTTP 请求但服务器到数据库这一段Prisma 提供了完全的类型安全。你会发现整个项目里没有任何手写的 REST endpoint也没有裸露的 fetch 逻辑在页面里乱飞。我为什么推荐这种请求不进浏览器的架构因为它天然适配 Next.js 的 Server Component 和 App Router。很多业内的全栈项目选型时纠结要不要用 Next.js其实就是拿它当纯前端壳子后端又另起炉灶白白牺牲了服务端渲染和流式渲染的好处。T3 Stack 默认把所有 tRPC 调用放在服务端得到的收益是首屏加载更快、数据不会在客户端裸奔、搜索引擎也能看到实际内容。1.3 功能规划一个能做事的模板既然是项目实践就不能只搭框架不写功能。我给t3code定的功能范围是一个带用户认证 TodoBoard 任务看板支持注册登录、任务 CRUD、按状态分组、基础筛选。功能不多但覆盖了全栈应用的常见需求用户系统NextAuth Prisma、数据库建模用户、任务两张表、业务逻辑tRPC 过程、前端交互React Hook和部署配置Vercel Postgres。对一个项目模板来说够全面也够练手。规划功能的时候我有一个原则宁少勿多。T3 Stack 的价值不在功能多而在结构清晰。第一次上手的人跑通一个完整的认证 CRUD 部署链路比做十个花哨页面有用得多。后面你完全可以基于这个模板加模块、加功能方向对了就不容易乱。2. 核心组件深度解析2.1 TypeScript类型安全不是玄学是纪律很多刚入门的同学觉得 TS 麻烦尤其是定义 interface、处理泛型时直挠头。但放到 T3 Stack 里TypeScript 不是可选项它是整个架构的地基。打个比方如果你把代码比作一栋楼JS 是盖完后靠经验检查墙体而 TS 是在建楼时就把承重墙数据配好设计图纸上标了哪里能开门哪里不能拆。T3 Stack 里这个图纸从数据库表结构到前端组件 props全部连通。具体到项目中Prisma 会根据schema.prisma自动生成类型tRPC 会根据路由器里的过程自动推导输入和输出类型React Hook 的返回值也是类型安全的。整个链条上只要有一处类型不对编辑器立刻弹红而不是等用户点击时才 500。我在项目里的一个实际体会是类型安全最大的收益不在写新代码的时候而在重构和加需求的时候。任务表要加一个标签字段数据库 schema 一改Prisma 自动更新类型前端用到tag的地方全部能搜到改错了编译期直接报错根本不需要靠测试用例去兜底。2.2 Tailwind CSS样式方案为什么不能拖后腿Tailwind 在 T3 Stack 里是默认样式方案理由很简单快。它是原子化 CSS直接用工具类在 HTML 里拼样式不用纠结命名规范、不用管理 CSS 文件层级。比如你要做一个按钮直接在 className 里写px-4 py-2 bg-blue-500 text-white rounded几秒钟完事不用想这个样式放哪个 class 里最优雅。可能有人觉得原子类会让 JSX 很难看。说实话代码确实长但可维护性反而变高了。因为你不再需要维护一个巨大的 CSS 文件也不会因为类名冲突调一晚上样式。加上 Tailwind 的 JIT 编译实际产出的 CSS 只包含用到的类不会一上来就给你塞几百 KB 的样式表。t3code里我用了 Tailwind 配合深色模式和时间线布局来做任务卡片没有写一行自定义 CSS整体效果也很干净。Tailwind 的响应式前缀sm:、md:也足够覆盖不同屏幕尺寸省掉写媒体查询的功夫。2.3 tRPCAPI 层消失之后发生了什么这是 T3 Stack 里最核心、也最容易被误解的部分。tRPC 不是一个服务和 HTTP 框架它是一套让前端直接调用后端函数的工具链。我们不用再去定义GET /api/v1/todo/:id这种 URL 了也不需要写 controllers。你只需要在后端写一个过程procedure比如getAllTodos,前端通过一个代理对象直接调用// 在后端定义过程 import { initTRPC } from trpc/server; const t initTRPC.context().create(); export const appRouter t.router({ getAllTodos: t.procedure.query(({ ctx }) { return ctx.prisma.todo.findMany({ where: { userId: ctx.session?.user.id }, }); }), }); export type AppRouter typeof appRouter;然后在前端组件里这样调用import { api } from ~/utils/api; export function TodoList() { const { data, isLoading } api.todo.getAllTodos.useQuery(); if (isLoading) return div加载中.../div; return div{data?.map((todo) TodoItem key{todo.id} todo{todo} /)}/div; }注意到重点了吗api.todo.getAllTodos这个调用路径完全是类型安全的没有字符串路径、没有手动指定 method、没有手写 Promise 的类型注解。data的每一项都已经是数据库里 Task 对象的完整类型字段缺失或类型错误在编译期就会被抓出来。tRPC 还能共享状态。useQuery自带缓存、重试和 loading 状态不用额外引 axios react-query 那套组合。useMutation则处理增删改操作成功后还能invalidate对应 query让列表自动刷新。这套机制的爽点是后端改一个字段前端立刻感知前端写错一个调用页面都无法编译。跟 GraphQL 比它省去了 schema 定义和 codegen 的步骤跟 REST 比它省去了接口设计和类型映射。缺点也有——它不适合对外提供公开 API因为调用前端的函数天然与服务端绑定开放给第三方是要暴露内部结构的。2.4 Prisma 数据模型与 NextAuth 认证集成t3code的数据库层用的是 Prisma PostgreSQL。Prisma 这类 ORM 的最大价值是把数据库表变成 TypeScript 类型且需要修改表结构时可通过迁移文件管理不至于把表改坏了还要手动跑去数据库还原。t3code里我定义了两张表用户表和任务表。用户表直接和 NextAuth 的账号模型关联任务表通过外键关联用户。// prisma/schema.prisma model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] tasks Task[] } model Task { id String id default(cuid()) title String status Status default(TODO) createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id]) index([userId, status]) } enum Status { TODO IN_PROGRESS DONE }NextAuth 在 T3 Stack 里是标配它负责处理登录、Session、OAuth 等逻辑。在 tRPC 的过程里我们可以通过ctx.session拿到当前登录用户更好地做数据隔离。简单来说就是每个人只看到自己的任务。这个过程其实就是一个受保护过程import { initTRPC, TRPCError } from trpc/server; const t initTRPC.context().create(); const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { user: ctx.session.user } }); });有了这个中间件后续所有需要登录的过程都只需要写成protectedProcedure.query(...)不需要每个接口都重复判断一次是否登录。3. 实操落地全过程3.1 快速初始化用 create-t3-app 搭出工程骨架T3 Stack 有一个官方脚手架create-t3-app这是一条最快上手的路径。我在t3code项目里就是用它初始化的npx create-t3-applatest t3code执行后它会问你几个问题按需选择即可。我当初的配置是这样的选 Next.js App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth数据库选了 PostgreSQL。不需要的可以手动取消。这个脚手架默认给出的目录结构已经非常清晰t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── pages/api/trpc/ │ ├── server/ │ │ ├── api/ │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ └── utils/api.ts └── package.json为何推荐从这个模板开始因为它把 T3 的最佳实践固化成了种子模板。tRPC 路由器的拆分方式、NextAuth 的配置位置、Prisma 客户端怎么复用都被组织好了。你只需要往里面加自己的业务模块不用纠结这个 auth 文件放哪个目录更合理这类问题。说句实话create-t3-app也是社区里口碑少有的开箱即用脚手架大部分开发者第一次跑完就能直接开始写业务教程不必从零手写配置。3.2 从数据库到页面用任务模块串起完整链路直接空谈没意思我们跑一个完整的 Todo 模块。第一步修改schema.prisma加入上面定义过的Task模型然后执行迁移npx prisma db push这会同步数据库结构并自动生成 Prisma Client 类型。第二步在src/server/api/routers/todo.ts里创建 tRPC 路由器import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const todoRouter createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) { return ctx.db.task.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), updateStatus: protectedProcedure .input(z.object({ id: z.string(), status: z.enum([TODO, IN_PROGRESS, DONE]) })) .mutation(async ({ ctx, input }) { return ctx.db.task.update({ where: { id: input.id }, data: { status: input.status }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.task.delete({ where: { id: input.id } }); }), });注意到每个过程都有输入校验zod不用再担心前端传过来脏数据把数据库打穿。这是不少初学者会忽略的细节但在 T3 里属于基本操作input字段必须有 zod 校验不然部署到线上迟早出事故。第三步在src/server/api/root.ts里把todoRouter挂上去export const appRouter createTRPCRouter({ todo: todoRouter, });第四步写前端组件直接用 React Hook 调用import { api } from ~/utils/api; function TodoPage() { const utils api.useUtils(); const { data: todos, isLoading } api.todo.getAll.useQuery(); const createTodo api.todo.create.useMutation({ onSuccess: () utils.todo.getAll.invalidate(), }); const updateStatus api.todo.updateStatus.useMutation({ onSuccess: () utils.todo.getAll.invalidate(), }); const deleteTodo api.todo.delete.useMutation({ onSuccess: () utils.todo.getAll.invalidate(), }); if (isLoading) return div加载中.../div; return ( div classNamep-4 form onSubmit{(e) { e.preventDefault(); const form new FormData(e.currentTarget); const title String(form.get(title) ?? ).trim(); if (title) createTodo.mutate({ title }); e.currentTarget.reset(); }} input nametitle placeholder输入任务标题 required / button typesubmit添加/button /form ul {todos?.map((todo) ( li key{todo.id} span{todo.title}/span select value{todo.status} onChange{(e) updateStatus.mutate({ id: todo.id, status: e.target.value as TODO | IN_PROGRESS | DONE, }) } option valueTODO待办/option option valueIN_PROGRESS进行中/option option valueDONE已完成/option /select button onClick{() deleteTodo.mutate({ id: todo.id })}删除/button /li ))} /ul /div ); }这一步跑通之后你已经拥有一个完整的、类型安全的全栈 CRUD 功能而且前后端代码量加起来不到 100 行。3.3 部署细节与线上环境配置开发环境跑通只是第一步部署才是真实动手能力的试金石。t3code部署时我用的 Vercel Neon Postgres。部署前要做的准备把schema.prisma里的数据库连接改成环境变量DATABASE_URL在 Vercel 项目设置里配置环境变量先跑一次npx prisma migrate deploy把表结构同步到线上库配置 NextAuth 的AUTH_SECRET、AUTH_URL等环境变量部署时有一个容易踩的坑Vercel 的无服务器环境是只读文件系统的Prisma 的引擎文件需要特殊处理。好在create-t3-app默认的prisma/client在 Serverless 下已经自动配置好了只要别手动去改prisma的生成目录基本没问题。线上环境的另一个常见问题是数据库连接数不足。Postgres 的免费额度通常只有几十个连接而 Serverless 环境会不断创建新实例导致连接池被耗尽。解决办法是用 Prisma 的连接池参数或者用 Neon 这种支持 Serverless 连接的数据库服务。我的建议是直接用 Neon它在 Serverless 场景下支持连接池省心很多。4. 常见问题与排查技巧实录好的项目不是不踩坑而是坑都帮你踩完了。以下这些问题是我在跑t3code过程中真实遇到的每一个都附上了排查思路希望能帮你少走弯路。4.1 类型检查过不了tRPC 的类型不匹配是头号敌人症状前端data上明明有个status字段却报类型错误提示可能是undefined。排查思路tRPC 的返回类型是根据后端函数的实际返回值推导的。如果后端返回的字段可能为空类型就会变成联合类型。比如Task.status如果可空前端就要做空值处理。解决方法其实也很简单在 Prisma 模型中把字段设为必填或者在前端做守卫判断。我在项目里处理这个问题的标准姿势是优先让数据层字段严格化避免到处写可空判断。比如任务状态默认值设为TODO新任务创建时不需要额外传 status数据库自动补全前端拿到的永远是一个确定的值。4.2 改了 schema 却没生效别忘了迁移和类型重新生成这个坑我踩得很扎实。有一次我改了schema.prisma加了字段本地页面和 Prisma Studio 都正常但 Vercel 部署后线上接口一直报字段不存在。最后发现是线上数据库没有执行迁移。本地db push只同步本地库线上库需要单独跑npx prisma migrate deploy所以在生产环境做数据库变更是有流程的先提交prisma/migrations目录然后构建时执行migrate deploy。建议在 CI/CD 流程里强制加这一步别每次都手动跑。4.3 NextAuth Session 为 null服务端组件拿不到用户开发时本地跑得好好的部署后某些页面出现了登录判断失效的问题。常见原因是环境变量没配齐。NEXTAUTH_SECRET新版叫AUTH_SECRET如果没配Session 加密校验会失败拿到的永远是null。另一个原因是你在服务端组件里用getServerSession(authOptions)时传入的authOptions必须和初始化 tRPC 时用的是同一个实例两个authOptions变量哪怕逻辑一样只要引用不同Session 就解不开。排查办法很简单页面里如果拿不到 Session先看环境变量再看引用是否是同一个authOptions模块。4.4 列表不刷新useMutation 后记得 invalidate新手第一次用 tRPC 的 mutation 时常见的问题是操作成功后页面列表不更新。原因很简单tRPC 默认有 query 缓存mutation 成功只是数据变更了缓存还是旧值。解决方式是在 mutation 的onSuccess回调里调用对应 query 的invalidateconst utils api.useUtils(); const createTodo api.todo.create.useMutation({ onSuccess: () utils.todo.getAll.invalidate(), });把失效逻辑写全之后所有相关的列表都会自动重新拉取不需要手动location.reload()。4.5 快速速查表问题现象可能原因解决方式前端类型报 unavailable后端返回类型包含 null数据模型字段设为必填或前端做守卫线上调用 500数据库迁移未执行执行prisma migrate deploySession 拿不到用户环境变量缺失或 authOptions 不一致配置AUTH_SECRET统一 authOptions增删改后列表不变query 缓存未失效onSuccess里调用invalidate部署后样式丢失Tailwind 内容路径未包含动态 class检查content配置是否包含所有组件目录5. 一些值得收藏的实操心得最后再分享几个我在这个项目里沉淀下来的细节可能不在任何官方文档里但我测试下来都很有用。第一个心得tRPC 的路由拆分按业务模块来别搞一个巨大的根路由。我在项目初期图省事把认证、任务、统计全部塞进一个appRouter里后来模块一多每次改代码都要滚动好久。拆成todoRouter、userRouter、statRouter这种子路由在root.ts里统一合并结构清晰类型依然安全前后端调用路径也不会乱。第二个心得zod 输入校验不能省。很多时候前端自己已经做了表单验证好像后端不需要再验一遍。但在真实项目里所有输入都可能被绕过尤其是有用户上传数据的功能不加 zod 校验等于把数据库裸奔。T3 Stack 天然集成了 zodT3 的规范更是要求你不要跳过这一步它花的时间几乎为零但能拦住大量脏数据。第三个心得数据库索引要有意识地加。像任务表这种核心表如果只按主键查询还行一旦按用户和状态筛选数据忘了建联合索引会让查询越来越慢。在 schema 里写下这样一个索引并不费力却能避免未来线上慢查询的问题model Task { // ... index([userId, status]) }第四个心得部署时prisma generate要放在构建阶段。Vercel 默认构建流程不会自动重新生成 Prisma Client如果线上 Prisma 版本和本地的 client 版本不一致会报一堆奇奇怪怪的错误。建议在package.json的构建脚本里加上prisma generate next build一劳永逸。我个人做完t3code后最大的感受是以前写全栈项目像开着两台对讲机通话一人一句对口号还经常听串现在用 T3 Stack就像两个人坐在同一张桌子上说错话对方马上纠正。前端、后端、数据库三者之间的翻译成本被压到了极低。如果你正为一个新项目的技术选型挠头或者已经被 REST API 的类型维护折磨得够呛强烈建议你照这个方案跑一个简单的 Todo 应用试试半天时间就够你感受到它的魅力。往后再往里面加协议字段、加权限中间件、加新的业务模块你会发现这条路是越走越宽的。