☰
基于 T3 Stack 的端到端类型安全全栈开发实践——从 t3code 看 tRPC 与 Prisma 的协作
2026/10/26 8:52:50 网站建设 项目流程

1. t3code 是什么:一个把“类型安全”贯彻到底的全栈项目

1.1 项目代号背后的真实需求

t3code 是我最近在团队里做的一个内部工具的项目代号,名字取得很直白:整个项目用 T3 Stack 来写,代码仓库就叫 t3code,所以从命名到技术栈都绑在一起。T3 Stack 指的是以 TypeScript 为第一优先级的全栈开发组合:Next.js + tRPC + Tailwind CSS + Prisma,外加可选的 NextAuth.js。核心思路就一句话:让前端、后端、数据库三层的类型定义共享同一份 TypeScript 类型系统,开发时不需要在接口文档和类型定义之间来回切换。

这个项目的业务场景其实很普通:我们团队日常会积累大量代码片段、技术文档链接、常用命令和脚本,之前散落在各自的本地笔记、聊天记录和飞书文档里,想找一个东西的时候经常要翻半天。t3code 的目标就是把这些零散资源统一收进一个内部系统里,支持按标签筛选、按关键词搜索、个人收藏,以及只有登录用户才能添加内容。看起来没什么稀奇,但做完之后我倒是觉得这类“小而不小”的内部工具,反而是体验 T3 Stack 全流程的最好载体:有数据模型、有接口、有页面、有鉴权,还能顺利部署上线。

先给读者校准一下预期。如果你是自己想学习现代全栈开发,或者要给团队快速搭一个内部中后台,t3code 这类项目是很好的切入点。如果你是想找一个大规模高并发的互联网应用模板,那 T3 Stack 未必是最合适的选择,后面我会在“这条技术路线的上限在哪里”专门聊。总之,这篇笔记的目标是让一个熟悉 JavaScript 但没怎么用过 tRPC 和 Prisma 的开发者,也能照着把项目从零跑到线上。

1.2 为什么选 T3 Stack,而不是传统的前后端分离

在动手写 t3code 之前,我其实先对比过几条路线:经典的前后端分离(Vue/React + Node API + Swagger)、Next.js 自带的 Route Handler + REST、GraphQL,以及最终选择的 T3 Stack。

传统前后端分离的痛点,写过的人都懂。后端写一个 GET /api/snippets,前端要自己维护一个对应的 types.ts,后端改了字段类型,前端的类型定义不会自动跟随,接口文档如果更新不及时,联调时就会出现“数据里明明有 createAt,前端类型里却只有 createdAt”这种低级又磨人的问题。GraphQL 的类型系统很强,但为了一个小工具引入 GraphQL 的服务端代码、客户端代码和缓存策略,复杂度明显是溢出的。

T3 Stack 抓的点非常准:你先把服务端的路由定义出来,tRPC 会通过 TypeScript 的类型推断,把路由和输入输出的类型自动“传送”到客户端。写前端代码时,调用某个 query 后返回的数据类型是编辑器自动补全的,字段名拼错了编译期就报错。这种体验一旦习惯了,是真的回不去。再加上 Prisma 也用同样的 TypeScript 类型体系生成数据库 client,等于把从前端组件到数据库这一整条链路上的类型全部打通了。

我选 T3 Stack 还有一个私人原因:它的默认脚手架 create-t3-app 非常克制。不像很多“全家桶”模板会顺便塞进去 Redux、ESLint、Prettier、测试框架、CI 配置,create-t3-app 默认只给你最核心的部分,其他全靠按需添加。这种“少即是多”的思路,对中小型项目减少心智负担有奇效。

1.3 这个项目适合谁、不适合谁

t3code 的经验到底适合什么样的团队和场景,我说说自己的判断。

