资讯动态

t3code全栈模板:基于T3 Stack的类型安全开发实践

发布时间:2026/10/27 16:25:21 来源:尧图企业网站定制
我维护 t3code 这个项目已经有段时间了。最早它只是朋友圈子里内部用的一个全栈脚手架后来慢慢长成了一整套从数据库到按钮的类型安全开发模板。很多人第一次看到这个名字以为是个新框架其实它不是。它是围绕 T3 技术栈——Next.js、TypeScript、Tailwind CSS、tRPC——沉淀下来的一套代码组织方式、工程约定和可直接复制的项目基底。这篇文章不打算讲什么高深理论就是把我从零搭这个模板、用它落地了好几个实际业务之后踩过的坑、想清楚的取舍、值得直接抄作业的地方都摊开讲一讲。如果你正用 Next.js 写全栈应用或者团队里每次改一个接口字段都要前后端各改一遍类型定义并暗自祈祷别漏掉那这篇应该能帮你省不少时间。1. 为什么我会把整套东西沉淀成 t3code1.1 最开始的痛点类型在两个端之间各说各话先说一件让我下定决心的事。之前我维护过一个项目后端 Next.js API Routes 配 Prisma前端直接 fetch。业务很简单就是文章列表。某次需求给文章加一个readingTime字段后端改完数据库和返回结构前端同学没同步更新类型TS 在编译期完全不报警等真上线了前端拿到undefined直接渲染出一个诡异的空白区域。这种问题我觉得所有人都经历过但真正让人难受的是它在没有错误的情况下悄悄出错。后来我统计了一下这类问题的根源不是谁手滑而是 REST 接口和前端调用方之间根本没有一个强制的类型契约。后端返回什么靠约定前端知道什么形状靠记忆。人可以约定会忘记忆会漂移。我要找的就是一个把类型契约写死在代码里的方案让两端不一致时编译都不通过而不是上线后才发现。1.2 T3 技术栈解决了什么又留下哪些空白T3 Stack 是国外开发者 Theo 提出的一个技术选型理念核心就几个词TypeScript 优先、类型安全、极简可靠。配套的 create-t3-app 脚手架可以一键生成包含 Next.js、tRPC、Prisma、NextAuth、Tailwind 的项目。这工具解决了新项目从零配一堆东西的苦差事我第一次用的时候确实被爽到三分钟左右项目就跑起来了。但用了一段时间我发现脚手架解决的是起跑没解决跑到第三个月不散架。比如业务代码怎么分层routers 越来越大要怎么拆分Server Component 和 tRPC 应该怎么配合还是说干脆不配合错误处理是每个 mutation 写一遍还是有个统一拦截Prisma 客户端到底在哪个文件里创建才能在 Next.js 开发模式下不把连接数打爆。这些问题是脚手架不负责回答的而它们恰恰决定了项目能活多久。t3code 就是我把这些问题的答案固化下来的产物。1.3 t3code 的定位让脚手架的项目跑三个月不散架一句话概括create-t3-app 负责让你三分钟跑起来t3code 负责让你跑三个月不重写。具体来说t3code 做了四件事第一把业务代码强制分成路由层—逻辑层—数据层路由层只放 tRPC 路由定义逻辑抽到 service数据访问统一走 Prisma第二约定所有跨端调用必须走 tRPC不允许在页面组件里直接 fetch 内部 API第三把鉴权公共逻辑收拢成protectedProcedure新写一个需要登录的接口时三行代码解决不用记中间件怎么写第四把错误处理、上传、环境变量管理这些高频场景的代码都整理成了开箱即用的模板。对独立开发者和中小团队来说这套约定成本很低收益很直接新人进来不用读一整天文档照着既有路由文件抄就能写出来一个符合规范的新接口。2. t3code 五个基础构件的选型逻辑2.1 为什么基座是 Next.js App Router选 Next.js 没什么悬念它现在是全栈 React 应用最成熟的载体。真正需要决策的是用 App Router 还是 Pages Router。t3code 从第一天就锁定 App Router。我的理由是这样的App Router 的 Server Component 模型天然适合做读多写少的页面比如列表页、详情页可以直接在服务端取数数据不会经过网络传一份到浏览器而需要交互、需要客户端状态的地方用use client标注的组件和 tRPC 客户端配合。这种默认服务端取数按需客户端取数的模式比 Pages Router 里那种所有页面都要自己选 ssr 还是 ssg的心智负担低很多。还有一个实际原因Next.js 团队所有新特性都优先给 App Router未来方向很明确。早切比晚切舒服。2.2 tRPC我为什么丢弃 REST 和 GraphQL这是 t3code 里争议最大也可能是价值最大的一层。先说说 REST。REST 本身没错但它作为内部前后端通信协议有个天然缺陷它不携带类型契约。OpenAPI 可以补这个空缺但实际项目里维护一份 OpenAPI 文档的意愿和成本都很高前端要依赖生成的 SDK 还得保证生成步骤在 CI 里永远执行。反正我见到的团队文档和代码说翻脸就翻脸。再说 GraphQL。我团队里几个人都学过 GraphQL也认同它强 schema 的思路但当一个项目的前后端都是 TypeScript 时GraphQL 需要你写 schema、跑 codegen、处理缓存、设计查询复杂度控制整套东西对一个本来不需要多端多语言服务的项目来说太重了。它解决的是多客户端、多语言、复杂聚合的问题而 t3code 面向的是一个人或三五个 TS 工程师包打前后端的场景。tRPC 的思路完全不同——它干脆不定义协议直接把服务端 router 的 TypeScript 类型暴露给客户端。你不需要写 schema不需要跑 codegen不需要生成 SDK。服务端加一个字段客户端类型和编辑器补全立刻同步。它的前提是前后端必须都是 TypeScript但 t3code 的用户恰好满足这个前提所以这个缺点对我们来说不存在。2.3 Prisma 与 TypeScript 一起锁定数据库类型数据层选 Prisma 几乎不需要犹豫。必要性在于手写 SQL 或直接用 ORM 返回的模型类类型都是靠人维护的数据库表改个字段名代码里相关类型不会自动变。Prisma 的做法是让你先定义 schema然后prisma migrate和prisma generate生成类型整个代码库的类型会跟着数据库 schema 联动。举个例子我在 t3code 里的一个文章模型长这样model Post { id String id default(cuid()) title String content String published Boolean default(false) createdAt DateTime default(now()) author User relation(fields: [authorId], references: [id]) authorId String }当我在 schema 里给Post加一个viewCount Int default(0)然后跑npx prisma migrate dev所有涉及查询 Post 的 tRPC 路由返回类型立刻带上viewCount。前端调用api.post.getAll.useQuery()拿到的数据也立即有这个字段提示。那种改完库整个链路类型自动全部纠正的感觉用过一次就很难回去。2.4 Tailwind CSS 和被我删掉的 Redux样式这块没什么好辩论的Tailwind 的原子类在 t3code 里负责所有组件样式配合 Next.js 开箱即用连 postcss 配置都是现成的。值得说的是状态管理。很多从 Vue 或老 React 项目转过来的人会习惯性引入 Redux Toolkit我在 t3code 的第一个版本也这么干过。后来发现真没必要。t3code 里大部分需要跨组件共享的数据是服务端状态——文章列表、用户信息、点赞状态这些完全可以交给 tRPC 底层的 React Query 缓存去管它有全局缓存、失效重取、乐观更新比手写 actions 和 reducers 高效得多。真的只剩一些本地 UI 状态比如某个弹窗开关那种东西用useState就够了。所以最终 t3code 的状态管理方案是服务端状态全部走 tRPC query cache客户端瞬时状态走useState不再引入任何全局状态库。2.5 被裁掉的 axios 和重复封装以前的项目习惯性会封装一个request.ts包一层 axios处理 token、错误码、请求拦截。t3code 里这个文件彻底删了。原因很简单tRPC 客户端本身就内置了 headers 注入、错误拦截、loading 状态管理。你不该在它外面再包一层请求工具那等于在已经打通的全链路上硬生生加一段需要维护的胶水层。我在 t3code 的模板里写了一个trpc/react.tsx负责创建客户端、注入鉴权 headers、配置全局错误 handler写完之后业务代码就只需要关心数据和输入HTTP 细节交给它。3. 从空目录到第一个业务接口完整实操记录3.1 初始化项目时的参数选择如果你要复现 t3code 这套结构最好直接从 create-t3-app 起步而不是手动往已有的 Next.js 项目里塞依赖。命令很简单npm create t3-applatest交互选项我建议这样选TypeScript、Tailwind CSS、tRPC、Prisma、ESLint 全部勾上NextAuth 第一次可以勾也可以不勾。如果项目最终一定要登录功能建议一开始就勾上因为 tRPC 的上下文context设计会依赖 session 类型后面补会比较麻烦包管理器选你团队日常用的npm/pnpm 都行我自己习惯 pnpm。有一句经验之谈脚手架生成的默认项目会有一个很直观的示例页面——一个 CRUD 示例带着 tRPC 路由和 Prisma 模型。很多人会急着把它删掉。我建议先别删读完那个示例的每个文件再删那几乎是理解这套技术栈最好的入门材料。3.2 目录结构解读真正该放代码的位置生成完项目目录结构是这样的。我标一下 t3code 里哪些是核心app/ api/trpc/[trpc]/route.ts # tRPC 的 HTTP 入口 layout.tsx page.tsx server/ api/ root.ts # 汇总所有子 router 的地方 routers/ post.ts # 你的业务路由 trpc.ts # tRPC 实例、中间件、上下文工厂 db.ts # Prisma 客户端单例 trpc/ react.tsx # 客户端 tRPC 创建工具 server.ts # Server Component 取数工具最容易踩的坑在server/api/root.ts。每新建一个routers/comment.ts都要记得在root.ts里把它挂到appRouter合并一下然后export type AppRouter。很多新人写完了路由但忘了注册前端一直说找不到api.comment.xxx查半天才发现是 root 没更新。t3code 实际结构里我会额外加一个server/services/目录用来放一些路由里比较重的业务逻辑。我对团队的要求是路由文件不超过 200 行超过就把逻辑抽到 service这样 router 文件的阅读成本非常低。3.3 第一个带鉴权的 tRPC mutation三行级联的体验初始化完成后我建议从写一个创建文章接口开始。先看一下 Prisma 的 Post 模型上面已经写过然后在server/api/routers/post.ts里写路由import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const postRouter createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });注意protectedProcedure是 t3code 里预定义好的它内部已经完成了没登录就抛 UNAUTHORIZED这个动作。你在业务代码里根本不需要判断 session 是否存在直接ctx.session.user.id类型上就已经保证了 user 非空。这就是把鉴权收敛进过程定义procedure的价值每个新接口都少写一段人人都会复制错的中间件代码。前端调用同样干净const createPost api.post.create.useMutation(); const onSubmit (data: { title: string; content: string }) { createPost.mutate(data, { onSuccess: () { // 创建成功后使列表缓存失效 utils.post.getAll.invalidate(); }, }); };从输入校验、鉴权、写库、类型推导、到缓存失效一次全链路写下来只有十几行代码。这就是 t3code 想给你的日常类型跟着走样板代码消失你需要关心的只有业务字段本身。4. tRPC 的类型继承是怎么做到不可能写错的4.1 从 Router 到 AppRouter 的引用链路关于类型继承很多新手第一反应是这不就是代码生成吗还真不是。区别在于代码生成比如 OpenAPI 生成 SDK、GraphQL codegen是把契约翻译成代码文件生成文件一旦过期类型就失真tRPC 是直接复用服务端类型本身不产生中间文件类型是活着的。链路是这样的。服务端在server/api/root.ts里把所有子路由合并成一个appRouterimport { postRouter } from ./routers/post; import { commentRouter } from ./routers/comment; import { createTRPCRouter } from ./trpc; export const appRouter createTRPCRouter({ post: postRouter, comment: commentRouter, }); export type AppRouter typeof appRouter;这里的AppRouter不是运行时对象它是一个类型。客户端拿到这个类型import type { AppRouter } from /server/api/root; import { createTRPCReact } from trpc/react-query; export const api createTRPCReactAppRouter();从这一刻起前端代码里api.post.create这个对象上的每个字段——参数、返回值、错误类型——都是从服务端 router 原封不动继承过来的。你在服务端给查询加了一个参数前端不传TypeScript 立刻报错你在服务端还改了返回结构里的一个字段名前端的补全和类型检查也会立刻失效。4.2 一个字符串的字段改动如何传遍全链路为了让你对类型活着有更直观的体感我模拟一个真实改需求的过程。需求文章列表要返回作者名。改动只有两处。第一处后端postRouter.getAll的查询加一个include: { author: true }getAll: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, include: { author: true }, }); });第二处前端列表组件里直接用const { data } api.post.getAll.useQuery(); // 这里编辑器会直接提示 data[0].author.name 是 string | undefined // 如果你写 data[0].auther.name —— 直接红线全程没有任何人手动同步过类型。如果没有 tRPC你需要改 Prisma 模型、改后端 DTO、改前端 interface、改 mock 数据。中间任何一步漏了就会回到文章开头那个悄悄出错的场景。4.3 为什么不建议在 t3code 里手写DTO有一个相反的提议我在团队评审时也听过我们保留了 REST API 和手写 DTO这不是更清晰吗我理解这个想法但它需要一个重要前提你的项目真的有很多不同的消费端Web、iOS、Android、第三方公开 API并且需要对外保证 API 永远不变。如果你的前端只有 Next.js 自己的页面组件那手写 DTO 就是在两份代码里人为制造漂移的机会。t3code 的取舍很明确内部调用一律直接返回 Prisma 模型不套壳、不做 DTO 转换只有当这个数据要对外暴露给不可控的第三方时才在路由出口做一次显式的映射。这个取舍让代码量少了很多也让数据库字段就是前端字段成为可能——单包项目的核心优势不应该主动放弃。5. 三个月实战踩坑从报错信息开始的完整排查链路这一部分我认为比前面的选型分析更值得读因为每一项都是我实际遇到、且网上资料通常只讲一半的。我会把排查链路写完整而不是直接丢结论。5.1 部署到线上后所有查询全部返回 UNAUTHORIZED现象很诡异本地开发环境一切正常一部署到 Vercel所有带鉴权的查询直接返回 401。我的排查顺序是打开浏览器 Network 面板确认请求确实打到了/api/trpc/post.getAll且 cookie 带了看服务端日志返回的 code 是UNAUTHORIZED说明不是网络问题而是代码层面认定你未登录检查 NextAuth 配置发现NEXTAUTH_SECRET在生产环境没有配置。NextAuth 在本地开发时可以不配 secret生产环境 JWT 签名完全依赖这个值缺失时它没法解析 session cookie于是每次请求的 session 都是 null补上环境变量重新部署问题消失。这不是复杂问题但我当时也没意识到 NextAuth 对生产环境 secret 是强校验。t3code 里我加了一个启动时检查缺少NEXTAUTH_SECRET时直接编译报错把这种线上才爆的问题提前到部署前。5.2 React hydration 不一致导致的白屏这问题典型得像教科书。某个页面的 header 组件从localStorage里读取用户偏好设置渲染导致客户端第一次渲染的 HTML 和服务端渲染出来的 HTML 不一致。结果就是浏览器控制台报Text content does not match页面直接白屏。排查链路定位报错组件发现它依赖浏览器端存储把读取逻辑从渲染期挪到useEffect里初始状态给一个确定值const [mounted, setMounted] useState(false); useEffect(() setMounted(true), []); if (!mounted) return null;页面恢复但有个副作用首屏会先空白一下这对体验有损。最终的优雅做法是服务端也读取同样的偏好数据如果是 cookie或使用suppressHydrationWarning做局部豁免。在 t3code 里我干脆规定了一个约定任何站在localStorage上的渲染都必须先判断typeof window ! undefined并走 mounted 模式不许直接读取。这个约定让新进来的成员少踩很多雷。5.3 类型推导突然变成 any 污染了整个页面这是个很隐蔽的问题。某天开始前端api.post.getAll.useQuery()返回的数据类型突然变成any编辑器里所有字段提示消失。我当时第一反应是 tRPC 配置坏了查了半天没结果。后来真正的原因在server/api/routers/post.ts里。我在写辅助函数时没有显式标注返回类型// 这里没有写 : PromisePost[] const fetchPosts async (db: PrismaClient) { return db.post.findMany(); };按说这也能推断出类型但那个文件里引入了循环依赖——post.ts顺便 import 了comment.tscomment.ts又回头引了post.ts里的一个类型。TypeScript 在循环依赖和泛型推断同时出现时推导会直接放弃把类型降级成any然后any顺着 tRPC 的类型传播把整个客户端页面污染了。解决办法分两步先把循环依赖拆掉——comment 里需要的类型提到一个公共types.ts再给所有公共 helper 函数显式标注返回类型切断推断中断的源头。这条经验我反复讲给团队t3code 的承诺是类型安全从服务端传到客户端但它依赖一个前提——你的 TypeScript 项目本身不能出现贯穿式的any。代码里一旦有未标注类型的any它就像一滴墨汁掉进清水里会把整个类型继承链路染脏。5.4 Prisma 连接数在生产环境被打爆有一次客户反馈线上高峰期应用极慢数据库日志全是连接超限。查代码才发现某个 Server Component 页面里直接new PrismaClient()了而且是在组件函数体内。Server Component 没有生命周期概念每个请求都会 new 一个新实例每个实例都去开连接连接数瞬间爆炸。正确做法是全局单例复用。t3code 里server/db.ts的标准写法是import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;globalThis是为了避免开发模式下热更新导致多个实例。更重要的一点是t3code 的路由层统一通过ctx.db拿 Prisma 实例而不是到处 importdb这样每个请求只需要一个 prisma 客户端引用从根上减少误用。6. t3code 还能往哪些方向继续演进6.1 文件上传的边界tRPC 只管元数据不碰文件本体一个经常被问到的问题是 t3code 里怎么做文件上传。我的建议比较明确不要把文件内容塞进 tRPC 请求。tRPC 本质是 JSON 序列化大文件转 base64 会导致请求体巨大、内存和带宽双高。推荐模式是这样的tRPC 负责申请上传凭证和记录元数据文件本体走对象存储的预签名直传。实现起来很清晰前端先调用api.upload.createPresignedUrl传入文件名和大小拿到一个带签名、有时效的上传 URL前端直接把文件 PUT 到那个 URL完成后调用api.upload.confirm把文件的 key、大小、类型写入数据库。这个方案对用户体感好上传速度快后端内存压力小而且进度条也好做。t3code 的模板里我把云存储 SDK 相关的调用封装在server/services/storage.ts哪个路由要用就调用它不散落得到处都是。6.2 和 NextAuth 集成时最容易漏的 userId 注入登录鉴权流程里新手最常问的问题其实是我在 session 里怎么拿 userId默认的 NextAuth session 类型里只有user.name、user.email、user.image没有user.id。t3code 的处理是在 NextAuth 配置的callbacks.session里手动把 token 里的sub即 userId注入 sessioncallbacks: { session({ session, token }) { if (session.user) { session.user.id token.sub as string; } return session; }, },然后做一件事声明 session.user.id 类型不为空。这一步之后protectedProcedure里ctx.session.user.id的类型推导就是可靠的 string而不是string | undefined。别看就这一行它直接决定了你在 mutation 里写authorId: ctx.session.user.id时到底要不要写一堆非空断言。写类型声明前被undefined追着跑写完之后整个世界清净了。这个细节我在 t3code 的 README 里专门开了一节。6.3 统一错误处理把 TRPCError 翻译成用户能看懂的话t3code 的最后一个演进方向是错误处理。tRPC 内置了一套错误码体系比如UNAUTHORIZED、NOT_FOUND、INTERNAL_SERVER_ERROR、PRECONDITION_FAILED。如果你在每个组件里手动catch然后分别处理代码会非常碎。我的做法是在客户端trpc/react.tsx里配一个全局onError拦截统一翻译const onError (err: TRPCClientErrorLikeAppRouter) { if (err.data?.code UNAUTHORIZED) { router.push(/login); return; } if (err.data?.code NOT_FOUND) { toast(记录不存在或已被删除); return; } if (err instanceof TRPCClientError err.data?.zodError) { const message err.data.zodError.fieldErrors; toast(formatZodErrors(message)); return; } toast(操作失败请稍后重试); };这样业务代码里不需要再关心错误处理所有 tRPC 请求的错误都被这个拦截器统一接走了。如果要针对某个请求做特殊处理可以在useQuery/useMutation的局部onError里覆盖或补充。最后说一个我特别坚持的习惯。在 t3code 里我不允许任何跨端类型出现any。真遇到类型推断不下去的情况宁可先用一个显式的类型断言撑着也要让编辑器红色标出来因为它提醒你这里断了需要你补上声明。一旦你放下这个口子让any顺着 tRPC 的类型链路溜进页面前面所有按类型对齐建立的效率优势几分钟内就能土崩瓦解。类型上的自由从来不是免费的你省的每一次断言以后都可能在某个深夜变成一场无声的线上事故我反正是领教过了。

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

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

免费获取报价 →
↑