☰
T3 Stack中后台实战:Next.js + tRPC + Prisma打造类型安全全栈应用
2026/10/28 23:24:42 网站建设 项目流程

去年接了一个中后台项目,团队不到五个人,前后端一共就四个工程师盯着,我负责全栈。项目代号就叫t3code,因为第一版技术方案我直接梭哈了社区里那套被喊了好几年的 T3 Stack——Next.js + TypeScript + Tailwind CSS + tRPC,数据层塞了 Prisma,认证走 NextAuth。一开始同事还担心这套组合太“网红”,怕坑多不落地。结果三个迭代下来,我自己的感受是:类型安全这东西一旦真正闭环,开发效率的提升是能直接感受到的,尤其是你经历过那种“后端接口文档改了一行,前端跑过来问半天”的协作模式之后,再回头用 tRPC,你会觉得以前的日子都是用手在写代码。

这篇就把t3code这个项目从选型、搭建、核心链路实现到部署优化和踩坑记录完整复盘一遍。不是给你抄作业,但如果你正打算用 Next.js 起一个中后台系统,或者对 tRPC + Prisma 这套组合感兴趣,里面至少有八成经验可以直接平移过去用。

1. 项目全貌与设计思路:t3code到底在解决什么问题

1.1 T3 Stack 的选型逻辑:为什么不是纯 REST

先说结论:t3code这类项目,核心痛点不是“页面长什么样”,而是“前后端类型永远对不上”。

传统 REST 模式下,后端拿着 OpenAPI/Swagger 写接口文档,前端拿着文档调接口,中间无论是字段命名差异、类型不一致,还是null和undefined互相打架,只能在运行时报错时一个个补。为一个小页面,前后端联调半小时是常态,遇到接口升级还得来回对版本。

T3 Stack 的解法很暴力:直接用 tRPC 把后端函数“穿透”到前端调用层。后端写一个procedure,前端 import 同一个类型定义,输入输出全部自动推导,字段改了立刻在编辑器里标红。没有文档要维护,也不需要生成客户端 SDK,因为类型就是文档。

我实际体验下来的对比相当明显:

维度传统 RESTtRPC
类型一致性需要手动同步 TS 类型或额外代码生成由@trpc/server与@trpc/client直接推导
文档成本依赖 Swagger/手写文档,易过时不需要独立文档,Router 定义即文档
联调时间接口字段变更前后端要反复沟通一端改了,另一侧编译直接报错
批量请求前端自行Promise.all,每个请求独立往返支持httpBatchLink自动合并请求
学习成本低中低,本质上就是写 TypeScript 函数

不是说 REST 不行,而是在t3code这种需要快速迭代、接口数量不小的中后台场景里,tRPC 的“粘合剂”属性太强了。团队里四个人全是 TS 出身,没有损耗,如果团队是纯 Java 后端加前端,我反而会考虑保守方案。

1.2 业务模块拆解:中后台项目怎么划分边界

t3code不是一个花哨的 C 端应用,更像一个带鉴权的内部业务控制台。我拆了三个核心域:

  • 用户与权限域:注册、登录、会话管理、角色标识。这套直接依赖 NextAuth,支持 credentials 登录,后面加 OAuth 也方便。
  • 内容管理域:文章/资源的 CRUD、标签系统、列表分页、关键词筛选。这是演示 tRPC 输入校验和中层复用的重点区域。
  • 仪表盘域:聚合统计信息,比如用户数、资源总数、最近动态。这个域比较适合演示服务端调用 tRPC 的能力,因为不需要复杂的客户端状态。

模块边界的原则很简单:一个域对应一组tRPC Router,跨域调用必须走公共的procedure封装,禁止直接 import 另一个域的内部数据访问函数。这样可以保证未来加权限逻辑时只动middleware,不需要回改业务代码。

1.3 为什么项目叫t3code

名字就是图个顺口。T3 的“3”在社区里有两层含义:早期含义是TypeScript、Tailwind、tRPC三种都以 T 打头的技术;后来随着 T3 Stack 完善,也有人开玩笑说是“Three Tier”(三层层级:前端、API、数据库)。t3code就是把“这套技术栈做出来的代码”当成一个独立交付物来管理,内部也方便在仓库名、包名、部署域名里直接复用,省得每次起新项目还要纠结叫啥。

2. 从零搭建工程:create-t3-app 与目录结构的每一处细节

