ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

t3code:用TypeScript+tRPC+Next.js构建类型安全的全栈模板

t3code:用TypeScript+tRPC+Next.js构建类型安全的全栈模板

t3code 这个项目我前后折腾了差不多一个多月,从最初只是想在周末快速搭一个全栈原型,到最后整理成了可以直接拿来当基建用的代码骨架,中间踩了不少坑,也推翻过好几个方案。今天把整个项目的设计思路、技术选型和实操过程完整梳理一遍,希望能给正在纠结全栈项目怎么落地、要不要上 tRPC、以及如何构建一套可复用型模板的朋友一些参考。

这套东西定位很直接:面向个人开发者和小型团队,借助 TypeScript + Tailwind CSS + tRPC + Next.js 的组合,把「从数据库到 UI 的完整类型安全链路」固化成一整套可复用的开源模板。它的核心价值不在于功能多,而在于帮你把全栈项目中最耗时、最容易出错的部分先稳妥地铺好,让你能用最短时间把想法变成一个可上线、可迭代的产品。适合前端想转全栈的人、做独立产品的开发者,以及那些不想在配置和重复性样板代码上浪费时间的团队。

1. 项目概述与设计初衷

1.1 典型痛点:为什么个人开发者做全栈项目总是会卡壳

我先说说自己做这个项目的出发点。以前做全栈项目,最烦的不是业务逻辑难写,而是「技术栈之间的缝隙」太多。传统做法通常是:前端 React/Vue 一套代码,后端 Express/Nest 一套代码,两者靠 REST API 通信。这个模式本身没什么问题,但对个人开发者或者小项目来说,代价其实很沉重。

第一个代价是重复声明。数据库表结构要在 ORM 里定义一遍,后端要在 DTO/Service 里再定义一遍类型,前端调用接口时还要再手动声明一遍类型。三个地方不同步,改一个字段可能要动四处代码。运气好的项目还能靠工具链同步一部分,但一旦类型不同步,运行时才会暴露问题。

第二个代价是接口调试成本。REST API 需要你维护路由、写文档、处理各种状态码映射。团队协作时这个成本还能摊薄到多人头上,但一个人做全栈,等于所有成本都自己扛。每写一个接口,都要先定义路由、处理请求、写文档、调试,然后前端再根据自己的理解去调用,来回折腾的时间比写业务代码还多。

第三个代价是模板和脚手架不统一。很多人一开始都是拿 create-react-app 或者 Next.js 官方的 starter 开搞,但这些脚手架往往只覆盖前端。要接入数据库、认证、lint、测试、部署,每一样都得自己查文档、翻旧帖、拼配置。我见过不少人的项目卡在「初始化一周 + 写业务三分钟」这个尴尬阶段。

t3code 想要解决的核心问题,就是把这堆重复操作收敛成一套「开箱即用的最小可行骨架」。让你拿到手之后不是从头扯配置,而是直接进入业务开发。说得直接一点,它的目标是把「从一个空目录到一个能跑通全栈 demo」的时间从三天压缩到半小时内。

1.2 设计哲学:一切都可以类型化,一切都可以被推导

这个项目在设计时贯穿了一条主线:类型安全是整个全栈项目的定海神针。如果把项目比作一栋楼,数据库结构是地基,API 是管线,UI 是墙皮。如果地基和管线没有统一标号,墙皮刷得再漂亮迟早要开裂。

传统的全栈项目里,类型往往只是编译期的一种「提示」,前后端之间没有强制约束。你改了后端的返回结构,前端可能还在用老的字段,直到运行时才发现。t3code 的思路完全不同:让类型成为唯一的「真相来源」,并且从数据库一路贯穿到页面组件。

在 t3code 里,数据库表由 Prisma Schema 定义,这个 Schema 本身就能生成完整的 TypeScript 类型。tRPC 的 Router 直接使用这些类型,前端调用时不仅自动有提示,而且如果接口签名变了,编译期就会直接报错。这一整条链路不需要额外的任何代码生成工具,全靠 TypeScript 自身的类型系统推导。你可能觉得这有点过度理想化,但实际操作下来你会发现,光是省下的「前后端联调时来回问字段」的时间就值回票价了。

