看到“t3code”这个词,第一反应是它和这两年在前端圈子里热度很高的T3 Stack脱不开关系。如果你不太熟悉这个名字,简单说,T3 指的就是 The Terminal Trio——由 Next.js、TypeScript、tRPC、Tailwind CSS 四条技术线组成的一套全栈开发组合。而 t3code,在我理解里,它可以被看作围绕这套技术栈的一种代码实践、模板工具或项目骨架的集合,目标就一个:让全栈开发在类型安全这件事上不再“半路上掉链子”。这篇文章我不会跟你扯概念,而是直接把我用这套技术栈从初始化到部署的真实流程、关键代码、踩过的坑全部过一遍,适合谁看呢?想从传统 REST API + 前端手动维护类型那种模式切换过来的人,以及正在犹豫要不要在下一个项目里引入 tRPC 的开发者。
1. t3code 的生态定位:T3 技术栈到底解决了什么痛点
1.1 全栈类型安全为什么值得较真
先说一个我自己的真实体验。早些年写全栈项目,前端和后端通常是两拨人在维护。后端用 Java 或 Node.js 写接口,前端用 TypeScript 调的,但问题来了——后端返回的 JSON,前端怎么知道字段名是userName还是username?只能靠文档,或者靠“猜”。“猜”的后果我很清楚:线上环境经常出现undefined is not a function,查了半天是字段对不上。
后来前后端都用 TypeScript 了,但中间还隔着一层 REST API。你可以在前端写一个 interface 去声明后端返回的数据结构,但这个声明是手写的。后端改了字段,前端不一定知道。类型安全是断掉的,靠人肉去维护同步。
T3 这套东西的核心思路,是把那条断掉的路重新接起来。它不是某个单一框架,而是一套组合生态。其中最关键的角色是 tRPC——它允许前端直接调用后端函数,并且是通过一个类型安全的 API 调用,不需要手写接口声明,不需要生成代码,也不用再维护一份和前端的契约文档。你后端函数的输入输出类型,会自动推到前端。这效果怎么说呢,就像你请了个文秘,所有会议纪要她都帮你同步好了,你只需要专注开会。
1.2 t3code 在这个生态里承担的职责
那 t3code 到底是干嘛的?结合名字和目前公开信息来看,它更像一个把 T3 技术栈标准化、模板化的工程项目。也就是说,它不是为了发明新技术,而是为了让人更快把一套 T3 项目跑起来。
我个人的理解是:t3code 可能是一个提供了完整基础能力的脚手架工程,帮你把 Next.js 的路由结构、tRPC 的 server 和 client 配置、Prisma 的数据库模型、NextAuth 的登录流程都预先搭好,你只需要往里面填业务代码。这和社区里比较有名的 create-t3-app 定位类似,但我倾向于把 t3code 理解成一个更偏向于代码实践和最佳范例的仓库,里面有各种场景下的典型实现,而不是一个纯生成器。
所以这篇博文,我不会局限于某个具体工具的安装命令,而是把 T3 技术栈的核心链路拆开给你看:从数据模型定义,到 tRPC Router 实现,再到 React Server Component 里怎么拿到数据,最后到线上部署。这套流程你走通了,以后换任何项目,底层逻辑都一样。
1.3 这套组合的技术选型逻辑
先解释一下为什么是 Next.js 而不是 Vite 或 Remix,因为很多人会疑惑。Next.js 提供了一套完整的全栈框架能力,既有 Server Component 和 Server Actions 这种针对服务端渲染的优化手段,又能轻松把前端和后端逻辑放在同一个代码仓库里。tRPC 需要同时运行在服务端和客户端,Next.js 的 API Routes 或者 Route Handlers 可以很自然地承载这个要求。
Prisma 则是数据模型层的最佳搭档。它用 schema 文件声明数据库结构,改完 schema 之后执行 migrate 就能自动生成 TypeScript 类型和数据库表结构。这种声明式体验和 tRPC 的“自动类型推导”理念是一致的:你只写一次模型,剩下的类型都由工具生成,避免手写可能出现的遗漏。
Tailwind CSS 在这里反而是最不核心的一环,它解决的是样式问题。你完全可以换成 CSS Modules 或其他方案,不影响整体架构。T3 把 Tailwind 纳入默认配置,只是因为它和 Next.js 的配合成本低、定制快,适合快速搭界面。
2. 初始化一个 t3code 风格项目:环境准备与目录结构
2.1 环境依赖和前置准备
在动手之前,先把环境清理干净。以我当前的实践环境为例,下面的版本是我实测能用且稳定的:
| 工具 | 版本要求 | 说明 |
|---|---|---|
| Node.js | 20.x 及以上 | 低于 18 会直接跑不起来,因为 tRPC 依赖 Web Streams API |
| pnpm | 9.x | 推荐使用 pnpm 而不是 npm,依赖安装速度差很多 |
| PostgreSQL | 14+ | 本地或远程实例均可,最好 14 以上 |
| React 版本 | 18.3 及以上 | 由 Next.js 14 指定,无需手动安装 |
你本地还应该装一个 Prisma CLI,我建议用pnpm add -D prisma在项目里装,而不是全局装,这样不同项目用的 Prisma 版本不会互相冲突。建项目的时候,我采用的方式是手动搭建。为什么不直接用官方脚手架?因为我想让你理解每一步在做什么,而不是黑盒生成后就完事了。
初始化一个项目的基础命令是:
mkdir t3code-demo && cd t3code-demo pnpm init pnpm add next@14 react react-dom pnpm add -D typescript @types/react @types/node prisma然后创建tsconfig.json。这里我直接给出一个适合 Next.js 14 的配置:
{ "compilerOptions": { "target": "ES2022", "lib": ["dom", "dom.iterable", "ES2022"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [{ "name": "next" }], "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] }这里重点说一下moduleResolution: bundler。以前在 Node.js 环境下写 TypeScript,通常用node10或node16解析模式,但 Next.js 的打包器和这种方式不匹配。改成bundler之后,很多东西的导入规则会宽松一些,也符合现代构建工具的行为。这个配置如果你漏了,经常会出现一些莫名其妙的模块找不到错误。
2.2 按功能域组织目录,而不是按文件类型
我见过的很多项目,目录习惯是components/、utils/、hooks/这样把同类型文件放一起。项目小的时候没问题,一大了你就发现改一个用户功能要来回跳转好几个目录。t3code 给我的启发是按“功能域”组织。
我推荐的结构是这样的:
src/ ├── app/ # Next.js 路由层,只负责页面 │ ├── layout.tsx │ ├── page.tsx │ └── api/trpc/[trpc]/route.ts # tRPC 的 HTTP 入口 ├── server/ │ ├── db.ts # Prisma Client 实例 │ ├── auth.ts # NextAuth 配置 │ ├── trpc/ │ │ ├── context.ts # 每次请求的上下文 │ │ ├── router.ts # 路由聚合 │ │ └── trpc.ts # tRPC 初始化逻辑 │ └── routers/ │ ├── user.ts │ └── post.ts ├── lib/ │ └── utils.ts └── styles/ └── globals.css这样的好处是,当你需要修改用户模块时,所有的相关文件都集中在server/routers/user.ts,页面使用则在src/app/user/page.tsx,一目了然。而app/api/trpc/[trpc]/route.ts在 Next.js 14 里就是一个标准的 Route Handler:
import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { createTRPCContext } from "@/server/trpc/context"; import { appRouter } from "@/server/trpc/router"; const handler = (req: Request) => fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, createContext: createTRPCContext, }); export { handler as GET, handler as POST };如果你用 App Router,务必要将 GET 和 POST 都导出。因为 tRPC 的httpBatchLink会把多个查询打包成一个 POST 请求,但浏览器预加载时可能会用 GET。少了任何一个,调试的时候就会遇到接口 404 或 405,这种问题在本地很难发现,部署后才开始报错。
2.3 环境变量管理和配置文件
创建一个.env文件:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/t3code_demo?schema=public" NEXTAUTH_SECRET="your-strong-secret-key-here" NEXTAUTH_URL="http://localhost:3000"关于NEXTAUTH_SECRET有个细节值得注意。很多教程会建议用openssl rand -base64 32生成,但如果你在多个环境(本地、预览、生产)之间复用同一个 secret,安全性会打折扣。我建议每个环境单独生成,并且使用 Next.js 14 支持的方式在next.config.js里读取环境变量,而不要把 secret 直接硬编码到代码里。
还有一个常见问题是.gitignore里忘记排除.env。别笑,我见过不止一个项目把云数据库的账号密码直接推到公开仓库。第一时间把.env、.env.local、*.sqlite这些都加进去,这是基本功。
3. 核心链路实现:从 Prisma 模型到 tRPC Router
3.1 定义数据模型,一次建模,全程受益
下面我们做一个实际的功能模块来跑通链路。就拿“文章发布”这个场景吧——它有用户、文章、标签三个实体,足够说明关系型数据的关联查询。Prisma schema 定义如下:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? 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]) authorId String tags TagOnPost[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique posts TagOnPost[] } model TagOnPost { postId String tagId String post Post @relation(fields: [postId], references: [id]) tag Tag @relation(fields: [tagId], references: [id]) @@id([postId, tagId]) }这里有一个前几年大家争论过的问题:主键用自增整数还是 CUID?整数主键在数据库层面性能更好,但在分布式环境下生成麻烦;CUID 可以在应用层生成,不依赖数据库自增序列。对于全栈项目,我倾向于 CUID,尤其是当你后续把数据库迁移到边缘环境或做读写分离时,应用层生成主键会让整个过程简单不少。Prisma 也提供了cuid()函数作为默认值,用起来顺手。
定义完成后执行:
npx prisma migrate dev --name init npx prisma generatemigrate dev会在本地先生成一个 SQL 迁移文件,再执行到数据库,并且触发generate自动重新生成 Prisma Client。你会发现 Prisma Client 的代码在node_modules/.prisma下,它提供了完全类型安全的数据库访问。此时如果你改了 schema 而忘记generate,VSCode 会报一大堆类型错误,这算是 Prisma 给你的一层隐形保护。
3.2 tRPC 上下文与路由初始化
tRPC 的核心机制有三个东西:initTRPC、procedure、router。先创建一个server/trpc/trpc.ts:
import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/server/auth"; import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; import { db } from "@/server/db"; export const createTRPCContext = async (opts: CreateNextContextOptions) => { const session = await getServerSession(authOptions); return { db, session, ...opts, }; }; const t = initTRPC.context<typeof createTRPCContext>().create(); export const router = t.router; export const publicProcedure = t.procedure; const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);这里你会发现,createTRPCContext里做了两件事:一是把数据库实例db放进上下文,二是把当前登录的 session 放进去。这样后续每个 procedure 都能拿到这两个东西,而不需要每个函数单独再调一次getServerSession()。这就是 tRPC 上下文的价值——把每个请求的公共依赖统一准备好。
关于类型推导,重要的一点:initTRPC.context<typeof createTRPCContext>()里面的泛型必须传上下文的创建函数类型,tRPC 会从中提取出返回的 context 类型。如果你写错了,后续 route 里拿不到ctx.db,VSCode 也会立刻提示。这就是 tRPC 的“少写但不错”的设计逻辑。
然后创建一个路由聚合文件:
import { router } from "./trpc"; import { postRouter } from "@/server/routers/post"; import { userRouter } from "@/server/routers/user"; export const appRouter = router({ post: postRouter, user: userRouter, }); export type AppRouter = typeof appRouter;export type AppRouter这句是必须的。前端要拿到后端路由的全部类型信息,靠的就是这个从typeof appRouter推导出来的类型。定义好之后,整个后端接口的类型就被“导出”到了前端的世界里。
3.3 业务路由的完整实现
现在来写文章模块。
import { z } from "zod"; import { router, publicProcedure, protectedProcedure } from "@/server/trpc/trpc"; import { TRPCError } from "@trpc/server"; export const postRouter = router({ list: publicProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), cursor: z.string().nullish(), }) ) .query(async ({ ctx, input }) => { const items = await ctx.db.post.findMany({ take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: "desc" }, include: { author: true, tags: { include: { tag: true } } }, }); let nextCursor: string | null = null; if (items.length > input.limit) { const nextItem = items.pop(); nextCursor = nextItem!.id; } return { items, nextCursor }; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(10), tagIds: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { const post = await ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { create: input.tagIds.map((tagId) => ({ tagId })), }, }, }); return post; }), byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id }, include: { author: true, tags: { include: { tag: true } }, }, }); if (!post) { throw new TRPCError({ code: "NOT_FOUND", message: "文章不存在" }); } return post; }), });你可能注意到了几点。
第一,我用 zod 做输入校验。这是 tRPC 的官方推荐搭配,写法很自然:input字段接受一个 zod schema,tRPC 会在请求到达时自动做校验和解析,解析后的类型会立刻被推导到input参数上。也就是说,你写了z.string().min(1),那么input.title在后续代码里必然是非空字符串,不需要再写if (!input.title)这种防御逻辑。
第二,protectedProcedure和publicProcedure的区别是中间件。刚才在 trpc.ts 里定义的enforceUserIsAuthed就是用在这里。文章创建必须是登录用户,而列表和详情是公开的。业务逻辑只需要选择正确的 procedure,不需要重复写鉴权判断。
第三,这里用了游标分页而不是传统的page/pageSize。在列表不断增长的数据上,游标分页更稳定,因为基于页码的分页在高并发插入时会出现重复数据。游标分页的关键逻辑是:每次取limit + 1条,如果取到的数据比 limit 多,说明还有下一页,取出最后一条的 id 作为下一次的 cursor。
数据库这块,还有一个 Prisma 的使用细节需要提一下。include: { tags: { include: { tag: true } } }这种嵌套查询虽然方便,但在列表接口返回大量数据时,会产生 N+1 查询。Prisma 会在底层做一些合并处理,但在复杂关联场景下,如果性能压测不达标,可以改成分次查询然后用映射组装数据。我通常的做法是:小项目直接嵌套 include,访问量大时再优化。过早优化往往是浪费时间的开始。
用户路由更简单:
import { router, publicProcedure } from "@/server/trpc/trpc"; export const userRouter = router({ me: publicProcedure.query(({ ctx }) => { return ctx.session?.user ?? null; }), });前端要用这个me来判断用户是否已登录,它是我们后续在 UI 上做权限控制的重要依据。
4. 前端接入:Server Component 与 Client Component 的平衡
4.1 为何推荐在 Server Component 里直接调 tRPC
tRPC 让人敬佩的一点是,它不只能从浏览器端调用后端接口,还能在 Next.js 的 Server Component 里直接以函数调用形式获取数据。这样一来,首屏数据可以做到真正的服务端获取、先生成 HTML 再返回给浏览器,对 SEO 和首屏速度都有好处。
在 App Router 下,你在一个 async 的 Server Component 里可以直接这么做:
import { appRouter } from "@/server/trpc/router"; import { createTRPCContext } from "@/server/trpc/context"; import { headers } from "next/headers"; const ctx = await createTRPCContext({ req: new Request("http://localhost:3000"), resHeaders: new Headers() } as any); const caller = appRouter.createCaller(ctx); export default async function PostListPage() { const { items } = await caller.post.list({ limit: 10 }); return ( <div className="grid gap-4"> {items.map((post) => ( <PostCard key={post.id} post={post} /> ))} </div> ); }这种方式绕过了 HTTP 请求,直接在服务端调用函数,性能损耗很小。但要注意,这个 ctx 是手动拼出来的,不是从真实 HTTP 请求里拿到的。如果是getServerSession逻辑,记得要传真实的 header。我在实践中更喜欢用next/headers包的cookies()或headers()方法去构造上下文,这样 session 能正确读取。
按照 Next.js 14 的约定,Server Component 默认是没有客户端事件的。你要点击按钮、改变状态,必须把组件标记为"use client"。我遇到的一个坑是:有些人把所有能交互的组件都变成了 Client Component,导致整个页面都进入客户端渲染,服务端渲染的优势完全没发挥出来。
一个合理的拆分方案是:页面本身是 Server Component,负责取数;按钮、表单、分页控件是小颗粒的 Client Component。数据通过 props 传下去,交互逻辑通过函数回调传上来。
4.2 设置 tRPC Client Provider
对于需要实时交互的页面(比如提交表单后刷新列表),还是需要从浏览器直接发起 tRPC 请求。这里要在src/app/providers.tsx里设置全局 Provider,这个文件是 Client Component:
"use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { createTRPCReact } from "@trpc/react-query"; import { useState } from "react"; import type { AppRouter } from "@/server/trpc/router"; export const trpc = createTRPCReact<AppRouter>(); export function TRPCProvider({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => trpc.createClient({ links: [ httpBatchLink({ url: "http://localhost:3000/api/trpc", }), ], }) ); return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </trpc.Provider> ); }注意这里createTRPCReact<AppRouter>()的泛型,再次使用了从服务端appRouter推导出来的AppRouter类型。至此全链路类型安全彻底打通。你从后端 router 里定义了一个post.list,前端输入trpc.post.list.useQuery()时,如果不传对参数,或者传错类型,VSCode 直接就标红了,不存在“页面报错了才发现接口字段对不上”的情况。
这里有个经验要讲:httpBatchLink会把同一时间发生的多个请求合并成一个 HTTP 请求,减少网络开销。但它并不是万能的。如果请求需要长时间挂起(比如上传文件、流式响应),batch 会把多个请求串在一起,反而影响体验。那些场景建议改用httpLink或者单独处理。
4.3 一个完整的交互组件:发布文章
接下来我们写一个真正的交互页面,让读者感受一下 tRPC 的 React Hook 风格:
"use client"; import { trpc } from "@/app/providers"; import { useState } from "react"; export function PostCreator() { const utils = trpc.useUtils(); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const createPost = trpc.post.create.useMutation({ onSuccess: () => { utils.post.list.invalidate(); setTitle(""); setContent(""); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); createPost.mutate({ title, content, tagIds: [] }); }} > <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="标题" className="border p-2 w-full mb-2" /> <textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="正文" className="border p-2 w-full mb-2" /> <button type="submit" disabled={createPost.isLoading} className="bg-blue-600 text-white px-4 py-2 rounded" > {createPost.isLoading ? "发布中..." : "发布"} </button> </form> ); }你看,createPost这个 mutation 对象自带isLoading、error、isSuccess等状态,不需要自己再用 useState 管理 loading 状态。发布成功之后,调用utils.post.list.invalidate(),React Query 会自动重新去获取列表数据。也就是说,你的数据更新逻辑不需要写“刷新页面”或者手动 setState 塞入新数据,整个缓存失效和重新拉取都由库来处理。
这里有一个 tRPC 和 React Query 结合的最舒服的点:useMutation里可以直接用onSuccess回调来做缓存刷新,配合乐观更新能力,可以实现极佳的交互体验。我后面详细说乐观更新。
5. 部署与线上环境:从本地到生产的技术决策
5.1 选择部署平台:Vercel 与自制服务器的取舍
T3 技术栈的部署其实有几种主流选择。如果你的项目规模不大、也没有特殊合规要求,直接用 Vercel 最省心。Next.js 是 Vercel 自家框架,构建缓存、边缘函数都无缝对接。数据库如果选了 Supabase 或 Neon 这类托管服务,那整个链路基本没有需要你自己运维的环节。
但如果你和我一样,在某些场景下需要把服务部署到自己的服务器上(比如数据库不能放到公网、需要内网访问),用 Docker 自部署是完全可行的。做法是:用一个 Dockerfile 把 Next.js 应用构建成 standalone 模式,然后运行node server.js。
在next.config.js里开启 standalone 输出:
/** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", }; module.exports = nextConfig;这样构建完之后,.next/standalone目录包含了所有服务端代码以及生产环境依赖,不需要 node_modules,可以拷贝到一个干净的镜像里跑。而静态资源(图片、CSS、JS 等)则在.next/static目录中,需要单独处理——一般是用 Nginx 或者 CDN 托管,或者把 static 目录也复制到镜像里并配置好。
如果你选择 Vercel,流程会简化很多:Git push 后自动构建、自动部署。Prisma 迁移需要额外注意:Vercel 上没法直接跑prisma migrate,因为那是 CLI 命令。常见做法是在 CI 流程里执行npx prisma migrate deploy,然后再构建应用。没有 CI?那你也可以在本地手动把迁移 SQL 文件拿到生产数据库执行,但务必==不要==在生产环境用prisma migrate dev,它会重置数据甚至触发 seed,后果很严重。
5.2 环境变量与部署时的安全细节
部署时要特别关注三件事:
| 变量 | 本地值 | 生产值建议 |
|---|---|---|
| DATABASE_URL | 本地 PostgreSQL | 云数据库连接串,使用 SSL 模式 |
| NEXTAUTH_SECRET | 随机字符串 | 独立生成,生产环境绝不复用本地值 |
| NEXTAUTH_URL | http://localhost:3000 | 你的正式域名 |
对于生产环境的 secret,我建议用密钥管理工具,比如 Vercel 的环境变量加密、云厂商的 Secret Manager,或者自建 Vault。如果服务器就一台,把 secret 放在系统环境变量里也是可以接受的,但==不要==写进启动脚本或仓库配置文件。
还有一个很多人踩过的坑:NextAuth 的回调地址。如果你配置了NEXTAUTH_URL为http://localhost:3000就部署到线上,那登录时回调地址会指向本地。这个问题在本地把所有环境变量一次性从.env.local复制到环境配置时特别容易发生。
5.3 数据库迁移与种子数据
生产环境数据库的第二个大坑是迁移时机。理想流程是:
- 在本地写 schema 变更,跑
prisma migrate dev生成迁移文件; - 将代码连迁移文件一起提交到 Git;
- 在 CI 或部署流程里执行
prisma migrate deploy,它只会按顺序应用还没有执行过的迁移,不会重置数据。
如果是团队协作,尤其要注意迁移文件不能被人为修改。某个成员本地改了一个已经合并的迁移文件,部署时就会报migration was modified after it was applied。遇到这种问题只能去数据库表_prisma_migrations里比对 checksum,或者让相关人员回退重新生成迁移。
种子数据建议用独立的prisma/seed.ts脚本管理,只在显式调用时运行,不要让它在 migrate 时自动执行。我踩过的一个坑是:写了prisma migrate reset之后忘加--force,交互式终端里它停下来等确认,CI 因此挂掉。如果你用 CI,记得在命令里加上--force。
6. 实测项目里的性能优化与缓存策略
6.1 页面渲染策略的顶层设计
Next.js 14 提供了三种渲染模式:静态生成(SSG)、服务端动态渲染(SSR)、客户端渲染(CSR)。t3code 风格的工程里,你通常要按内容特性来选择。
文章列表这种变化不频繁、但也不是完全不变的页面,适合 On-demand Revalidation(按需重新验证),每次发布文章后调用 API 触发重新验证。文章详情页如果参数不同,建议用 dynamic rendering,避免把所有详情都静态化导致构建时间过长。用户后台这种高度个性化的页面,用 Client Component + tRPC query 最直白。
记住了,不要把整个应用默认设置为静态导出,除非你的内容真的不变化。否则部署后会发现,线上所有页面都是旧的,发布新文章后依然显示旧列表。
6.2 给 tRPC 查询加上合理的缓存时间
React Query(也就是 TanStack Query)默认的staleTime是 0,意思是任何数据一旦获取完,立即视为过期。每次组件挂载都会重新请求。这在页面刚加载时没问题,但如果你在一个页面里来回切换 tab、反复挂载同一个列表组件,就会频繁请求后端。
我推荐在全局 Provider 里覆盖默认 staleTime:
const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { staleTime: 10 * 1000, refetchOnWindowFocus: false, }, }, }) );staleTime: 10_1000表示 10 秒内数据不会被认为过期,组件重挂载时会直接用缓存,不发请求。refetchOnWindowFocus: false关闭了窗口聚焦时自动刷新,避免用户切回来时突然发生一次无法控制的请求。
这个策略适合“数据相对稳定、但不是完全不变”的场景。如果你的数据实时性要求很高(比如体育比分),那 staleTime 应该设短一点或维持默认。
6.3 数据库查询性能的实测调整
当列表页数据量变大之后,我发现嵌套 include 的查询会明显变慢。Prisma 对 include 的处理虽然会做一些合并,但当关联表有大量数据时,生成 SQL 的 JOIN 复杂度和返回数据的冗余度都会上升。
我在一个实际项目里做过一次性能对比,数据规模是 5 万篇文章、每篇平均 3 个标签:
| 方式 | 查询耗时 | 返回数据大小 |
|---|---|---|
include嵌套 | 约 320ms | 约 21MB |
| 先查文章再批量查标签 | 约 85ms | 约 6MB |
差距很明显。你可以用findMany先拿文章,再拿文章的 id 集合去查标签关联表,然后在 JS 层组装:
const posts = await ctx.db.post.findMany({ take: 20 }); const postIds = posts.map((p) => p.id); const tagRelations = await ctx.db.tagOnPost.findMany({ where: { postId: { in: postIds } }, include: { tag: true }, }); const tagMap = new Map<string, typeof tagRelations>(); tagRelations.forEach((relation) => { const list = tagMap.get(relation.postId) ?? []; list.push(relation.tag); tagMap.set(relation.postId, list); }); const items = posts.map((post) => ({ ...post, tags: tagMap.get(post.id) ?? [], }));这个优化本质上是把数据库的重型 JOIN 拆成了两个轻量查询,把关联组装放到了应用内存里。对于中小规模数据,收益非常明显。
7. 调试工具、中间件扩展与进阶玩法
7.1 在 Next.js 里调试 tRPC 请求
开发 tRPC 项目时,最大的便利在于类型提示,但遇到运行时错误,仍然需要一些调试手段。我在日常开发中用到两个工具:
trpc-panel:可以自动生成一个像 GraphQL Playground 一样的可视化面板,列出所有路由、输入 schema、响应类型。直接在项目里启动后访问/panel就能看到。- 浏览器的 Network 面板:tRPC 的请求 URL 形如
/api/trpc/post.list?input=%7B%220%22%3A%7B%22limit%22%3A10%7D%7D,input是 JSON 序列化后的内容。你用浏览器开发者工具能看到清晰的请求和响应结构。
如果某个查询在浏览器里报 500,第一反应不是看页面,而是去服务端日志里找 tRPC 抛出的原始错误。我遇到过一个典型的场景:错误信息在服务端显示为PrismaClientKnownRequestError,但前端只看到了INTERNAL_SERVER_ERROR,因为 tRPC 默认不把内部错误详情返回给客户端。这时候如果你想让开发环境看到详细的错误栈,可以在 router 的 errorFormatter 里配置:
import { TRPCError } from "@trpc/server"; const t = initTRPC.context<typeof createTRPCContext>().create({ errorFormatter: (opts) => { if (process.env.NODE_ENV === "development") { return { message: opts.error.message }; } return opts.shape; }, });生产环境保持默认行为,避免泄露内部细节。
7.2 用中间件封装通用逻辑:请求日志与慢查询监控
tRPC 的中间件能力远比“鉴权判断”强大。你可以像 Express 那样,为特定 procedure 或者全局添加逻辑。
下面是一个统计查询耗时的中间件示例:
const logMiddleware = t.middleware(async ({ path, type, next, input }) => { const start = Date.now(); const result = await next(); const durationMs = Date.now() - start; if (durationMs > 500) { console.warn(`[SLOW] ${type} ${path} took ${durationMs}ms`); } return result; }); export const publicProcedure = t.procedure.use(logMiddleware);这个中间件能帮你快速定位影响用户体验的慢路由。用熟了之后,你还可以结合 OpenTelemetry 把路径、耗时、用户 ID 一起上报到监控系统。tRPC 提供的path和type参数,在排查问题时非常有用——它直接告诉你调用的是哪个路由、是 query 还是 mutation。
7.3 乐观更新与细节交互优化
乐观更新是 React Query 的一个杀手锏。拿“点赞文章”这种高频操作来说,按传统写法:点击按钮 -> 发请求 -> 等响应 -> 更新 UI,会有一个明显的延迟。乐观更新则是:点击后立刻更新 UI,再发请求,失败了再回滚。
在 tRPC + React Query 里这样实现:
const likePost = trpc.post.like.useMutation({ onMutate: async (postId) => { await utils.post.byId.cancel({ id: postId }); const prev = utils.post.byId.getData({ id: postId }); utils.post.byId.setData({ id: postId }, (old) => ({ ...old!, likes: old!.likes + 1, })); return { prev }; }, onError: (err, postId, ctx) => { utils.post.byId.setData({ id: postId }, ctx?.prev); }, onSettled: (postId) => { utils.post.byId.invalidate({ id: postId }); }, });核心逻辑是:onMutate时先取消正在进行中的相关查询(避免旧数据覆盖新数据),然后立即用setData更新本地缓存,返回之前的缓存快照;onError时用快照回滚;onSettled时再做一次后台失效重取,确保与服务器状态一致。这套模式的体验是:点击按钮,数字立刻变化,没有任何等待感。
7.4 将 t3code 扩展成多端架构
最后聊一下扩展性。tRPC 最大的一个卖点是它不限定于 Next.js。如果你后续需要做一个 React Native 的移动端,或者一个 Electron 桌面客户端,都可以直接使用这份AppRouter类型。
移动端只需要:
import { createTRPCClient, httpBatchLink } from "@trpc/client"; import type { AppRouter } from "./server/trpc/router"; const trpc = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: "https://your-api.com/api/trpc" })], }); const posts = await trpc.post.list.query({ limit: 10 });你不需要重新定义任何接口、任何类型。后端 router 定义了哪些路由,前端客户端就能直接调用哪些。这是 tRPC 相对传统 REST 的最大优势——当项目从 Web 扩展到移动端、桌面端时,接口层不需要重复建设和维护。而 t3code 这类代码实践项目,恰恰就是帮你把这条通路从第 0 行代码开始就铺好,让后续的每一次扩展都建立在类型安全的基础上。
写在最后:我实际推进项目的体会
如果你以前写全栈项目时,总是被前后端类型不一致、字段对不上、文档过时这些问题搞得恼火,那 T3 技术栈是一个非常值得尝试的替代方案。把 tRPC 引入到现有 Next.js 项目的改造量其实可控——你不需要一次性把所有路由都改成 tRPC,可以先用它新建一个小模块,跑通了再逐步替换。我个人的经验是:当一个项目里 tRPC 路由超过 15 个之后,维护成本会比传统 REST API 低很多,因为每一次改动,编译器都在帮你检查两端是否同步。
最后再分享一个实用小技巧:把 tRPC 的createCaller封装到底层函数,抽出一个“服务端数据访问层”,这样无论是 Server Component、Page Router 的 getServerSideProps,还是深夜加班时写的一个脚本,都能复用同一套带鉴权的业务代码,而不会被 UI 层绑死。t3code 这类项目在我看来最大的价值就是给人一种启发:代码的组织方式,决定了你未来一年里每个加班的夜晚究竟是轻松部署还是焦头烂额。