适合的一类场景是内部系统、管理后台、运营工具、个人作品集这类业务逻辑清晰、用户量不大但功能迭代很快的项目。这类项目最怕的不是性能瓶颈,而是改需求的时候前后端各改一遍、类型对不上,白白浪费时间。T3 Stack 让前后端类型天然同步,一个小团队两三个人就能把一个全栈项目维护得很好。

不适合的场景也客观存在。如果你的业务需要对外开放大量 REST 接口,需要兼容移动端、小程序、第三方开发者等多个客户端,那么 tRPC 的“客户端也是 TypeScript”前提就失效了。再比如你要做的是电商中台这种多团队并行、接口需要严格版本管理的系统,传统的 OpenAPI 规范反而更合适。另外,如果页面全部是静态展示、几乎没有服务端逻辑,那么直接 Next.js 静态导出就完了,没必要引入数据库和 tRPC。

我自己的经验是:在动手之前先问一句,这个项目的“端到端”到底是从哪里到哪里。如果是从浏览器到数据库,T3 Stack 几乎是完美匹配;如果是从浏览器到第三方开放平台,那就老老实实写 REST。

2. 动手之前先理解架构:T3 Stack 的协作方式

2.1 端到端类型安全的密码:tRPC 的类型推断

第一次接触 tRPC 的人,最困惑的就是“它到底是怎么把后端的类型传到前端的”。我换个生活化的说法解释:你可以把 tRPC 的路由定义想象成一份目录,后端定义了“这是一个查询,输入是 id,输出是一个代码片段对象”,TypeScript 会为这份目录生成一份详细的类型签名。前端在 import 这个类型签名之后,编辑器里的 api.snippet.getById.useQuery 就已经知道自己返回什么结构了。

下面这个例子是 t3code 里最典型的一个查询:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(({ ctx, input }) => { return ctx.db.snippet.findUnique({ where: { id: input.id }, include: { tags: true, author: true }, }); }), });

注意看,这里的 input 用 zod 定义,zod 既做了运行时校验,又通过 TypeScript 的类型推断把 input 的类型自动提供给前端。前端调用时,input 对象必须包含 id 字段,而且 id 必须是 string,否则编译器直接亮红灯。这种“一份 schema 双重用途”的设计,比手写两套类型省事得多。

tRPC 的第二个关键设计是 procedure 的概念。publicProcedure 表示任何人都能调用的接口,protectedProcedure 则要求必须有登录态。这个权限信息会体现在类型层面吗?不会。tRPC 不会在类型上区分一个接口是否需要登录,但运行时如果用户没登录,会直接抛 UNAUTHORIZED 错误。所以在团队里约定好“所有操作类接口必须用 protectedProcedure”,比指望类型系统约束所有事情更现实。

2.2 数据层:Prisma 模型设计与迁移流程

t3code 的数据模型其实很简单,就三张核心表外加一张中间表。第一张是 User,用来存储用户信息;第二张是 Snippet,记录代码片段标题、内容、语言、创建时间;第三张是 Tag,用于分类;因为一个代码片段可以打多个标签,一个标签也可以属于多个代码片段,所以还需要一张 _SnippetToTag 的中间表,这是 Prisma 处理多对多关系的标准方式。

Prisma schema 的核心定义长这样:

model User { id String @id @default(cuid()) name String? email String @unique snippets Snippet[] } model Snippet { id String @id @default(cuid()) title String content String language String @default("plaintext") createdAt DateTime @default(now()) author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] @relation("SnippetTags") } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] @relation("SnippetTags") }

在开发初期我直接用 SQLite 跑通全流程,一个文件搞定数据库,不用额外启动服务。等要上线时再把 provider 改成 postgresql,重新生成一次 migration。Prisma 的迁移工具在这一点上做得很好,schema 改完执行prisma migrate dev --name init,它会自动对比差异并生成 SQL 迁移文件。遇到团队协作时,只需要把 migration 文件提交到仓库,其他人执行prisma migrate deploy就能同步表结构。

