T3 Stack 实战:用 TypeScript、Next.js、tRPC、Prisma 构建全栈内部工具

发布时间:2026/10/26 23:35:00
T3 Stack 实战:用 TypeScript、Next.js、tRPC、Prisma 构建全栈内部工具

去年年底我接了一个内部工具的重构需求,项目代号就叫t3code。最初的需求很朴素:把团队散落在各个文档里的流程、命令和模板整理成一套可交互的网页工具,让新同学能照着操作,老同学能快速查参数。但做着做着,我发现这个项目的技术选型比业务本身更值得聊——它最终落到了 TypeScript + Next.js + tRPC + Prisma + Tailwind 这一整套全栈组合上,也就是社区里常说的 T3 Stack。这篇文章我会把t3code从立项到部署全过程的思路、配置、代码和踩过的坑完整写出来,如果你正在纠结要不要用这套技术栈,或者已经入坑想看看别人的实践,这篇应该对你有用。

先说明一下,t3code不是什么开源框架,也不是某个现成产品的仓库名,它只是我给这个内部项目起的代号。但这个代号背后选型的过程,以及每一层技术承担的角色,完全可以作为一个全栈项目的参考样本。接下来我会按照项目推进的时间线来讲:先讲整体设计和技术选型,再讲脚手架搭建和数据库建模,然后是 tRPC 的完整链路实现,最后是部署和那些你上网搜都不一定搜得到的坑。

1. 项目背景与整体设计思路

1.1 项目要解决的痛点与拆解方式

t3code这个项目的原始需求,本质上是把团队里的“隐形知识”变成“显性工具”。我之前统计过,团队内部常用的命令、脚本、配置片段大概有六十多条,分散在 Confluence、群公告、个人笔记里,版本还对不上。比如同一个部署命令,有人写成 Docker 形式,有人写成裸进程形式,新同学照着旧文档跑必然报错。

所以第一版设计就定了三个原则:所有内容必须有唯一数据源、所有操作尽量能在线执行而非复制粘贴、所有代码示例必须与实际代码仓库保持同步。

为了做到这三点,技术选型一开始就没什么悬念。前端需要一套成熟的 React 生态,后端需要一个能直接复用前端类型定义的服务端方案,数据库需要一个支持快速迭代且类型安全的 ORM。结果就是 TypeScript 打底,Next.js 做页面和接口宿主,tRPC 做端到端类型安全的通信,Prisma 管理 PostgreSQL,最后用 Tailwind 快速搭界面。这五个组件正好就是 T3 Stack 的完整阵容。

这里我多说一句为什么不用传统的 REST 方案。如果按 REST 来写,前端需要维护一套 API 路径常量,后端需要写路由和中间层校验,两边各维护一份类型定义,联调时总会有一方先改。tRPC 的思路是让你直接“调用函数”,而且函数签名里的类型是自动从前端推断到后端的,改一处,另一处马上报错。对于t3code这种需要频繁增删字段的配置管理工具,这个特性省下的是实实在在的开发时间。

1.2 为什么用 T3 Stack 而不是其他组合

其实我在立项前也对比过几套方案。第一套是 Next.js + GraphQL + TypeScript,GraphQL 的 schema 定义和安全校验都很强,但对一个几十个接口的内部工具来说,写 schema、配 resolver、维护 fragment 的复杂度偏高,收益不明显。第二套是 NestJS + React 前后端分离,工程上是标准做法,但两个仓库之间的联调和类型同步成本,在两个人开发的小项目里会被放大。

最终定下 T3 组合的核心原因可以归纳成三条:

  • 单一代码仓库、单一语言、单一类型系统,前后端不存在“跨语言边界”。
  • 初始化体验极好,create-t3-app会把 Next.js、tRPC、Prisma、Tailwind 的工程模板一次性装好,省去手工拼装的时间。
  • 社区维护积极,Next.js 和 Prisma 的版本迭代都会被模板及时跟进。

