资讯动态

t3code全栈重构:T3 Stack类型安全实践与踩坑复盘

发布时间:2026/10/28 7:16:38 来源:尧图企业网站定制
t3code 是我给那个内部任务看板重写项目起的代号。起因说起来有点丢人老项目用的是 Express 后端加 React 单页前后端全靠手写类型和接口文档撑着。后端同事有一天给任务列表接口加了个priority字段我这边枚举值拼写错了文档里也写的是high实际后端要的是urgent前端整整排查了两个多小时才定位到原因。这种问题不复杂但出现频率一高人很容易疲惫。那段时间社区里不少人聊 T3 Stack核心卖点是前后端类型安全。我一开始觉得这概念不稀奇TypeScript 我也一直在用。真正让我决定动手试的是 T3 把整套常用件都拼好了Next.js 管渲染和接口入口TypeScript 管全链路类型tRPC 替代手写 REST 路由Tailwind 管样式Prisma 管数据库。从数据库到 React 组件类型几乎全程跟着代码走不需要再单独维护一份 API 类型定义。这篇更像是我把 t3code 从脚手架到部署这段日子的决策记录和踩坑复盘。适合已经写过 React、也碰过后端 API但还没系统用过 T3 这套组合的开发者。后面每节我都会先说为什么这么选再放可以直接抄走的代码和操作。1. t3code 项目背景我为什么决定用 T3 Stack 重写这个内部小工具1.1 从 REST 到全栈类型安全的迁移动机先交代一下旧架构。看板最早是 Express React SPA后端在routes/task.js里定义router.get(/tasks)返回任务列表前端在services/api.ts里手动封装fetch(/api/tasks)同时维护一套Task接口类型。问题在于这套类型全是手工同步的两边一旦不同步编译期完全看不出来。举个例子后端想把用户 ID 字段从assigneeId改成assigneeUid前端Task类型还停在assigneeIdTypeScript 不会报错因为运行时数据没有强约束。等到渲染函数里写task.assignee.name可接口返回的却是assignee: null页面在用户刷新瞬间突然白屏。这类 bug 在小项目里靠自觉还能忍团队加进第五个人之后接口文档基本就没人敢信了。T3 Stack 解决的问题不是消灭所有 bug而是把前后端不一致这类问题提前到编译阶段暴露。数据模型定义完Prisma Client类型自动生成tRPC router 的输入输出类型由数据模型推导前端调用trpc.task.list.useQuery()拿到的返回值直接就是带类型的。后端改了返回字段前端引用旧字段的地方立刻标红。这个反馈链路很值钱——改接口不再是你改完告诉我一声而是编译器先帮我筛一遍。我把这个重写项目取名为 t3code就是想验证它在小团队内部工具里到底顺不顺手顺便把旧看板的问题逐个清掉。1.2 T3 Stack 四件套各自承担的角色把这几件事的分工说清楚后面读代码才不会迷路。层工具在 t3code 里承担的事语言TypeScript全链路类型约束数据库到组件都吃同一套类型框架Next.js页面渲染、API 入口、Server Component 数据获取样式Tailwind CSS原子化样式做内部工具基本够用API 层tRPC类型化调用替代手写 REST 路由数据层Prisma数据库建模与类型化 ORM表结构一变类型自动变经常有人问我为什么不用 GraphQL。GraphQL 也能做到前后端类型同步但引入 Schema、Resolver、Codegen 之后配置成本明显更高。t3code 是个内部工具团队人不多最需要的是少维护一层中间约定GraphQL 相当于为了上电梯先修一栋楼不划算。还有人提 NestJS如果团队规模很大、模块化要求很重NestJS 确实有优势但就 t3code 这种业务量T3 的扁平结构写起来更直接。说白了选型不是比赛谁先进而是看谁和你的团队结构匹配。这一点到脚手架部分会更明显——T3 默认帮你规避了很多选择困难。2. 脚手架初始化create-t3-app 的选项直接影响后面工作量2.1 初始化命令和选项怎么选创建项目走的还是官方脚手架pnpm create t3-applatest项目名我输入了t3code框架默认走 App Router。交互式选项里我按这个策略选的TypeScript必选不用犹豫。Tailwind CSS选上。除非你明确要自己写 CSS否则配 ESLint、Prettier 很顺。tRPC选上这是 T3 和普通 Next.js 模板区别最大的地方。AuthenticationAuth.js选了。内部工具几乎必有登录模板直接给你接入逻辑。Prisma选了。想少写 SQL又不想引入太重的东西Prisma 是目前比较省心的方案。ESLint / Prettier选了团队代码风格靠它兜底。新版脚手架可能还会问 Drizzle 还是 Prisma。我选了 Prisma理由是 migration 体系成熟团队其他人对 Prisma 也熟。这里多说一句选好之后不要中途换Drizzle 和 Prisma 的 API 风格差别太大写了一半再搬家的成本很伤。2.2 初始化后最值得先读的几个文件生成完目录先别急着写页面把下面这几个文件过一遍后面的困惑会少一半src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx server/ auth.ts db.ts api/ trpc.ts routers/ root.ts trpc/ react.tsx server.tsapi/trpc/[trpc]/route.ts是所有/api/trpc/*HTTP 请求的入口Next.js 收到请求后转发给 tRPC 处理。这个文件基本不用改但它的存在决定了 tRPC 在 App Router 里的工作方式。server/db.ts是 PrismaClient 单例关键点在于开发模式热重载时不会为每个组件重新创建数据库连接池。server/auth.ts放着 Auth.js 配置session 回调、Provider 都在这。server/api/trpc.ts是 tRPC 初始化的核心文件publicProcedure、protectedProcedure都在这里定义后面所有路由的权限控制都依赖它。trpc/react.tsx把 tRPC 和 React Query 绑在一起导出带类型的前端trpc对象前端所有数据请求都从它来。理解这几个文件之后你会发现 create-t3-app 真正的价值不是帮你写代码而是帮你建立了一套数据层怎么分层的规范。我第一次用的时候最大的收获其实是这套目录划分——它逼着你把路由、数据库、认证边界分开。2.3 初始化后第一件要先改的配置脚手架生成的是能跑的模板不代表能直接上线。按我自己的顺序第一个要动的是.envDATABASE_URLpostgresql://user:passwordlocalhost:5432/t3code AUTH_SECRET$(openssl rand -base64 32) AUTH_URLhttps://t3.example.com注意AUTH_URL旧项目里是NEXTAUTH_URL最好一开始就写成未来线上要用的域名别先用http://localhost:3000然后指望后面会想起来改。这个坑我在部署阶段踩得很深后面专门写了。第二个要留意的是next.config.mjs如果后面要展示外部图片记得先配images.remotePatterns否则 Next.js 会直接报错拒绝加载。第三个是tailwind.config.ts改一下主题色和字体变量内部工具至少看着不像默认模板。另外用 pnpm 装完依赖后如果 postinstall 脚本报错先查是不是原生模块比如sharp、bcrypt这类大概率是系统缺编译工具不是项目问题。3. 数据模型与 tRPC 路由设计把 CRUD 做成类型闭环3.1 先定义 Prisma Schemat3code 的业务我拿书签收藏当例子展开。内部工具经常要收藏各种链接这个模型简单但覆盖了 CRUD 的典型场景model Bookmark { id String id default(cuid()) title String url String unique description String? userId String user User relation(fields: [userId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }改完 Schema 之后执行npx prisma migrate dev --name init这一步会生成 SQL migration 文件和新的 Prisma Client。之后所有ctx.db.bookmark的类型都是根据模型推导出来的字段拼错了会在编辑器里直接标红不用等请求发出去再被数据库报错。对新手来说这种改模型立刻改类型的体验比传统 ORM 舒服很多。3.2 tRPC router 的落地写法t3code 里对应的路由文件长这样import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const bookmarkRouter createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) { return ctx.db.bookmark.findMany({ orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), url: z.string().url(), description: z.string().optional(), }), ) .mutation(async ({ ctx, input }) { return ctx.db.bookmark.create({ data: { ...input, userId: ctx.session.user.id, }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { await ctx.db.bookmark.delete({ where: { id: input.id } }); return { success: true }; }), });简单解释一下createTRPCRouter是路由容器procedure是一个接口。query对应读操作mutation对应写操作。输入为什么要用 zod因为 zod 的 schema 推导出的类型会同时暴露给前端等于后端校验规则对前端是可见的。前端的title如果是个空字符串发送请求前编辑器就能标出来不必等后端返回 400。写完后在根路由注册export const appRouter createTRPCRouter({ bookmark: bookmarkRouter, });同时记得导出AppRouter类型export type AppRouter typeof appRouter;很多人改完 router 后前端没反应多半是忘了重启 dev server或者编辑器还没识别到根路由的类型变化。tRPC 能做到类型同步靠的就是这个AppRouter类型被前端的react.tsx引用属于类型即 API。3.3 protectedProcedure 中间件的写法protectedProcedure是 create-t3-app 已经帮你写好的权限校验中间件但它的默认实现只是判断ctx.session是否存在。我的建议是再加一层判断不要用!断言export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } }, }); });TRPCError的code会被 tRPC 自动映射成 HTTP 状态码UNAUTHORIZED对应 401。前端挂一个全局onError处理就能统一展示登录过期或未授权的情况。这块最常见的翻车点是只判断ctx.session而不判断ctx.session.user导致 session 对象存在但 user 字段为空时后续ctx.session.user.id直接抛错。另一个坑是手贱在中间件里写next({ ctx: { session: ctx.session! } })然后抱着侥幸心理往下传。类型断言掩盖的不是类型问题是逻辑漏洞迟早会在线上还回来。我个人的习惯是中间件第一行就亮出来if (!ctx.session || !ctx.session.user)看着啰嗦但一天跑几千次的接口经得起这种保守。4. 认证接入环节Auth.js 与 tRPC 中间件协同的完整踩坑记录4.1 Provider 选择内部工具优先考虑 OAutht3code 做的是内部看板我直接选 GitLab OAuth团队日常登录已经依赖 GitLab加一个第三方认证入口密码管理成本几乎为零。Auth.js 里配置 GitLab Provider 大概是这样import GitLabProvider from next-auth/providers/gitlab; export const authConfig { providers: [ GitLabProvider({ clientId: process.env.GITLAB_CLIENT_ID, clientSecret: process.env.GITLAB_CLIENT_SECRET, }), ], };如果你用的是 Auth.js v5session 策略默认是jwt这也是 create-t3-app 模板的默认值。想换成数据库 session用auth/prisma-adapter存 Session 表也不是不行但要评估多了一套表登录的查询链路变长对小工具来说收益不大。我最后仍采用 jwt 策略理由很简单——内部工具最重要的是登录稳定和回调配置简单JWT 天然无状态部署时少一个 Session 表要维护。4.2 session 回填 userId 的那处关键配置这是我在 t3code 里印象最深的一个坑。用 jwt 策略时session回调的第二个参数是token不是user所以网上那种抄session.user.id user.id的写法在默认模板下根本拿不到user。正确做法是先往 token 里塞字段再从 token 里取callbacks: { jwt: ({ token, user }) { if (user) { token.id user.id; } return token; }, session: ({ session, token }) { if (session.user) { session.user.id token.id as string; } return session; }, }如果不回填idctx.session.user.id会一直为空tRPC 中间件里userId永远拿不到。最难受的是代码逻辑完全没问题前端也不报错直到创建数据时userId为 undefined数据库约束给你一个措手不及的错误。这类问题在开发环境出现的概率很低因为很多示例数据根本不走登录一旦接上真实登录马上暴露。4.3 tRPC context 里正确获取 sessionsrc/server/api/trpc.ts里的 context 构造函数是 tRPC 拿到 session 的关键export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authConfig); return { db, session, headers: opts.headers }; };Auth.js v5 里也可以直接用auth()效果类似。需要留意的是部分版本要求把opts.headers传给auth()或getServerSession否则在代理环境下 session 可能解析不出来。如果你发现本地登录正常、线上登录一直是 anonymous优先怀疑这个位置少传了 headers。还有个小陷阱AUTH_SECRET没设的情况下Auth.js 启动时会报MissingSecret。这个错误在本地跑一般不出现因为模板会生成一个开发用的随机值但部署到服务器时如果你没把环境变量写进生产环境的.env而只是写在.env.local里build 时一切正常运行时却会突然报错。建议直接把AUTH_SECRET作为独立环境变量注入不要指望.env文件能跨环境迁移。4.4 反代和回调地址登录成功为什么跳回 localhost我把 t3code 部署在 Nginx 后面第一次测登录就翻车了GitLab 回调成功后浏览器地址却跳到了http://localhost:3000/api/auth/callback/gitlab页面 404。排查半天两个原因AUTH_URL没设置成外部域名Auth.js 按照请求的 Host 头来拼回调地址代理把内部端口传过去了。Auth.js v5 默认不信任反向代理的X-Forwarded-Host头需要显式开启。解决方案是在生产环境设置AUTH_URLhttps://t3.example.com AUTH_TRUST_HOSTtrue同时 Nginx 配置要保留 Host 头并记录原始协议。如果外部走的是 HTTPSNginx 没配X-Forwarded-Proto httpsCookie 的secure属性也会出问题表现为登录成功但 Cookie 不写入浏览器刷新后又回到未登录状态。这个问题排查起来很磨人因为前端完全看不到报错只是永远登不上。我的经验是遇到登录类问题不要先怀疑代码先把AUTH_URL、AUTH_TRUST_HOST、代理三件套检查一遍比看半天堆栈有效率。5. 数据刷新策略React Query 缓存失效和改完看不见的解法5.1 tRPC hooks 的常规用法t3code 前端组件里调用接口很简单use client; import { trpc } from /trpc/react; export function BookmarkList() { const utils trpc.useUtils(); const { data, isLoading } trpc.bookmark.list.useQuery(); const createMutation trpc.bookmark.create.useMutation({ onSuccess: () utils.bookmark.list.invalidate(), }); if (isLoading) return div加载中.../div; return ul{data?.map((item) li key{item.id}{item.title}/li)}/ul; }这里的trpc.bookmark.list是类型推导出来的所以前端不需要一个独立的 API 封装文件。invalidate()是 React Query 里的缓存失效操作作用是标记对应 key 的缓存为过期并重新请求。tRPC 把完整的路由地址作为 key因此可以做到很细粒度的失效——只刷新bookmark.list不需要全页面重新拉。5.2 并不是每次写操作都值得 invalidateinvalidate很好用但如果列表已经很大每次删一条都要重新拉 100 条其实不划算。在 delete 这种操作里我更推荐直接操作缓存const deleteMutation trpc.bookmark.delete.useMutation({ onSuccess: (_data, variables) { utils.bookmark.list.setData(undefined, (old) old?.filter((item) item.id ! variables.id), ); }, });这样删除后没有多余请求列表直接少一行。但是要注意setData的第一个参数是 query 入参。bookmark.list没有入参所以传undefined如果你的列表带筛选条件比如useQuery({ filters })要把同样的 filters 对象传进去否则更新的是无入参的缓存界面上毫无反应。这个细节很容易写错我一开始就在这里卡过一刻钟。如果对数据一致性要求更高可以删完后再补一个invalidate兜底onSuccess里两个都调用。代价是一次重复请求换来的是一致性更强具体取舍看你的业务。5.3 乐观更新按钮立刻反馈后台再校验t3code 里有一个标记书签为已读的操作一开始每次点击都等服务器返回再更新 UI。网络状况不稳定的时候按钮反馈明显慢半拍。后来改成乐观更新体验提升了一个档次const updateMutation trpc.bookmark.update.useMutation({ onMutate: async ({ id, title }) { await utils.bookmark.list.cancel(); const prev utils.bookmark.list.getData(); utils.bookmark.list.setData(undefined, (old) old?.map((item) (item.id id ? { ...item, title } : item)), ); return { prev }; }, onError: (_err, _input, ctx) { if (ctx?.prev) { utils.bookmark.list.setData(undefined, ctx.prev); } }, onSettled: () utils.bookmark.list.invalidate(), });这里有几个关键点。cancel()的作用是取消进行中的 refetch避免旧数据把刚改过的缓存覆盖掉getData()把旧数据存起来方便 onError 回滚onSettled里的invalidate()是最后的兜底即使乐观更新逻辑有偏差服务器数据最终还是会把前端拉回真实状态。这个模式的执行顺序不要乱调整尤其不能把invalidate放到onMutate前面否则 UI 会先闪一下再被旧缓存覆盖。5.4 服务端组件里调 tRPC 用独立网关App Router 之下服务端组件如果也想直接查数据不要用 hooks而是用服务端网关import { api } from /trpc/server; export default async function Page() { const bookmarks await api.bookmark.list.query(); return div{bookmarks.length} 条书签/div; }api是createCaller()生成的服务端调用器它可以绕过 HTTP直接执行路由函数。但是注意服务端调用默认没有浏览器上下文session 可能拿不到所以只适合读公共数据或者确保createTRPCContext里正确注入了请求 headers。我在 t3code 里用服务端数据获取做首页的默认展示需要登录态的请求仍然走客户端 hooks这样既省了一次客户端加载的闪烁又不会把权限模型搞乱。6. 部署阶段经验修正本地跑通 Build 之后还会翻车的地方6.1 Build 失败多半和代码无关t3code 第一次提交到 CI 跑构建报了一个让我摸不着头脑的错误PrismaClientInitializationError: Cant reach database server。本地明明跑得好好的为什么 build 时突然要连数据库原因在于 Next.js 的服务端代码在构建阶段就会执行一部分逻辑包括 PrismaClient 的初始化。如果你的 CI 环境变量里DATABASE_URL指向一个不存在的数据库或者那个数据库不允许 CI 容器的 IP 连入build 就直接失败。解决办法很简单构建前确认DATABASE_URL在 CI 里可见且可达。如果在 GitHub Actions 里用 service container 开一个临时 Postgres把环境变量指过去就能跑。这条经验后来帮工作室另外两个项目省了不少时间。6.2 数据库迁移顺序deploy 不是 dev生产环境的数据库迁移一定要用prisma migrate deploy而不是prisma migrate dev。dev会先尝试重置数据库还可能要求交互确认deploy只会按顺序应用 migration 文件。我建议把迁移放到构建流程里顺序是npx prisma generate npx prisma migrate deploy pnpm build如果数据库是托管的比如 Supabase、Neon、RDS记得把部署机器的 IP 或 VPC 网段加进白名单否则 migration 会在连接阶段就超时。还有一点如果多台机器同时部署不要让多实例同时跑 migrate数据库锁竞争会造成一次成功的 migration 被另一个实例跳过。最好由 CI 单独控制迁移步骤应用实例只负责启动。6.3 standalone 模式漏掉静态资源create-t3-app 默认生成的next.config.mjs没什么特殊设置但为了部署体积我给 t3code 加了output: standalone。这个配置会把运行时代码打包到.next/standalone目录部署时只需要复制这个目录和.next/static就能跑。问题来了standalone目录默认不包含.next/static。直接运行node .next/standalone/server.js后页面 HTML 能出来JS 和 CSS 全部 404刷新还白屏。我第一次部署时在这个问题上卡了整整一晚最后翻到官方文档才意识到要手动复制静态资源cp -r .next/static .next/standalone/.next/static这条已经写进了 t3code 的postbuild脚本里避免下次再忘。如果你用的是 PM2 守护进程记得启动命令里带环境变量文件比如pm2 start ecosystem.config.js否则.env不会被自动读取。6.4 线上日志排查的经验顺序线上出问题我一般按这个顺序排查效率最高先看进程日志500 是代码异常、连接超时还是数据库错误。再确认 tRPC 接口是否通畅直接用 curl 打一个接口返回UNAUTHORIZED就是 session/cookie 问题返回P1001就是数据库问题。最后查 session cookie域名、secure 属性、过期时间三个维度逐一检查。举个例子t3code 上线第一天有个页面报 500日志里是 Prisma 连接超时但数据库面板显示连接数不高。排查半天发现我用的是默认连接池配置高并发请求时连接被占满。后来在 schema 的连接串里加了几条 pool 参数问题才解决。这类问题不到线上不会暴露尽早做一轮压力测试是值得的。最后分享一个过程里养成的习惯我把 t3code 里所有 mutation 相关的缓存处理集中在一个模块里用useUtils()统一暴露给页面组件。刚开始觉得多此一举后来发现只需要维护一个文件就能把 invalidate、乐观更新、回滚这三件事理清楚排查问题时不用翻遍每个页面。另外一个感受是遇到 tRPC 类型标红不要急着as any。绝大多数情况是 zod schema 或 Prisma 模型写错了类型报错本身就是最有用的调试信息顺着它改根因比绕过去舒服得多。后面如果有时间再把 t3code 的定时任务和 WebSocket 通知方案整理出来发一篇。

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

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

免费获取报价 →
↑