2. 技术选型与架构拆解

2.1 框架层:为什么选择 Next.js 而不是其他方案

全栈项目的框架选型上,我认真对比过几套方案:Remix、SvelteKit、基于 Vite 的纯 React SPA + 独立后端、以及 Next.js。最终选择 Next.js 的原因有三个。

第一是部署心智成本最低。Next.js 在 Vercel 上几乎属于零配置部署,而国内其他云平台也大多有标准的 Next.js 部署文档。对个人开发者来说,把「写代码」和「部署上线」分开是件很奢侈的事。项目能一键部署,意味着你可以更快地把产品丢给真实用户去测试。比起在混合方案里自己配 Nginx、配环境变量、搞进程守护,这一项体验优势非常明显。

第二是渲染模式的灵活性。Next.js 的 App Router 可以精细控制哪些页面是服务端渲染、哪些走静态生成、哪些完全客户端渲染。在 t3code 这个模板里,我刻意没有把渲染模式锁死,而是针对不同场景做了示例。比如,面向游客的内容页直接走 SSR/SSG,登录后的后台界面则更多依赖客户端交互。这种灵活性让模板能适配各种产品形态,不会强行逼你的业务朝某一个方向靠拢。

第三是生态杠杆。Next.js 作为目前最活跃的 React 元框架,周边工具链非常成熟,几乎所有我需要的功能——认证、ORM、缓存、监控、表单——都能找到官方推荐或者社区验证过的落地方案。一个人做项目最怕花时间造轮子,选生态丰富的框架等于是站在一整个社区的肩膀上做事情。

对比之下,SvelteKit 的写法更纯粹,但生态相对小;Remix 很优雅,但队加载器和表单的抽象有一定上手门槛,对想要快速跑通原型的人来说不如 Next.js 直接。

2.2 数据通信层:tRPC 为什么会取代 REST

这是 t3code 里争议最大、也是我觉得最值得展开讲的一层。

在传统 REST 思路里,客户端和服务端以「资源」为单位通信,你想拿到「用户列表」就走/api/users,想拿到「某个订单详情」就走/api/orders/:id。这本身没毛病,问题是大量业务场景并不符合「资源」模型,你往往是需要「一组聚合数据」,比如「某个用户的含商品明细的最近订单」。这时候 REST 就会面临选择:是搞 N 个接口分别调用,还是搞一个比较啰嗦的聚合接口,后者又会破坏 REST 风格的一致性。

tRPC 的做法完全不同——它直接以「函数」为单位进行远程调用。前端像是调用本地函数一样调用后端的某个 Router,框架帮你把参数序列化传送过去,再把结果类型安全地传回来。

用生活化的类比理解:REST 像是去餐厅,你要通过菜单(开放的 URL 清单)点菜,每道菜对应一个资源;tRPC 则像是让一位私人厨师直接给你配好一道完整的菜(一个函数返回你要的全部数据),而且这位厨师对你的饮食偏好(TypeScript 类型)了如指掌,给错食材当场就会提醒。

那 tRPC 的优势具体体现在哪?一是完全不需要手写 API 层,Router 写好后前端直接引用其类型,几乎没有多余的胶水代码;二是类型双向推导,改动后端返回结构,前端编译立刻报错,不用等着运行时联调。当然,它也有不能盲目适用的场景,比如对第三方开放 API 时仍然建议挂一层 REST 或 GraphQL。但在 t3code 这样一个面向内部全栈项目的模板里,tRPC 带来的开发效率提升是肉眼可见的。

2.3 数据库与 ORM 层:Prisma 是地基里的钢筋

数据库这一层,我选择了 Prisma,而不是 TypeORM 或者 Drizzle。理由有三个维度。

第一是模式定义直观。Prisma 的 Schema 语言非常接近人脑建模的习惯,写起来就像在画 ER 图。模型之间的关联关系、索引、唯一约束,都能在一眼扫过的范围里看明白,这对快速原型阶段至关重要,因为原型阶段的模型结构往往一天改三次,Prisma Schema 重迁成本低。