2.1 初始化命令与交互选项逐个过一遍

搭建 T3 项目不需要手动拼接一堆配置文件,直接用官方脚手架。终端里执行:

npm create t3-app@latest

交互提示会让你填项目名,我填t3code,然后是一堆技术选项。这一步很多人会顺手全选,我劝你稍微想一下,我当时的选择是:

√ Select framework: Next.js √ Select language: TypeScript √ Use App Router? Yes √ Use React Compiler? No √ Enable tRPC? Yes √ Enable Prisma? Yes √ Enable NextAuth.js? Yes √ Enable Tailwind CSS? Yes √ Enable ESLint? Yes √ Enable Prettier? Yes

有几个选项需要额外说。

App Router我用的是最新版,它和 Pages Router 的差异很大。t3code里客户端组件、服务端组件、服务端调用 tRPC 全依赖 App Router 的约定路由,所以必须选。如果你还在用 Pages Router 老项目,建议先做好迁移评估再接 tRPC。

tRPC选项是这个项目的核心。脚手架会自动生成完整的server/api/trpc.ts和server/api/root.ts骨架,避免手动搭建 initTRPC 时踩那种类型地狱。

React Compiler我选了 No,因为它当时还不够稳,跑重渲染较多的列表页容易出意外,不如先用 React 18 的原生优化。等后续稳定了再开也不迟。

Prisma 和 NextAuth同时启用,脚手架会引导你配置数据库连接字符串。这里注意:如果你还没准备好数据库,可以先用 SQLite 顶上,create-t3-app默认给的就是 SQLite,后续换 PostgreSQL 也只是改DATABASE_URL再加几个 schema 方言调整。

初始化完成后直接进目录:

cd t3code npm run db:push npm run dev

浏览器打开http://localhost:3000如果能看到带 tRPC 健康检查链接的页面,骨架就算起来了。

2.2 目录结构拆解:一个能满足小团队长期维护的布局

脚手架生成的目录结构不是乱放的,我按实际职责逐个讲:

t3code/ ├─ prisma/ │ ├─ schema.prisma # 数据模型定义 │ └─ seed.ts # 初始数据脚本 ├─ src/ │ ├─ app/ │ │ ├─ api/ │ │ │ └─ trpc/ │ │ │ └─ [trpc]/ │ │ │ └─ route.ts # tRPC HTTP 入口 │ │ ├─ layout.tsx # 根布局,注入 SessionProvider │ │ └─ page.tsx # 首页/仪表盘入口 │ ├─ server/ │ │ ├─ api/ │ │ │ ├─ routers/ # 各业务域 Router 文件 │ │ │ ├─ root.ts # 合并所有子 Router │ │ │ └─ trpc.ts # tRPC 实例、middleware、公共 procedure │ │ ├─ auth.ts # NextAuth 配置 │ │ └─ db.ts # PrismaClient 单例 │ └─ trpc/ │ ├─ server.ts # 服务端调用 tRPC 工具 │ └─ react.tsx # 客户端 Provider 与 hooks ├─ .env # 非敏感环境变量 └─ .env.example # 提交到仓库的变量模板

这里我要强调一个最容易出错、也最值得花时间的点:server/api/trpc.ts和src/trpc/server.ts是两个完全不同的东西,别混。前者是服务端定义 Router 的工厂,后者是让你在 Server Component 里调用已有的 tRPC 接口的能力封装。很多新手把远端调用逻辑写进前者,结果一圈圈递归,不明所以地报类型错误。

2.3 环境变量与基础配置:提前避开的两个坑

.env里至少要有这几个变量:

DATABASE_URL="file:./db.sqlite" # 或者 postgresql://user:pass@host:5432/db NEXTAUTH_SECRET="随便生成一段足够长的随机字符串" NEXTAUTH_URL="http://localhost:3000"

NEXTAUTH_SECRET必须强调一下:生产环境一定换一个新的强随机值,可以用命令生成:

openssl rand -base64 32

我见过有人直接把脚手架里的示例密钥推上生产,那等于把登录态当做没有加密。第二个坑是DATABASE_URL中的file:./db.sqlite,注意相对路径是相对于prisma/schema.prisma所在目录的,而不是项目根目录,如果你挪动了 schema 文件,路径要跟着调整。prisma官方建议直接写成file:./dev.db这种相对路径,然后把.db文件加进.gitignore。

