资讯详情

资讯详情

t3code:基于Next.js+tRPC+Prisma的全栈类型安全工程模板

做一个东西最怕的不是功能做不到而是做到一半回头发现代码已经乱到自己都不想维护了。t3code 就是我为解决这个问题折腾出来的全套全栈工程模板。它不是一个知名度很高的框架而是以 TypeScript 语言栈为底座把 Next.js、tRPC、Prisma、Tailwind 这几样东西拧在一起的一套编码实践方案。我对它的定位很直接让数据库里的字段、服务端的接口、前端页面的展示从类型层面一路打通没有断点。这套方案非常适合两类人。一类是正在用 Next.js 写全栈应用但还在被接口联调和类型不一致折磨的开发者另一类是经常起新项目、希望把“鉴权、路由、数据校验、目录组织”这些重复劳动一次性沉淀下来的团队。如果你只是写个纯展示页那用不上它但只要是带登录、带数据写入、带业务状态的中小型应用t3code 这套思路基本能让你少加一半左右的班。下面我把这个方案从设计初衷到具体落地一步步拆开讲清楚。1. 项目定位t3code 到底解决什么问题1.1 三类真实痛点是它的出发点我做了好几年全栈开发最头疼的不是业务复杂度而是工程复杂度。典型的前后端分离项目里前端和后端用 REST 接口通信接口文档或者 Swagger 就是两边的唯一契约。后端改一个字段名前端如果没同步改页面就白屏或者数据悄悄变成 undefined。运气好一点QA 能测出来运气不好用户先踩到。这种“字段断裂”问题在项目中期几乎天天发生。第二个痛点是重复劳动。登录、注册、权限校验、请求日志、错误处理、环境变量校验这些事情在每个新项目里都要重新来一遍。哪怕只是复制粘贴也要花时间改模板、调参数而且最容易出 bug 的恰好是这些非业务代码。第三个痛点是“代码该放哪”的认知负担。项目一旦过了一万行新人不知道路由应该注册在哪类型定义应该写在哪个文件数据库模型和页面组件之间的关系怎么梳理。没有强约定的项目代码风格会迅速碎片化。t3code 的出发点就是冲这三个痛点去的。它不是发明新框架而是把已经被社区验证过的优秀工具用一套合理约定组织起来形成可以直接复用的工程底座。1.2 设计目标从“能跑”到“跑得舒服”我给 t3code 定了几条原则这几条原则直接影响后面所有的技术选型和代码组织方式。第一条是类型全链路贯通。从 Prisma 的数据库 schema 生成类型到 tRPC 的 router 定义再到前端 React 组件的useQuery返回值全程都由 TypeScript 编译器盯着。任何一端改了字段另一端在编译阶段就会报错而不是上线之后用户发现页面少了一块数据。第二条是最大程度减少 API 层的概念负担。传统方案里你需要考虑 REST 资源设计、接口版本、HTTP 状态码语义、错误响应结构。t3code 基于 tRPC接口就是一个 TypeScript 函数调用。你不需要定义“GET /api/user/:id”还是“POST /api/user/list”这种 URL 语义只需要决定:getUserById、createUser。这对中小型应用来说心智负担直接降一个量级。第三条是约定优于配置。目录放在哪里、文件怎么命名、公共组件放哪、服务端逻辑放哪在项目模板里都有明确的位置。新成员进来不需要问“代码放哪”只需要遵循目录结构。第四条是默认安全。登录态的校验不是写在某个接口里而是作为 tRPC 的中间件统一挂在受保护的路由分组上避免开发者忘记给单个接口加鉴权。需要提醒的是这套方案不是银弹。它最适合的是业务逻辑以 CRUD 为主、实时协作需求不强的 Web 应用。如果你的项目是大型多人协同编辑器、IM 系统或者超高性能的 C 端产品那 t3code 这套以易用性为先的选型需要重新评估。2. 技术选型拆解为什么是 Next.js tRPC Prisma Tailwind2.1 四件套各自的角色经常看我文章的朋友应该知道我选技术栈的标准是“这个工具是否显著降低维护成本”。T3 生态这套组合恰好就是围绕这个标准长出来的。工具解决的问题替代方案我的选择理由Next.js全栈渲染框架负责页面、路由、API 入口Vite React Express一套代码同时覆盖前端页面和服务端逻辑目录即路由tRPC端到端类型安全 RPC消除接口文档维护成本REST OpenAPI、GraphQL类型直接从后端函数推到前端不需要生成代码或维护 schemaPrisma类型安全的 ORM负责数据库建模与迁移TypeORM、Drizzle、Kyselyschema 文件直观、迁移工具成熟、类型推导体验最顺滑Tailwind原子化 CSS负责样式普通 CSS、CSS Modules、CSS-in-JS不用维护大量类名改样式不用到处翻文件这里我想重点说一下 tRPC 的选择。很多人第一次听到 tRPC 会问这和 GraphQL 有什么区别GraphQL 的核心优势是客户端按需取字段但它需要写 schema、维护 resolver后端复杂度并不低。tRPC 的思路更朴素——不重新定义接口协议而是让前端像调用本地函数一样调用后端函数类型自然继承过去。它不需要 codegen因为 TypeScript 本身就是协议。对中小型项目来说tRPC 几乎是效率最高的选择。2.2 全链路类型安全是怎么流动的我用一个生活化的比喻来解释 t3code 的类型流动机制。把数据库想象成小区的水塔Prisma 是水管入户的那段标准接口它把水数据转换成带有明确标注的管道类型安全的数据访问层。tRPC 是家里的水龙头它把管道里的水直接接到你的杯子里不会经过任何“第三方转述”。前端组件就是拿起杯子喝水的人。在这个链条里任何一个环节改动了数据形状TypeScript 编译器在后端校验 Prisma 类型、在前端校验 tRPC 返回类型两处都会立刻报错。你不会等到部署上线、用户打开页面才发现“哦这个字段没了”。代价是编译速度会慢一点但和联调时节省的时间相比完全划算。2.3 目录结构如何支撑这套设计t3code 的目录结构看起来比普通 Next.js 项目多几层但每一层都有明确职责新成员看一眼就知道代码该往哪里放。典型结构如下└── src/ ├── server/ │ ├── api/ │ │ ├── routers/ │ │ │ ├── post.ts │ │ │ └── user.ts │ │ └── root.ts │ ├── auth.ts │ └── db.ts ├── pages/ │ ├── api/ │ │ └── trpc/ │ │ └── [trpc].ts │ └── index.tsx ├── styles/ │ └── globals.css └── utils/ └── api.tsserver/db.ts负责 Prisma Client 的实例化server/auth.ts封装登录态获取逻辑server/api/routers按业务域拆分子路由最后在root.ts统一合并。前端页面通过utils/api.ts里导出的trpc对象发起调用。所有关于“数据从哪里来、如何保证类型”的逻辑都收拢在server这一层页面组件里基本只关心业务数据。3. 从零搭建 t3code核心实操记录3.1 初始化项目与依赖安装最快的方式是使用社区官方的脚手架我用的命令如下先拿它把基础骨架生成出来再按 t3code 的思路调整pnpm dlx create-t3-applatest my-app如果只想自己手动搭一遍核心依赖也就这几个pnpm add next react react-dom tanstack/react-query zod pnpm add trpc/server trpc/client trpc/react-query trpc/next pnpm add prisma prisma/client pnpm add tailwindcss autoprefixer postcss pnpm add superjson之所以加superjson是为了解决 Date、Map 等复杂对象在 tRPC 序列化时的兼容问题。没有它你返回一个Date类型到前端会变成字符串类型上还是 Date运行时就出错这种深浅不一致非常坑。3.2 数据库建模与 Prisma 接入建一个博客或者笔记类应用是最能覆盖典型场景的。我定义两个模型用户和笔记它们构成一对多关系。schema.prisma的写法如下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? notes Note[] } model Note { id String id default(cuid()) title String content String? createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id]) }这里有几个细节值得说明。ID 字段我用cuid()而不是自增整数原因是后续如果做分布式部署或者数据合并字符串 ID 比整数冲突概率小得多。?表示字段可空updatedAt会自动维护最后修改时间。完成建模后执行一次迁移和类型生成pnpm prisma migrate dev --name init pnpm prisma generateprisma generate这一步非常关键它根据 schema 生成完整的 TypeScript 类型到node_modules/.prisma/client。后面所有 tRPC Router 里对数据库的访问类型都由这里推导。3.3 编写第一个 tRPC Routerserver/api/routers/note.ts是业务逻辑的核心。我给它加上了基于 zod 的输入校验、数据库操作、以及一个需要登录才能调用的“创建笔记”过程。import { z } from zod; import { createTRPCRouter, publicProcedure, protectedProcedure } from ../trpc; const createNoteInput z.object({ title: z.string().min(1, 标题不能为空), content: z.string().optional(), }); export const noteRouter createTRPCRouter({ listMine: protectedProcedure.query(({ ctx }) { return ctx.prisma.note.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(createNoteInput) .mutation(({ ctx, input }) { return ctx.prisma.note.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), // 演示一个不需要登录也能访问的公开查询 latest: publicProcedure.query(({ ctx }) { return ctx.prisma.note.findMany({ take: 5, orderBy: { createdAt: desc }, select: { id: true, title: true }, }); }), });注意create使用mutation而不是query这是 tRPC 的一个约定任何会修改数据的操作都应该用mutation查询数据用query。这样在前端调用时语义清晰也能配合 React Query 的缓存机制做失效处理。protectedProcedure和publicProcedure是 tRPC 的中间件机制。它确保只有登录用户才能调用create未登录会抛错。像这种“创建笔记必须登录” 的鉴权逻辑你不会忘加因为漏掉的可能性在中间件层面被堵住了。3.4 挂载 Router 到根节点在server/api/root.ts里把子路由合并这样前端只需一个统一的trpc对象就能访问所有接口。import { noteRouter } from ./routers/note; import { userRouter } from ./routers/user; import { createTRPCRouter } from ./trpc; export const appRouter createTRPCRouter({ note: noteRouter, user: userRouter, }); export type AppRouter typeof appRouter;AppRouter类型是整个前端类型安全的源头。utils/api.ts里的trpc对象就是基于这个类型创建的前端组件里点trpc.note.create.useMutation()时入参类型、返回值类型全部由这里的类型定义推导出来。前端调用代码极其直观import { trpc } from ../utils/api; function NoteList() { const { data, isLoading } trpc.note.listMine.useQuery(); if (isLoading) return p加载中.../p; return ul{data?.map(note li key{note.id}{note.title}/li)}/ul; } function CreateNote() { const utils trpc.useUtils(); const createNote trpc.note.create.useMutation({ onSuccess: () utils.note.listMine.invalidate(), }); const handleSubmit async (title: string) { await createNote.mutateAsync({ title }); }; return input placeholder标题 onBlur{e handleSubmit(e.target.value)} /; }这里最有价值的是两行代码:trpc.note.listMine.useQuery()和trpc.note.create.useMutation()。你不需要在任何地方定义前端 API 请求函数IDE 的智能提示会直接告诉你可以传什么参数、返回什么字段。创建完成后调用listMine.invalidate()React Query 会重新拉取列表数据页面自动刷新。3.5 一次完整的联调体验实际动手写过一遍之后你会发现这个方案的开发流程几乎变成了“写数据库模型、写 tRPC 函数、写前端组件”没有中间那层“定义接口”的步骤。我记得第一次在这个模板下写一个带标签筛选的笔记列表从需求到跑通整个链路只用了一个下午放在原来的 REST 方案里光是对接口字段就要来回对两三轮。这种效率提升的本质是把“前后端之间的沟通成本”压缩到趋近于零因为 TypeScript 编译器承担了两端对齐的工作。4. 常见问题与排查技巧实录4.1 类型推到一半就断了我在实际使用中最常遇到的情况是页面上的data字段访问报错报错信息说“类型上不存在属性 xx”。这种情况百分之八十以上是 Prisma 类型没有生成或者 tRPC Router 的返回类型没有正确推导。排查路径很固定先跑pnpm prisma generate再检查createTRPCRouter的返回函数里是否把select对象写错。Prisma 的select会改变返回类型如果你在选择字段时漏了一个字段前端那侧报的错其实就是在提醒你数据库层已经没这个字段了。另外要注意tsconfig.json里的paths别名。t3code 里我通常配置/*指向src/*如果服务端代码和客户端代码的路径别名不一致类型推导一样会断。之前有个同事把/server/db写成了相对路径../../server/db运行一切正常但类型检查开始在部分文件里失效排查了很久。4.2 React Query 缓存不刷新另一个常见现象是调用useMutation成功之后列表数据没有更新。这不是 tRPC 的问题而是 React Query 的缓存策略导致的。useQuery的结果会缓存mutation 完成后你需要手动让缓存失效。最推荐的方式是用utils.note.listMine.invalidate()来让整个查询重新执行。如果你希望更精细地控制可以使用setData直接更新缓存避免重新请求。比如创建新笔记后与其重新拉列表不如把新记录提前插到当前缓存里。这在列表接口比较慢时体验很好。还有一个隐蔽的问题如果列表查询带了筛选参数invalidate默认会刷新所有相同 key 的查询这通常是你想要的。但如果列表是分页的你可能只想刷新当前页这时需要给invalidate传一个 filter 函数或者干脆用setData手动更新。4.3 Prisma 连接数被打满部署到 Serverless 环境时最容易爆的问题就是数据库连接数不够。每个 Serverless 实例都会创建一个 Prisma Client连接池一下子被占满数据库直接拒绝服务。t3code 的server/db.ts里专门做了全局单例处理原理是挂在globalThis上避免每个请求都创建新实例import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const prisma globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma prisma;这行代码的核心逻辑是在开发环境热更新会反复重新加载模块如果不做全局单例每次加载都会创建一个新的 Prisma Client在生产环境的 Serverless 场景每个实例也是复用的。加上之后连接数能稳定在一个较低的水平。如果还是不够比如并发量很高那就需要给连接池单独配置 PgBouncer 或 Prisma Accelerate这属于部署层面的事项我会在下一节展开。4.4 常见问题速查表报错或现象直接原因处理方法Property id does not exist on typePrisma 类型未生成或 select 字段缺失重跑prisma generate检查 select调用 mutation 后列表不更新React Query 缓存未失效调用utils.xxx.invalidate()数据库连接数超限开发环境模块反复创建 Client 实例采用全局单例模式Date类型在前端变成字符串缺少 superjson 序列化在 tRPC 初始化时引入superjson生产环境接口 401鉴权中间件未覆盖该接口改用protectedProcedure而不是publicProcedure构建时类型全部报错tsconfig路径别名配置不一致统一/*或相对路径5. 优化、部署与生产级配置5.1 按业务域拆分 Router 与代码组织项目规模上来之后单个note.ts会膨胀我习惯按业务域继续拆分而不是把所有函数塞进一个文件。比如在routers/note.ts里只放与笔记相关的 CRUD与用户相关的登录注册放到user.ts与统计相关的再单独建stats.ts。每个 Router 内部有公共逻辑就抽成 tRPC 中间件比如“必须传组织 ID”的校验一段逻辑到处复用。这样前端的调用层面也会跟着自然分组trpc.note.create、trpc.user.login、trpc.stats.overview语义清晰查找方便。5.2 环境变量校验别偷懒生产环境出问题有很大一部分是环境变量缺失或格式不对。t3code 的模板里我对所有环境变量做成 zod 校验在应用启动时直接检查缺少直接抛错而不是等运行到某个接口才报 500import { z } from zod; const envSchema z.object({ DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(32), NEXTAUTH_URL: z.string().url().optional(), }); export const env envSchema.parse(process.env);我以前在这上面吃过亏少了NEXTAUTH_SECRET本地开发正常部署上去被 NextAuth 默默拒绝登录排查了很久才在日志角落找到提示。现在直接启动时校验最多 30 秒就能定位问题。5.3 部署策略与生产环境注意点t3code 的应用可以部署到任意支持 Node.js 的平台最省心的是 Vercel因为你不太需要关心 Node 服务器的运维。它的工作方式是每个请求触发一个 Serverless 函数所以要注意两点一是数据库连接必须采用单例模式二是 Prisma 引擎文件需要正确打包到函数产物中在 Vercel 上一般不需要额外处理但在其他 Serverless 平台有时需要在构建命令里加上prisma generate。如果选择自托管 Node 服务推荐用 Docker 打包。一个稳妥的构建流程是FROM node:20-alpine AS base WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm prisma generate pnpm build CMD [pnpm, start]镜像里记得把.env放到运行时环境不要打进镜像。数据库迁移建议在发布流程里单独跑prisma migrate deploy不要和应用启动同时进行避免并发迁移的竞态问题。5.4 其他我看重的生产细节日志方面我在 tRPC 中间件里加了一层简单的请求耗时记录把每个 procedure 的执行时间输出到 stdout。不需要专门买日志平台console.log在 Serverless 环境下会组合到 CloudWatch 或 Vercel Logs 里够用且零成本。错误处理上tRPC 默认会把未捕获异常包装成 INTERNAL_SERVER_ERROR这会导致前端看到的是笼统的“服务器错误”。我会在 root 里加一个 error formatter把 zod 校验错误、数据库唯一约束冲突之类的错误转成结构化的code message返回前端就能直接提示“该邮箱已注册”这样的文案。最后说一点个人的体会t3code 这套方案我用了快两年前前后后用它在实际工作和个人项目里起过不下十个应用包括内部运维平台、内容管理后台、SaaS 的客户门户。踩过的坑、填过的细节基本都沉淀在模板里了。我最大的感受是技术选型的价值不在于“用了多新的框架”而在于它能不能把每个改动都控制在可理解的范围里。t3code 让我的每一个数据模型变更都变成一次全链路的强制检查而不是一次未知的线上事故。如果你也在类型断裂和联调低效里耗了太久建议亲手搭一个这样的模板跑一次。把基础工程固化下来省下的时间拿去琢磨业务本身这笔账怎么算都划算。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →