说实话,接 t3code 这个项目的时候,客户技术负责人问我的第一句话是:“你们打算怎么保证前后端字段对不上?”当时我愣了一下,因为这个问题太常见了,常见到大多数人已经默认它无法根治。过去这几年,我经手的项目里,联调阶段最耗时间的从来不是功能逻辑,而是改接口文档、对字段名、调参数类型。所以我把新项目代号定为 t3code,核心思路只有一条:整条链路用 TypeScript 打通,让后端定义一次类型,前端自动继承。这篇文章就记录我从零搭建这个全栈项目的完整过程,包括技术选型依据、脚手架细节、数据链路打通方法,以及几个实测翻车后的补救方案。如果你正准备做一个前后端频繁迭代的中小型应用,或者想把 Next.js、Prisma、tRPC 这条链路跑通,这篇文章应该能帮你省下大量试错时间。
1. t3code 的起点:为什么“全栈 TypeScript”成了我的默认答案
1.1 前后端类型割裂的痛,比想象中更贵
先说说我为什么对“前后端各写一份类型”这件事这么敏感。传统的前后端分离项目里,后端用 Java 或 Node 定义一套 DTO,前端再用 TypeScript 手写一套 interface,两者之间靠接口文档维持脆弱的一致性。问题是文档会过期,字段会改名,同一个字段在不同接口里可能叫createdAt,也可能叫createTime,甚至同一个项目里两种写法并存。
我印象最深的一次事故:后端把一个列表接口的分页参数从pageSize改成了limit,前端没有同步更新,结果上线后列表页第二页永远加载不出来,而且因为接口返回 200,监控根本没有告警。最后是用户反馈才发现。那次排查花了两天,原因是团队里没人意识到这个字段在三个地方被引用。
这类问题本质上是“信息的重复维护”。只要同一份类型定义出现在两个地方,就一定存在它们不同步的时刻。t3code 想解决的就是这个:让类型定义只存在于后端,前端通过工具链直接继承。
1.2 T3 组合拳:tRPC 负责类型传递,Prisma 负责数据契约
T3 这个名字本身就来自 Typesafe、Tailwind、tRPC 三个以 T 开头的关键词。这整套组合的核心逻辑是:每个环节都找一个“类型源头”,其余环节自动对齐。
- Prisma:schema 是唯一的数据库契约。表结构、关系、枚举都写在这里,生成的 client 自带完整类型。
- tRPC:后端定义的路由(procedure)会被自动推导成前端的调用函数,输入输出类型完全同步。
- Next.js:作为应用外壳,提供服务端渲染、路由和 API 承载能力。
- NextAuth.js:统一鉴权,session 类型可以一路传到数据库查询条件里。
- Tailwind CSS:处理样式,减少类名冲突和维护成本。
如果让我用一句话总结这套方案的价值,就是“数据库结构变成了全链路的唯一事实来源”。你在 Prisma schema 里加一个字段,从数据库迁移到 API 参数到前端表单的类型提示,全部自动更新。这在传统架构里需要至少三四个文件同步修改。
提示:T3 并不是什么高深架构,它更像是一种“类型安全优先”的开发习惯。如果你已经熟悉 Next.js 但没用过 tRPC,上手成本很低——tRPC 的核心概念不超过三个,半天就能跑通。
2. 用 create-t3-app 把 t3code 的骨架拉起来
2.1 一条命令初始化,比想象中省事
脚手架工具create-t3-app是社区维护的标准初始化方案,它比手动搭 Next.js 项目多做了几件关键事情:预置了 tRPC 的 server/client 拆分、Prisma 的基础目录、NextAuth 的回调路由,以及一套严格的环境变量校验逻辑。
pnpm create t3-app@latest t3code执行后会进入交互式选择,我当时的选择如下,供你参考:
| 选项 | 我的选择 | 原因 |
|---|---|---|
| Next.js | App Router | 服务端组件+RSC 预取,性能上限更高 |
| tRPC | 是 | 本项目的核心诉求 |
| Prisma | 是 | 需要一套稳定的数据库层 |
| Tailwind CSS | 是 | 快速出 UI,减少重复样式代码 |
| NextAuth.js | 是 | 鉴权需求明确 |
| ESLint / Prettier | 是 | 团队协作必备 |
需要特别提醒的是:如果你暂时用不到数据库,可以不勾 Prisma;如果项目是纯内部工具、不需要登录,NextAuth 也可以先不装。脚手架的好处是可选,不是让你全都要。
2.2 目录结构到底怎么分
初始化完成后,t3code 的目录结构大致长这样:
t3code/ ├─ app/ │ ├─ api/trpc/[trpc]/route.ts # tRPC 的 HTTP 入口 │ ├─ projects/ │ └─ layout.tsx ├─ prisma/ │ └─ schema.prisma ├─ server/ │ ├─ api/routers/ # 业务路由:post.ts, user.ts... │ ├─ api/trpc.ts # tRPC 初始化与中间件 │ ├─ auth.ts # NextAuth 配置 │ └─ db.ts # Prisma Client 单例 ├─ trpc/ │ ├─ server.ts # 服务端调用入口(RSC 用) │ └─ client.ts # 客户端 hooks(浏览器用) └─ env.js # 环境变量运行时校验这个结构里有一条硬性规则:server/目录下的代码永远不能从客户端组件里直接 import。一旦有人试图在"use client"组件里引server/db.ts,整个打包就会异常。脚手架在初始化时默认安装了server-only包来强制这个边界,但它只拦截写了一句import "server-only"的文件,其他新文件需要你主动加上这一行。
提示:我习惯给所有
server/目录下的文件第一行加上import "server-only",这样如果团队里有人不小心从客户端引用,构建直接报错,而不是等到运行期才暴露问题。
2.3 依赖与 Node 版本的管理心得
这几年我踩过最冤枉的坑就是 Node 版本不一致导致的 Prisma client 崩溃。本地是 Node 20,CI 是 Node 18,Prisma generate 生成的 client 在运行时出现莫名其妙的undefined is not a function。
解决办法很朴素:项目根目录放.nvmrc,内容写死20,CI 里第一步就按这个版本安装。另外强烈建议固定包管理器,t3code 我用的是 pnpm,因为它对 node_modules 的软链接处理在 Prisma 这种带二进制依赖的包上表现比较稳定。
还有一个小细节:prisma generate必须在依赖安装后执行,且生成结果要跟着版本控制走。很多人把node_modules/.prisma当成普通依赖目录忽略掉,结果换机器后没跑 generate,启动直接报找不到 client。
3. 打通数据链路:Prisma 模型、tRPC 路由与页面渲染的协同
3.1 先写 schema,再谈功能
t3code 的第一个业务模块是项目管理。我在动手写任何接口之前,先把prisma/schema.prisma定义好:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Project { id String @id @default(cuid()) name String slug String @unique status Status @default(ACTIVE) ownerId String owner User @relation(fields: [ownerId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([ownerId, status]) @@index([createdAt]) } enum Status { ACTIVE ARCHIVED }这里有几个细节值得展开说。第一,主键用cuid()而不是自增整数,对于需要多实例生成数据的场景更安全,也避免遍历式抓取暴露业务量。第二,@@index一定要在建表初期就加好,尤其是ownerId加status这种组合查询条件。等到数据量到十万级再补索引,迁移表要锁表,线上会有一段时间的写入阻塞。第三,slug用@unique约束,保证项目地址唯一,应用层无需再查一次确认。
写完之后执行迁移:
pnpm prisma migrate dev --name init pnpm prisma generatemigrate dev会在本地生成迁移文件并应用,generate则更新 client 类型。以后每次改 schema,都要成对执行这两个命令。
3.2 tRPC 路由:查询与变更的写法
数据模型就绪后,对应的 tRPC 路由长这样:
// server/api/routers/project.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const projectRouter = createTRPCRouter({ list: protectedProcedure .query(async ({ ctx }) => { return ctx.db.project.findMany({ where: { ownerId: ctx.session.user.id }, orderBy: { updatedAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ name: z.string().min(1).max(50), })) .mutation(async ({ ctx, input }) => { return ctx.db.project.create({ data: { name: input.name, slug: `${Date.now()}-${input.name}`, ownerId: ctx.session.user.id, }, }); }), });两个要点:第一,protectedProcedure表示必须登录才能调用,它内部会把 NextAuth 的 session 注入到ctx里,所以后面可以用ctx.session.user.id直接作为数据归属条件,省掉了在每个接口里手动判断登录态的重复代码。第二,input用 zod 约束,前端传错类型时,请求会在进业务逻辑之前被拦截并返回明确错误信息,不用再写一坨手动的参数校验。
在根路由里聚合所有子路由:
// server/api/root.ts import { projectRouter } from "./routers/project"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ project: projectRouter, }); export type AppRouter = typeof appRouter;AppRouter这个类型会被导出到前端,成为所有 hooks 的类型来源。它就像是整个 API 层的“类型地图”。
3.3 从路由到页面:类型真的同步了
接下来的体验是这个方案最爽的部分。服务端组件里可以直接调用后端路由,不需要走 HTTP:
// app/projects/page.tsx import { api } from "~/trpc/server"; export default async function ProjectsPage() { const projects = await api.project.list(); return <ProjectList initialData={projects} />; }注意这里api.project.list()的返回值类型,是由后端 Prisma 查询的返回类型直接推导出来的。也就是说,你在 schema 里改了Project的字段结构,这个页面和它下面的客户端组件会同时在编译期报错,根本不可能出现“线上字段对不上”的问题。
而客户端组件里如果需要动态查询,就用 hooks 形式:
"use client"; import { api } from "~/trpc/client"; export function CreateProjectButton() { const utils = api.useUtils(); const createProject = api.project.create.useMutation({ onSuccess: () => { utils.project.list.invalidate(); }, }); return ( <button onClick={() => createProject.mutate({ name: "新项目" })}> 创建 </button> ); }创建成功后调用utils.project.list.invalidate()刷新列表,这一套数据失效机制是 tRPC 内置的,不用自己手动去重新请求或者维护状态。
提示:如果你是从 REST 转过来的,最需要适应的点是“接口的输入输出类型变少了”。刚开始可能不习惯,但两周之后你就不会再想在函数里写
response.data这种没有任何类型提示的代码了。
4. t3code 开发中实测翻车的五个地方
4.1 服务端组件里不能直接调 tRPC hooks
第一次写页面时我顺手就在 RSC 里用了api.project.list.useQuery(),结果直接报错。原因很简单:服务端组件是 async 函数,不能调用 hooks,而 tRPC 的useQuery是 React hooks。
正确做法有两种:如果数据只在首屏用,就用trpc/server.ts导出的api直接await查询,然后把结果通过 props 传给子组件;如果这个数据需要响应式刷新,就把使用它的组件标记为"use client",在里面正常用 hooks。
我后来形成的习惯是:默认用服务端组件预取,只有需要交互才会下沉到客户端组件。这样页面首屏的请求数会少很多,返回的 HTML 里已经带上了数据。
4.2 Prisma 连接数在 Serverless 环境被打满
t3code 部署到 Serverless 平台之后,第一个星期就出现了数据库连接数打满的问题。原因是每个 Serverless 实例都会创建一个 Prisma 连接池,实例数量随着请求量弹性伸缩,连接数成倍上涨,PostgreSQL 默认的连接上限很快被吃光。
解决方案需要组合拳:第一,在DATABASE_URL里显式加上连接池参数,限制每个实例的连接数;第二,如果用的是 Prisma 官方代理服务,区分DATABASE_URL和DIRECT_URL两个地址,业务请求走代理,迁移命令走直连;第三,在server/db.ts里用全局单例,避免热更新时反复创建 client 实例。
// server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = db; }这段代码是脚手架默认生成的,但很多人不知道它存在的意义:开发模式下 Next.js 热重载会多次重新加载模块,如果不复用同一个 PrismaClient 实例,每改一次代码就会新建一个数据库连接,非常容易触发连接数告警。
4.3 鉴权中间件的匹配顺序
NextAuth 集成到 t3code 时,比较隐蔽的问题是中间件(middleware)的matcher配置。默认写法如果不加限制,会把所有路径都拦截一遍,包括静态资源和 API 回调路由。有一次我发现登录页图片加载不出来,排查半天才发现是中间件把/public下的静态资源也重定向到了登录页。
我的最终配置是:
// middleware.ts export { default } from "next-auth/middleware"; export const config = { matcher: ["/projects/:path*", "/settings/:path*"], };这里的逻辑是:只有真正需要保护的路由才放进matcher,而不是在中间件里做复杂的判断逻辑。另外 NextAuth 的回调路由/api/auth/*必须放行,否则登录流程会死循环。
4.4 环境变量不统一导致“本地好好的,线上崩了”
这个问题我在几乎每个全栈项目里都会遇到。本地.env里随手写了一个DATABASE_URL,部署的时候忘了配DIRECT_URL,代码里用 Prisma 的代理地址连接,结果生产环境完全起不来,而本地因为走的是 localhost 直连所以一切正常。
t3code 的做法是使用env.js里基于 zod 的运行时校验。每次启动都会检查所有环境变量是否存在、格式是否合法,缺少任何一个直接抛错,而不是等到某个接口被请求时才暴露。我把这个文件称为“启动时的体检报告”,它耗费不到一分钟,但能避免非常多只有到运行时才能发现的问题。
4.5 构建缓存把旧环境变量带进产物
还有一个同类问题:在 CI 里构建的时候,环境变量是在构建命令之前注入的,但 Next.js 的.next缓存目录如果不清除,可能会把上一次构建时的NEXT_PUBLIC_*变量打进产物里。表现就是“配置明明改了,线上怎么还是老样子”。
我现在的部署流程固定为三步:清理缓存、注入环境变量、构建。具体到代码就是构建脚本里先执行rm -rf .next,再跑next build。虽然多花几秒,但能根治这类缓存问题。
5. 从“能跑”到“好用”:t3code 的性能调优记录
5.1 用 RSC 预取数据,减少客户端瀑布流
项目功能稳定后,我开始关注性能。第一个优化点是数据请求的瀑布流问题。传统客户端渲染模式下,页面加载要先下载 JS,再执行组件,组件挂载后再发起 API 请求,请求回来再渲染列表。这样一串下来,用户看到首屏内容至少要经过网络往返三四次。
t3code 改用服务端组件预取后,页面 HTML 在服务端就已经完成了数据拼接,客户端只需要显示内容。优化后的效果比较直观:首屏时间从 1.8 秒降到了 0.9 秒,因为省掉了“JS 下载->执行->请求->再渲染”这个循环。
如果你需要的是局部实时刷新,就在客户端组件里用api.xxx.useQuery()配合initialData使用,把服务端预取的数据作为初始值,这样既不丢性能,又能保留客户端的响应式能力。
5.2 tRPC 的请求批处理与取舍
tRPC 默认使用的httpBatchLink会把同一时间窗口内的多个查询请求合并成一个 HTTP 请求。这个特性在列表页一次性调用多个路由时非常有用,实测能把请求数从 5 个降到 1 个。
但批处理不是万能药。如果有一个请求特别慢(比如导出大文件的接口),它会拖累同一个批次里的其他请求。我后来把这类慢接口单独拆成一个 router,使用独立的httpLink,避免它们影响正常查询。这也是 tRPC 的灵活性所在,你可以针对不同路由选择不同的传输方式。
5.3 前端体积控制:防止服务端代码溜进客户端包
全栈项目最容易出现的问题,是写着写着把服务端逻辑 import 进了客户端组件。一但发生,Prisma 的二进制、数据库连接代码都会打进浏览器 bundle,体积直接爆炸。
我的控制手段有三个:第一,所有server/目录文件第一行加import "server-only",从构建层面建立防线;第二,对体积敏感的路由页面使用next/dynamic做动态导入,让非首屏组件按需加载;第三,定期跑一次next build看打包产物大小报告,发现某个 chunk 异常增大就去查是谁被引了进来。
5.4 用户维度的数据与静态渲染的结合
项目列表页的数据是用户相关的,不能直接静态生成。但有些内容(比如公开的项目模板列表)是全局共享的,可以配合 Next.js 的revalidate或缓存函数做定期刷新,减少数据库压力。
我实测下来的配置是:全局共享数据用unstable_cache包一层,设置 30 秒过期时间;用户私有数据保持实时查询。这样既享受了缓存带来的速度提升,又不牺牲用户数据的准确性。
写在最后:如果再让我选择一次
t3code 做完后的最大感受是:类型安全不是锦上添花,而是开发效率的底层保障。过去让我熬夜排查的字段不匹配、文档过期、参数错位之类的问题,在这套体系里直接不可能发生。如果要说遗憾,那就是我在项目初期没有把权限模型完整设计在 Prisma schema 里,导致后续加了两个角色之后需要重新迁移。下次再做类似项目,我会先把所有数据访问边界画清楚,再开始写路由——毕竟 schema 才是这个体系里真正的“宪法”。
如果你正在纠结要不要用这套技术栈,我的建议是:先拿一个非核心业务模块做两周试点,重点感受一下“改一个字段名全项目自动同步”的体验。一旦你习惯了这种开发方式,就很难再回到手写接口文档的日子了。