第二是生成的客户端体验非常好。Prisma Client 的查询 API 设计得很贴近业务直觉,嵌套查询、关系加载、条件筛选写起来都非常顺。更重要的是,它生成的类型是完整且精确的,能够配合 tRPC 实现「数据库 -> API -> 前端」的类型全链路。

第三是迁移工具链稳定。prisma migrate dev这套本地开发中的迁移工作流非常稳,更重要的是它的迁移历史文件会完整保留在项目里,多人协作时不容易出现「我的库和你的库结构不一致」这种尴尬情况。

我深知 Drizzle 在性能上可能更轻量,也更接近 SQL 本身,但它的类型体操复杂度对模板项目而言反而会放大使用者的心智负担。一个模板的设计逻辑是「降低进入门槛」,而不是「追求极限灵活性」,所以 Prisma 更符合 t3code 的定位。

2.4 样式与 UI:Tailwind CSS 的实用配置

样式层选择 Tailwind CSS,除了它本身足够流行之外,更关键的原因是它适合 t3code 想要服务的「快速原型 → 产品化」过程。

很多人对 Tailwind 的不满是「写起来像内联样式」,觉得它不够「工程化」。但实际用下来,Tailwind 的原子类恰恰能在原型阶段极大降低心智负担。你不用去想「这个 class 应该叫什么名字」,不用维护一堆层叠样式和命名空间,所有样式属性就在你眼前的元素上。需要改间距就改间距,需要调颜色就调颜色,改坏了大不了重来。这种低约束、高危操作的风格特别适合探索阶段。

在 t3code 里,我做了几个关键配置:

  • 默认使用 CSS Variables 而非硬编码颜色。这样切换主题或针对不同用户做品牌色定制就简单了很多,你只需要改几个变量的值,整个 UI 的色彩体系会联动变化。
  • 暗色模式采用 class 策略。配合next-themes组件,可以在首屏加载时根据用户系统偏好或手动选择决定亮暗模式,无闪烁、无额外体积开销。
  • 组件层抽取了几十个小函数,比如按钮、输入框、卡片、Badge 等。这个不是组件库,而是一层「样式常量模板」,保证在项目里做任何新页面,风格基线是统一的。

Tailwind 的 JIT 引擎还有一个隐藏优点:只有用到的样式类才会进入生产包,最终 CSS 体积非常小。在 Next.js 里配合 PostCSS 配置几乎零成本,所以这个方案在性能上也站得住。

3. 实操过程与核心环节实现

3.1 初始化项目并理解脚手架的目录结构

安装过程本身没什么复杂度。在终端里执行创建命令,选择 TypeScript、Tailwind、Prisma、NextAuth 这几个模块,让脚手架自动生成项目。但如果你想让项目从一份你自定义的模板开始,直接改模板仓库的代码再发布即可,这是 t3code 的一个设计分支。

初始化完成后,项目的目录结构大致如下:

. ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── public/ ├── src/ │ ├── components/ │ ├── lib/ │ │ ├── auth.ts │ │ ├── prisma.ts │ │ └── utils.ts │ ├── pages/ │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ │ │ └── db.ts │ ├── styles/ │ └── types/ ├── next.config.js ├── tailwind.config.ts └── tsconfig.json

从工程视角看,有几个目录是设计重点:

src/server/api/routers/是 tRPC 路由的集中地。每个业务域一个文件,比如postRouter、userRouter,通过根路由聚合后再暴露给前端调用。这个目录相当于传统后端项目里的 Controller 层,但省掉了所有「请求解析、参数校验、响应包装」的模板代码。

src/lib/放的是跨端共享的工具函数、认证配置和数据库单例。注意这里的prisma.ts必须用全局单例模式初始化 PrismaClient,避免开发环境下由于热重载导致数据库连接爆炸。

prisma/schema.prisma是整个项目的数据库真相来源。不要把它理解成普通的配置文件——它是所有类型推导的源头。改这里的模型,就等于改了全项目的类型地貌。

