“t3code”并不是某个现成框架的官方代号,而是我最近几个月手里一个全栈项目的内部代号。拆开看就是 T3 + code。T3 在开发者圈子里,通常会指向一套以 TypeScript 类型安全为主线的 Web 全栈范式,核心成员是 Next.js、tRPC、Prisma、Tailwind 这几个工具。我给项目起这个代号,就是想表明这套代码从数据库到按钮,类型是全程贯通的。它解决的最实际的问题只有一个:后端返回什么形状,前端写代码时就看到什么形状,字段名写错、类型对不上、接口文档过期这一类问题,基本可以告别。这篇文章就围绕 t3code 这个项目,讲讲为什么这么选型、完整实现过程,以及我踩过的几个坑。适合正在做全栈项目、被联调和接口文档折磨过的开发者参考。
1. 整体设计与思路拆解
1.1 从“连接”到“合约”:t3code 的核心需求
我刚开始做 t3code 的时候,需求其实特别普通:一个带登录、内容管理和列表查询的小型内部平台。按传统做法,前端一个 React 应用,后端一个 Node API,再写一份接口文档,前后端各维护一套类型。项目小的时候这套流程还能忍,一旦字段开始变化,问题就成串出现:后端把createdAt改成了created_at,前端没同步改;接口返回了{ data: [...] }而前端类型写的是{ list: [...] };联调阶段一半时间不是在解决问题,而是在对齐字段名。
所以 t3code 在设计阶段就没有走传统前后端分离的老路,而是把“前后端之间的连接”升级成了“前后端之间的合约”。这个合约不是手写的接口文档,而是被编译器强制检查的类型系统。后端的过程通过 tRPC 暴露给前端,前端调用它的时候,参数和返回值的类型会自动推导出来,不需要任何额外注解。后端router里定义了返回Post[],前端的useQuery结果就带出Post[]的完整结构,连可选的published字段、createdAt的类型都一目了然。
这套设计解决的不只是联调成本,还包括重构成本和安全成本。类型检查跑一遍,所有引用了改动字段的地方全部标红,线上一处隐藏的接口不匹配 bug 变成了一次本地编译错误。我把这个过程理解为:把一部分原本需要在运行时才发现的问题,提前到了开发阶段解决。
1.2 三条设计原则:类型驱动、契约内聚、最小胶水
t3code 的整个架构是围着三条原则设计的。
第一,类型是唯一事实来源。无论数据存在 PostgreSQL 里,还是经由 API 返回给前端,都以 Prisma Schema 为源头,一次定义,自动推导到前端。我不会为了“灵活”去手写任何重复的 interface,除非真的需要对外暴露独立接口。
第二,业务逻辑尽可能靠近数据访问层。tRPC 的 router 相当于后端入口,但真正的数据处理都封装在 service 函数里,router 只是薄薄的一层转发。这样做的好处是路由可测试性高,而且 tRPC 的类型约束会迫使我把输入输出定义清楚。
第三,尽量减少胶水代码。传统方案里需要写 REST Controller、请求参数校验、DTO 转换、前端 API 封装,这些在 t3code 里大多数被框架本身消除了。tRPC 的输入经过 zod 校验后,编译器天然知道参数类型;Prisma 查询出来的对象直接就是可用数据,不用再 map 成 DTO。
这三条原则并不是为了“酷炫”,而是被真实痛点逼出来的。t3code 项目有过一次中期重构,如果按传统方式,改动涉及几十个文件和两份接口文档;有了类型推导和统一 schema,重构只花了不到一个下午。这种体验一旦习惯了,就回不去了。
1.3 这套方案的边界在哪
当然,t3code 这种方案也不是银弹。它在一些场景下会显得别扭,我提前把这些边界讲清楚,免得有人把它用在不该用的地方。
首先是开放 API 场景。tRPC 是给“自己的前端”用的,调用方必须是一个能跑 TypeScript 策略的类型引导的客户端。如果项目需要给第三方开放公开 API,比如外部开发者调用你的数据,最好还是在 tRPC 外面再包一层 REST 或 GraphQL 网关,把内部过程转成标准接口。强行让外部直接调用 tRPC 是不现实的,因为文档生态、版本管理都不匹配。
其次,重实时通信的项目。tRPC 也有 subscription,但它本质上是基于 WebSocket 的封装,在复杂实时协作场景下,和专门的 Socket.IO、Liveblocks 类方案相比,工具链和客户端生态都更轻量。如果 t3code 的业务里有大量多人协同编辑,我不会把实时部分全押在 tRPC 上。
第三个边界是团队。如果团队里一部分人不熟悉 TypeScript 或没有类型思维,这套方案的优势会大打折扣。类型安全是全链路的,任何一段手写any都可能成为破窗。所以我在团队里定了一条不成文的规矩:新的业务代码禁止出现any,除非带注释说明原因并经过 review。这个是比技术本身更难推的事。
2. 技术栈选型解析
2.1 五个核心组件的分工
t3code 用的技术栈是标准的 T3 组合,但不是所有 T3 项目都完全一样,我的选型有自己的针对性。下面这个表格是项目里五个核心组件各自承担的角色,先整体看一眼。
| 组件 | 在 t3code 中的职责 | 为什么是它 |
|---|---|---|
| Next.js | 页面渲染、路由、SSR/SSG、服务端 API 宿主 | App Router 天然区分 server 和 client 代码,适合类型安全架构 |
| TypeScript | 全链路的类型基础 | 一切类型安全的底层前提 |
| Prisma | 数据库访问、迁移、类型生成 | Schema 是最可靠的数据事实源 |
| tRPC | 前后端过程调用 | 让后端过程以类型安全函数的方式被前端调用 |
| Tailwind CSS | 样式系统 | 减少样式代码,不引入新的运行时 |
不会是必须,但实际项目里我还加了 NextAuth,用来处理登录会话。NextAuth 对 Next.js 的适配最直接,而且 session 类型可以注入到 tRPC 的 context 里,在路由里做权限判断非常顺手。
2.2 关键选型对比
选型的时候,我其实犹豫过几组方案,最后是依靠具体场景定下来的。
Prisma 和 Drizzle、TypeORM 的对比是我花时间最多的一块。Prisma 的类型生成是自动且完整的,schema 文件改了之后跑一下prisma generate,客户端类型立刻同步;Drizzle 质轻、写法贴近 SQL 也很香,但它在类型推导和迁移工具链的整体感上,没有 Prisma 那么贴合“schema 即源头”的思路。TypeORM 我用过一阵,装饰器写法配 Active Record 虽然容易上手,但复杂的查询会变得越来越难维护。对于 t3code 这种需要快速迭代的中型项目,Prisma 的 model 定义和 migration 工作流是最省心的。
前后端调用这块,我在 tRPC 和 GraphQL 之间犹豫过。GraphQL 的代码生成也很强,但它的 schema 定义、resolver 拆分、缓存策略比 tRPC 重不少。t3code 没有多端消费需求,也没有复杂的聚合查询,tRPC 那种“定义过程,直接调用”的模型明显更贴近业务。这里有一个对比:GraphQL 是“你告诉我你要什么字段”,tRPC 是“你调用这个函数,我给你返回它声明的东西”,后者更符合应用开发的直觉。
样式层我没有用 CSS Modules 或 styled-components,而是选了 Tailwind。不是因为 Tailwind 在功能上多强,而是因为它能压缩项目的文件增量,utility class 写起来也快。Tailwind 的类型提示本来就很够用,配合良好的 class 组织习惯,不会出现难以追查的样式覆盖问题。
2.3 目录与代码组织
选完工具之后,我做的第一件事是把目录结构定下来。create-t3-app 默认结构已经比较合理,我在上面做了一些调整,最终长这样:
t3code/ prisma/ schema.prisma src/ server/ db.ts auth.ts api/ routers/ post.ts user.ts root.ts trpc.ts trpc/ server.ts client.ts react.tsx app/ layout.tsx page.tsx features/ post/ post-list.tsx post-create-form.tsx utils/ api.ts .env package.json next.config.mjs这个结构的特点是:server 目录专门放服务端逻辑,trpc 目录放 tRPC 的初始化代码,features 目录按业务模块放前端组件。server/api/trpc.ts负责创建 tRPC 实例,server/api/root.ts汇总所有 router,utils/api.ts构造前端可用的 API 客户端。这样前后端各自的关注点完全隔离,查找修改业务的时候只要顺着 routes 和 features 进去就行。
目录定得好,后面接业务逻辑会顺畅很多。模块之间相互调用大多通过rootRouter暴露的 router 完成,不会出现前端直接引用 server 文件的情况。
3. 实操过程与核心环节实现
3.1 初始化 t3code
我建议直接使用社区维护的脚手架初始化,这样可以省去大量环境配置细节。初始化命令是:
npm create t3-app@latest交互里面我会勾选 Next.js、Tailwind、Prisma、tRPC,认证选择 NextAuth。如果你不想要 NextAuth,后面接权限会有一定麻烦,比如要自己在 context 里手动注入用户信息,所以我还是推荐保留。初始化完成后,进入目录安装依赖并做首次检查:
cd t3code npm install npx prisma migrate dev npm run dev如果一切顺利,浏览器打开http://localhost:3000就能看到一个可用的 t3code 骨架。这个脚手架会自动生成 Prisma client、tRPC server 和 client 的基础代码,以及一个示例的postrouter。
手动安装其实也完全可行,Next.js、tRPC、Prisma 的官方文档都有单步指引,但没必要把时间花在这上面。脚手架生成的结果是清晰可读的,没有黑魔法,改起来也顺手。
3.2 用 Prisma 固化数据模型
t3code 里数据模型是整个类型体系的源头。我建议先从业务实体的关系入手,梳理出User、Post、Tag三个核心模型。schema 文件里做如下定义:
model User { id String @id @default(cuid()) name String? email String @unique posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String? // 允许空内容,用于草稿 published Boolean @default(false) author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) authorId String? tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }这里我特意用了cuid()而不是自增整数 id。自增 id 会暴露业务规模,而且在分布式环境下生成会有冲突风险;cuid()是客户端可生成的,写测试、做批量刷新都比较方便。主键类型确定后,执行迁移:
npx prisma migrate dev --name init npx prisma generate迁移会生成 SQL 文件和对应的客户端代码。之后每次修改 schema,都要跑这两条命令,让 Prisma 客户端保持最新。这里有个小细节:在开发环境可以用prisma migrate dev,生产环境迁移我更喜欢用prisma migrate deploy,前者可能会触发 seed,后者只执行挂起的迁移,不会误伤生产数据。
3.3 用 tRPC 把后端过程“变成”前端函数
tRPC 的典型用法是先初始化一个实例,再通过 router 声明过程。t3code 的src/server/api/trpc.ts大致是这样的:
import { initTRPC, TRPCError } from '@trpc/server'; import type { Context } from '../trpc/context'; const t = initTRPC.context<Context>().create(); export const router = t.router; export const publicProcedure = t.procedure; // 一个带登录校验的中间件示例 const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { ...ctx, session: ctx.session } }); }); export const protectedProcedure = t.procedure.use(isAuthed);注意这里Context是在src/trpc/context.ts里创建的一个对象,它把 Prisma 客户端和 NextAuth 会话挂载上去。所有的过程都能从这个 context 里取到数据库实例和当前登录用户。
然后创建业务 router,比如src/server/api/routers/post.ts:
import { z } from 'zod'; import { router, publicProcedure, protectedProcedure } from '../trpc'; export const postRouter = router({ getPublished: publicProcedure.query(async ({ ctx }) => { const posts = await ctx.prisma.post.findMany({ where: { published: true }, include: { author: true, tags: true }, }); return posts; }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().optional(), tagNames: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });这一段有几层信息需要理解。publicProcedure.query表示这是一个只读过程,任何人都能调用;protectedProcedure.mutation表示这是一个需要登录的写操作。zod在这里不仅做运行时校验,还作为类型来源帮你推导 input 的类型,ctx.prisma供给给数据访问。整个 router 的定义非常集中,一眼就能看出每个接口的入参、出参和权限策略。
汇总所有 router 的root.ts也很简单:
import { router } from './trpc'; import { postRouter } from './routers/post'; import { userRouter } from './routers/user'; export const appRouter = router({ post: postRouter, user: userRouter, }); export type AppRouter = typeof appRouter;export type AppRouter = typeof appRouter这一行特别关键。前端客户端需要的类型全部由它驱动,你不需要手写任何 API 类型,这就是 t3code 类型闭环的终点。
3.4 前端调用与类型推导
前端调用在src/utils/api.ts注册一个 React 客户端:
import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '../server/api/root'; export const api = createTRPCReact<AppRouter>();在根布局包一个TRPCReactProvider,然后页面里直接就能使用了:
import { api } from '~/utils/api'; export default function PostList() { const { data: posts, isLoading } = api.post.getPublished.useQuery(); if (isLoading) return <div>加载中...</div>; return ( <ul> {posts?.map((post) => ( <li key={post.id}> {post.title} — {post.author?.name ?? '匿名'} </li> ))} </ul> ); }写这段代码的时候,posts的类型会自动带上Post & { author: User | null; tags: Tag[] }的结构。编辑器里点一下post.,候选字段直接弹出来,不存在“忘了后端返回什么字段”的情况。创建操作的写法也类似,input类型和校验规则完全与后端一致:
const createPost = api.post.create.useMutation(); await createPost.mutateAsync({ title: '标题', content: '内容' });如果输入缺少title,编译器会直接标红,因为 zod schema 推导出的类型已经有title: string的约束。这种前后端契约的一致性,是靠 tRPC 的类型推导实现的,而不是靠开发者记住。
3.5 权限中间件与部署配置
权限是业务里跑不掉的环节。t3code 使用 protectedProcedure 之后,跨页面的权限校验逻辑就集中在了 tRPC 层。比如只有作者能删除自己的文章:
deletePost: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const post = await ctx.prisma.post.findUnique({ where: { id: input.id } }); if (!post || post.authorId !== ctx.session.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }); } return ctx.prisma.post.delete({ where: { id: input.id } }); }),部署的时候有一个容易踩坑的点:serverless 环境(比如 Vercel)是无状态的,每次请求都会新开一个运行环境,数据库连接不能像传统 Node 服务一样用一个长连池。t3code 正是在 Vercel 上部署,我把 PrismaClient 做成了全局单例,避免每个请求都重新创建连接:
import { PrismaClient } from '@prisma/client'; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== 'production') { globalForPrisma.prisma = prisma; }同时还要设置环境变量。DATABASE_URL是数据库连接的必需项,NEXTAUTH_SECRET用于签名会话 cookie,NEXTAUTH_URL指定部署后的正式域名。这三项在开发和生产环境必须分开维护,千万别把.env提交到 git 仓库。
4. 常见问题与排查实录
4.1 类型不更新:提前阻断 vs 事后排查
t3code 开发过程中最常遇到的一类问题是“改了 schema 或者 router,但前端拿到的类型还是旧的”。这通常是因为 Prisma client 没有重新生成,或者开发服务器没有完全重启。我的实用建议是:
改完prisma/schema.prisma之后,先跑npx prisma generate,再跑npm run dev。如果你用的编辑器是 VSCode,有时 TypeScript server 会缓存类型,按Ctrl+Shift+P手动重启 TypeScript Server 就能生效。
tRPC 类型的“陈旧感”还可能是升级依赖后造成的。tRPC v10 和 v11 的 API 有些差异,如果你从老版本升级,很多.query()调用方式会改变,类型报错会遍布整个项目。这种问题没有快速解法,只能仔细看 changelog。一个有效的预防措施是把版本锁定在package.json的精确版本号,不要用^或~,不然小版本升级都可能导致类型行为变化。
4.2 数据库连接被打满
这个问题在 serverless 部署后特别明显。传统 Node 服务启动一次只创建一个连接池,而 serverless 会为每个并发请求创建实例,每个实例都会尝试建立新的数据库连接。连接数一多,数据库甚至会拒绝服务,报错信息类似P1001: Can't reach database server或连接超时。
解决方向有两个。一是把 PrismaClient 挂到全局对象上,复用已经建立的实例,这是代码层的做法。二是控制并发,如果是并发量很大的应用,可以考虑给数据库换连接池模式,或者把异步用到的数据库查询拆出去。对于 t3code 这个量级,全局单例加环境变量配置已经能解决 95% 的问题。千万不要在生产环境里直接new PrismaClient()放在每个函数里。
4.3 SSR 水合不一致
当页面在服务端和客户端渲染结果不一致时,Next.js 会启动失败,报错信息里会提到Hydration failed because the server rendered...。t3code 里最容易出现这个问题的地方是页面上用了api.post.getPublished.useQuery(),服务端渲染时数据已经加载好,但客户端初始化时先渲染了 loading 状态,两者就产生了差异。
解决办法是给useQuery提供initialData,或者使用 tRPC 的 SSR helper 在服务端把数据注入到页面。t3code 采用的方式是先用serverClient在服务端获取数据,然后把数据通过 props 传给客户端:
import { serverClient } from '~/trpc/server'; export default async function Page() { const posts = await serverClient.post.getPublished(); return <PostList initialPosts={posts} />; }这样服务端已经把 posts 拿到并渲染到了 HTML,客户端直接用同一份数据,不需要等待接口返回,水合一致性也能保持。
4.4 依赖升级引起的类型爆炸
t3code 运行一段时间后,依赖升级是绕不开的。最常见的坑发生在@prisma/client和prisma两个包版本不一致时。很多人只升级了prismaCLI,没有同步升级@prisma/client,结果类型和实际生成的数据库方法不匹配,到处报错。记住一条铁律:这两个包必须保持完全相同的版本号。
另一个坑在 Next.js 的大版本升级。App Router 的 API 变化较快,服务端组件的写法、params属性是否被 Promise 包裹等细节都可能造成类型错误。我的建议是不要盲目用npm install next@latest升级,先用官方提供的升级工具跑一遍,看看迁移说明,再动手。升级后第一件事就是跑npx tsc --noEmit检查整个项目的类型健康度,把所有报错清零再继续开发。
4.5 问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 前端类型没有新字段 | Prisma client 未重新生成 | 检查node_modules/.prisma/client时间戳 | 执行npx prisma generate |
| 数据库连不上 | DATABASE_URL未设置或错误 | 查看.env和日志 | 确认数据库连接串,测试连通 |
| 接口提示未授权 | session 未写入 context | 检查 NextAuth 配置 | 确认NEXTAUTH_URL和 secret |
| 页面渲染不一致 | 客户端初始化状态和 SSR 不同 | 查看控制台水合警告 | 使用initialData或 serverClient 预取 |
| 类型报错集中在升级后 | 依赖大版本变更 | npm run type-check定位 | 锁版本,按 changelog 迁移 |
| 写入时提示字段不存在 | schema 未同步迁移 | 查看数据库迁移状态 | npx prisma migrate dev |
排查这类问题,我的口诀是“先看类型,再看连接,最后看运行环境”,顺序不要反。类型层面解决的问题不要在运行时去找,运行时的问题也不要一开始就去改类型。
最后说点我个人的实际操作体会。t3code 这个项目让我印象最深的地方,不是它跑得多快,而是开发过程中我很少再出现“想当然”的错误。每一次字段变更、每一次权限调整,都会被编译器提前把关。哪怕是新手,只要被这套类型系统保护着,也很难写出默默不吭声的 bug。它让“代码和真实数据结构保持一致”从一种自觉变成了系统默认。如果以后我开新项目,只要场景合适,大概率还是会复用这套 T3 路线。类型安全这件事,最吃亏的不是搭起来麻烦,而是后续每一处不确定性都在替你买单。