这里有个实用经验:凡是要上生产环境的项目,尽量不要在生产环境直接执行prisma db push。db push 适合开发阶段快速改表,但它不会产生迁移记录,生产环境一旦出了问题很难回滚。正确姿势是开发阶段用 migrate dev,正式发布前用 migrate deploy 应用已经提交的迁移文件。

2.3 路由组织:从 schema 定义到 tRPC Router 的映射

t3code 的接口没有按传统的 controller 去组织,而是完全按业务模块拆 router。代码片段的接口全部放在 snippetRouter 里,标签的接口放在 tagRouter 里,然后通过 rootRouter 合到一起:

export const appRouter = createTRPCRouter({ snippet: snippetRouter, tag: tagRouter, user: userRouter, }); export type AppRouter = typeof appRouter;

前端调用的时候,路径层级和 router 层级一一对应。比如api.snippet.create.useMutation()、api.tag.list.useQuery(),阅读代码的时候非常直观。这在项目大了之后尤其有价值,你不必猜测这个接口到底是 GET 还是 POST,也不必去翻路由表,业务模块和接口路径的映射关系就摆在那里。

ctx 的设计也值得一提。createTRPCContext 里面通常会注入 db 实例和 session。这样每个 procedure 都能在 ctx 上拿到当前登录用户和数据库客户端。我强烈建议不要在 procedure 里直接 import 数据库单例,而是统一通过 ctx 注入,否则测试的时候很难 mock 数据源。

3. 实操:用 t3code 从零实现一个完整的增删改查

3.1 初始化与基础环境准备

用官方脚手架创建项目是最省事的方式,我执行的是:

pnpm create t3-app@latest

交互式命令行会让选择项目名,我填了 t3code。后面一系列选项我建议按需勾选:Next.js 的 App Router 一定要开,Tailwind CSS 建议开,tRPC 和 Prisma 是本项目的核心必须开,NextAuth.js 看你的鉴权方案,如果一开始不想碰登录,可以不开,后续也能手动补。

脚手架跑完之后会生成一个非常整洁的目录结构:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts │ │ └── page.tsx │ ├── components/ │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── trpc.ts │ │ │ └── routers/ │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ ├── client.ts │ │ └── react.tsx │ └── env.js ├── .env └── next.config.js

看到这个结构的时候我就知道这个脚手架是真的为“全栈类型安全”设计的:服务端代码放在 src/server,客户端封装的 tRPC 调用放在 src/trpc,两者各司其职,互不越界。如果项目后续变复杂,还可以在这个基础上加 tests、packages 之类的目录,但那是后话。

启动开发环境的命令是:

pnpm dev

打开 http://localhost:3000 就能看到默认模板页。如果第一时间就报 env 相关的错误,说明 .env 文件还没配置好,install 之后我记得脚手架会提示把 .env.example 复制成 .env。

3.2 配置 Prisma 与本地数据库

开发期我用 SQLite,所以 .env 里的 DATABASE_URL 写的是:

DATABASE_URL="file:./db.sqlite"

要注意,这个路径是相对 prisma 目录的,不是项目根目录。如果你写错位置,Prisma 会找不到数据库文件,连接时报错。

接下去依次执行三个命令:

pnpm prisma migrate dev --name init pnpm prisma generate

migrate dev 会创建数据库文件并生成迁移记录,generate 会根据 schema 生成 TypeScript client。很多第一次用 Prisma 的新手卡在“我改了 schema 为什么编辑器还是没有类型提示”这个问题上,其实就是因为忘了每次改完 schema 都要跑 generate。Prisma client 的类型是生成出来的,不是自动热更新的。

如果你打算把 t3code 部署到线上,我建议一开始就在本地装好 PostgreSQL,开发期就用 Postgres 而不是 SQLite,避免后面切换带来的小坑。SQLite 和 Postgres 在 Prisma 下的语法基本一致,但有些字段类型、默认值函数不完全相同,比如 Postgres 支持 jsonb,SQLite 没有类似能力。小项目无所谓,项目一旦复杂,尽早使用与生产一致的环境能省掉很多麻烦。