另外严格区分.env和.env.example:前者永远不要提交,后者保留全部变量名但值随机或写注释。小程序团队的意识就在这里养成,不用等到出了安全事故才后悔。

3. 核心业务链路实现:从数据库到页面的一次类型安全闭环

3.1 数据模型设计与迁移:先建模还是先写接口

我个人的习惯是先建模,再写 tRPC Router,最后写页面。数据模型是一切的骨架,模型错了接口写得再漂亮也得重构。

t3code的prisma/schema.prisma里我定义了三个主要模型:

model User { id String @id @default(cuid()) name String? email String @unique password String? createdAt DateTime @default(now()) posts Post[] } model Post { id String @id @default(cuid()) title String content String @db.Text published Boolean @default(false) createdAt DateTime @default(now()) author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }

选择cuid()而非自增Int的原因有两条:内部系统的数据以后做同步、迁移、分库分表时,字符串 ID 天然避免冲突;另一个原因是从安全角度,业务 ID 不容易被遍历枚举。对 SEO 没有要求的后台系统,cuid是最省心的选择。

改完 schema 后执行:

npx prisma db:push

这么做是不生成迁移历史的,适合开发前期模型还在剧烈变化时用。你问我为什么不用migrate dev?因为migrate dev每次改动模型都会生成新的迁移文件,前期加了删、删了加,迁移历史一多反而干扰。到了模型趋稳后再切换到npx prisma migrate dev,正式生成带时间戳的迁移记录。

3.2 编写 tRPC Procedure:中间件、输入校验与类型推导

脚手架生成的src/server/api/trpc.ts里默认有两个基础 procedure:

export const createTRPCRouter = t.router; export const publicProcedure = t.procedure;

正常开发时,业务基本都是带登录态的,所以我加了一个protectedProcedure:

import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; import { type Session } from "next-auth"; const t = initTRPC.context<{ session: Session | null }>().create(); const isAuthed = t.middleware(({ next, ctx }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session }, }); }); export const protectedProcedure = t.procedure.use(isAuthed);

中间件设计成从getServerSession读取会话,这样每个接口都能拿到当前用户,且不需要在前端手动传 token。加中间件后,如果你想写一个只有登录用户能创建文章的接口,代码会非常短:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), tagNames: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tagNames?.map((name) => ({ where: { name }, create: { name }, })), }, }, }); }), });

这里最值得一提的就是输入校验层。zod的 schema 在运行时会真实校验,同时在编辑器里会被推导成 TypeScript 类型。前端调create时,如果title字段没传,编辑器直接告警,根本到不了运行时。这种双重保障比传统“后端校验 + 前端手写类型”的方案省掉太多维护成本。

connectOrCreate这种嵌套写入是 Prisma 的特色,跟 tRPC 其实没关系,但很多新手会卡在这里。它表达的意思是:对于tagNames里的每个名字,如果 Tag 表里有就关联,没有就创建。注意where: { name }要求 Tag 模型里name字段有@unique约束,这就是为什么我在设计模型时给它加了 unique 属性。

3.3 在页面里调用 tRPC:服务端调用与客户端 hook 双线并行

t3code用 App Router,一个重要原则是:默认优先在 Server Component 里拿数据,客户端组件只在需要交互时使用 hooks。

服务端调用方式如下:

// src/app/dashboard/page.tsx import { api } from "~/trpc/server"; export default async function DashboardPage() { const stats = await api.post.stats(); return <div>总文章数:{stats.total}</div>; }

~/trpc/server内部封装了createTRPCProxyClient和headers()处理,它跟客户端调用用同一套类型定义,因此api.post.stats的返回类型就是后端定义的返回类型,一秒都不会猜错。这是我在t3code项目里最喜欢的部分——写页面的时候完全不用回头翻接口文档,编辑器就是最好的 API 说明书。

再看客户端交互场景,比如文章列表的“发布/下架”按钮:

"use client"; import { api } from "~/trpc/react"; export function ToggleButton({ postId, published }: { postId: string; published: boolean }) { const utils = api.useUtils(); const toggle = api.post.toggle.useMutation({ onSuccess: () => { utils.post.list.invalidate(); }, }); return ( <button onClick={() => toggle.mutate({ id: postId, published: !published })} > {published ? "下架" : "发布"} </button> ); }