3.2 配置数据库与认证:从迁移到登录的常见步骤

先看数据库。创建一个 MySQL/PostgreSQL/SQLite 数据库(本地开发我通常开 SQLite,保持零依赖;生产环境再切 PostgreSQL),然后在.env里配置DATABASE_URL。连接地址格式要注意不同数据库的差异,比如 PostgreSQL 的链接通常有?schema=public之类的参数。

接下来执行迁移命令,把 Prisma Schema 变成真正的数据库表结构:

npx prisma migrate dev --name init

执行成功后,migrations/目录下会出现这次迁移的 SQL 文件,数据库里也会生成对应的表。这些迁移文件要提交到 Git 仓库——它们是团队成员同步数据库结构的「道路图」,也是你回滚变更的依据。

认证方面,我选的是 NextAuth.js(现在叫 Auth.js)。先创建一个配置文件,指定认证提供商,最常用的是 Credentials 和 OAuth。OAuth 配置里需要去对应平台创建应用拿到客户端 ID 和密钥。Credentials 方式则需要你在后端写一处比对用户名密码的逻辑。

配置 NextAuth 的关键点有三个:

  1. Secret 一定要设置,否则生产环境会报错,开发环境也会出现不可预期的 Session 问题。
  2. 如果使用了 OAuth,数据库里要配置好 Account、Session、User 这几个模型,Prisma Schema 我已经内置了,直接迁移即可。
  3. Session 策略建议选 database 而不是 jwt。Database session 能更容易地和后端逻辑协同做权限判断,撤销会话也方便。

最后把认证相关的能力封装成 tRPC 的中间件。比如requireAuth中间件会检查 Session 中是否有用户信息,没有就直接抛出未认证错误。前端调用时需要登录的接口时,只需要在路由定义处挂上这个中间件即可。

3.3 实现一个真实业务功能

理论说得再多,不如一个实际的例子直观。我以「待办事项管理」为例,走一遍从数据库到 UI 的完整流程。

第一步:在 Prisma Schema 里定义模型

model Todo { id String @id @default(cuid()) title String completed Boolean @default(false) createdAt DateTime @default(now()) userId String user User @relation(fields: [userId], references: [id]) }

执行npx prisma migrate dev --name add_todo完成迁移。这一步做完,Prisma 客户端会自动获得Todo相关的类型。

第二步:在 tRPC 服务端定义一个 Router

import { initTRPC, protectedProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const todoRouter = createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) => { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const todo = await ctx.db.todo.findUnique({ where: { id: input.id }, }); if (!todo || todo.userId !== ctx.session.user.id) { throw new Error("无权操作该待办事项"); } return ctx.db.todo.update({ where: { id: input.id }, data: { completed: !todo.completed }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.delete({ where: { id: input.id } }); }), });

注意到几处细节:protectedProcedure是经过认证中间件的 Procedure,我在模板里已经封装好;zod在输入校验上起到第一道防线的作用;在toggle操作中,除了校验输入格式,还做了「归属校验」,防止用户操作别人的数据。这类业务权限逻辑在服务端做,而不是依赖前端隐藏按钮。

第三步:在页面中安全地调用接口

以服务端组件为例,获取待办列表可以这样写:

import { api } from "~/lib/api"; import { TodoList } from "~/components/todo-list"; export default async function TodoPage() { const todos = await api.todo.list.query(); return <TodoList initialTodos={todos} />; }

前端调用api.todo.list.query()的时候,IDE 能自动补全list、create、toggle、delete这些方法,传参数错误或者返回值类型对不上时,编译阶段就红了。这就是 t3code 里类型全链路的价值——你很难写出一个接口路径拼错、字段传错或者类型不匹配的调用。

第四步:写交互组件

在组件里,创建一个新的待办事项只需要:

const addTodo = api.todo.create.useMutation({ onSuccess: () => { utils.todo.list.invalidate(); }, });