3.3 第一个 tRPC 接口:创建代码片段

第一个接口我选择 createSnippet,因为它是典型的 mutation,能完整展现输入校验、权限控制、数据写入全流程。

import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; const createSnippetInput = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), language: z.string().default("plaintext"), tagNames: z.array(z.string()).max(10).default([]), }); export const snippetRouter = createTRPCRouter({ create: protectedProcedure .input(createSnippetInput) .mutation(async ({ ctx, input }) => { const authorId = ctx.session.user.id; const snippet = await ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, authorId, tags: { connectOrCreate: input.tagNames.map((name) => ({ where: { name }, create: { name }, })), }, }, include: { tags: true }, }); return snippet; }), });

这里有几个细节值得说清楚。第一,protectedProcedure 在触发 mutation 之前就会检查 ctx.session,如果用户未登录,tRPC 会自动抛 401 错误。第二,tagNames 的处理用了 connectOrCreate,这是 Prisma 很实用的 API:标签存在就关联,不存在就创建后再关联,省掉了一次“先查再写”的手工逻辑。第三,input 里所有字段都经过 zod 校验,长度、必填、默认值全在一处维护。

开发完这个接口,我马上在浏览器里用 t3code 的前端页面测试了一遍,前端调用非常简单:

const utils = api.useUtils(); const createSnippet = api.snippet.create.useMutation({ onSuccess: () => { utils.snippet.list.invalidate(); }, });

onSuccess 里调用 invalidate 让列表自动刷新,这是 React Query 的核心用法。很多人把缓存失效想得太复杂,其实这里就是一句话:旧数据标记为过期,重新拉取。

3.4 页面接入:React Query 与 tRPC 的配合

t3code 的列表页用了两种方式接入 tRPC,我分别说说它们的适用场景。

第一种是 Server Component 直接调用。在 App Router 的页面组件里,通过 createCaller 创建一个服务端调用器,然后直接在服务端执行查询,把结果传给前端组件。这种方式的好处是首屏数据在服务端就渲染好了,适合列表页、详情页这类对首屏速度敏感的场景。代码大概是:

import { api } from "~/trpc/server"; export default async function SnippetListPage() { const snippets = await api.snippet.list.query({ limit: 20 }); return <SnippetList initialData={snippets} />; }

第二种是 Client Component 里用 useQuery。用户登录后的个人片段页、标签筛选页,因为交互频繁,需要实时响应用户操作,所以在客户端调用更好。注意,这两种方式用的是同一套 API 路径,server caller 和 client hook 共享同一份类型定义,不存在“服务端能用但客户端不能用”的割裂感。

我踩过一个小坑:Server Component 里调用 tRPC 时,如果页面依赖登录态,直接调用 publicProcedure 能拿到数据,但拿不到当前用户。因为服务端调用时 ctx 里的 session 是在 server 请求上下文中解析的,你需要在 createCaller 之前先调用 auth() 拿到 session。create-t3-app 默认的 server caller 已经处理了这层逻辑,但你自己手写的时候很容易忽略。

3.5 部署到生产环境的几个注意事项

t3code 部署,我主要讲两条路线,Vercel 和自托管 Node 容器。

如果走 Vercel,需要注意三点。第一,环境变量要重新配一遍,包括 DATABASE_URL、NEXTAUTH_SECRET 和 NEXTAUTH_URL。很多人本地跑得好好的,一部署就 500,十有八九是环境变量没同步。第二,构建命令要确保在 build 阶段之前执行 Prisma generate,Vercel 默认的构建流程通常能识别 Prisma,但如果你用了自定义构建命令,就要留意这一步。第三,数据库迁移不能在 Vercel 构建时自动跑,我习惯在 CI 里单独执行prisma migrate deploy,或者手动到服务器上跑一次。