另外要提一个反向观点:T3 Stack 不是银弹。如果你的项目需要对外提供开放 API,或者有其他语言的服务端要对接,tRPC 的“函数调用”模式就不太合适,REST 或 GraphQL 才是兼容性更好的选择。所以这个选型适合“前后端都由同一拨人维护的内部工具类项目”,t3code恰好属于这一类。

2. 从零搭建 t3code:脚手架与核心配置

2.1 初始化项目与选项选择

t3code的初始化我用的是官方脚手架,一条命令就能拉起整套工程:

npx create-t3-app@latest t3code

过程会问你几个问题,这里把当时的选择列出来并解释原因:

  • TypeScript:必选,整个 T3 栈的基石,不选就没有类型安全可言。
  • Next.js:选 App Router 模式,新项目没必要再用 Pages Router。
  • tRPC:选,这是前后端联通的核心。
  • Prisma:选,配合 PostgreSQL 使用。
  • Tailwind CSS:选,界面开发效率和一致性都靠它。
  • 初始化 Git:选,方便随时回滚。
  • 导入路径别名:选,@/别名能让代码结构更清晰。

跑完之后目录结构是这样的核心部分:

t3code/ ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── client.ts │ └── styles/ │ └── globals.css └── .env

这个结构最有价值的地方在于:src/server/api里写路由,src/trpc里提供前后端共享的类型工具,前端页面完全可以直接引用server层的类型,不需要额外的 d.ts 文件。

2.2 环境变量与数据库配置

接下来是数据库部分。t3code用的是 PostgreSQL,我在本地用 Docker 起了个实例,.env 配置如下:

DATABASE_URL="postgresql://postgres:password@localhost:5432/t3code?schema=public" NEXT_PUBLIC_APP_URL="http://localhost:3000"

注意一个细节:create-t3-app生成的模板默认会在src/server/db.ts里用globalThis做 Prisma 客户端的全局缓存,防止开发模式下热更新导致连接数暴增。这个是模板自动处理的,但如果你手动搭建 Prisma,一定要记得自己加这段。

然后执行迁移:

npx prisma migrate dev --name init

这一步会根据 schema 生成迁移 SQL 并同步数据库,同时生成最新的 Prisma Client,整个链路到这里就通了。

2.3 Tailwind 主题定制

界面方面,我没有用现成的 UI 组件库,而是直接用 Tailwind 写了基础样式。t3code是一个内部工具,不需要花哨的视觉,要的是信息密度和一致性。我在globals.css里定义了几个关键的设计变量:

@tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 210 40% 98%; --foreground: 222 47% 11%; --primary: 221 83% 53%; --muted: 214 32% 91%; } }

这样做的原因有两点:第一,CSS 变量驱动的主题方便后续加暗色模式,不需要改组件代码;第二,Tailwind 的hsl(var(--...))写法比硬编码颜色更可维护。写组件时直接bg-background text-foreground就能跟随主题变化,非常顺手。

3. 核心链路:用 tRPC 打通全栈类型安全

3.1 tRPC 的三个核心概念

tRPC 上手前,先记住三个词:procedure、router、caller。procedure 是对后端函数的一个抽象,分 query(查询)和 mutation(写操作)两类;router 是多个 procedure 的集合,可以嵌套;caller 是服务端内部调用路由的方式,用于在 Next.js 的 Server Component 里直接拿数据。

t3code里我设计了一个基础路由结构:

// src/server/api/routers/entry.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const entryRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.entry.findMany({ orderBy: { updatedAt: "desc" }, }); }), byId: publicProcedure.input(z.string()).query(async ({ ctx, input }) => { return ctx.db.entry.findUnique({ where: { id: input } }); }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), tags: z.array(z.string()).default([]), })) .mutation(async ({ ctx, input }) => { return ctx.db.entry.create({ data: input }); }), });

