T3 Stack全栈项目重构实录:TypeScript端到端类型安全实践 刚把一个内部代号叫 t3code 的全栈项目从零重构完整体跑下来之后我觉得是时候把这套设计和踩坑记录整理出来了。如果你正准备上手一个以 TypeScript 为主、既要保证开发速度又不想牺牲类型安全的中小型全栈应用或者你听过 T3 Stack 这个概念但一直没搞明白它到底怎么串起来这篇文章应该能帮你省掉不少摸索时间。t3code 本质上是一个“类型安全优先”的全栈项目骨架它把 Next.js 的 App Router、TypeScript、Tailwind CSS、tRPC、Prisma 和 Auth.js 整合到同一个代码库里让前端调用后端接口时像调用本地函数一样全程有类型提示编译期就能拦住一大批低级错误。我这次重构不只是换个框架版本而是把整个工程的边界、数据流和配置规范全部重新梳理了一遍。1. 项目定位与技术选型背后的逻辑1.1 t3code 到底解决了什么核心痛点先说说我做这个项目之前的处境。以前写全栈应用最常见的组合是 Next.js 前端加一个独立的 Express 或 NestJS 后端前后端通过 REST API 通信。听起来很清晰但实际维护起来痛点非常明显前端要维护一份接口类型定义后端要维护另一份中间再夹一层 axios 封装和手工的类型断言稍微改动一个字段前端有时候根本感知不到等运行时才发现数据对不上。t3code 核心解决的就是这坨“类型断层”问题。它把所有代码放进同一个 TypeScript 项目里通过 tRPC 让前端直接调用后端写好的 procedure双方共享同一套类型推断。我改一个数据库字段Prisma 的 schema 变了tRPC 的返回类型跟着变前端的入参和出参提示立刻同步这一条链路完全是自动的不需要手工同步任何东西。对于中小型项目来说这种“单仓库、单类型系统、全链路类型安全”的开发体验是最舒服的。这个项目适合三类人第一类是刚接触全栈开发、想用一套代码同时搞定前后端的同学第二类是被 REST API 类型维护折磨过的团队想试试更紧凑的协作方式第三类是想把 Next.js App Router 和 tRPC 在实际项目里跑通、而不是停留在 Demo 层面的人。我的定位是t3code 不是大型微服务架构的答案而是中小型应用、内部工具、个人项目这类场景下开发效率最高的方案之一。1.2 技术选型为什么是这套组合而不是其他T3 这个前缀按社区里的说法对应的是 TypeScript、Tailwind CSS 和 tRPC 这三个核心件。这个组合不是随便拼的每一环都在解决一个具体问题。TypeScript 是整个项目的地基。没有它前后端共享类型就是空谈。t3code 里所有的业务类型、数据库模型、API 入参出参最终都以类型的形式存在编译期里编辑器提示、重构安全、代码补全全部构建在这个基础上。Tailwind CSS 的选择更偏向工程效率。我早期用过一段时间的传统 CSS 方案类名分散在多个文件里改样式要在 HTML、CSS 分支之间来回跳。Tailwind 把样式直接放在组件里配合原子类其实非常契合“一个组件就是一个完整单元”的开发模式。有人说它让 HTML 变丑了但换来的是不用再给类名起名、不用担心全局样式污染、删除组件时样式跟着一起删掉对于单人开发和两三人小团队来说净收益是正的。tRPC 是整套组合里最灵魂的部分。它解决的问题是在前后端不分离的架构下为什么还要用 OpenAPI 规范去描述接口为什么要手动写客户端请求函数tRPC 用 TypeScript 的类型系统直接把服务端 router 的类型暴露给客户端生成类型安全的调用函数不需要代码生成、不需要额外 schema 语言开箱即用。REST API 当然没错但对于内部接口来说它带来的前后端解耦价值远小于类型维护成本。Prisma 负责数据库层。选它不光是看中 ORM 的便捷更重要的是它的 schema 文件天然适合做类型源头。一张表改字段迁移文件、类型定义、tRPC 的返回类型全部串起来这条链路是 t3code 能保持类型闭环的基础。Auth.js 则负责用户认证它在 Next.js 生态里集成度最高配合 tRPC 的中间件机制可以很容易地实现“只有登录用户才能调用的 procedure”。1.3 目录结构先想清楚边界再写业务代码我这次重构项目时第一件事不是写业务而是把目录结构定下来。t3code 的目录设计遵循一条原则服务端代码和客户端代码物理隔离但共享类型。这是 tRPC 项目最容易犯浑的地方很多人把数据库查询直接写在组件里短期很爽长期重构到想哭。src/ ├── app/ # Next.js App Router 页面与路由 │ ├── (auth)/ # 登录、注册相关的路由组 │ ├── (main)/ # 主界面路由组 │ └── api/ # 必要的 Route Handlers比如 webhook ├── server/ │ ├── db/ # Prisma Client 实例 │ ├── auth/ # NextAuth 配置 │ └── api/ # 业务逻辑按领域模块拆分 │ ├── routers/ │ └── root.ts # tRPC 根 router ├── trpc/ │ ├── server.ts # 服务端 tRPC 初始化 │ ├── client.ts # 客户端 tRPC 初始化 │ └── caller.ts # 服务端调用入口 └── styles/ # 全局样式这个结构里最关键的是把server/api/routers作为业务逻辑的唯一出口页面组件不直接操作 Prisma而是通过 tRPC 的 procedure 间接操作数据。这样一来权限校验、入参校验、日志打点都集中在 router 层而不是散落在各个组件里。我的习惯是每个领域一个 router 文件比如user.ts、post.ts、comment.ts最后在root.ts里合并。2. 工程底座开发环境与代码规范的精细配置2.1 初始化项目与环境要求t3code 的初始化过程其实已经被官方脚手架大幅简化了但我在实际使用时发现理解脚手架背后生成的每个文件比直接点击 yes 重要得多。环境方面Node.js 版本建议 18.17 以上我机器上用的是 20 LTSpnpm 作为包管理器。之所以选 pnpm不只是因为安装快更重要的是它严谨的依赖隔离机制能提前暴露隐式依赖问题这在长期维护的项目里能省下不少莫名其妙的 bug。初始化命令很简单pnpm create t3-applatest交互式命令行会让你选择需要的模块我的建议是Next.js 选 App RouterTypeScript、Tailwind CSS、tRPC、Prisma、Auth.js 全选。如果你确定项目里用不到数据库可以暂时去掉 Prisma但 t3code 的设计哲学是类型闭环数据库几乎是全栈项目的标配所以默认还是建议带上。脚手架生成之后第一件事是跑一次完整的类型检查和生产构建确保基线干净。这一步很多人会跳过但我的经验是脚手架生成的代码版本更新很快偶尔会出现某个依赖版本之间的兼容问题提早发现比写到一半再排查要省事得多。2.2 路径别名、环境变量与 TypeScript 配置的细节路径别名是我每次都要手动加固的地方。脚手架默认配了/指向src/但我在实际项目里发现仅靠这一个别名容易让人偷懒比如../../server/db这样的相对路径会出现在业务代码里。我的做法是在tsconfig.json里增加更精细的路径映射{ compilerOptions: { baseUrl: ., paths: { /*: [./src/*], server/*: [./src/server/*], trpc/*: [./src/trpc/*] } } }这个配置看起来只是少打几个字但它真正的作用是约定server开头的模块只能在服务端代码里出现一旦你在客户端组件里 import 了server下的东西代码审查时一眼就能看出来配合 ESLint 的边界规则可以拦住很多架构级错误。环境变量管理也是我重点整理的部分。t3code 用 zod 在env.mjs里做了运行时校验这意味着应用启动时如果缺少必填环境变量会直接抛出带有明确提示的错误而不是在运行到某一行才报 undefined。我的经验是所有环境变量必须在启动时完成校验和类型推导尤其是数据库连接串和认证密钥宁可在本地启动时暴露也不能等到部署到线上才发现。import { z } from zod; export const env z .object({ DATABASE_URL: z.string().min(1), NEXTAUTH_SECRET: z.string().min(1), NEXTAUTH_URL: z.string().url().optional(), }) .parse(process.env);注意NEXTAUTH_URL我设为可选这是因为在 Vercel 这类平台上环境变量会自动注入本地开发时默认才需要手动指定。这个细节如果不注意本地跑得好好的部署之后反而因为环境变量校验失败而启动不了。2.3 代码规范与质量门的落地工程化的另一个大头是代码规范。t3code 里我用了 ESLint 9 配合 typescript-eslint 和 Prettier。很多人觉得这些工具属于形式主义但我在重构过程中最大的体会是静态检查的真正价值不是抓语法错误而是强迫每个人写出边界清晰的代码。我额外加了两个插件一个是eslint-plugin-boundaries用来约束模块依赖方向确保app/只能依赖server/api/routers暴露出来的接口不能直接穿到server/db去查库另一个是eslint-plugin-import用来强制 import 排序。别小看 import 排序它让代码审查时 diff 更干净尤其是多人协作的仓库里import 乱序会让“哪一行是新增”变得特别难找。提交前强制跑pnpm lint和pnpm typecheck这两个命令我直接写进了 package.json 的pre-commit钩子{ scripts: { lint: eslint ., typecheck: tsc --noEmit, db:push: prisma db push, db:studio: prisma studio } }我还会在 CI 上跑同样的命令。本地和 CI 双保险的好处是即使有人绕过 husky 钩子强行提交也过不了远程的流水线。3. 核心功能实操从数据模型到完整业务链路3.1 数据模型设计用一套 schema 管住所有关系t3code 里最有代表性的业务场景是一个带用户点赞的博客系统。这个例子麻雀虽小但涵盖了用户、内容、多对多关系三类最常见的建模需求。我在设计 Prisma schema 时遵循的原则是字段名要暴露意图关系要显式声明索引要考虑查询方向。model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] likes Like[] } model Post { id String id default(cuid()) title String content String published Boolean default(false) authorId String author User relation(fields: [authorId], references: [id]) likes Like[] createdAt DateTime default(now()) updatedAt DateTime updatedAt index([authorId]) } model Like { userId String user User relation(fields: [userId], references: [id]) postId String post Post relation(fields: [postId], references: [id]) id([userId, postId]) }这里有几个细节值得展开。第一ID 没有用自增整数而是选了 CUID。自增 ID 在分布式部署和多环境同步时容易撞车CUID 在客户端和服务端都能生成避免了插入前先查一次序列的尴尬。第二Like表用复合主键id([userId, postId])直接从数据库层面保证了一个用户对同一篇文章只能点赞一次这是比在应用层写 if 判断更可靠的方案。第三每个外键字段都显式加了索引像authorId这种高频查询条件没有索引的话数据量一上去查询就会退化。3.2 tRPC 路由少写一层 API多出一份类型安全数据模型定好之后核心就是写 tRPC router。我见过不少新手一开始把 tRPC 当成 REST 的替代品写出来的 router 全是getUser、createPost这类功能函数名这其实没有发挥出 tRPC 的核心优势。在 t3code 里我的组织方式是按领域聚合 按用途拆分export const postRouter router({ list: publicProcedure .input( z.object({ cursor: z.string().cuid().nullish(), limit: z.number().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) { const posts await ctx.db.post.findMany({ where: { published: true }, take: input.limit 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, include: { author: { select: { name: true, image: true } }, _count: { select: { likes: true } }, }, }); let nextCursor: typeof input.cursor | undefined undefined; if (posts.length input.limit) { const nextItem posts.pop(); nextCursor nextItem?.id; } return { posts, nextCursor }; }), like: protectedProcedure .input(z.object({ postId: z.string().cuid() })) .mutation(async ({ ctx, input }) { await ctx.db.like.create({ data: { userId: ctx.session.user.id, postId: input.postId }, }); return { success: true }; }), });这个list查询不是简单的全表查而是做成了基于游标的分页一次只取 11 条超过限制的那一条用来计算下一页的游标然后弹出这样客户端拿到的永远是 10 条整齐的数据。分页这块很多人偷懒用 offset数据量破万后会越来越慢游标分页虽然 SQL 写起来稍绕但性能是稳定的。likemutation 用了protectedProcedure这是 tRPC 中间件的应用作用是确保只有登录用户才能执行点赞操作。注意我在处理重复点赞的冲突时没有在路由里先查再建那样有竞态条件两个请求同时进来可能都查到“未点赞”然后一个成功一个抛错不如直接 catch 数据库层面的唯一索引冲突或者用upsert更稳妥。3.3 鉴权与用户体系protectedProcedure 的正确姿势鉴权是全栈项目里最容易写歪的部分。t3code 采用的是 Auth.js 的 JWT 策略配合 tRPC 的中间件机制做访问控制。为什么选 JWT 而不选数据库 session因为 JWT 无状态对 API 响应速度影响最小也方便在服务端组件和中间件里同时校验不需要每次请求都查一次 session 表。服务端初始化 tRPC 时核心代码是这样的export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); return { db, session, ...opts, }; };每次请求进来先拿 session再放进 context。这样所有 router 都能通过ctx.session判断当前用户。在根 router 里定义两个基础 procedureexport const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); });protectedProcedure的next返回时把 session 重新塞进 ctx这样下游函数拿到的 session 一定是非空的TypeScript 也能正确收窄类型不需要在业务代码里一遍遍做空值判断。这个设计是我认为 t3code 最值得抄的地方——把通用逻辑收敛在框架层让业务代码保持干净。4. 前端接入与端到端类型安全的完整链路4.1 客户端调用React Query 与 tRPC hooks服务端 router 定义好之后客户端的调用体验是 t3code 最神奇的部分。脚手架在trpc/client.ts里创建了带有类型信息的 tRPC 客户端然后在app/_trpc/Provider.tsx中包裹整个应用use client; import { QueryClient, QueryClientProvider } from tanstack/react-query; import { httpBatchLink } from trpc/client; import { trpc } from /trpc/client; export function TRPCProvider({ children }: { children: React.ReactNode }) { const queryClient new QueryClient(); const trpcClient trpc.createClient({ links: [ httpBatchLink({ url: /api/trpc, }), ], }); return ( trpc.Provider client{trpcClient} queryClient{queryClient} QueryClientProvider client{queryClient}{children}/QueryClientProvider /trpc.Provider ); }这里有个选择需要解释httpBatchLink会把同一时间窗口内的多个 tRPC 请求合并成一个 HTTP 请求发送这是 tRPC 非常聪明的优化尤其适合页面初载时同时拉取多个资源。但要注意如果你的某些接口延迟很高批处理会导致前面的请求等待最慢的那一个所以我会对实时性要求高的接口单独用httpLink不能无脑全局 batch。在页面组件里调用方式是const listQuery trpc.post.list.useQuery({ limit: 10 }); const likeMutation trpc.post.like.useMutation({ onSuccess: () { utils.post.list.invalidate(); }, });useQuery和useMutation都是从 tRPC 客户端根据 router 类型自动生成的你永远不用担心方法名拼错或者参数少传编辑器会直接报错。4.2 服务端组件与 RSC 边界处理Next.js 的 App Router 引入了一个新问题服务端组件里怎么调 tRPC如果直接使用客户端那套 hooks 是不行的因为服务端组件里不能调用useQuery。t3code 的解法是提供一个caller在服务端直接调用 router 的函数不走 HTTPimport { createTRPCContext } from /trpc/server; import { appRouter } from /server/api/root; export const trpcCaller async () { const ctx await createTRPCContext({ headers: await headers() }); return appRouter.createCaller(ctx); };然后在服务端组件里const caller await trpcCaller(); const { posts } await caller.post.list({ limit: 10 });这个设计让服务端组件可以直接复用同一套业务逻辑不需要为了 SSR 另写一套数据获取代码。同时服务端组件的调用不受权限中间件影响吗不是因为createTRPCContext里依然会取 session所以protectedProcedure在服务端调用时也会正常拦截未登录用户。这里有一个边界需要团队达成共识什么数据用服务端组件直接拉什么数据用客户端 hooks 拉我的判断标准是——首屏不依赖交互的数据尽量在服务端组件拿减少客户端请求数和白屏时间而需要响应用户操作后即时更新的数据用客户端 hooks 配合缓存失效机制。这个边界如果不提前约定很容易出现两套逻辑写重的情况。4.3 状态同步与缓存失效策略前端状态管理这块t3code 没有引入 Redux 这类重型状态库而是完全依赖 React Query 的缓存体系。很多人第一次用 React Query 会把所有数据都放进全局 store这其实是心智模型没切换过来。React Query 的核心是“服务端状态缓存”它关注的是数据什么时候过期、什么时候重新拉取、失败怎么重试而不是客户端 UI 状态。点赞这个场景最能说明问题。点赞成功后要做的不是手动修改listQuery里的_count.likes而是调用utils.post.list.invalidate()让缓存失效React Query 会自动重新拉取最新列表。这个写法简洁但有个性能需要注意如果列表查询参数很多invalidate()会失效所有post.list的缓存哪怕只是第 2 页的数据。我的做法是在点赞这类低频操作上使用定向失效invalidate({ queryKey: [post, list] })配合 tRPC 内部维护的路径前缀粒度可以控制到单个 procedure 级别。还有一个细节是 React Query 的staleTime。t3code 里我给默认 query 设置了staleTime: 60_000意味着 60 秒内的数据直接命中缓存不重新拉请求。这个参数调优要看具体业务比如文章详情页可以设长一点而点赞数这种需要实时反馈的数据就要设短一些。5. 部署、性能与常见问题排查5.1 构建之前必须检查的三件事每次准备部署新版本时我会固定跑一遍三件套检查这已经成了肌肉记忆。第一件是pnpm lint它的作用不只是查代码风格更重要的是边界规则会拦截掉那些“客户端组件偷偷 import 服务端模块”的架构越权问题。第二件是pnpm typecheck这个命令在 t3code 里意义更重因为 tRPC 的类型是跨端传播的后端一个字段改动会导致前端多个组件类型报错tsc --noEmit能在构建前把这些错误全部暴露。第三件是pnpm buildNext.js 的构建过程会执行服务端组件渲染很多运行时错误比如数据库连接失败、环境变量缺失反而是在这一步暴露的而不是在跑起来之后。我在团队协作时还加了一条规矩编译产物不能有 warning。很多人对 warning 不以为然但 Next.js 构建时警告某些 chunk 过大、某个插件版本过期这些在项目中期会变成真实的性能问题和安全问题尽早处理成本最低。t3code 的 CI 流水线里如果出现任何 warning构建直接标记为失败这条规则帮我拦下过很多潜在的隐患。5.2 部署形态选择托管平台与自托管t3code 的部署形态我分别试过托管平台和自托管两种各自有明确的适用场景。托管平台的典型路径是直接推 GitHub 仓库平台自动识别 Next.js 项目安装依赖、构建、产出静态资源一切都非常顺滑。数据库方面依然用 Prisma只是把连接串换成了托管数据库实例的地址部署前跑一次prisma migrate deploy把迁移文件应用到生产库。这种方式适合团队没有专职运维、想最快上线验证业务的场景。自托管的典型路径是 Docker 容器。t3code 的 Dockerfile 我采用的是多阶段构建先把依赖安装完成、构建产物生成最后用精简的基础镜像跑 standalone 模式。这里有个关键点Next.js 需要配置output: standalone它会输出一个自带最小 node_modules 的独立目录镜像体积能缩小将近一半。Docker 部署最大的优势是环境完全可控不受平台限制但代价是你需要自己处理日志收集、进程守护、系统更新这些运维杂事。两种方案我都跑过我的建议很简单没有专人运维的团队先无脑选托管平台省下来的时间全部花在业务上等项目规模大到需要自定义网络策略或合规审计时再迁移到自托管也不迟因为 t3code 本身的抽象层保证了代码不需要为部署形态做特殊改造。5.3 典型坑位与排查技巧速查表重构过程中我记录了最常见的坑位整理成速查表这些都是网上文档里不太会写的实战经验。现象根本原因解决方案修改后端类型后前端编辑器提示不更新tRPC 类型是 ts 编译期推导的编辑器的语言服务没刷新重启 TS Server命令面板里执行 “TypeScript: Restart TS Server”部署后登录态失效每次刷新都跳登录页JWT 存储在 Cookie 中跨域或 secure 属性配置不对确认NEXTAUTH_URL与线上域名一致Cookie 的 sameSite 设为lax在 HTTPS 环境下secure为 truePrisma 连接数打满页面间歇性 500Serverless 环境每个请求都新建 Prisma Client没有全局复用在server/db.ts中把 PrismaClient 实例挂到globalThis避免热重载和并发请求重复创建tRPC 在服务端组件里调用报headers()错误App Router 的headers()只能在请求作用域内调用不能在模块顶层执行把createCaller封装成函数在组件内 await 调用而不是在模块加载时执行点赞后列表数据不刷新缓存的 queryKey 没有对准或 invalidate 粒度太粗/太细用utils.post.list.invalidate()精确失效指定 procedure 的缓存生产构建时忘记生成 Prisma ClientPrisma 版本更新后 Client 产物与 schema 不同步在 Dockerfile 或部署脚本里固定执行prisma generate再执行next build这里我特别想展开说一个坑在 Next.js 的中间件middleware.ts中访问 session。Auth.js 默认把 session 存在 cookie 里中间件里通过getToken可以解析出 JWT但如果你在中间件里调用了getServerSession那大概率会失败因为中间件运行在 Edge Runtime没有完整的 Node.js API。我的处理方式是登录态跳转这种粗粒度控制放中间件基于用户角色的细粒度控制放 tRPC 的 protectedProcedure。两条线各干各的不混在一起。另外一个值得警惕的是 tRPC 的 batch 行为在部署后的表现。本地开发时httpBatchLink会把请求合并看起来没事但线上如果 CDN 缓存策略处理不好GET 请求的批处理响应可能会被二次缓存导致用户 A 的数据被用户 B 看到。我在正式环境里对带敏感数据的 GET 请求会显式设置cache: no-store或者在响应头里加Cache-Control: private这个问题如果没有意识到线上出现数据串号会特别难排查。5.4 性能优化与扩展方向t3code 目前的性能优化重点集中在两个方向数据库查询和前端包体积。数据库方面Prisma 的 query 有合理的分页和索引之后剩下的瓶颈往往在 N1 查询。t3code 里我通过include或select一次性加载关联数据杜绝循环里查数据库的写法。比如文章列表要显示作者头像在列表查询时就把它 join 进来而不是循环里逐个查作者。前端包体积方面t3code 默认按路由自动分包但有些公共依赖会被重复加载。我持续用next/bundle-analyzer定期检查构建产物如果发现某个巨大的依赖比如日期处理库或图表库被打进了首屏包就会用动态加载把它挪到交互时再加载。这个优化对首屏性能的影响是肉眼可见的尤其是移动端网络环境下。再往后扩展可以朝三个方向走一是接入 WebSocket 做实时能力tRPC 的 subscription 已经内置了订阅机制可以在现有 router 上平滑添加二是把 schema 与文档沉淀为 OpenAPI用trpc-openapi把某些对外接口暴露成标准的 REST 风格组件方便第三方接入三是引入多租户数据隔离在 Prisma 层通过 middlewares 按租户 id 自动过滤查询而不是在业务代码里到处补 where 条件。这些扩展都不需要推翻现有架构这也是我把 t3code 做成“骨架优先、业务接入”的原因。我做这个项目最大的体会是类型安全不应该靠自觉而应该靠架构设计去兜底。t3code 通过把数据库、API、前端调用串成一条类型闭环让很多低级错误从“运行期暴露”变成了“编译期拦截”这种安全感的提升是写多少代码都换不来的。如果你正准备起一个新的全栈项目建议直接拿这套骨架去试先跑通一个最小闭环再用真实业务去填项目里的每个模块。等你跑完一遍你会感受到前后端共享类型、不再为接口文档和类型定义来回折腾的体验有多么顺滑。