这段的关键是api.useUtils()和invalidate(),它做的事情是:修改数据成功后,自动把post.list相关的缓存标记为过期并重新拉取,不需要手动刷新页面或者写一个onSuccess回调去重整列表。对于中后台这种“改完必须看到最新状态”的场景,这个心智负担比 Redux Toolkit Query 要小太多。

注意客户端调用时一定要保证根布局里挂了TRPCReactProvider:

// src/app/layout.tsx import { TRPCReactProvider } from "~/trpc/react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="zh-CN"> <body> <TRPCReactProvider>{children}</TRPCReactProvider> </body> </html> ); }

4. 实操现场记录:我踩过的坑与排查技巧

4.1 常见问题速查表

没有哪个全栈技术是零坑的,t3code开发到上线,我记录下了一批典型问题。整理成速查表,方便大家直接对号入座:

现象原因解决办法
页面报RSC failed to fetchServer Component 中调用 tRPC 时未能正确传递 headers 或 cookies确认使用的是~/trpc/server而不是直接 new tRPC client
PrismaClient 实例过多,开发时终端警告Next.js 热更新重复加载模块在src/server/db.ts用globalThis缓存单例
NextAuth 在app/api/auth/[...nextauth]/route.ts中报Edge Runtime不兼容Prisma 或其他 Node API 在 Edge 环境不可用给 route handler 加export const runtime = "nodejs"
客户端调用 mutation 后列表不刷新忘记调invalidate()在onSuccess中执行对应 router 的invalidate
tRPC input 类型报错,提示parse不存在忘了给.input传 zod schema 或用了裸类型对象所有输入校验统一用z.object({...})
部署后访问登录页白屏缺少NEXTAUTH_URL或 secret 不对检查环境变量,Secret 必须和本地一致
SQLite 并发写入时报database is locked多人同时开发或生产低配流量SQLite 只适合本地,生产直接上 PostgreSQL

这个表对我自己定位问题帮助很大,遇到现场 Bug 先快速比对是不是已知坑。

4.2 两个印象深刻的排查实录

第一个坑是服务端调用 tRPC 时报TypeError: Cannot read properties of undefined (reading 'headers')。看报错信息根本想不到是哪里出了问题,后来反复比对才发现,问题出在我直接在 Server Component 里面createTRPCProxyClient手动去创建客户端实例,而脚手架里src/trpc/server.ts的createCaller是依赖每个请求的 headers 的。解决办法就是把所有服务端调用都改成从~/trpc/server引入api,这样 headers 会被内部通过next/headers动态注入。这一点在 Next.js 文档里提过,但确实容易略过。

第二个坑更经典:开发环境跑得好好的,一next build就报PrismaClientInitializationError,大意是初始化时找不到数据库引擎。原因是生产构建发生在next build阶段,而当时.env里的DATABASE_URL不是指向我构建机器可访问的库。特别是用 SQLite 时,生产构建壳子环境里没有写入权限,Prisma 的 query engine 也会跟着出问题。解决办法是:构建机设置正确的DATABASE_URL,或者对 SQLite 场景改用一个临时的file:./dev.db路径,让构建只做类型检查,不实际连库。上线后改用 PostgreSQL 就不会有这个烦恼。

4.3 提升开发体验的三个小技巧

玩熟这个技术栈之后,有几个小习惯让日常工作顺畅不少。

技巧一:为 tRPC 打开 logger 中间件。在server/api/trpc.ts里加一行:

t.middleware(async ({ path, type, next }) => { const start = Date.now(); const result = await next(); console.log(`${type} ${path} ${Date.now() - start}ms`); return result; });

这样每次接口调用的耗时直接在控制台里打点,性能问题一目了然,不需要额外接监控。

技巧二:利用httpBatchLink自动合并请求。客户端 Provider 里默认用的是httpBatchLink,它的原理比较巧妙:如果在同一个 tick 内触发多个 tRPC 请求,会合并成一个 HTTP 请求,减少网络往返。但注意它依赖浏览器端fetch的实现,如果你在 Server Component 里也想用批处理,不要去搞什么自定义 link,直接调用api.xxx的方法就行,服务端调用本身就走的是一个进程内的调用链,没有网络开销。

技巧三:路由组装时保持清晰的命名空间。我在root.ts里把所有子路由挂到appRouter时,会强制按业务域加前缀,接口调用时语义就非常直接:

export const appRouter = createTRPCRouter({ auth: authRouter, post: postRouter, user: userRouter, dashboard: dashboardRouter, });