如果自托管,我建议直接打一个 Node 容器镜像。Dockerfile 里的关键步骤是:先安装依赖,再 prisma generate,然后 next build,最后用node server.js启动。这里有一个细节,生产环境不要用next start直接跑,而是先执行next build生成独立输出模式,这样才能在你的容器里正确启动。数据库连接如果走公网,记得在生产环境的 DATABASE_URL 里配置连接超时参数,免得数据库偶尔抖动的时候接口一直挂起。

4. 踩坑记录:t3code 开发中我遇到的高频问题

4.1 类型不同步、Prisma 连接池与缓存不刷新

第一类高频问题跟类型相关。改完 tRPC 的 router 之后,前端调用处经常报类型错误,或者编辑器还是旧类型。原因通常是 dev server 的 HMR 没有完全重新加载 tRPC 的类型。我的解决方法是重启 dev server,或者手动重新跑一次tsc --noEmit确认类型是否真的更新了。如果你用的是 pnpm,别忘了 pnpm 有全局 store 的硬链接,偶尔会出现依赖版本没刷新的问题,可以pnpm install --force一下。

第二类高频问题是 Prisma 连接池耗尽。出现的情况是:接口在本地没问题,部署到生产后突然一堆 500 错误,日志里全是“Can't reach database server”或“connection limit exceeded”。原因多半是每个 serverless 函数实例都创建了一个新的 PrismaClient,实例一多,连接数就爆了。解决方案是使用 Prisma 官方推荐的全局单例模式,create-t3-app 模板里的 src/server/db.ts 已经处理了这一点,会用到 globalThis 做缓存,不要在业务代码里再新建 PrismaClient。如果你自部署长时间运行,可以再把连接池大小调小一点,比如?connection_limit=5。

第三类问题是列表更新不及时。tRPC + React Query 的缓存机制很聪明,但也经常让人困惑。在 mutation 的 onSuccess 里忘记 invalidate 对应的 list,界面上就会出现“创建成功但列表没有新增”的假象。排查顺序是:先看数据库里有没有写入,再看前端有没有 invalidate,最后看是不是页面本身用了服务端缓存,需要 router.refresh()。

4.2 鉴权与多用户数据隔离

t3code 的多用户隔离,刚开始我只做了“创建时需要登录”,但很快就发现问题:一个用户登录后能通过猜测 ID 查看所有用户创建的片段。这种漏洞在内部工具里尤其危险,因为用户量不大,大家反而容易忽略权限边界。

所以我在所有查询和 mutation 里都强制加上 authorId 过滤:

getMySnippets: protectedProcedure .query(({ ctx }) => { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id }, }); })

如果你要提供一个“管理员可见全部”的权限模型,我建议直接给 User 表加一个 role 字段,并在 tRPC 里封装一个 adminProcedure:

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

这样的抽象比在每个 procedure 里都写 if 判断要干净得多。另外一个很容易踩的点是:NextAuth 的 JWT session 默认不包含数据库里的 role 字段,你在 callback 里需要额外查询数据库并把它塞进 session 里,否则 protectedProcedure 里拿到的 session.user 永远没有 role。

4.3 性能优化:批量查询与无限滚动的取舍

t3code 早期版本在列表页一次性查出全部片段,数据少的时候没感觉,等片段数量到几百条,页面明显变慢。这时候才开始做分页,属于典型的“性能问题来得比想象中早”。

我采用的方案很简单,t3code 内部片段数量级不大,所以用 offset 分页就够了:

list: publicProcedure .input(z.object({ limit: z.number().min(1).max(50).default(20), offset: z.number().default(0) })) .query(async ({ ctx, input }) => { const [items, total] = await Promise.all([ ctx.db.snippet.findMany({ orderBy: { createdAt: "desc" }, skip: input.offset, take: input.limit, include: { tags: true, author: { select: { name: true } } }, }), ctx.db.snippet.count(), ]); return { items, total }; });

