
去年年底我接了一个内部工具的重构需求项目代号就叫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 TypeScriptGraphQL 的 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-applatest 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_URLpostgresql://postgres:passwordlocalhost:5432/t3code?schemapublic NEXT_PUBLIC_APP_URLhttp://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是否包含连接池参数例如?pgbouncertrueconnection_limit1。这一点很多人会忽略但实际部署后遇到连接数报错九成是这个原因。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} altcover 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 加?pgbouncertrueconnection_limit1前端类型与后端不一致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 都能统一控制访问权限项目的完整度会再上一个台阶。