一直在想怎么把手里这套全栈流程固化下来,后来干脆做了一个叫 t3code 的实践项目。它不算什么颠覆性的框架,更像是把我这几年做全栈开发时最顺手、最省心的一套组合拳装进一个可复用的工程骨架里。如果你已经厌倦了在前后端接口联调中反复对齐字段,或者在 Next.js、数据库、类型系统之间来回做桥接,那这篇文章大概率对你有用。
t3code 的定位很简单:以 TypeScript 为绝对核心,串起 Next.js、tRPC、Prisma 和 Tailwind CSS,让数据模型、API 的过程和前端组件三者共享一套类型。项目本身附带了一个典型的业务模块(比如带权限区的用户管理),可以直接作为中后台应用的起步模板。文章里会讲清楚每个环节为什么这么选,也会把自己在真实开发中踩过的坑一并交代出来,适合已经能独立写 CRUD 接口、但想进一步减少重复劳动的前端或全栈开发者参考。
1. t3code 解决的核心痛点:类型断裂、配置碎片化、慢在联调
1.1 一个典型场景:改字段名引发的连锁反应
先说一个几乎人人经历过的场景:数据库里有个username字段,产品因为业务调整要改成nickname。在传统的前后端分离架构里,你需要依次改动数据库表、后端实体类、DTO、Controller 层、API 文档、前端类型定义、前端页面里的表单 submit 和详情展示。哪怕只是加一个avatar字段,上述流程同样要全部走一遍。如果文档更新不及时,前端同事看的是旧文档,后端已经改了接口,联调阶段光是“你按文档写、我按代码写”的冲突就会消耗整整半天。
我在 t3code 里想解决的,就是把这套流程中“人肉记忆接口字段”的部分彻底拿掉。数据模型写一次,其他部分通过类型推导自动同步,改一个字段时数据库和检查器同时提示你哪些地方需要跟着改,而不是等到运行时才发现某个字段在 API 响应里变成了 undefined。
1.2 配置碎片化带来的隐性成本
另一个容易被低估的问题是配置碎片化。一个全栈项目往往要同时维护 tsconfig、eslint、dockerfile、CI 脚本、数据库迁移工具、环境变量 sample,这些配置分散在package.json、.env.example、next.config.js等各层文件里。新人接手项目时需要把这些零散约定拼起来,才能弄清楚一个请求是怎么从页面代码走到数据库的。
t3code 的核心思路是“链路式约定”:一条链路上用到的工具全部放在同一个项目里,用 TypeScript 编译器作为验证枢纽。页面上调用一个函数,函数的参数类型和返回值类型来自数据库模型,任何一步不匹配都会在编译阶段亮红灯,而不是等到线上报错才从监控系统里排查是前端页面还是后端服务的问题。这个逻辑听起来像是业界常说的 end-to-end type safety,但实际做起来我发现它带来的收益远不止类型安全本身,更多的是一种“敢大胆改代码”的信心。
2. t3code 的技术选型:不追新,而是各取所长的实用主义组合
t3code 不是发明了一套新东西,而是把几个已经被大规模验证的库重新组合。选型时我反复纠结过几条路线,最终敲定了下面这套,表格里也列了为什么不选备选方案的理由。
| 层 | 选型 | 备选方案 | 不选备选方案的主要原因 |
|---|---|---|---|
| 应用框架 | Next.js(App Router) | 纯 Vite + React SPA | 需要服务端能力,且 t3code 里包含轻量 API 层,App Router 的 server component 能和 tRPC 优雅共存 |
| 数据库访问 | Prisma | TypeORM / Drizzle | Prisma 的 schema 文件配合 migrate 的工作流最贴近“数据模型即类型源头”的设计目标 |
| API 层 | tRPC | REST / GraphQL | REST 需要手写两端类型定义,GraphQL 的 codegen 又多了一步生成链,tRPC 能满足原生的类型传递 |
| 样式方案 | Tailwind CSS | styled-components / CSS Modules | 组件原子化配合设计 token 的维护成本最低,不需要单独维护样式类型 |
| 校验方案 | Zod | class-validator / yup | 与 tRPC 的 input parser 天然衔接,且 schema 可以直接复用给前端表单校验 |
2.1 为什么选择了 Next.js 而不是直接裸 React
t3code 的定位里有服务端渲染需求。很多人会想,做管理后台为什么还需要 SSR?只做纯前端不行吗?实际上,即使是中后台,也存在首屏性能敏感的场景,比如给客户做的数据看板,用户从邮件里点链接进去,第一眼看到的是白屏还是完整的图表,直接决定了这次演示的效果。Next.js 的 App Router 在这里扮演的角色不只是 SSR 框架,它还能让我把路由、布局、loading 状态都放进同构文件里,不用单独引入 react-router-dom 再配置一层。
同时,Next.js 是 tRPC 官方文档里支持最完善的框架。tRPC 的 server 端 adapter 在 Next.js 里只需要一个app/api/trpc/[trpc]/route.ts文件就能挂上,写法几乎就是模板级别的稳定。选 Next.js 最大的额外收获是它的 Server Component 可以直接读取数据库或者调用 service 层函数,有些页面完全不需要从浏览器发请求到 API 层,省掉了一次网络往返。
2.2 tRPC 的取舍:我能得到什么、放弃了什么
在 t3code 里,核心的 API 层逻辑全部走 tRPC。它的工作方式不像 REST 那样先定义 URL 再做映射,而是让你直接导入一个函数并调用它。客户端拿到的是一个trpcRouter,里面每个 procedure 都有自己的输入校验和输出类型。
这个取舍相当实用。过去我写 REST 接口时,每个接口都要考虑GET /user/:id和POST /user的返回结构要不要统一包一层{ code, data, message }这种状态码包装。用 tRPC 后,这些约定变成了配置文件,甚至可以省略。响应数据直接就是模型类型,出错的场景由 tRPC 的 error formatter 统一处理。同时,因为 tRPC 过程是 RPC 风格,页面端调用看起来就像本地函数,回调时的 debug 体验反而比 REST 更直接。
不过代价也有:tRPC 本质上绑定 TypeScript,如果你的客户端不是 TypeScript(比如某些外链的轻量脚本),就会出现访问不顺畅的情况。这个约束对 t3code 这种纯 TS 项目不是问题,但对想开放旧体系接口的团队,确实要提前评估。
2.3 Prisma 与 Zod 的双层校验怎么写才合理
虽然 Prisma 在数据库层自带@db级别的约束,但很多业务限制它管不到。典型情况:title字段不能为空字符串、age必须在 1 到 120 之间,这些需要用业务层校验。t3code 的做法是:数据库层只做存根约束(类型、唯一、外键、长度),业务输入层用 Zod 定义 schema 传到 tRPC 的 input 里再做强校验。
两层都写校验听起来像重复劳动,但各自的职责边界其实是清晰的。Zod 负责“进得了 API 门”的规则,Prisma 负责“落得了数据库”的规则。比如用户的邮箱格式是否合法属于 Zod 管,邮箱字段是否有唯一索引属于 Prisma 管。把这两层分开以后,前端表单可以直接用同一个 Zod schema 做客户端实时的输入反馈,后端就不用再写一套重复的手工 validate 逻辑,实用价值很高。
3. t3code 的项目落地:目录结构、数据模型与核心链路实现
接下来进入正题,展示 t3code 里一个真实业务模块——用户管理模块——从数据库到页面的完整落地路径。你自己的项目如果要做功能扩展,完全可以拿这条链路作为参照。
3.1 目录结构设计:让类型传递路径一目了然
t3code 采用了一个依赖方向透明的目录结构,我尽量让每个人走到项目里都能一眼看出“类型源头在哪里、API 过程在哪里、页面在哪里”。
prisma/ schema.prisma src/ app/ api/trpc/[trpc]/route.ts layout.tsx dashboard/ page.tsx ... server/ db.ts trpc.ts routers/ user.ts post.ts procedures/ protectedProcedure.ts validators/ userSchema.ts components/ user-form.tsx user-table.tsx utils/ trpc.tsprisma/schema.prisma是整个仓库的类型源头。src/server/db.ts负责初始化 PrismaClient,src/server/trpc.ts负责创建 tRPC 实例,src/server/routers/user.ts暴露业务过程,src/validators/userSchema.ts定义 Zod 输入约束,src/utils/trpc.ts是客户端的 typed 封装。数据流向是从prisma/schema.prisma出发,经过 server 层中间件,最终到达components组件,全程不用额外生成一份接口描述文件。
3.2 Prisma 数据模型设计的几个细节
用户模块涉及角色和权限,我在 schema 里设计了User和Role两个模型。下面这个定义里藏着三个容易踩坑的点。
model User { id String @id @default(cuid()) email String @unique name String? password String roles Role[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("users") } model Role { id String @id @default(cuid()) name String @unique // 权限字段按业务需要扩展,例如 permissions String[] }第一个坑是关于 ID 生成:cuid 比自增 int 更适合分布式部署。自增 ID 在数据库扩容或分表时会暴露业务量,而 cuid 在生成层面就避免了碰撞。
第二个坑是@@map的作用:表名在数据库中显示为users,但 Prisma Client 里依然用User访问。如果你直接把模型名命名为user,在 PostgreSQL 里 app 与表名同名的冲突问题会很频繁,@@map可以防止模型名和库内保留词撞车。
第三个坑是updatedAt的维护:Prisma 会自动维护@updatedAt字段,开发者不需要在 update 时手动刷新,这个约定在出问题时经常被忽略。很多人查 bug 时发现数据库里updatedAt没变,以为是更新逻辑没走,实际上只是因为手动插入数据时没使用 Prisma client 而是直接改了库。
3.3 tRPC 路由的完整写法与错误处理
server 端 route 是 tRPC 的核心。t3code 里的userRouter包含list、getById、create、update、delete五个 procedure,覆盖了常规 CRUD 的全部操作。最核心的是create这个写操作,它的写法能体现 tRPC 的输入校验与错误处理结合的典型模式。
// src/server/routers/user.ts import { z } from 'zod'; import { protectedProcedure, publicProcedure, router } from '../trpc'; import { db } from '../db'; import { hashPassword } from '../../utils/password'; const createUserSchema = z.object({ email: z.string().email(), name: z.string().min(1).max(50).optional(), password: z.string().min(8), }); export const userRouter = router({ list: protectedProcedure .query(async () => { return db.user.findMany({ include: { roles: true }, }); }), getById: protectedProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ input }) => { return db.user.findUnique({ where: { id: input.id }, include: { roles: true } }); }), create: protectedProcedure .input(createUserSchema) .mutation(async ({ input }) => { const exists = await db.user.findUnique({ where: { email: input.email } }); if (exists) { throw new TRPCError({ code: 'CONFLICT', message: '该邮箱已被注册', }); } const hashed = await hashPassword(input.password); return db.user.create({ data: { email: input.email, name: input.name, password: hashed, }, }); }), });整个过程里,第一层是 Zod 对入参做基础格式校验(邮箱格式、长度、必填),第二层是业务规则校验(邮箱唯一性)。protectedProcedure意味着这个过程必须通过登录鉴权才能调用,具体鉴权逻辑在src/server/trpc.ts里通过 middleware 实现。
这里有一个容易忽略的细节:Prisma 中create返回值默认不会包含roles关联模型。如果你希望创建完成后立刻组装出完整的用户对象,需要手动补一次查询,或者在 service 层再findUnique一次,否则前端拿到的roles字段是 undefined。我刚开始写的时候就是因为少补这一步,前端展示角色列时总是先显示空白再刷新出来,白白折腾了半天。
3.4 前端调用:用 utils 保持组件层的类型干净
前端组件的调用方式和写本地 async 函数几乎没有区别,全靠在src/utils/trpc.ts里封装的一个trpc对象。
// src/utils/trpc.ts import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '../server/routers'; export const trpc = createTRPCReact<AppRouter>();组件里最常见的用法是这样:
// src/components/user-table.tsx 'use client'; import { trpc } from '@/utils/trpc'; export function UserTable() { const { data, isLoading, refetch } = trpc.user.list.useQuery(); if (isLoading) return <div>加载中...</div>; return ( <ul> {data?.map((user) => ( <li key={user.id}>{user.email}</li> ))} </ul> ); }useQuery返回的data类型是 Prisma 里User模型的完备类型。前端不需要再手动声明interface UserResponse { id: string; email: string; ... },这就是类型继承带来的最大收益。当prisma/schema.prisma里字段发生变化时,编辑器的类型检查会直接指向组件里使用旧字段的位置,几乎不可能出现“后端改了,前端忘了改”的情况。
不过要注意的是,所有用trpc的组件必须标记为'use client'或者在 client 组件内使用,不能在 Server Component 里直接调用trpc的原生 hook。这一点在混用 App Router 时太容易出错了,下面专门讲一下我踩的坑。
4. 实测踩坑记录:类型同步问题、Server Component 混用、错误边界
t3code 本身没有实现太复杂的业务逻辑,但正因为链路完整,开发过程中踩的几个坑反而很有代表性,每个都是真实生产环境会见到的问题。
4.1 坑一:Prisma 生成客户端与 tRPC 类型不同步导致红色波浪线
第一次跑通用户列表时,前端页面报了一个类型错误:Property 'roles' does not exist on type 'User'。我检查了prisma/schema.prisma,明明定义了roles,但编辑器就是提示不存在。
排查后发现,这个报错发生在prisma generate生成的客户端代码里,而新版 Prisma 默认生成的类型并没有自动把关联模型include的返回类型推到前端。要想让Prisma.validator返回精确的关联类型,需要显式用Prisma.UserGetPayload或在findMany里使用include后显式标注返回值,否则类型检查器会把include结果泛化成一个基础User类型。
解决方式是在list过程里显式声明返回类型。比如:
type UserWithRoles = Prisma.UserGetPayload<{ include: { roles: true } }>;然后把这个UserWithRoles作为query的返回声明,前端拿到的就是一个带roles的完整类型。这个坑提醒我,Prisma 不像 mongoose 那样单纯用运行时对象描述 schema,类型层面的精确性需要自己调整写法的粒度。
4.2 坑二:Server Component 里直接调用 trpc 的 query 导致只渲染 loading
我在一个服务端页面里想直接读取用户列表,于是把useQuery用在了 server component 里,页面渲染时一直停留在 loading。原因很简单:tRPC 的 React Query 集成依赖 client 侧的 provider,server component 无法响应 client 状态更新。
正确的做法有两种:一是在 server component 里直接调用 tRPC server 侧的 caller 获取数据,另一种是用一个通用的 client 容器组件包裹。第一种会让整个服务端页面变成同步数据源,效率高但拿不到 React Query 的缓存和重试能力;第二种则保留了完整的缓存状态,适合交互性强的区域。
我最终采用的方式是:页面级骨架使用 server component,直接调用 service 层函数或 tRPC caller 拿首屏数据;局部交互区(如表格、表单、筛选器)使用'use client'组件包上 tRPC hook。这样既保证了首页首屏不白屏,也保留了后续筛选、分页的响应式更新能力。
4.3 坑三:错误处理边界,前端拿不到 tRPC 抛出的业务错误
在用户创建接口里,我抛了TRPCError({ code: 'CONFLICT' })。前端也写了error.message的展示,但页面里却一直显示一个笼统的网络错误,看不到“该邮箱已被注册”的具体提示。
查了一下 tRPC 的配置,才发现默认的errorFormatter会把内部错误信息抹掉,只保留一个通用的message。如果想让前端拿到我们自己抛出的错误细节,需要在 server 端配置 errorFormatter 把message透传出来。
// src/server/trpc.ts export const trpc = createTRPCContext({ errorFormatter({ shape, error }) { return { ...shape, message: error.message, }; }, });透传时要注意安全,不要让内部 stack 或 Prisma 原始错误一起泄出去。我的做法是只对TRPCError做 message 透传,其他未知错误仍然统一成“Internal Server Error”。这个配置的好坏直接影响前端调试成本,值得花时间仔细处理。
5. 部署与性能优化:把 t3code 从开发机搬到生产环境
代码能跑通只是第一步。t3code 项目里我整理了 Dockerfile、迁移流程和缓存策略,让它可以平滑地部署到小规模云服务器或容器平台。
5.1 Dockerfile 的构建链路与常见坑
Next.js 项目最容易在 Docker 构建时遇到依赖安装时机的问题。我的 Dockerfile 采用多阶段构建,核心思路是:先装完整依赖做构建,再在运行阶段只安装生产依赖。
# 阶段一:构建 FROM node:20-alpine AS builder WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm prisma generate RUN pnpm build # 阶段二:运行 FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV production COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/prisma ./prisma CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]这里有一个需要提前预判的问题:server.js是 Next.js standalone 模式的产物,需要先在next.config.js里启用:
const nextConfig = { output: 'standalone', }; module.exports = nextConfig;如果不开启 standalone 模式,.next目录里就没有可以直接执行的 server 脚本,你在容器里还得用next start再补环境,不如 standalone 模式轻量。另外,如果你的镜像里需要执行prisma migrate deploy,一定要确保prisma/schema.prisma和生成的 migration 文件被拷贝进运行阶段镜像里,否则部署时会提示找不到 schema 文件。
5.2 数据库迁移的安全流程
开发机上的数据库和线上数据库肯定不是一套。t3code 里的流程是:开发环境修改prisma/schema.prisma后,在本地执行prisma migrate dev生成新的 migration 文件;代码提交后,在 CI 或部署脚本里执行prisma migrate deploy把新的迁移应用到生产库。
核心原则是:migration 文件必须经过 review,不允许任何人在生产环境的数据库里直接手工执行 SQL 改表。这个原则在团队协作中尤其重要,因为一个小字段的类型变更,可能伴随数据清洗或者索引调整,直接在线上执行 SQL 极易出错且不可回滚。
5.3 缓存与性能策略的实际效果
对中后台项目来说,数据库查询压力一般不会特别大,但页面响应速度仍然可以直接影响用户体感。t3code 的客户端用 React Query 做数据缓存,trpc.user.list.useQuery()的结果默认会被缓存,切换页面回到列表时不会重新拉取数据。
在服务端,我针对几个高频查询做了内存缓存,主要用 Prisma 的中间缓存思路:比如用户的权限列表几乎不会频繁变化,可以缓存 5 分钟。这个实践不是简单的加cache: true,而是利用 tRPC 的 middleware 对getById这类读操作做一个简单的 memory cache,命中后直接返回,减少数据库压力。
实测中,列表页从数据库直出每次约 120ms,加上缓存后同类请求几乎降到 2ms 以内。不过也要注意,写操作之后要主动失效缓存,t3code 里的做法是refetch相关 query 或调用api.invalidate()。如果缓存策略做得太粗,会出现修改用户信息后列表仍然显示旧数据的明显 bug。
6. 从 t3code 延伸出的实用想法
做完 t3code 之后最大的感受是,所谓全栈类型安全并不是什么高不可攀的理论,而是一种工程编排上的选择。数据模型、API、前端组件三者如果使用同一套类型源头,工作方式的改变是实实在在的。
6.1 什么时候该考虑放弃 tRPC 的强类型约束
tRPC 不是所有场景的银弹。如果你的 API 需要对外开放,给非 TypeScript 项目或者第三方调用,REST 或 GraphQL 依然具备更好的兼容性。tRPC 最适合的场景是内部调用:前后端同仓,且你希望快速迭代业务模块。t3code 内部所有页面与 tRPC 过程都是内聚的,破坏面只在代码仓库内部。
另外,如果你的项目需要极低延迟的纯边缘计算部署,tRPC 的过程需要额外打包一份 server adapter,体积和使用体验未必优于一个简单的 REST JSON 接口。判断标准很简单:调用方是不是你同一仓库内的 TypeScript 代码。是,就放心用 tRPC;不是,就要谨慎了。
6.2 后续扩展方向
我计划在 t3code 里再补两个模块:一个是带审批流的任务工单模块,用来验证复杂状态流转时类型安全如何支撑枚举和角色权限;另一个是轻量版的仪表盘模块,用来展示聚合查询与前端图表组件之间的类型打通。
如果你手里也有一些重复劳动率较高的内部中后台项目,完全可以拿 t3code 的思路自己拼一套。先建一个最小可运行的全栈骨架,再加业务模块,等疆域稳定下来了再考虑要不要把某些过程拆出去。全栈类型安全的收益,只有当你亲手动过几个实际业务模块之后才会感知到,它减轻的不是写代码时的疲惫感,而是每到联调或者重构阶段涌上来的那种不确定性。