这里的zod校验是 tRPC 框架强推的,因为输入类型和安全校验可以一次定义。比如create这个 mutation,如果前端传了空字符串,程序会在进数据库之前直接拒绝,不需要在业务代码里再写一遍 if 判断。

3.2 一个完整的 CRUD 实现链路

上面这段只是服务端的路由,前端是怎么调用的呢?t3code的页面组件里调用方式是这样的:

"use client"; import { api } from "~/trpc/client"; export function EntryList() { const { data, refetch } = api.entry.list.useQuery(); const createEntry = api.entry.create.useMutation({ onSuccess: () => refetch(), }); return ( <div> {data?.map((entry) => ( <div key={entry.id}> <h3>{entry.title}</h3> <p>{entry.content}</p> </div> ))} <button onClick={() => createEntry.mutate({ title: "test", content: "hello" })} > 添加 </button> </div> ); }

这段代码里有两个容易被忽略但很关键的点。

第一,api.entry.list.useQuery()返回的data类型,是直接从服务端entry.list这个 procedure 的返回值推断出来的。也就是说,如果后端改了返回结构,前端这里会立刻出现类型报错,不用等接口联调发现问题。

第二,createEntry.mutate的入参类型,也是从服务端输入校验的 zod schema 自动推断的。服务端要求title必须是字符串且至少 1 个字符,那么前端传数字或空字符串,在编辑阶段就会报错。

这里的时间收益是很直观的:内部工具的需求变化特别频繁,昨天可能还要tags字段,今天就要改成category,在 REST 体系下要改后端 schema、前端 interface、接口文档三处;在 tRPC 下只需要改一处,剩下两处类型会自动传播。

3.3 tRPC 和 Next.js Server Component 的结合

除了客户端调用,t3code的很多页面其实不需要交互,只需要在服务端渲染时把数据取好。这种情况下不需要走 HTTP,而是直接用createCaller:

// src/app/entries/page.tsx import { api } from "~/trpc/server"; export default async function EntriesPage() { const entries = await api.entry.list(); return ( <main> {entries.map((entry) => ( <div key={entry.id}>{entry.title}</div> ))} </main> ); }

注意这里的api是~/trpc/server导出的,不是客户端那个api。它会在服务端直接调用 router 里的函数,数据不经过网络栈,性能上比传统 SSR 还要好。这种写法非常适合内容展示类的内部页面。

不过要注意,Server Component 里不能直接调用需要登录态的 mutation,因为 mutation 本身需要返回给前端触发。t3code的做法是:只读数据用 Server Component,需要交互的表单、编辑操作都用 Client Component 调 mutation。

4. 数据建模与迁移实战:Prisma 的细节与坑

4.1 t3code 的数据模型设计

t3code里最核心的数据是“条目”,也就是那条命令或配置片段。它的模型定义如下:

