这些年我一直在TypeScript全栈这条路上折腾,从最早的传统前后端分离,到后来用GraphQL,再到整个工具链打通类型系统,说实话每一步都有收获,但每到一个新项目就得重新配一轮脚手架、理一轮类型定义,重复劳动特别多。直到有一次在社区里看到一个叫t3code的示例仓库,才第一次完整感受到“全栈类型安全”是什么体验。这个项目不复杂,就是一个用 T3 Stack 搭起来的待办事项应用,但它把 Next.js、tRPC、Prisma、Tailwind 这几个工具串成了一个闭环,从前端页面到数据库查询,类型是全程贯穿的,基本告别了“API返回一个any,前端自己猜字段”这种老戏码。
t3code这个名字其实有两层意思:一是它明确站在 T3 Stack 这条技术路线上,二是它强调代码本身的完整性和可复现性。如果你平时写 React + Node 接口,但经常因为类型对不上、环境变量配置混乱、部署后数据库连不上这类问题浪费时间,那么这个项目的拆解思路会很适合你。它不适合那种想用一个框架包打天下的同学,更多是给你一套“每一步都明确为什么这样做”的全栈样板。
1. 项目整体设计与技术栈取舍
1.1 为什么选择 T3 Stack,而不是自己拼一套脚手架
很多团队做新项目时,第一反应是“用我熟悉的组合”:前端用 create-react-app 或 Vite,后端用 Express 写 REST 接口,数据库用 Sequelize,再手动配一个 CORS 转发。这套组合不是不能用,但每次都要处理几个固定的问题:前后端类型不同步、字段改名后两边都要改、接口文档过期、生产环境跨域配置出错。t3code当初选的路线完全不同:直接用 T3 Stack 的官方脚手架create-t3-app起步,把 Next.js、tRPC、Prisma、Tailwind 这四样作为基础,不做额外的架构发明。理由是这几样东西在设计上有一个共同点:都是为TypeScript服务的。
Next.js 提供页面渲染和路由,但它的关键优势是内置了 API 路由和 Server Components,这让 tRPC 可以在服务端和客户端之间无缝传递类型。Prisma 负责数据库访问层,它的 schema 文件是唯一的模型真源,生成出的客户端类型可以直接被 tRPC 引用。Tailwind 则负责样式,它和 TypeScript 没有直接关系,但省掉了写 CSS 文件的上下文切换,让整个开发流程保持节奏。你可以把 T3 Stack 理解成一顿套餐:单独买可乐、汉堡、薯条也能凑一顿,但套餐的好处是搭配经过了验证,不用自己纠结比例。
1.2 各技术组件在 t3code 中扮演的角色
具体到t3code这个项目,每个组件的职责分得非常清楚:
| 组件 | 在项目中的角色 | 为什么必须用它 |
|---|---|---|
| Next.js | 应用框架,负责 SSR、路由、API 路由 | 只有 Next.js 能把 tRPC 的服务端逻辑和 React 页面放在同一个应用里 |
| tRPC | API 层,代替 REST 或 GraphQL | 它不需要写 schema,直接从服务端函数推倒出客户端类型 |
| Prisma | ORM,负责数据库读 | 在 schema 中定义模型,生成类型安全的数据库客户端 |
| Tailwind CSS | 样式 | 响应式工具类,快速完成界面布局,不用维护 CSS 文件 |
| NextAuth.js | 身份认证 | t3code 里加登录功能时选的方案,和 tRPC 配合做 session 校验 |
这套组合的核心价值,简单说就是“一个函数从数据库取完数据,经过 tRPC 暴露给前端,全程类型可追溯”。我在早期使用 REST 时,后端返回的 JSON 结构靠 postman 里的一个历史请求记录记着,前端要用 interface 重新写一遍,字段一多就难免写错。tRPC 的做法是直接让前端调用一个普通函数,函数的入参、出参类型自动从服务端传到客户端。这意味着如果我改了一个 Prisma 模型的字段名,tRPC 的返回类型会自动变更,前端代码里涉及这个字段的地方会立刻报错,而不是等到运行时才发现数据变成 undefined。就这一条,就足够让我抛弃自己拼脚手架的想法。
2. 核心细节解析:数据库层与 API 层
2.1 Prisma 模型设计,以及它如何决定全栈类型
t3code项目的业务模型很朴素,就是一个 todo 列表。但这套非常简单业务的背后,却很适合展示 Prisma 的类型穿透力。项目里有两个模型:User和Todo。User保存用户邮箱和登录信息,Todo关联用户、保存标题和完成状态。关键点是 Prisma 的 schema 文件是类型系统的起点,所有模型约束都定义在这里。例如:
model User { id String @id @default(cuid()) email String @unique name String? todos Todo[] } model Todo { id String @id @default(cuid()) title String completed Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt ownerId String owner User @relation(fields: [ownerId], references: [id]) }这里值得注意的一点是:t3code没有把数据库的 id 设置成自增整数,而是用了cuid()。我一开始也犹豫过要不要用 autoincrement,后来在社区讨论里看到有人的理由是:自增整数在并发高时容易暴露数据总量,而且分库时迁移麻烦。cuid 是字符串类型,但它是时间排序的,对建立索引很友好。如果你做一个内部小工具,也许 autoincrement 更直观,但 t3code 既然定位是示例项目,它选 cuid 是想告诉大家一个更通用的生产实践。
Prisma 生成客户端后,你可以在服务端这样写一个查询:
import { prisma } from "~/server/db"; const todos = await prisma.todo.findMany({ where: { ownerId: session.user.id }, orderBy: { createdAt: "desc" }, });这行代码本身没什么惊奇的,强大的地方在于:findMany({ where: { ownerId: ... } })的字段名、类型、可空性都是被强类型的。如果写成orderBy: { createAt: "desc" },编译器会在你开发时就报错,而不是等查询执行后才发现语法错误。这种能力让我在重构字段时底气足了很多。
2.2 tRPC Router 构建:类型安全的 API 层实现
数据库层是基础,API 层则是把这些数据暴露出去的唯一入口。t3code里的 tRPC 代码集中在server/api/routers目录,核心是定义一个todoRouter。tRPC 的特色是:它不需要单独创建一个 API 文档,因为你的 Router 定义本身就是文档,也是类型来源。
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const todoRouter = createTRPCRouter({ getAll: protectedProcedure.query(({ ctx }) => { return ctx.prisma.todo.findMany({ where: { ownerId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(({ ctx, input }) => { return ctx.prisma.todo.create({ data: { title: input.title, ownerId: ctx.session.user.id, }, }); }), });这里有个容易被忽略的细节:protectedProcedure不是随便起的名字。它意味着访问这个接口必须先通过登录校验,否则直接返回错误。这个校验逻辑位于一个中间件里,它检查 session 是否存在,再把 user 放进 ctx。这比你在每个 handler 里手写 if (!session) 要整洁得多,而且类型上也保证了ctx.session.user不会是 null。
前端调用时,代码也极其简单:
import { api } from "~/utils/api"; const { data: todos, refetch } = api.todo.getAll.useQuery(); const createTodo = api.todo.create.useMutation();第一眼看上去,这就像一个本地状态库的写法,但实际上它是一个网络请求。api.todo.getAll.useQuery()的返回类型todos会自动推导为Todo[],根本不需要自己写一个 interface 去描述。更意外的是,tRPC 还自带refetch方法,用来在增删后重新拉取数据。这个项目里的新增和列表操作,天然形成了一套完整的类型闭包。我在之前的 REST 项目中,即使使用 ts-rest 这类工具,也没有这么直接的全栈类型体验。
3. 实操过程:从零搭建 t3code
3.1 使用 create-t3-app 初始化项目并配置环境
我当时搭建t3code时,几乎没费什么力气。一个核心原因就是官方脚手架create-t3-app很成熟,它会一次性把 Next.js、React、tRPC、Prisma、NextAuth、Tailwind 全部按最佳实践配置好,不用自己手工折腾几十个配置文件。比如初始化一条命令:
pnpm create t3-app@latest t3code在交互选项中,我可以选择删除一些用不到的功能,比如我没有用 Tailwind 的 dark mode 复杂主题,也不需要next/dynamic的复杂按需加载。但保留的核心选项是:Next.js、Tailwind、tRPC、Prisma、NextAuth。之后项目目录里就会生成完整的src/server、src/pages(或 app router)结构,并且已经配好了 TS 路径别名。
接下来需要做的事是写.env文件。这是整个项目里最容易踩坑的一步。t3code根目录里原本只有一个.env.example,需要自己复制为.env并填上数据库连接地址、NextAuth 的 secret 等。数据库我先用了本地 SQLite 作为开发环境,等到部署时才改用 PostgreSQL。因为 SQLite 不需要额外启动服务,最快的验证方式是:
cp .env.example .env # 编辑 .env,把 DATABASE_URL 改成 sqlite 路径 DATABASE_URL="file:./db.sqlite"然后运行pnpm db:push把 Prisma schema 同步到 SQLite。这一步本质上是prisma db push,它只做一次同步,不需要生成迁移文件。对于学习阶段很舒服,但对于生产环境,越早切换到prisma migrate dev越稳妥。
3.2 实现一个核心业务功能:登录、创建待办、删除待办
其实create-t3-app生成的代码里已经包含了一个安全等、带样式的基础页面,但那是骨架页面。t3code真正的核心业务功能是登录后,用户可以新建待办、列出待办、删除待办。这几件事分别对应前端页面、tRPC Router、Prisma 三个层次。我逐个梳理一下。
先看登录。NextAuth 的配置在server/auth.ts中。最简单的方式是用 GitHub OAuth,或者用邮件验证登录。t3code里头我用的是 GitHub Provider,因为这样不需要自己处理密码哈希。如果你在本地开发,需要在 GitHub 上创建一个 OAuth App,回调地址填http://localhost:3000/api/auth/callback/github,然后把生成的 ID 和 Secret 填到.env。这里有一个常见的困惑:为什么 NextAuth 回调路径是固定的?因为它默认把认证路由挂载在pages/api/auth下,这是 NextAuth 的实现细节,也是脚手架已经配好的约定。
再看业务功能。刚才在 2.2 部分已经写过一个todoRouter,但在实际开发中,我会把指令拆得更细:把create、delete、toggle分开。每个 mutation 都需要一个输入校验。例如删除待办:
delete: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { await ctx.prisma.todo.deleteMany({ where: { id: input.id, ownerId: ctx.session.user.id }, }); }),这里我特意用deleteMany而不是delete,是为了保证用户只能删除自己名下的待办。如果只用delete,用户随便传一个别人的 id 就能删掉,这在小应用里不算大事,但一旦涉及权限,这就是一种很常见的越权漏洞。deleteMany配合 where 条件,从数据层面锁死了归属关系。
前端页面的 React 代码,看起来就是普通组件。但用 tRPC 时有几个小习惯:当 mutation 成功后,不要手动刷新整个页面,而是调用之前getAll返回的refetch。createTodo是 mutation,它的onSuccess回调里我只需要refetch()。如果用 React Query 的思路理解,这样会保持缓存同步,界面不会闪烁。
3.3 用 Tailwind 快速搭建界面并保持响应式
t3code的前端页面没有引入任何组件库,全部用 Tailwind 的 utility class 完成。这样做的好处是体积小,而且不会因为第三方组件库的样式约定而干涉 tRPC 组件的布局。比如一个简单的输入表单,可能看起来是:
<form onSubmit={(e) => { e.preventDefault(); createTodo.mutate({ title: inputValue }); }} className="mb-4 flex gap-2" > <input type="text" value={inputValue} onChange={(e) => setInputValue(e.target.value)} className="flex-1 rounded-md border border-gray-300 px-3 py-2" /> <button type="submit" className="rounded-md bg-blue-500 px-4 py-2 text-white"> 添加 </button> </form>很多新手会有一个疑问:既然 Next.js 支持 Server Components,那是不是所有的组件都应该在服务端渲染?其实并不需要。t3code里涉及交互的组件,比如 Form、按钮点击、实时列表,都必须有客户端状态,所以它们都应该标注为"use client"。而一些纯展示型页面和数据处理逻辑,可以放在服务端做。这里的一个实操心得是:tRPC 在客户端组件中非常好用,因为它天然是 React Query 驱动的,能够处理 loading、error 状态。如果你强制在 Server Component 里调用 tRPC,那反而会失去很多前端交互特性。
关于响应式,我用 Tailwind 的sm、md断点来控制布局。一个小经验是先做移动端适配,再往宽屏扩展。因为待办应用的主要使用场景,要么是手机浏览器的窄屏,要么是桌面宽屏。另一件容易被忽略的事是:Tailwind 的 class 是运行时扫描的,如果你动态拼接类名,比如bg-${color}-500,Tailwind 不会生成对应的样式,一定得写成完整的类名。
4. 常见问题与排查技巧实录
4.1 数据库连接与 Prisma 相关的坑
4.1.1 环境变量加载不到(找不到 DATABASE_URL)
如果你在启动项目时报错说找不到环境变量,不要急着怀疑代码。检查点有这样几个:先确认.env确实存在且书写正确;再确认启动命令用的pnpm dev是否从项目根目录执行;最后确认是否把.env放在了src目录里。Prisma CLI 默认是从项目根目录读取.env,而不是从src读取。我曾经在一个项目里因为把.env放错了位置,导致prisma generate成功但运行时却读不到变量,折腾了半小时。
4.1.2 默认生成的 client 引擎与运行时不同
还有一个比较隐蔽的问题是:Prisma 在本地开发用 SQLite,在部署时换成了 PostgreSQL,但生成客户端时如果本地引擎和运行时引擎不一致,会报一个尴尬的错误。解决办法就是每次切换数据库后,重新prisma generate,并且确保.env指到了正确的数据库地址。对于部署到云平台的情况,我会把prisma generate放进构建命令里,而不是只在本地执行一次。这是我在t3code部署到 Vercel 后遇到的第一个问题,当时的解决方案是设置构建指令为:
pnpm prisma generate && pnpm build4.2 tRPC 类型不匹配和 HTTP 状态码 405
另一个高频问题出现在调用 tRPC 时:报 405 Method Not Allowed 或 404。这通常不是业务逻辑错误,而是请求路径不对。tRPC 默认把所有路由挂在trpc这个 API 路由前缀下,所以如果你团队里有人手动改过src/pages/api/trpc/[trpc].ts这个文件,或者使用了错误的 base URL,就会出现这种诡异的错误。最好从一开始就不要去改脚手架生成的路由文件,保持它的约定。
如果类型报错,例如Mutation input type not assignable,绝大多数时候是因为 zod 的校验规则和 Prisma 字段类型不一致。比如前端传undefined而 zod 期望string,又比如 Prisma 字段默认可空,但 tRPC 的输出类型里出现了null。这类问题最好的调试方式是:把 tRPC 的.input()里 zod 的 schema 定义尽量做严格约束,例如z.string().min(1),这样大部分无效入参会在验证阶段拦截,而不是在数据库查询阶段报错。同时,如果团队里有人尝试用any或类型断言去“修复”报错,我建议立刻拒绝这种改动。t3code 之所以有价值,就是因为它全程没有any。
4.3 登录会话失效和 NextAuth 兼容性
t3code里使用 NextAuth 作为认证库后,有一个特别容易迷惑的点:为什么有些接口需要登录,有些不需要?因为定义了protectedProcedure和publicProcedure两种访问级别。如果你在某个页面上发现明明登录了,但ctx.session还是 null,大概率是因为session没有被正确注入。解决方法是检查server/auth.ts里的 session 回调是否返回了需要的字段,例如把user.id放进去。有些情况下,NextAuth 默认的 session 类型并不包含 id,但 Prisma 模型里 user id 必须是可获取的。所以我们要把 session 的类型导出到全局,覆盖默认类型。
这里提供一个最简单的排查技巧:在页面里临时打印 session:
console.log("session", session);如果 session 一直为 null,那么请先检查浏览器里有没有 cookie,再检查 NextAuth 有没有正确读取到JWT_SECRET或NEXTAUTH_SECRET。开发环境可以随便填一个字符串,但部署到生产时必须设置一个足够长、固定不变的值,否则用户每次部署后都会被迫重新登录。
4.4 部署时的常见问题清单
t3code的部署我测试过 Vercel 和 Docker 两种方式,各自有需要注意的点。Vercel 上最方便的路线是利用它的 Serverless 函数,但要留意冷启动时间。如果你是免费版,偶尔会影响体验。Docker 部署则建议把数据库放到同一网络里的容器,或者使用托管数据库,避免容器重启后数据丢失。下面是我平时整理的几个部署相关问题表:
| 问题 | 原因 | 解决办法 |
|---|---|---|
| 数据库迁移未执行 | 构建时只编译了代码,没有跑 db migrate | 构建命令中加入prisma db push或prisma migrate deploy |
| 环境变量缺失 | Vercel 或 Docker 的环境变量没配置完整 | 检查 DATABASE_URL、NEXTAUTH_SECRET、GITHUB_ID 等是否全部设置 |
| 跨域访问被拦截 | 没有正确设置 API 的 baseURL | tRPC 自动同源,除非你单独分离了 API 服务,否则不需要额外配置 CORS |
| 客户端缓存数据和数据库不同步 | 多个并发 mutation 导致 refetch 覆盖旧数据 | 尽量在 mutation 成功后使用 React Query 的 invalidate 或 refetch 机制 |
5. 说点实际的:哪些场景最适合用 t3code 这套方案
如果你问我,t3code到底适合什么样的项目,我会说它特别适合中小型全栈应用、内部工具、黑客松项目、快速验证产品原型的阶段。因为这类项目业务逻辑不会特别庞杂,但迭代速度又很快,最怕的是类型来回变、接口来回改。T3 Stack 舍弃了 REST 文档、GraphQL 结构定义等重量级约束,换来的是一种“即改即用”的体验。一旦你习惯了api.todo.getAll.useQuery()这种写法,就很难再回到“先写接口文档、再写前端类型”的老节奏里。
当然,它也有不完美的地方。如果团队的成员对 TypeScript 不够熟悉,刚开始可能会被 tRPC 的泛型、类型推断绕晕。另外,当服务端逻辑需要大量异步重处理或后台任务时,Next.js API 路由并不总是最合适的载体,可能需要额外引入队列和 Worker。所以使用t3code之前,需要评估一下自己的业务到底是否适合这种“一体式”结构。
在项目启动初期,我会用这种组合快速完成核心功能,并给团队成员提供一个可以长期维护的样板目录。等到真的出现性能瓶颈或架构边界时,再按需拆分服务。也就是说,t3code 的意义不是解决一切问题,而是让新手和团队能少走弯路。
6. 一些可以继续扩展的方向
基于t3code这个项目,我后来也尝试过几种方向的扩展,过程都挺顺的。如果你读完上面这些内容,想拿它做二次开发,下面这几个扩展方向可以一试。
一是加入文件上传功能。Prisma 的模型并不会限制二进制文件,但一般我会把文件存到对象存储(比如 S3 兼容的服务),数据库里只保存文件的 URL。tRPC 的 mutation 入参里传一个文件 key,前端用预签名 URL 上传,这样可以避免 API 网关的直接传输压力。
二是加入任务分片或分页。目前的示例里getAll一次取回所有待办,数据量一大就会拖慢首次加载。用 tRPC 的时候,可以给getAll增加一个take和cursor参数,然后用 React Query 的useInfiniteQuery来实现无限滚动。这个模式比你想的要简单,因为类型已经打通,改起来非常快。
三是增加数据持久化之外的缓存层。如果业务复杂,可以在 tRPC 的 procedure 里增加一个 Redis 缓存中间件。需要注意缓存 key 一定包含用户身份,否则容易串数据。
我个人在实际操作中最大的体会是:全栈类型安全真正改变的不是某个单独的技术细节,而是协作方式。以前后端改字段,需要在前端代码里扒一遍哪些地方用了这个字段,现在编译器就是最好的检查工具。因为类型提示的存在,连代码补全都准确了很多。t3code这个项目,哪怕不做任何业务扩展,单纯把它作为团队新成员学习全栈 TypeScript 的起点,也非常值得。最后再分享一个小技巧:每次写完一个 tRPC procedure,先别急着写前端页面,用一下 tRPC 自带的 Playground(也可以装一个集成插件),在浏览器里直接调用一次接口。这个习惯能省去很多来回调试的时间。