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 LTS,pnpm 作为包管理器。之所以选 pnpm,不只是因为安装快,更重要的是它严谨的依赖隔离机制,能提前暴露隐式依赖问题,这在长期维护的项目里能省下不少莫名其妙的 bug。
初始化命令很简单:
pnpm create t3-app@latest交互式命令行会让你选择需要的模块,我的建议是:Next.js 选 App Router,TypeScript、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 里定义两个基础 procedure:
export 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 的函数,不走 HTTP:
import { 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 build,Next.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为 true |
| Prisma 连接数打满,页面间歇性 500 | Serverless 环境每个请求都新建 Prisma Client,没有全局复用 | 在server/db.ts中把 PrismaClient 实例挂到globalThis,避免热重载和并发请求重复创建 |
tRPC 在服务端组件里调用报headers()错误 | App Router 的headers()只能在请求作用域内调用,不能在模块顶层执行 | 把createCaller封装成函数,在组件内 await 调用,而不是在模块加载时执行 |
| 点赞后列表数据不刷新 | 缓存的 queryKey 没有对准,或 invalidate 粒度太粗/太细 | 用utils.post.list.invalidate()精确失效指定 procedure 的缓存 |
| 生产构建时忘记生成 Prisma Client | Prisma 版本更新后 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 有合理的分页和索引之后,剩下的瓶颈往往在 N+1 查询。t3code 里我通过include或select一次性加载关联数据,杜绝循环里查数据库的写法。比如文章列表要显示作者头像,在列表查询时就把它 join 进来,而不是循环里逐个查作者。
前端包体积方面,t3code 默认按路由自动分包,但有些公共依赖会被重复加载。我持续用@next/bundle-analyzer定期检查构建产物,如果发现某个巨大的依赖(比如日期处理库或图表库)被打进了首屏包,就会用动态加载把它挪到交互时再加载。这个优化对首屏性能的影响是肉眼可见的,尤其是移动端网络环境下。
再往后扩展,可以朝三个方向走:一是接入 WebSocket 做实时能力,tRPC 的 subscription 已经内置了订阅机制,可以在现有 router 上平滑添加;二是把 schema 与文档沉淀为 OpenAPI,用trpc-openapi把某些对外接口暴露成标准的 REST 风格组件,方便第三方接入;三是引入多租户数据隔离,在 Prisma 层通过 middlewares 按租户 id 自动过滤查询,而不是在业务代码里到处补 where 条件。这些扩展都不需要推翻现有架构,这也是我把 t3code 做成“骨架优先、业务接入”的原因。
我做这个项目最大的体会是:类型安全不应该靠自觉,而应该靠架构设计去兜底。t3code 通过把数据库、API、前端调用串成一条类型闭环,让很多低级错误从“运行期暴露”变成了“编译期拦截”,这种安全感的提升是写多少代码都换不来的。如果你正准备起一个新的全栈项目,建议直接拿这套骨架去试,先跑通一个最小闭环,再用真实业务去填项目里的每个模块。等你跑完一遍,你会感受到前后端共享类型、不再为接口文档和类型定义来回折腾的体验有多么顺滑。