model Entry { id String @id @default(cuid()) title String content String category String? tags String[] status Status @default(ACTIVE) authorId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum Status { ACTIVE ARCHIVED } model Tag { id String @id @default(cuid()) name String @unique entries Entry[] }

这里我特意选了cuid()而不是自增 id,原因很简单:内部工具的数据会频繁导入导出,cuid 不会因为数据合并而冲突。tags直接用 PostgreSQL 的 string[] 类型,省一张关联表,查询hasTag的场景用tags && ["docker"]就能完成,够用且简单。

关于category,一开始我用了必填字段,后来发现实际录入时很多条目其实不好归类,改成了可选。这个调整是在实践过程中发现的,也印证了数据建模初期别把约束设得太死,尽量给可选,等数据量上来了再收紧更稳妥。

4.2 迁移与重置的坑

Prisma 的migrate dev在开发环境很好用,但我踩过一个具体的坑:当我把一个字段从必填改成可选时,prisma migrate dev生成了一个带ALTER COLUMN的 SQL,但本地数据库已经有数据,老数据里这个字段是 null,导致在线迁移失败。

这个问题的实际处理方案是用prisma db push来绕过迁移历史,直接同步 schema 到数据库。但注意,这会改变数据库结构而不产生迁移文件,所以只在纯开发环境用。如果是多环境部署,还是老老实实改迁移 SQL,或者在 schema 改动前先写一个默认值填充脚本。

另外还有一个高频坑:改了schema.prisma之后,Prisma Client 的代码是自动生成的,但在 Next.js 开发模式下偶尔不会自动刷新,导致新字段报“Unknown argument”。处理方法是强制重新生成:

npx prisma generate

然后重启 dev server,基本都能解决。

4.3 种子数据与测试数据准备

t3code需要一个稳定的种子数据来验证界面和接口。我在prisma/seed.ts里面放了十几条真实常用的命令模板,然后用 tsx 运行:

npx tsx prisma/seed.ts

种子脚本用 upsert 而不是 create,这样重复执行不会报唯一键冲突:

import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const dockerEntry = await prisma.entry.upsert({ where: { id: "seed-docker" }, update: {}, create: { id: "seed-docker", title: "Docker 容器启动模板", content: "docker run -d --name demo -p 8080:80 nginx", tags: ["docker", "nginx"], }, }); console.log(`Seeded: ${dockerEntry.title}`); } main().finally(() => prisma.$disconnect());

这里用固定 id 是一个经验之谈:开发时反复 seed 不会生成重复数据,而且可以放心地在代码里硬编码这个 id 作为界面演示数据。

5. 部署链路与性能优化要点

5.1 部署到 Vercel 的完整配置

t3code最终部署在 Vercel 上,因为 Next.js 应用用它部署最省心,而且支持自动预览部署,分支推送就能生成独立预览环境,对团队协作很友好。主要配置如下:

  • 环境变量:DATABASE_URL 用生产 PostgreSQL 的地址。
  • 构建命令:prisma migrate deploy && next build,注意这里是migrate deploy而不是migrate dev,前者只会执行已生成的迁移文件,不会在 CI 里创建新迁移。
  • 框架预设:Vercel 会自动识别 Next.js,不需要额外配置。

数据库用的是 Neon 的 PostgreSQL 免费版,本身支持连接池。不过在 Vercel Serverless 环境下,数据库连接数很容易被并发请求打满,我的建议是检查.env里的DATABASE_URL是否包含连接池参数,例如?pgbouncer=true&connection_limit=1。这一点很多人会忽略,但实际部署后遇到连接数报错,九成是这个原因。

5.2 构建体积与图片优化

t3code这个项目本身不依赖重型可视化库,但 Next.js 默认会把所有页面打包到构建产物里,时间久了 bundle 会膨胀。我做了两个优化:

第一,动态导入只在需要时加载的重组件,例如代码高亮器:

const CodeBlock = dynamic(() => import("~/components/code-block"), { loading: () => <p>加载中...</p>, });

第二,所有静态图片走 Next.js Image 组件,自动处理尺寸和 webp 格式。对于内部工具,图片不多,但每次访问都要拉原始大图也没有必要。Image 组件的sizes属性一定要写,否则会浪费不少带宽:

<Image src={thumbnail} alt="cover" width={400} height={225} sizes="(max-width: 768px) 100vw, 400px" />

5.3 性能数据与复盘

上线后我观察了 Vercel 分析面板,t3code的首页 LCP 稳定在 1.2 秒以内,交互页面 INP 在 200ms 以内。对于一个内部工具来说这个数据已经足够好。需要注意,如果未来条目数量增长到上万条,entry.list的裸查可能会成为瓶颈,那时需要在 Prisma 查询里加take分页参数,或者引入搜索。目前t3code的数据量在几百条量级,索引都命中,没必要过度设计。

6. 常见问题与排查技巧实录

6.1 类型不更新的典型问题