如果你预期数据量会到十万条级别,我建议改用 cursor 分页,游标“轻量”很多,翻页不会因为 offset 太大而变慢。但游标分页的缺点是前端要维护游标状态,代码会比 offset 复杂不少。t3code 这类内部工具,我倾向于先用 offset,直到真的出现性能问题再迁移。

还有一个容易被忽视的优化点:列表查询里 includ 的关联表字段,一定要用 select 精确挑选。比如作者信息只需要 name,就不要把整个 User 对象带出来。这能省掉相当一部分传输体积和数据库负载。

5. 一些值得长期保留的经验

5.1 团队约定:目录结构与命名规范

t3code 做完之后,我沉淀了一套小团队可用的约定。首先是目录结构,业务 router 全部放在 src/server/api/routers 下,文件名按业务模块命名,比如 snippet.ts、tag.ts、user.ts,不要在文件名里加上 router 后缀,因为目录已经表达了含义。

其次是 procedure 命名,我规定查询统一用 getXxx / listXxx,变更统一用 createXxx / updateXxx / deleteXxx。这样从代码里一看到 api.snippet.getById 就知道是查询,不用去翻实现。输入 schema 统一以 Input 结尾,放在 router 文件顶部,或者抽到 src/server/api/schemas 目录。小项目可以放在同文件里,项目变大再拆分 schema。

最后是代码评审约定:凡是操作类接口必须使用 protectedProcedure,凡是查询用户私密数据必须按当前 session 用户过滤。这些约定不比写文档更复杂,但配合 tRPC 的类型安全,能明显减少低级 bug。

5.2 从 t3code 看 monorepo 的演进方向

t3code 本身是一个独立应用,不需要用 monorepo。但它的代码结构天然适合往 monorepo 方向演进。如果未来团队要同时维护管理后台、对外官网、移动端 API 网关,共享 Prisma schema 和工具函数,那么我建议拆成 Turborepo 结构:

apps/ web/ # Next.js 主应用 docs/ # 文档站 packages/ db/ # Prisma schema 和 client ui/ # 共享 UI 组件 config/ # 共享 ESLint / TypeScript 配置

这么拆的核心收益是:数据库相关代码只写一份,所有应用引用同一个 db package,schema 变更的影响范围一目了然。注意,这需要团队已经有明确的“多应用共享”需求,否则过早拆包只是徒增配置成本。t3code 这种单应用,保持现在的单仓模式是完全正确的。

5.3 这条技术路线的上限在哪里

聊点实在的,T3 Stack 不是银弹,但它在中小型全栈项目里的体验确实非常好。t3code 让我最满意的地方是,从数据库表结构到前端组件,类型错误几乎在设计阶段就被拦截了,联调时间大幅缩短。这种“一个类型贯穿一条链路”的开发方式,特别适合两个人到十个人的小团队快速交付内部系统。

如果项目未来的发展方向是开放大量 API 给外部三方,或者业务模块多到需要独立部署不同的服务,那么 tRPC 就不太够用了。到时候换 REST 或者 GraphQL,不是技术不行,而是边界发生了变化——你的“端”已经不只是你的前端了,类型安全的闭环被打破,tRPC 的核心优势也就不存在了。

我个人建议:内部工具、中后台、个人项目,放心用 T3 Stack;开放平台、大规模微服务、多客户端生态,老老实实评估 OpenAPI。判断标准始终是那条——你的数据流是不是从前端一下就通到了数据库,如果是,就别折腾。

最后再分享一个小技巧:做 t3code 这类的内部全栈项目时,我会把登录后的常用操作全部录成短视频或者 GIF 存在项目 docs 目录里,比如“如何创建标签”“如何删除片段”。这个习惯投入很小,但给团队带来的便利远超预期,因为内部工具最怕的就是“明明做了,但没人知道怎么做”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询