资讯动态

t3code:基于T3 Stack的端到端类型安全全栈基线工程

发布时间:2026/10/14 6:38:04 来源:尧图企业网站定制
t3code这个名字最早是我一个仓库的代号后来慢慢成了我日常开全栈项目时最顺手的起点。先说清楚它是干嘛的一个以 T3 技术栈为核心的全栈应用基线工程把 Next.js、TypeScript、tRPC、Prisma、NextAuth 这一整套组合沉淀为可以反复复用的模板代码。解决了什么痛点说白了就是三件事前后端接口类型不一致导致联调返工、每个新项目要重新搭一遍鉴权和数据库连接、团队协作时各写各的导致代码结构五花八门。适合谁来参考如果你正在从零搭一个现代的 TypeScript 全栈应用或者你团队需要一个统一的工程底座这篇文章值得花十分钟看完。我自己是从什么时候开始用 T3 这套思路的大概是从那次被 REST API 的“类型漂移”坑惨之后。前端定义User后端返回UserDTO中间再加一层手写的字段映射等发现字段对不上已经是部署前一天。所以 t3code 的基本立场很明确能用一端推导出另一端的类型就绝不让类型在两个地方各写一遍。整个项目就是围绕着“端到端类型安全”展开的从数据库模型到页面组件类型一路贯通改一处就同步暴露另一处的问题。下面我按自己的实际搭建过程把 t3code 从设计思路到踩坑实录完整拆出来。代码示例都是可以直接抄走用的。1. t3code 是什么从一次失败的联调说起1.1 为什么会有这个项目当时接手一个中型后台项目前端 React后端 NestJS两边各维护一套接口文档。需求变了后端改了字段名文档忘了更新前端联调时数据怎么都对不上。为了定位是后端返回错了还是前端解析错了造了至少六个“安慰变量”来打印日志。那段时间我悟出一个道理一旦接口类型不能从单一数据源自动生成项目越大维护成本越陡峭。t3code 就是在这种背景下出现的。它不是某个新框架而是一个工程模板通过选型把类型安全的链路从数据库一路铺到浏览器。核心组合固定在几件事上Next.js 做应用框架tRPC 做 API 层Prisma 做数据层Zod 做校验NextAuth 做鉴权。这套组合在圈内叫 T3 Stackt3code 则是这个栈的具体实现骨架。1.2 核心概念端到端类型安全端到端类型安全这个概念说人话就是后端函数的参数类型和返回值类型能直接被前端 TypeScript 识别。你不再需要手写 API 请求的类型声明因为类型是从服务端代码里面推导出来的。想一想数据库里Todo.title是stringPrisma 的模型类型是stringtRPC 路由的返回值类型是string前端拿到数据后编辑器提示也是string。中间任何一环类型对不上编译期就报警出来了。2. t3code 技术选型每一步都是权衡后的选择2.1 为什么选 Next.js 而不是纯 Node 后端t3code 里用 Next.js 并不是因为它名气大而是它天然支持在同一仓库里同时写客户端和服务端代码。App Router 出现后服务端组件和客户端组件的边界变得更加明确配合server-only包可以强制把数据库访问代码隔离在服务端避免不小心在浏览器环境里执行了敏感逻辑。实际开发中我经常听到一种疑问要不要前后端彻底分离再用 Nginx 做代理如果你只有一两个前端和一个 API 服务分离架构当然没问题。但一旦项目多起来每个项目都要处理 CORS、部署编排、环境变量分发复杂度是线性增长的。t3code 选择合在一起本质上是牺牲一点点“部署独立性”换来整个开发生命周期的简化。对于十个以内开发者、一个主应用加少量子应用的中小型团队这个取舍非常值得。2.2 tRPC 替代 REST 的一万个理由REST 的优点很多但有一个巨大的隐形成本契约维护。OpenAPI 规范、postman 集合、前端接口文档每一样都需要额外的人力去维护。tRPC 则完全不同路由里定义的所有query和mutation就是用 TypeScript 写的普通函数前端通过类型导入直接获得完全一致的签名提示。比如后端定义了一个getUserById输入参数是{ id: string }返回类型是{ name: string; email: string }。在前端代码里调用api.user.getUserById.useQuery({ id: 1 })你会发现data.name、data.email都有自动补全。如果后端把返回字段改成了displayName前端代码会立刻出现类型错误而不是等运行时才发现。这就是 t3code 的核心红利类型就是契约编译就是联调。2.3 Prisma 和 Zod从数据库到表单的类型直通没有 Prisma 之前数据库表结构跟 TypeScript 类型是两套体系。直接在 SQL 里加字段ORM 模型忘了改代码就静默地把新字段丢了。Prisma 的做法是通过schema.prisma这个单一文件定义数据模型然后自动生成类型安全的客户端。模型改了运行prisma generate所有的类型即刻同步漏改的可能性被降到最低。Zod 在这里的角色是输入校验。前端传来的数据是不可信的这句话我强调再多都不过分。tRPC 路由的input校验用 Zod 的z.object定义后端收到的数据已经是经过类型校验和结构清洁的合法数据。同时因为 Zod 的类型可以推导出 TypeScript 类型前后端对“输入长什么样”也有了统一认知。类型安全链路的最后一环就这样接上了。3. t3code 项目结构从目录到鉴权权限体系3.1 目录结构解读server 和 client 的边界t3code 的目录结构从顶层上就强调了服务端和客户端代码的隔离。我在项目里固定保留下面的骨架src/ ├─ app/ # Next.js App Router 页面 │ ├─ layout.tsx │ ├─ page.tsx ├─ server/ │ ├─ api/ │ │ ├─ root.ts # 根路由聚合 │ │ ├─ routers/ # 每个业务域一个 router │ ├─ db.ts # Prisma 客户端单例 │ ├─ auth.ts # NextAuth 配置 ├─ trpc/ │ ├─ server.ts # tRPC 服务端初始化 │ ├─ client.ts # tRPC 客户端代理 ├─ styles/其中src/server下的代码在 t3code 里用server-only包做了防护。这个包的作用很干脆如果在客户端组件里不小心 import 了这里的代码构建直接报错。数据库连接、session 读取这些操作只能发生在服务端从物理上杜绝了把数据库凭证带到浏览器的低级事故。依赖方向也是清晰到近乎强迫症app层可以引用server和trpcserver层不能反向引用app层。路由和页面只是“薄薄”的一层表现壳真正的业务逻辑都在server/api/routers下。这样一旦业务规则有变更开发者的第一反应是去 router 文件而不是去页面里翻 useEffect。3.2 权限体系自定义 Proceduret3code 里权限不是靠中间件配置“魔法”实现的而是通过 tRPC 的 procedure 抽象。你会先定义底层的publicProcedure再通过.use()中间件扩展出protectedProcedure。它们的区别在于是否校验ctx.session。下面是我在trpc/server.ts里的实际写法import { initTRPC, TRPCError } from trpc/server; import superjson from superjson; import { ZodError } from zod; import type { Context } from ./context; const t initTRPC.contextContext().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const router t.router; export const publicProcedure t.procedure; const enforceUserIsAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure t.procedure.use(enforceUserIsAuthed);这个写法的好处是在业务路由里只要看到数据是从protectedProcedure出来的就默认它需要登录态拿到的ctx.session.user也是类型安全的不需要到处写判空。3.3 NextAuth 集成与 session 失效的铁律NextAuth 和 tRPC 的组合很多新手会卡在 ctx 拿不到 session。t3code 的处理方式是在创建 tRPC context 时主动读取 sessionexport const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); return { db, session, ...opts, }; };一个此前反复踩的坑是NextAuth 默认用 JWT 策略时getServerSession才能正确解码 session如果手动去调getSession()或解析 cookies经常遇到session为null的情况。原因在于 NextAuth v4/v5 的 session 编码方式和老版本不同。我自己一度以为“获取 session 就那么简单”后来发现只是运气好没有跨路由调用用getServerSession之后稳定至今没再出过幺蛾子。4. 实操实录用 t3code 半小时搭一个全栈 Todo4.1 初始化项目与数据库建模创建 t3code 从初始化开始。如果你用的是官方脚手架命令是npx create-t3-applatest my-app在选项里勾选 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。如果你已经有一个 t3code 的基准仓库直接 clone 后按下面的流程改业务就好。业务从数据库模型开始。我用一个 Todo 应用来做演示schema.prisma如下model Todo { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) userId String user User relation(fields: [userId], references: [id]) } model User { id String id default(cuid()) name String? email String? unique todos Todo[] }模型定义完执行数据库同步和客户端生成npx prisma db push npx prisma generatedb push适合开发阶段快速改表结构。如果项目进入稳定期我建议改用prisma migrate dev生成规范的迁移记录方便后续审计表结构变更。4.2 写 tRPC 路由和前端调用数据库就绪后来到核心环节定义 tRPC 路由。Todo 相关的功能被我收敛在routers/todo.ts里import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const todoRouter createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), add: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) { return ctx.db.todo.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string(), completed: z.boolean() })) .mutation(async ({ ctx, input }) { return ctx.db.todo.update({ where: { id: input.id }, data: { completed: input.completed }, }); }), });这里必须强调一下ctx.db的类型来源。createTRPCContext返回的对象里包含db而db是PrismaClient的单例。只要 tRPC 能正确推导出 context 类型router 里ctx.db.todo的所有模型方法都能直接提示出来。上下文类型一旦定义错误整个链路就会出现大面积的类型报错所以建议定义 context 时显式标注返回类型让问题尽早暴露。前端页面调用时数据请求和缓存失效逻辑都靠 React Query 在背后支撑。一个典型组件长这样import { api } from ~/trpc/client; function TodoList() { const utils api.useUtils(); const { data, isLoading } api.todo.getAll.useQuery(); const addTodo api.todo.add.useMutation({ onSuccess: () { utils.todo.getAll.invalidate(); }, }); if (isLoading) return div加载中.../div; return ( ul {data?.map((todo) ( li key{todo.id} span{todo.title}/span span{todo.completed ? 完成 : 未完成}/span /li ))} /ul ); }真正惊艳的地方在于data的类型直接来自 tRPC 路由的返回类型你不需要在TodoList里专门定义TodoItem接口。列表组件的渲染逻辑只依赖后端返回数据的真实形态需求变更是通过后端类型变化传导过来的而不是靠前端“人工记忆”。4.3 部署到 Vercel 时踩过的坑t3code 部署到 Vercel 的整体流程很顺但有几个细节不处理就是定时炸弹。首先环境变量必须完整。DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL一个都不能少。其中NEXTAUTH_URL在生产环境必须写死为正式域名如果留空NextAuth 在某些场景下会尝试从请求头里推断地址一旦通过 Vercel 的预览域名访问session 回调地址就会对不上导致登录成功却无法保持登录状态。其次数据库连接串的问题。我在 Vercel 上第一次部署时直接拿本地 SQLite 的连接串塞进DATABASE_URL结果毫无疑问地崩溃了。部署到线上的应用必须使用远程数据库而且需要注意连接池限制。很多免费的 PostgreSQL 服务会给每个实例一个有限连接数而 Serverless 环境下每次请求都可能创建新的连接。我当时把连接数打满之后全站直接 500。解决方案是把 Prisma 的连接池配置明确写进schema.prisma的datasource块datasource db { provider postgresql url env(DATABASE_URL) directUrl env(DIRECT_URL) }配合 Vercel 的 Serverless 模式directUrl单独指定直连地址可以有效降低连接被占满的概率。这个配置在本地无法完全复现问题只在部署后才暴露建议搭建阶段就先把两套地址分开。5. 常见问题与排查技巧实录5.1 我踩过的 8 个坑第一tRPC client 路由不匹配导致 404。前端api.todo.getAll和服务端todoRouter挂在根路由的名字不一致时请求会静默失败。排查时打开 Network 面板看 API 路径的 prefix 是否包含api/trpc。第二Prisma generate 后类型未刷新。改动schema.prisma后忘记执行npx prisma generateTypeScript 还在用旧类型报错信息往往会指向完全无关的位置。第三Zod input 报错信息不友好。解决方式是上面提到过的errorFormatter把zodError返回给前端前端就能按字段拆分错误提示而不是只看到一条笼统的INVALID_INPUT。第四NextAuth 的NEXTAUTH_SECRET泄露到浏览器端。排查方式是全局搜索NEXTAUTH_SECRET是否在前端组件里被 import一旦出现就是生产事故级别的隐患。第五Server Component 中使用 tRPC 客户端时报错。t3code 里服务端组件不能直接调用api.*.useQuery这种 React Query 钩子这是客户端组件专属 API。正确的做法是在服务端组件里直接调用 tRPC 的 server caller或者在客户端组件里做数据获取再把数据通过 props 传给服务端组件。关于这一点目录结构里的server和trpc分界文档值得一读。第六Session 更新后 UI 不刷新。NextAuth 的 session 数据是服务端获取的前端如果要响应登录态变化需要在session.status变化时主动触发 router 重新请求或者使用 middleware 里的revalidate逻辑。总之不要把 session 对象直接塞进 UI 的实时状态里否则会出现“登录成功但页面没有反应”的现象。第七多个 PrismaClient 实例警告。开发模式热更新偶尔会创建多个实例建议在db.ts里用全局变量缓存const globalForDb globalThis as unknown as { prisma: PrismaClient }; export const db globalForDb.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForDb.prisma db;这段代码能够避免开发环境重复初始化导致的连接泄漏。第八Tailwind 的原子类结果在服务端组件和客户端组件中表现不一致。这是比较隐蔽的问题常见于dark模式。我建议把 T3 里起项目时默认的tailwind.config.ts中的 class 策略保持原样不要为了“减少文件体积”而去掉某些变体因为内容类不完整时客户端组件首屏渲染和客户端水合后的样式会产生闪动。5.2 排查思路总纲如果 t3code 项目报错我建议按这条线路去找问题先看编译或 lint 输出。类型安全的正向收益是90% 的问题在编译期就能暴露。如果类型全绿但运行报错大概率是运行时环境的问题比如环境变量没配、数据库连不上。再看服务端日志重点关注 tRPC 路由的输入数据是否能通过 Zod 校验。最后看浏览器请求确认请求打到的是正确路径、正确方法、正确 body。数据库相关的问题优先查DATABASE_URL和连接数这个坑我在前面已经展开说过。这一套排查顺序虽然朴素但覆盖了我在 t3code 使用中遇到的大部分故障。养成这个习惯之后再遇到报错定位速度会明显提高。6. 在团队里推广 t3code 的心得真的把 t3code 引入团队比个人使用多一层难度因为每个人都有自己的习惯和偏好。我的经验是不要一开始就做“全面推翻重来”的整改而是挑一个新项目试点让团队成员先感受到端到端类型安全的爽快感用体验去说服人。我在实际操作中发现一个更好用的落地方式不要急着把所有业务路由写满而是先搭好一个最小闭环选一个高频小需求比如“用户资料获取”从页面按钮点击到数据返回完整体验一遍。等大家意识到“新同学不需要后端文档就能写接口调用”这个事实后后面推动就是顺水推舟的事了。这种真实体验带来的说服力比任何流程规范文档都管用。t3code 后续能扩展的方向我也复盘过模块化路由拆分、接入事件总线、加入更多监控埋点这些都可以基于现有骨架平滑扩展。但最重要的还是守住那条底线任何可能破坏类型安全的改动都不允许进入基线。守住这一条t3code 的长期价值就会越滚越大。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