invalidate是 tRPC 内置的缓存失效机制,执行完 mutation 后,对应查询会自动刷新。你不用手动去管理 React Query 的缓存 key,类型系统会替你把它们关联起来。

3.4 测试与部署的完整流程

项目骨架里内置了两层测试:Vitest 做单元测试和组件测试,Playwright 做端到端测试。

单元测试层面,重点放在 Zod 输入校验和工具函数上。比如对create接口传一个空字符串标题,断言它抛出校验错误;对toggle接口用别的用户 ID 操作,断言它抛出权限错误。这些测试跑起来非常快,能兜住很多逻辑边界的低级错误。

端到端测试,我建议至少覆盖一条核心主链路:用户登录,创建一个待办事项,标记完成,刷新页面,确认数据持久化。这条链路一旦稳定,后续加功能就不太容易把地基搞坏。

部署方面,我强推 Vercel。把仓库推到 GitHub 后,在 Vercel 上导入项目,添加环境变量,一键部署即可。需要注意两件事:

  • 数据库连接字符串一定要用生产环境的连接地址,不要用本地localhost。
  • 如果数据库实例在云厂商的防火墙后面,记得把 Vercel 的出口 IP 加入白名单,否则线上环境会持续出现数据库连接超时。

Vercel 部署完成后,每次 push 到主分支都会自动触发新的预览部署和生产部署。整个 CI/CD 流程零成本,适合独立开发者和小项目快速迭代。

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

4.1 数据库连接池耗尽,应用频繁超时

在项目跑起来之后,我遇到的第一个坑是开发环境一切正常,但部署到线上没多久就出现数据库连接数暴涨。排查下来,问题出在 Prisma Client 的初始化方式——按官方文档直连方式创建的 Client 在 Serverless 环境里会出现每个请求都创建新连接实例的情况,时间一长连接池就被撑爆了。

解决方案:全局单例管理。在src/lib/prisma.ts里用全局变量缓存 PrismaClient 实例。同时可以调整连接池配置:Serverless 环境下建议把connection_limit调到 1,因为每个 Serverless 实例同时只处理一个请求,没必要保留多个连接。

4.2 tRPC 的「灵异类型报错」:IDE 不报错,但tsc报错

有个很坑的情况是,你在 IDE 里打开文件,TypeScript 提示一切正常,但 CI 里的tsc --noEmit报了类型错误。排查后发现,这是因为 IDE 通常用的 tsconfig 是项目根目录的,但 CI 运行脚本可能没有正确加载 Next.js 的tsconfig.json里的路径别名配置。

解决思路:确保所有涉及路径别名(如@/”、~/)的配置同时存在于tsconfig.json和打包工具配置里。另外,CI 环境的命令建议统一为next build而不是单独跑tsc,因为 Next.js 内部会处理完整的编译链路。

4.3 部署后认证失效,Cookie 无法写入

本地登录一切正常,部署到 Vercel 之后,登录接口反复返回成功但前端一直显示未登录。原因通常是 Cookie 的 SameSite 属性问题。如果前端域名和后端接口域名不一致,或者用了代理转发,Cookie 可能被浏览器拦截。

解决方案:在 NextAuth 配置里明确指定useSecureCookies和环境变量对应的NEXTAUTH_URL。如果用了自定义域名,还要检查有没有把域名加入受信列表。这类问题排查时,浏览器的开发者工具里看请求头带没带Set-Cookie、下次请求带没带Cookie,基本几秒钟就能定位。

4.4 本地开发时热更新导致数据库状态错乱

开发 Next.js 时,每次保存文件都会触发热更新,tRPC 服务端代码也跟着重新执行。如果 PrismaClient 初始化逻辑写得不够健壮,就会出现多个实例同时连接数据库,反复创建临时表或者锁表等待。

这种问题通常不会立刻让你挂掉,但会让你产生一种「项目时不时抽风」的错觉。解决方案还是回到全局单例,并且把prisma generate生成的客户端也放到全局变量空间里规避。

4.5 缓存失效问题:前端改数据后列表不刷新

