t3code 实战:T3 Stack 全栈模板的端到端类型安全实践 最近在折腾 t3code 这套全栈项目模板说直白点它就是基于 T3 Stack 整理出来的代码骨架。T3 Stack 这四个字母在开发圈几乎已经成了 TypeScript 全栈的默认答案Next.js 负责页面和接口tRPC 负责打通前后端的类型Prisma 负责数据库访问Tailwind 负责样式最后用 TypeScript 把整条链路焊死。t3code 的价值在于它帮你把这些最佳实践固化成能直接改业务代码的模板而不是让你每次起项目都把路由、鉴权、数据库连接、样式配置重新搭一遍。我最早接触 T3 Stack 是因为受不了手写 REST API 类型定义的日子。前后端一分离改一个字段要同步改三处地方后端 DTO、前端的接口类型、还有文档。偶尔忘改运行时直接白屏。t3code 最打动我的点就是端到端类型安全数据库表字段改掉之后前端调用处会立即标红根本不需要我记着什么契约文档。这篇文章我就从项目设计、核心技术点、实操过程、排错经验四个维度拆一拆 t3code 这套东西希望对想搞全栈、或者正在纠结技术选型的朋友有点参考价值。1. 项目整体设计与思路拆解1.1 它到底解决了什么问题传统全栈项目最常见的痛点是类型断裂。后端用 Prisma 操作数据库前端用 fetch 调接口中间隔着一层 JSON。只要某天有人把后端字段从completed改成isDone前端不编译报错只有运行时才能看到。t3code 的整个设计就是围绕“类型信息安全地穿过网络边界”这一目标进行的。它把后端 Router 的类型直接暴露给前端前端调用接口时入参和出参的 TypeScript 类型全部由编译器推导少写大量的 interface 定义把错误拦截在编译期。第二个痛点是样板代码太多。传统全栈项目一般要建 controllers、services、repositories、validators、types 五层目录每个接口都要在多个目录里各写一遍。t3code 里这部分被压缩成了一个 router 文件业务函数直接挂载zod 校验写在 input 里类型由 tRPC 自动推导。代码量大约只有传统方案的三分之一但清晰度和可维护性反而更高。第三个痛点是新项目初始化的决策疲劳。技术选型是个说起来复杂、做起来更要命的事情要不要用 GraphQL数据库用 SQLite 还是 Postgres样式方案用 Tailwind 还是 CSS Modules每个方案都有理由也都有社区拥趸。t3code 的做法是直接帮你做决定把有一致共识的方案固化成模板让你把精力留给真正有价值的业务逻辑。1.2 为什么选中这套技术组合先说一下 Next.js。它提供了 SSR、SSG、ISR 三种渲染模式同时从 13 版本开始力推 App Router支持 Server Components这让服务端逻辑和前端组件可以写在同一个文件里。对于 t3code 来说页面组件里可以直接发起 tRPC 查询而 tRPC 又会调用 Prisma 访问数据库整条链路都在同一个进程里完成不需要额外部署一个 API 服务。再说 tRPC。它不生成协议文件也不需要像 GraphQL 那样维护 schema它只是把服务端函数“投影”到客户端。换成人话讲你在服务端定义了todoRouter.create前端就能直接api.todo.create.useMutation()去调用而且参数类型、返回值类型全部自动匹配。对中小型项目来说这比 REST 更省事比 GraphQL 更稳。数据库层选 Prisma 而非 TypeORM 或 Drizzle主要看中它 schema 驱动的开发流程。写模型、生成 migration、同步到数据库、生成类型四个步骤像流水线一样顺。虽然 Prisma 在复杂 SQL 场景下不如手写 SQL 灵活但 CRUD 占到业务总量的八成以上这一点取舍非常划算。样式层 Tailwind 的逻辑是 utility-first用原子类组合代替自定义 CSS。在 Server Components 场景下Tailwind 的 class 会在编译期直接生成不会在客户端注入任何运行时样式代码性能表现很稳。它的生态里还有 shadcn/ui 这样的组件库能把 Tailwind 的使用体验再提升一个档次。1.3 选型背后的代价与权衡没有完美的技术栈只有适合场景的组合。t3code 把前后端放在同一个 Next.js 项目里意味着这两部分没法独立扩缩容。如果将来业务量爆炸需要把 API 独立拆出去就需要额外引入 API Gateway 或者改用微服务架构。另外tRPC 的设计目标是服务自己的前端不适合直接对外暴露成公开 API。如果产品计划做开放平台那就要考虑接一层 BFFBackend for Frontend或独立网关。代价还包括 Prisma 对数据库的抽象。ORM 的好处是类型安全、迁移方便但复杂查询、窗口函数、子查询这些场景写起来就别扭。我的经验是遇到复杂报表查询时不要硬用 Prisma直接在 router 里暴露一个手写 SQL 的查询方法反而干净。这些权衡不是缺陷而是明确的取舍。t3code 面向的是产品开发的核心价值快速验证、快速上线、快速迭代。等到真正遇到性能和扩展瓶颈时你通常已经赚到了足够的时间去重构或者已经验证了业务方向不需要继续膨胀。2. 核心细节解析与实操要点2.1 tRPC 的端到端类型安全到底是怎么回事要理解 tRPC得先忘掉“接口”这个概念。它没有 URL 路径、没有 HTTP method、没有 OpenAPI 文档它就是 TypeScript 函数直接调用。服务端定义一个 router// server/api/routers/todo.ts export const todoRouter createTRPCRouter({ getAll: publicProcedure.query(({ ctx }) { return ctx.db.todo.findMany(); }), });客户端拿到的类型是这样的// 前端某个组件里 const { data } api.todo.getAll.useQuery();data的类型不是any也不是手写的 interface而是 Prisma 的Todo[]类型。这里没有代码生成没有运行时校验纯粹是 TypeScript 的类型推断在起作用。原理说起来也不复杂createTRPCReactAppRouter接收的是服务端 router 的类型签名tRPC 的类型体操会把每个 procedure 的输入输出全部推导出来客户端 proxy 对象只是这个类型签名的载体。这里要补一句别在服务端手动加类型断言。有些人觉得 Prisma 返回的类型还不够“精确”在 router 里写return data as SomeDTO[]这一下就把类型推导链路打断了前端拿到的类型就不再可信。正确的做法是直接在 Prisma schema 里用select、omit或者查询时指定select字段让类型系统自己完成剩余工作。2.2 zod 到底起了什么作用tRPC 本身只负责调用参数校验靠 zod。拿create方法举例create: publicProcedure .input( z.object({ title: z.string().min(1, 标题不能为空).max(100), }) ) .mutation(async ({ ctx, input }) { return ctx.db.todo.create({ data: input }); }),zod 的 schema 同时承担了两个职责运行时校验请求参数是否合法以及编译期提供输入类型。前端传了一个空字符串后端会直接返回可读的校验错误而不是数据库报错。前端传了一个多余的字段也会被 zod 默认过滤掉这一点对安全性很重要。如果你用 REST 方案这一步通常要在 controller 里手写一堆 if-else非常容易漏。我见过一些项目为了“省事”把 tRPC 的 input 类型直接写成any这等于把端到端类型安全这条护城河填平了后面一定会看到数量膨胀的运行时 bug。所以 t3code 模板里给每一个有入参的 procedure 都配好了 zod schema你在改业务时也应该保持这个习惯。顺便说一下zod 还能和表单库配合。比如 react-hook-form 的zodResolver直接把表单校验、类型推导、后端校验三件事合并成一份 schema 定义前后端永远不会有校验规则不一致的问题。2.3 Prisma 模型设计与迁移工作流schema.prisma 是数据层的唯一事实来源。我一般习惯先定模型再想接口model Todo { id Int id default(autoincrement()) title String completed Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt index([completed]) }写完模型后执行npx prisma migrate dev --name init这条命令会做三件事比对 schema 与数据库的差异、生成新的 migration 文件、同步到本地数据库。同时它会自动重新生成 Prisma Client让 TypeScript 类型立即跟上模型变化。生产环境就不要用 dev 了要用npx prisma migrate deploy它只执行 migration 文件不会尝试生成新的差异安全很多。实操中有个很容易踩的坑修改 schema 之后没有重启 TypeScript 语言服务编辑器里 Prisma Client 的类型还是旧的你会看到一堆莫名其妙的类型报错。解决办法很简单VSCode 里执行 TypeScript: Restart TS Server或者直接重启编辑器。另外一个细节SQLite 适合本地和原型但 t3code 最终上生产建议切换到 PostgreSQL。切换逻辑非常简单只需要改DATABASE_URL然后重新生成一次 migration。t3code 的 schema 里也不需要写方言相关的代码因为 Prisma 会在背后处理这块做得相当稳。2.4 Tailwind 的配置与组件化套路Tailwind 在 t3code 里的地位是“把样式写得不难受”。它本质上是按类名生成 CSS所以配置的关键在于 content 路径要覆盖到你所有的源码文件export default { content: [./src/**/*.{js,ts,jsx,tsx}], darkMode: class, theme: { extend: { colors: { brand: rgb(var(--brand) / alpha-value), }, }, }, plugins: [], } satisfies Config;我推荐团队里统一设计 token主色、边框、圆角、间距都通过 CSS 变量定义Tailwind 里用rgb(var(--brand))引用。这样暗黑模式切换只需要换个>npm create t3-applatest my-t3code交互阶段有几个选项需要确认TypeScript必须开Tailwind CSS开tRPC开Prisma开NextAuth如果是需要登录态的项目就开不然后续手动加也很麻烦ESLint / Prettier开初始化完成后先把.env配置好。模板里.env.example已经写好了变量名你需要复制一份.env并填写DATABASE_URLfile:./db.sqlite NEXTAUTH_SECRET$(openssl rand -base64 32) NEXTAUTH_URLhttp://localhost:3000NEXTAUTH_SECRET 注意不要用带NEXT_PUBLIC_前缀的变量名因为服务器端密钥一旦打进浏览器 bundle 就等于泄露了。然后启动开发服务器npm run dev第一次启动可能会遇到PrismaClient is not defined这是因为还没有执行任何 migration。跑一句npx prisma migrate dev或者npx prisma db push就能解决。我自己更推荐先建好模型再 migrate一步到位。目录结构长这样src/ ├── app/ │ ├── api/trpc/[trpc]/route.ts │ ├── layout.tsx │ └── page.tsx ├── components/ ├── server/ │ ├── api/ │ │ ├── root.ts │ │ ├── routers/ │ │ └── trpc.ts │ └── db.ts └── utils/ └── api.tsserver/api/trpc.ts里定义了 base procedureserver/api/root.ts是挂载所有子 router 的入口utils/api.ts是前端获取类型化 proxy 的地方。这三个文件是 t3code 的核心新手第一次看容易绕晕但弄懂之后新增一个业务模块基本只需要改两个文件。3.2 实现一个带数据库的 Todo 模块从模型开始。在prisma/schema.prisma里加模型model Todo { id Int id default(autoincrement()) title String completed Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt }然后生成迁移npx prisma migrate dev --name init接着在src/server/api/routers/todo.ts里写业务逻辑import { z } from zod; import { createTRPCRouter, publicProcedure } from /server/api/trpc; export const todoRouter createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) { return await ctx.db.todo.findMany({ orderBy: { createdAt: desc }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) { return await ctx.db.todo.create({ data: input }); }), toggle: publicProcedure .input(z.object({ id: z.number() })) .mutation(async ({ ctx, input }) { return await ctx.db.todo.update({ where: { id: input.id }, data: { completed: true }, }); }), delete: publicProcedure .input(z.object({ id: z.number() })) .mutation(async ({ ctx, input }) { return await ctx.db.todo.delete({ where: { id: input.id } }); }), });再到root.ts里挂载import { todoRouter } from /server/api/routers/todo; import { createTRPCRouter } from /server/api/trpc; export const appRouter createTRPCRouter({ todo: todoRouter, }); export type AppRouter typeof appRouter;前端页面组件我放在src/app/todos/page.tsxuse client; import { useState } from react; import { api } from /utils/api; export default function TodosPage() { const utils api.useUtils(); const { data: todos, isLoading } api.todo.getAll.useQuery(); const createTodo api.todo.create.useMutation({ onSuccess: () utils.todo.getAll.invalidate(), }); const [title, setTitle] useState(); if (isLoading) return p正在加载.../p; const handleAdd () { if (!title.trim()) return; createTodo.mutate({ title }); setTitle(); }; return ( div classNamep-6 h1 classNametext-2xl font-boldTodo List/h1 div classNamemt-4 flex gap-2 input classNameborder rounded px-3 py-2 value{title} onChange{(e) setTitle(e.target.value)} placeholder输入待办事项 / button onClick{handleAdd} classNamebg-brand text-white px-4 py-2 rounded 添加 /button /div ul classNamemt-6 space-y-2 {todos?.map((todo) ( li key{todo.id} classNameflex items-center gap-3 span{todo.title}/span /li ))} /ul /div ); }几个关键点use client必须有因为这里用了 useState 和 useMutation。api.useUtils()是 React Query 的 hook里面封装了各种 invalidation 方法。createTodo.mutate发送 mutation成功后utils.todo.getAll.invalidate()会自动重新拉取列表页面不需要手动刷新。这就是 t3code 里最常用的数据流模式mutation 成功后让相关 query 失效React Query 重新请求。3.3 部署到 Vercel 的完整流程部署前先检查数据库。本地用的是 SQLiteVercel 的 serverless 环境不方便持久写文件所以生产环境一定要换 Postgres。我习惯接 Vercel Postgres 或者 Supabase因为两者都能直接拿到连接串填进DATABASE_URL即可。接着在项目根目录跑npx prisma migrate deploy然后推送代码到 GitHub在 Vercel 里创建 new project选择仓库环境变量里填好DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL点击 Deploy 就完成了。这里有个高频报错部署后页面能打开但一提交数据就报 500。十有八九是没执行 migrate deploy数据库里根本没有表。另一个容易忽略的是 Vercel 的 Node.js runtime 版本Prisma 需要 nodejs runtime如果你在自己的 API Route 或 page 上面手动加了export const runtime edge就会看到 PrismaClient 初始化失败。t3code 默认不主动用 edge runtime所以只要你没乱改一般不会碰这个问题。4. 常见问题与排查技巧实录4.1 高频报错速查表我把这段时间实际踩过、身边朋友也问过的问题整理成一张表方便你直接对照现象可能原因处理方式PrismaClient is not defined没执行 prisma generate运行npx prisma generate运行时报Input validation failedzod 校验不通过检查 input schema 和前端传参是否一致useQuery 能拿到数据但类型是 any手动断言或类型引用链断了删除多余类型断言检查 AppRouter 导出方式部署后数据都是空的migration 未部署npx prisma migrate deploy组件不显示样式tailwind content 路径没覆盖确认 content 包含 src 下所有模板NextAuth 回调跳转失败NEXTAUTH_SECRET 缺失或不稳定设置固定随机 secret修改模型后编辑器类型不更新TS Server 缓存重启 TypeScript 服务4.2 我踩过的几个坑第一个坑是use client忘记加。在 App Router 里如果页面里用了useState、useMutation这些 hooks但忘记声明use client编译会直接报错。这不是技术难点但很容易在复制模板时漏掉这一行报错信息又长又吓人最后定位到只是少了一行注释非常搞心态。第二个坑是 tRPC procedure 里 catch 了错误但不抛出。我一开始做删除接口时担心 Prisma 报错导致页面崩溃就在 mutation 里 try-catch 然后返回undefined。结果前端拿到 undefined直接渲染出一个空对象页面就静默崩溃了。排查好久才明白异常还是应该抛出让 tRPC 把错误信息结构化返回前端才能在统一的地方处理。第三个坑是日期序列化。Prisma 返回的createdAt是 Date 对象如果直接放在页面组件里React 报“不能序列化 Date”。create-t3-app 默认集成了 superjsontRPC 序列化会自动处理这类问题。但如果你自己手动搭项目忘了配 superjson就会踩到。所以除非很有把握否则不要轻易从模板里去调 superjson。4.3 性能和安全优化建议先谈性能。tRPC 默认走 React Query重复的 useQuery 会用同一个 key 做缓存所以同一个接口在多个组件里调用不会重复请求。如果你有一些数据变化不频繁的查询比如系统配置可以给 useQuery 设置staleTime: 60_000一分钟内即使组件重新挂载也直接用缓存省掉大量请求。再谈安全。t3code 模板里的 procedure 分为 publicProcedure 和 protectedProcedure后者依赖 NextAuth 的 session 做鉴权。如果某个 mutation 需要登录用户才能调用记得用 protectedProcedure不要图省事全写 public。另一个隐性问题是 Prisma 查询返回的字段默认会把 schema 里的所有字段都返回。用户模型里如果有 passwordHash就一定要在 select 里排除它否则就算 router 写得很漂亮隐私还是漏出去了。t3code 虽然安全基线远比手写 REST 高但该做的数据最小化工作一项都省不了。最后说一个实用技巧如果页面里需要服务端渲染直接让 tRPC 查询在 Server Component 中使用api.todo.getAll.query()调用一次再把结果作为 prop 传给客户端组件。这样既拿到了 SSR 的效果客户端又不需要重复请求。这个模式在 t3code 里是完全支持的很多新手不知道往往白白多加载一次数据。坦白说我从 REST 迁到 tRPC 最深的感受就是类型安全不该是奢求而应该成为全栈开发的基线。t3code 这个模板的价值不在于它用了多么新奇的技术而在于它把端到端类型安全、数据库迁移、样式构建这些原本分散的知识浓缩成了一个可复用的起点。我团队里现在新开的内部工具、原型验证项目全都跑在这个模板上几乎不用花时间在基建上。最后再分享一个小技巧当你给 useMutation 加 onSuccess 回调时不要只写utils.todo.getAll.invalidate()可以顺手await utils.todo.getAll.refetch()这样针对关闭了自动 refetch 的场景也能稳妥刷新列表。这个细节很多人不会写在文档里但实测下来能省不少排查时间。