这样调用侧是api.post.create、api.dashboard.stats,比扁平化命名可读性好太多,对新人尤其友好。

5. 部署与性能优化:别让类型安全变成上线后的负担

5.1 一站式部署与 Runtime 选择

t3code最终部署在 Vercel 上,原因很简单:Next.js + tRPC + Prisma 基本都是 Vercel 生态的首选,构建配置几乎不需要改。但要注意一点,NextAuth 的 route handler 默认可能走 Edge Runtime,而 Prisma 依赖 Node 环境且不兼容 Edge。在src/app/api/auth/[...nextauth]/route.ts里显式加一行:

export const runtime = "nodejs";

不加这行,部署到线上后可能出现登录接口突然 500 的状况。这个问题在本地很难复现,因为本地 dev server 默认就是 Node runtime,一上生产就露馅。

5.2 Prisma 在 Serverless 环境下的冷启动优化

Serverless 函数的冷启动对 Prisma 是实打实的痛。Prisma 查询引擎以二进制形式加载,冷启动时可能要消耗接近 500ms 甚至更多。两个常用的优化手段:

一是使用 Data Proxy 或 Prisma Accelerate,把查询引擎放到远端托管,本地不再加载二进制。适合对冷启动特别敏感的项目,但要注意网络延迟和费用。t3code内部系统对首屏要求没那么极端,我没有上加速器,而是走了第二个方案。

二是限制 PrismaClient 实例化次数并确认没有在构建阶段执行多余的数据库查询。前面提到过,在src/server/db.ts里用全局缓存单例,防止每个 Lambda 实例重复创建。代码大概是:

import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

这不算什么魔法,但确实是很多人甩不掉冷启动问题的原因之一。

5.3 缓存、批处理与请求合并

中后台的“性能优化”不像 C 端那么强调 XX ms 指标,但也不能用户每次开仪表盘都慢悠悠转圈。t3code里我做了三层缓存策略:

  • 数据库查询层:对不常变化的数据用 Prisma 的cached字段自己控制的简单 Cache,或者直接依赖 Postgres 的查询缓存,不过更多是靠合理的索引。
  • React 层:在 Server Component 里对小体量的聚合数据使用unstable_cache或 React 的cache()函数,按时间窗口缓存,避免每次进入页面都重算。
  • 请求层:tRPC 的httpBatchLink自动合并同一 tick 内的请求,减少网络往返,这是白给的优化,开箱即用。

调用cache()的示例:

import { cache } from "react"; import { db } from "~/server/db"; export const getPostCount = cache(async () => { return await db.post.count(); });

因为 Server Component 默认在 Next.js 里是异步渲染的,同一请求周期内第二次调用getPostCount会直接命中 React 层缓存,不会执行真正的数据库查询。对仪表盘这种多卡片汇总页来说,减少的重复查询是很可观的。

5.4 部署后的最后一道验证

上线不能只盯着 “能访问” 三个字。t3code发布后我做了一套很轻量但有效的上线检查清单:

  1. 无痕模式登录注册是否正常,刷新后 session 是否保持。
  2. tRPC 全部 procedure 是否在/_trpc路径下可访问,可以用浏览器直接打开https://你的域名/trpc/post.list?input=...验证 JSON 响应。
  3. 创建文章、打标签、发布/下架、列表刷新,整条主链路过一遍。
  4. 打开 Network 面板,确认批量请求是否真正合并成单个POST /trpc/post.*请求。
  5. 用next build产物在本地模拟生产模式启动一次,确认没有构建期依赖数据库的查询残留。

这一套虽然不是自动化,但每次迭代发布前花十来分钟过一遍,比事后看排查告警舒服太多。

结尾的几句大实话

折腾完t3code,我最深的体会是:技术栈再怎么新,最终都是在给团队省心智。tRPC 不是银弹,如果你们的接口大多要开放给第三方或非 TypeScript 消费方,老老实实做 REST 反而是更保守理智的选择。但如果是像我这样,前后端一拨人、全栈 TypeScript,那 T3 Stack 能让你把以前花在接口对齐上的时间全抢回来。

最后分享一个小细节:我后来在项目里加了一个自定义 tRPC middleware,专门用于输出 key 为duration的响应头,测接口延迟不用再翻日志文件。这种小成本、高收益的定制改造,才是用这套技术栈越用越顺的原因所在。希望这篇复盘对你的 Next.js 全栈项目有参考价值,至少让你少踩两个我曾经踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询