如果组件里用到了 React Query 的缓存,新增或更新数据后列表不自动更新,大概率是忘记调用invalidate。tRPC 的写操作是 mutation,port 层面不会自动刷新查询缓存,必须在onSuccess里手动失效对应的查询 key。

强调一点:不要通过清空所有缓存的方式解决,那样会让整个应用重新请求一遍数据,降低体验。要精确到对应的 Router 和 Procedure,让类型系统帮你维护这个映射。

5. 关于扩展性:t3code 还能往哪些方向延伸

5.1 加入权限系统:从「登录即可用」到「细粒度权限控制」

现在模板的认证只做到「登录了就能操作自己的数据」。如果产品需要管理员、编辑、访客多层角色,可以在schema.prisma里给 User 模型增加role字段,然后在 tRPC 中间件里加一层角色校验。

可以在protectedProcedure的基础上派生adminProcedure:

export const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.session.user.role !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } return next(); });

所有需要管理员权限的接口,换成使用adminProcedure即可。代码量很少,但权限模型清晰,后续接后台管理系统会很舒服。

5.2 接入对象存储与文件上传

很多全栈项目都绕不开文件上传。模板里可以先接一个文件上传服务,比如 S3 兼容的对象存储。实现方式不复杂:前端把文件二进制流直接传给服务端,服务端调用对象存储 SDK 上传,然后把返回的 URL 存到字段里。需要注意安全校验:只允许特定的文件类型和大小,否则容易被人上传恶意文件。

5.3 什么情况下不建议套用 t3code

这个也要说清楚。如果你的项目需要对外提供公开 API,比如第三方开发者要通过你的平台接口拿数据,tRPC 并不是最佳形态,因为 tRPC 协议没有标准的开放文档生态。这种情况应该在 t3code 的对外层再包一层 REST API 或 GraphQL。

如果你是一个大团队、大项目,模块边界非常复杂,微服务拆分势在必行,t3code 这种「单体全栈 + 类型直通」的模式可能不够灵活。它不是给这种规模准备的。它的舒适区是 1-5 个人的小团队或者独立开发者,目标是快速验证产品、降低全栈复杂度。

5.4 后续迭代建议:从模板到平台的路径

t3code 未来值得考虑的方向,我总结了三个:

  • 内置测试样例更丰富:让每个路由文件都自带一组最小可用的单测和 e2e 测试,这样复制到新项目后,测试可以作为功能重构的安全网。
  • 启用 OpenTelemetry 监控:在 tRPC 中间件里挂载链路追踪,把每个请求的处理时长、数据库调用次数输出到可观测平台,帮开发者快速定位性能瓶颈。
  • 进一步完善代码生成能力:单一模型对应 CRUD 这种模式完全可以脚本化,比如从 Prisma Schema 自动生成对应的 tRPC Router 和前端页面模板,让「重复劳动」进一步消失。

6. 最后一些实操感受

从我个人的使用体验来看,t3code 最大的价值不是某一个框架的引入,而是把「类型安全」从一句口号变成了整个开发流程的默认约束。过去我开发一个功能,代码写完之后总要在浏览器里点点点、看 Network 请求返回了什么、前端控制台有没有 undefined。现在大部分场景下,编译过了基本就通了,很多低级错误在写代码的过程中就会被类型系统拦下。

当然它并不是万能的。如果你对 TypeScript 本身不熟,或者对 React Query 的缓存机制没有概念,直接上手这个模板会感到有些吃力。建议先在官方文档把 TypeScript 基础过一遍,再动手改业务代码。否则你可能会觉得自己在操控一台复杂的机器,但不知道这台机器为什么这么运转。

最后分享一个小技巧:使用 t3code 这类模板项目时,不要去改模板里已经封装好的底层代码。比如src/lib/prisma.ts、认证中间件、tRPC 初始化这些文件,它们是整个链路的承重墙。你应该把精力和修改都放在新增业务模块上——新增 Prisma 模型、新增 Router、新增页面。让承重墙保持稳定,让业务在稳定的结构里快速生长,这才是这类项目给你的最大杠杆。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进