tRPC 类型传播是它的卖点,但开发中有一个很烦的现象:后端路由改了,前端报错却还是旧类型。这个通常不是 tRPC 的问题,而是 TypeScript 的编译器没有重新生成声明。在我遇到的情况里,重启 Next.js dev server 能解决九成问题,剩下的一成是tsconfig.json的include没有覆盖src/trpc目录。

排查步骤整理如下:

# 1. 强制重新生成 Prisma Client npx prisma generate # 2. 检查 tRPC 类型,看是否有显式 any # 在代码里搜索 : any,如果有,替换为具体类型 # 3. 重启开发服务器 # 如果还不行,删除 .next 目录再启动 rm -rf .next && npm run dev

这个三板斧在几十个项目里都验证够了,不要一上来就怀疑框架有问题。

6.2 Prisma 连接数超限的排查

前文提到了连接池,这里展开讲一下真实的情况。部署初期t3code在 Vercel 上经常报Too many clients错误。排查步骤是:先看日志确认是数据库连接数超过 Neon 的限制,再看DATABASE_URL有没有配置连接池参数,最后检查代码里有没有重复创建 PrismaClient。

create-t3-app模板自带globalThis缓存,不会重复创建,最终问题就落在连接池参数上。加上之后服务稳定了,没有再次出现连接数告警。

6.3 常见问题速查表

我整理了一张表,记录了t3code开发中实际遇到的高频问题,查阅或排查起来很省事。

现象原因解决方案
本地接口返回 500Prisma Client 版本与数据库 Schema 不匹配执行npx prisma generate后重启 dev server
部署构建失败迁移文件未执行构建命令加npx prisma migrate deploy
生产环境连接数报错缺少连接池参数DATABASE_URL 加?pgbouncer=true&connection_limit=1
前端类型与后端不一致dev server 缓存旧类型rm -rf .next后重启
种子脚本重复执行报错未使用 upsert改用upsert并设置固定 id
字段改名后迁移失败数据库已有数据手动编写迁移 SQL 或先补默认值

6.4 两个值得分享的排查经验

除了上面这些常规问题,再讲两个t3code特有的经验,都是网上教程很少提到的。

第一个是 Server Component 里调用 tRPC 时,如果不小心把query写成了mutation,浏览器端不会直接报错,而是会在服务端渲染过程中抛出一个非常不直观的错误。排查的时候要看完整堆栈,别只盯浏览器 console。正确的做法是:只读数据用api.someRouter.query,写操作才用mutation,两者不要混用。

第二个是 Tailwind 的自定义颜色变量在服务端渲染时可能会闪烁,因为 CSS 变量在服务端首屏渲染时还没挂载到:root。解决办法是确保变量定义在@layer base里,且没有在组件里覆盖同名变量。这个问题在新项目初始阶段不明显,但一旦有人往全局样式文件里多写了几个--primary,就会开始乱。

讲一个更实际的经验。t3code第二次迭代时,我需要批量更新几十条 seed 数据,当时偷懒没有写脚本,直接在 Prisma Studio 里手动改。改到一半发现某个字段要全局替换,才意识到这个操作应该在脚本里做。后来我整理了一遍规范:所有数据的导入导出、批量更新、字段迁移,都必须通过prisma/scripts目录下的脚本执行,禁止直接在数据库管理工具里手改。这个规范看起来是自找麻烦,但它保证了所有变更都有记录、可回放,对于内部工具来说,这比花哨的功能更重要。

我自己的体会是,T3 Stack 给的真正优势,不是某一个框架多强,而是它们组合起来把“定义一次、到处使用”变成了现实,前后端共享类型带来的省心感,只有当你改完后端字段,前端整条链路自动同步时才有直观感受。如果你手上也有类似t3code这种前后端同团队维护的内部项目,不妨按这套思路试一次,从某种意义上说,它会改变你对全栈开发的某些习惯。最后留个扩展建议:下一步可以给t3code加上简单的基于会话的登录鉴权,接入 tRPC 的 middleware,这样每一条 procedure 都能统一控制访问权限,项目的完整度会再上一个台阶。