t3code 是我最近在跑的一个全栈项目仓库代号核心目标很直接用一套 TypeScript 技术栈把 Web 应用从数据库一路做到浏览器中间不换语言、不手写第二套类型。这个代号里的 t3指的就是 T3 Stack——Next.js、Tailwind、tRPC、Prisma 这一组组合再加一个 TypeScript 贯穿始终。如果你正在纠结要不要上全栈 TS 方案或者已经被前后端联调时的类型问题折磨过这篇文章里从选型理由、项目骨架、数据层、鉴权链路到上线部署和踩坑排障全部都有。我会用 t3code 这个真实仓库的开发过程来讲尽量把“为什么会这样做”也讲透而不只是丢一堆配置。现在的全栈开发其实很割裂。大部分团队是 React 前端加 Node 后端前端维护一份请求封装后端维护一份接口文档中间再用 Postman 或者 OpenAPI 同步。类型安全基本靠自觉。我开 t3code 这个仓库的初衷就是想验证一件事如果整个链路都用 TypeScript 的类型推断来驱动前后端的对接到底能省掉多少无效沟通。1. 为什么是 t3code一次全栈技术选型的冷思考1.1 从联调痛苦说起我在以前的团队里做过一个小型管理后台技术栈是 React Express 普通 REST API。页面不算复杂问题却集中在类型上后端返回的createdAt是字符串还是 Date分页接口的page从 1 开始还是从 0 开始这些细节靠口头约定前端每次都要写 interface后端每次改字段前端编译直接报错的地方一片红。更麻烦的是某些接口在页面里根本用不到但请求还是会发出去仅仅因为后端把数据一次性返回。为了一个小标签页的状态前端需要提前知道一堆不相关的字段接口文档越来越厚越来越没人看。后来我把这个项目命名为 t3code就是想从头建立一个“类型即契约”的示例让团队里的人看看接口的定义和维护可以不那么痛苦。1.2 锁定 T3 组合的取舍逻辑T3 Stack 不是一个新框架它是几个成熟工具的“组装方案”核心是让 TypeScript 的类型系统贯穿所有层级。具体到我这里的选型逻辑Next.js 负责页面路由、服务端渲染和 API 路由。App Router 的 Server Component 让大部分数据获取在服务端完成减少客户端请求。tRPC 负责前后端调用协议。它不生成文档不定义 schema而是直接从后端 router 的返回值倒推出前端调用时的类型。后端改了字段前端使用的地方立刻编译报错。Prisma 负责数据库访问。它用 schema 文件定义表结构同时生成完整的 TypeScript 类型查询语句的返回类型可以精确到字段级别。Tailwind CSS 负责样式接着 TypeScript 生态的“确定性”风格用工具类把设计约束固定下来。NextAuth 负责登录态在同一条类型链路里提供用户 session。有人会问为什么不用 GraphQLGraphQL 确实是种方案它通过 schema 定义 API也有类型生成工具。但引入 GraphQL 意味着要维护 resolver、query、mutation、fragment 一套体系对中小型项目和内部系统来说偏重。tRPC 不做抽象它就是函数调用前端直接调用后端函数类型自然流通。1.3 和传统方案的直观对比我把之前用过的几种方案放一起看了下差异主要在类型维护成本和请求控制粒度上方案类型来源前端请求方式典型痛点适合场景REST 手写 interface手动维护fetch/axios接口变更后类型不同步团队已有后端OpenAPI 生成客户端代码生成器生成的 SDK生成器版本、命名难控制公司级 API 平台GraphQLschema 驱动query/mutationresolver 重复、缓存策略重多端消费复杂数据tRPC后端函数类型推断函数调用需要前后端同仓或 monorepo全栈 TS 项目tRPC 有个隐藏前提前后端最好在同一个代码仓库里否则要共享类型包。t3code 从一开始就选择了单仓因为我不只是为了做接口而是要把完整功能做出来。单仓意味着改一个 Prisma model从数据库迁移到前端页面提示全部跟着变这是这个项目最爽的地方。2. 从 create-t3-app 落地t3code 的基础骨架与目录设计2.1 脚手架初始化流程我用脚手架生成初始项目避免手写一堆模板代码pnpm dlx create-t3-applatest t3code交互式命令行里会问要不要启用各个模块。我全部选了 yesTypeScript、Tailwind CSS、tRPC、Prisma、NextAuth、App Router。值得提醒的是如果你确定用不到 Prisma可以先不勾后面手动加虽然可以但 create-t3-app 默认帮你配好的db.ts单例、seed 脚本入口这些都要重新搭不划算。数据库驱动我选了 PostgreSQL。这个选择有理由Prisma 对 PostgreSQL 的关系查询、枚举类型、Json 字段支持都最完整。SQLite 适合速原型但在并发场景下的表现差很多生产环境早晚要换与其后面迁移不如一开始就统一。2.2 目录结构背后的三条边界脚手架生成后目录结构是这样的t3code/ prisma/ schema.prisma seed.ts src/ app/ api/ auth/[...nextauth]/route.ts trpc/[trpc]/route.ts _trpc/ Provider.tsx layout.tsx page.tsx server/ api/ root.ts routers/ trpc.ts auth.ts db.ts trpc/ server.ts react.tsx shared.ts这个结构把三层职责分得很清楚src/server是后端层所有数据库、鉴权、业务函数都在这src/trpc是 tRPC 的桥接层把后端 router 暴露给前端src/app是 Next.js 的页面层。理解这个分层之后写代码就很少出现“不知道放哪”的问题。src/server/db.ts里有个容易被忽略的细节用globalThis缓存 PrismaClient 实例。开发环境的热更新会让模块反复加载如果不缓存每次刷新都新建数据库连接局部开发到后期连接数能堆到几十个。2.3 环境变量与基础配置项目根目录的.env需要配置这几项DATABASE_URLpostgresql://user:passwordlocalhost:5432/t3code NEXTAUTH_SECRET替换成随机字符串 NEXTAUTH_URLhttp://localhost:3000NEXTAUTH_SECRET可以用openssl rand -base64 32生成。很多人开发时随便填一个固定值等服务部署到线上才想起来Secret 一变所有登录用户的 session 直接失效。这个在部署前一定要把生产环境的 Secret 单独生成并保管好。TypeScript 路径别名也建议一开始就配好。create-t3-app 默认支持/指向src/我实际开发中基本不会写相对路径因为组件嵌套深的时候../../../../实在太容易出错。3. 类型安全链路的核心tRPC 过程与数据访问的接线方式3.1 tRPC 的自动类型推断原理tRPC 最大的卖点是不生成任何接口文档却能保证前端调用时类型完全匹配。它靠的是 TypeScript 的infer能力后端定义了一个router内部每个 proceduretRPC 对 query/mutation 的称呼都有明确的输入输出类型前端从同一个 router 类型里导出 hooks 后TypeScript 就能在编译期推导出传入参数和返回值类型。这等于把类型定义从“代码之外”搬到了“代码本身”。我在 t3code 里改了一个接口的返回结构前端页面里用到这个字段的地方全部飘红这种体验是传统 REST 没法给的。3.2 定义第一个带 zod 校验的 mutation拿任务模块举例。t3code 里有一个任务列表功能任务归属于项目需要做一个“切换完成状态”的接口。用 tRPC 写起来是这样的import { z } from zod; import { TRPCError } from trpc/server; import { createTRPCRouter, protectedProcedure } from /server/api/trpc; export const taskRouter createTRPCRouter({ list: protectedProcedure .input(z.object({ projectId: z.string().min(1) })) .query(async ({ ctx, input }) { return ctx.db.task.findMany({ where: { projectId: input.projectId, project: { userId: ctx.session.user.id }, }, orderBy: { order: asc }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) { const task await ctx.db.task.findUnique({ where: { id: input.id }, select: { project: { select: { userId: true } } }, }); if (!task) { throw new TRPCError({ code: NOT_FOUND, message: 任务不存在 }); } if (task.project.userId ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN, message: 无权操作 }); } const updated await ctx.db.task.update({ where: { id: input.id }, data: { done: true }, select: { id: true, done: true }, }); return updated; }), });zod 在这里同时承担了运行时校验和类型推导两件事。z.object({ projectId: z.string() })会自动推导出input的类型前端调用时如果多传或漏传字段编译就直接报错不需要等请求打过去才发现。3.3 服务端调用与客户端调用的差异tRPC 的调用分两种在 Server Component 里可以直接用服务端 caller不用发 HTTP 请求import { api } from /trpc/server; export default async function ProjectPage({ params }: { params: { id: string } }) { const tasks await api.task.list({ projectId: params.id }); return TaskList initialTasks{tasks} /; }在客户端组件里通过 hooks 调用use client; import { api } from /trpc/react; export function TaskList({ projectId }: { projectId: string }) { const { data, isLoading } api.task.list.useQuery({ projectId }); if (isLoading) return div加载中.../div; return ( ul {data?.map((task) ( li key{task.id}{task.title}/li ))} /ul ); }两种调用方式的底层逻辑完全不同。服务端 caller 直接执行函数不经过网络层所以它天然适合首屏数据加载的场景客户端 hooks 则像 React Query 一样处理缓存、重试和失效更新。t3code 里的经验是能服务端拿的数据就不要到客户端再发一次请求服务端调用既快又安全还少暴露接口细节。3.4 鉴权钩子protectedProcedure 做了什么protectedProcedure不是魔法它本质上是publicProcedure加了一层 session 校验import { getServerSession } from next-auth; import { authOptions } from /server/auth; import { db } from /server/db; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); return { db, session, headers: opts.headers }; }; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); });问 anyone 用户都能访问公共接口但protectedProcedure保证后面注册的接口必须登录。ctx 里直接拿到session.user.id在查询时再加一层数据归属校验这就是防御纵深登录只是第一步还要确认“这数据是你的”。我见过不少项目只做了登录校验没做归属校验一个用户改个 id 就能看别人的数据这在 t3code 里是绝对不允许的。4. Prisma 数据层实战t3code 的表结构、迁移与查询细节4.1 业务模型项目、任务与应用的用户边界t3code 的核心模型很简单用户拥有项目项目包含任务。表结构在prisma/schema.prisma里定义model User { id String id default(cuid()) name String? email String? unique projects Project[] tasks Task[] } model Project { id String id default(cuid()) name String archived Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id]) tasks Task[] } model Task { id String id default(cuid()) title String done Boolean default(false) order Int default(0) projectId String project Project relation(fields: [projectId], references: [id]) }主键我用 cuid 而不是自增 id。自增 id 在分布式环境或者多环境导入导出时容易冲突cuid 是字符串客户端生成也无所谓很多场景下不需要等数据库返回自增值。代价是索引比数值大一点但对于中小项目完全可接受。4.2 迁移、种子数据与 schema 演进定义好模型后创建迁移文件并同步数据库pnpm prisma migrate dev --name init这个命令会在prisma/migrations下生成带时间戳的 SQL 文件同时自动重新生成 Prisma Client。注意migrate dev是开发环境用的它会检测 schema 漂移并允许你继续写生产环境应该用migrate deploy它只执行未应用的迁移不做任何交互式操作。种子数据也必须在项目初期就配好。我在prisma/seed.ts里造了一批用户、项目、任务然后用pnpm prisma db seed填充到本地库。有了种子数据调试前端时不用手工插记录前后端联调的效率会高很多。4.3 关系查询的常见性能陷阱t3code 里最容易出性能问题的地方是关系查询。Prisma 用include一次性级联取数据很方便但无脑 include 会把不需要的数据也全查出来。比如任务列表页面只需要任务的title和done你却把整个project连带用户信息都取回来数据库压力翻了几倍。更严重的是 N1 查询。遍历项目列表时在每个项目里单独查一次任务这就是 N1。Prisma 的include其实可以一次性 join 出来但前提是你真的需要这些数据。我现在的原则是先用select精确到字段再按需include分页数据一定用take/skip限制条数。项目上过生产环境后这些优化直接反映在接口耗时上一页数据从 200ms 降到 50ms 很常见。5. 前端渲染边界Server Components、Tailwind 与客户端状态的取舍5.1 尽量用服务器组件但不要排斥 use clientNext.js App Router 的 Server Component 让 t3code 的大部分页面默认在服务端渲染。首屏数据直接在服务端用await api.xxx()拿返回给页面的已经是完整 HTML这种模式对性能很友好也减少了客户端请求。但交互性强的区域比如任务勾选、弹窗、搜索框必须用客户端组件。我的划分规则很朴素如果这个组件里有useState、useEffect、或者需要响应用户事件就加use client如果它只是把数据渲染出来就保持 Server Component。有人为了追求“全服务端渲染”而把所有组件都改成 RSC结果导致交互区域嵌套混乱。实际上客户端组件可以随意被服务端组件引用只要数据是通过 props 传下去的这就够用了。5.2 Tailwind 的 token 化与动态类名问题Tailwind 在 t3code 里的配置比默认多一点我自定义了几个颜色 token 和断点保证设计稿的间距、圆角、阴影都有唯一来源。组件里基本不再写行内样式视觉改版时只需改tailwind.config.ts全站同步更新。但有个坑必须说Tailwind 的 JIT 编译器只会识别源码中完整的类名字符串。bg- color -500这种动态拼接写法在运行时生成的类名Tailwind 编译期根本看不到样式自然不生效。解决办法有两个一是把所有可能用到的类名完整写进代码二是在tailwind.config.ts里配置safelist让编译器无论如何都保留这些类。做一个带颜色选择器的标签页时我用 safelist 把整个色板的常用类名提前注册了safelist: [ { pattern: /^bg-(red|blue|green|yellow)-500$/, }, ]这是 Tailwind 社区的常规解法不算高级但很多人直到上线才发现某些动态颜色没出来排查很久才定位到是编译期裁剪的问题。5.3 React Query 缓存之外的客户端状态管理t3code 的客户端组件大量使用 tRPC 的 React Query hooks查询结果自带缓存。任务列表切换项目时只要参数不变数据就不会重新请求。真正需要额外管理的是那些不属于服务端数据的 UI 状态比如侧边栏的开关、筛选条件的临时值。我的选择是直接用 React 的useState加 context不再引入 Redux 或 Zustand因为组件树不大全局状态也少工具越重越难维护。弹窗或表单的错误消息也可以放在组件局部状态里。tRPC mutation 返回的错误通过 React Query 的error字段读取配合 zod 在输入端校验大部分错误在请求发出前就被拦截了前端根本不需要大量 if/else 去猜后端返回的错误码。6. 部署与监控t3code 上生产环境的链路编排6.1 数据库连接策略连接池与 Serverless 的兼容t3code 部署到生产环境第一件要处理的事是数据库连接数。Serverless 平台上的每个请求实例都可能会创建自己的数据库连接而传统 PostgreSQL 的连接数是有限制的。如果不加处理并发一旦上来数据库会直接拒绝连接。我的做法有两个层面第一PrismaClient 用globalThis单例缓存避免同一个实例重复创建连接第二连接串里显式限制连接池大小DATABASE_URLpostgresql://user:passwordhost:5432/db?connection_limit5如果是托管 Postgres比如 Neon 或 Supabase通常有配套的连接池代理连接串会带?pgbouncertrue。有代理的时候Prisma 会走事务级连接池进一步减少连接占用。这里要特别注意连接池参数不是随便加的它必须和数据库服务端匹配不然反而会出连接超时。6.2 Vercel 与 Docker 自托管的选择t3code 在上线时对比过两个部署方案维度VercelDocker 自托管配置成本低连 Git 仓库即可自动部署需要自己维护镜像和进程扩展性自动扩缩容Serverless 模式需要自己搭负载均衡和监控数据库连接数受限依赖连接池可以在内网直连延迟更低冷启动有冷启动问题常驻进程无冷启动完全控制受平台限制完全可控最终我是 Vercel 和 Docker 都跑通了。Vercel 适合快速演示和低流量阶段Docker 适合稳定服务。Docker 部署要用 Next.js 的 standalone 输出你需要在next.config.js里加module.exports { output: standalone, };然后用多阶段构建把产物复制到最小镜像里。这一步省掉了 node_modules 里的开发依赖镜像体积能缩小一半以上。6.3 日志、错误追踪与性能防守t3code 自托管后日志直接打到 stdout然后由容器平台收集。开发环境我用了 pino生产环境也保持一致因为 pino 输出的是结构化 JSON检索和聚合都很方便。Sentry 接在了 Next.js 的 instrumentation 上前后端错误都统一上报前端某些用户操作异常、后端 tRPC 的 500 错误都能在同一个面板里看到堆栈。性能防守方面React Query 的缓存能减少重复请求但更关键的是数据库查询要控制数量。我给 tRPC 的 query 加了响应耗时记录超过 500ms 的请求会在日志里打 warning这样每次上线后扫一遍日志就能知道哪些接口需要加索引或改分页。6.4 CI 流水线里的检查项t3code 的 CI 在 GitHub Actions 里跑核心检查包括 lint、typecheck、build、prisma generate。特别注意CI 里不能运行prisma migrate dev它会尝试创建 shadow database并且可能因为缺少交互而卡住。正确做法是pnpm prisma generate pnpm lint pnpm typecheck pnpm build迁移操作放在部署阶段单独执行prisma migrate deploy。如果 CI 里直接跑 build 而不先prisma generate通常会因为 Prisma Client 没有生成而导致类型错误或运行时找不到模块这是新人最常见的 CI 失败原因。7. t3code 开发中遇到的五个坑与完整排查路径7.1 坑一服务端渲染时 tRPC context 拿不到 session现象部分页面在刷新后显示未登录但在客户端跳转后又是登录状态。排查链路先在页面里直接打印await getServerSession(authOptions)结果正常。再在 tRPC 的createTRPCContext里打印 session结果为空。进一步对比发现前端正常访问时走的是 HTTP 请求而首屏 Server Component 直接调用 tRPC caller它需要从传入的headers中读取 cookie。Next.js 的 App Router 路由如果被静态化请求头里的 cookie 不会在每个请求上都重新读取session 就丢了。修复方式在src/trpc/server.ts中创建 caller 时调用await headers()这一步会将该路由标记为动态渲染。部署后这个坑只出现一次但排查过程让我对整个 App Router 的服务端 context 机制有了更清晰的认识。7.2 坑二Prisma 在 Serverless 环境连接数耗尽现象Vercel 部署后过不了多久数据库报too many connections。排查链路打开数据库管理面板看到大量 idle 连接。起初以为是连接池没关闭后来发现是多个 Serverless 实例同时启动每个实例都创建了独立连接连接数叠加超过数据库上限。修复方式先在src/server/db.ts里确认全局单例生效再把连接串改为带连接池代理的形式并限制connection_limit。在 Neon 上我用了?pgbouncertrueconnection_limit3上线后连接数稳定在个位数。这个改动虽然小但引发的教训是Serverless 的确改变了连接管理方式连接池不是可选项而是必备方案。7.3 坑三Tailwind 动态拼接类名失效现象标签页选择不同颜色背景色始终不变控制台里类名看起来也对但样式就是没有。排查链路先检查元素发现生成的 class 确实存在但 CSS 文件里根本没有这条规则。马上意识到 Tailwind JIT 是按源码扫描的bg-${color}-500这种字符串在构建时是未知的编译器不会为它生成工具类。修复方式在tailwind.config.ts中增加 safelist。这里有个补充点不要一股脑把整个色板都 safelist 进去只 safelist 业务里真正用到的有限颜色即可否则 CSS 体积明显增大。7.4 坑四乐观更新与查询失效顺序导致旧数据闪现现象点击任务左侧的勾选按钮界面先显示完成随后闪了一下旧数据才变成正确状态。排查链路先检查 mutation 是否成功接口正常。再检查onSettled里的invalidate发现它会重新请求任务列表而这时候服务端可能还没完成事务或者查询缓存未正确处理返回了旧数据。其实根因是onMutate里没有先取消正在进行的查询也没有正确回滚。修复方式在onMutate里先utils.task.list.cancel()然后手动setData更新本地缓存最后在onSettled里才invalidate。这样顺序是取消旧请求、写入乐观值、发起 mutation、最后重新拉取服务端数据。过程中的界面状态就一直是平滑的。7.5 坑五数据库迁移在 CI 里被卡住现象GitHub Actions 的部署步骤执行迁移时超时日志停在某个位置不动。排查链路查看日志发现卡在prisma migrate deploy的分支检测。原因是某个开发分支的迁移文件与主分支产生了冲突数据库里已经有一段不存在的迁移记录部署环境却试图重新执行。这也是我不会在 CI 里跑migrate dev的原因之一dev会默认尝试创建和迁移 shadow database一旦权限不足或环境变量缺失整个过程会一直等待交互输入。修复方式调整流程本地开发用migrate dev部署流水线只用migrate deploy。如果迁移文件有冲突先在本地prisma migrate resolve处理再推到远端。CI 里加上pnpm prisma generate并把迁移步骤从 build 中拆出来单独跑失败更容易定位。最后一个建议也算是我做 t3code 最大的心得既然选了类型安全的链路就要信任它。tRPC 的类型推断、Prisma 的模型类型、NextAuth 的 session 类型这三者串起来以后很多之前需要文档和口头确认的东西都变成了编译期错误。改 schema 的时候不用担心漏掉某个前端页面因为 TypeScript 会帮你全部查出来。这种开发节奏一旦适应真的不太想回到手写接口类型的日子。以后如果继续扩展 t3code我会考虑把任务模块做成独立的 npm 包用 monorepo 拆出共享类型和工具函数让这套方案可以复制到更多项目里。
阅读完成 · 觉得有帮助?