☰
T3 Stack 全栈实践:用 tRPC 与 Prisma 实现类型安全的 CRUD
2026/10/14 15:25:24 网站建设 项目流程

第一次看到 create-t3-app 这个脚手架的时候,我其实有点不以为然。一个命令行工具,把 Next.js、TypeScript、Tailwind、tRPC、Prisma、NextAuth 打包到一起,听起来像包装出来的"全家桶"。直到我真把一个社区项目的技术栈从 REST + Redux 迁到这套组合之后,才意识到 t3code 代表的不是又一个框架,而是一种"少写胶水代码、靠类型系统把前后端焊死"的开发方式。

这篇文章会讲清楚 t3code 到底是什么、每个环节为什么这么选、从零到一怎么搭、完整 CRUD 怎么做,以及我实际踩过的坑。适合两类人看:一是刚接触全栈 TypeScript、想在 Next.js 里找到一套不出错的默认方案的开发者;二是已经在用 T3 Stack、但希望把 tRPC 和 Prisma 用得更有章法的人。我不会只贴配置,会把每个关键决策背后的理由讲透,这样你拿到自己的项目里也能活学活用。

1. 项目概述:t3code 到底是什么

1.1 它不是新框架,而是一套"高内聚"的技术组合

t3code 这个名字乍看像个独立项目,其实指的是社区里常说的 T3 Stack,核心组件是 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma,再加一个按需引入的 NextAuth.js。T3 最早是"The T3 Stack"的意思,强调三样打头字母是 T 的东西——TypeScript、Tailwind、tRPC——后来演进成一套更完整的默认方案。

你完全可以不依赖 create-t3-app 手工拼出这套组合,但那会很痛苦。关键不在于"用了这几个库",而在于它们之间的缝隙被填得有多严实。传统全栈项目里,前端调后端接口要手写类型、后端查数据库要手写模型映射、认证状态要在两端各维护一份,这些重复劳动在 t3code 里几乎被消灭了。脚手架把 Next.js 的 API 路由、tRPC 的 server/client 分层、Prisma 的 schema 声明全部预置好,你打开项目就能直接进入业务开发,而不是先花两天把基建连起来。

我见过不少团队纠结"要不要上 t3code"。我的判断标准很简单:你的项目是不是前后端一体的、是不是以 TypeScript 为主语言、有没有数据库交互。只要三个都满足,这套组合的收益就远大于学习成本。如果项目是纯前端或纯后端,或者团队对类型安全没有强需求,那就不必硬套。

1.2 它想解决的四大痛点

第一,前后端类型割裂。传统 REST 接口,前端拿到的是运行时才见分晓的数据,哪怕后端用 TypeScript 写了类型,前端也要手写一份 dto 同步维护。t3code 用 tRPC 让 API 的类型定义从 server 端直接导出给 client 使用,改一个字段,另一侧立刻报错。

第二,数据库访问不安全。直接写 SQL 容易注入,手写 repository 又容易漏掉关联查询。Prisma 用 schema 建模后生成类型安全的 client,查询写错了在编译期就爆红。

第三,认证上下文重复传递。NextAuth 登录后,你希望每个接口都能知道"当前用户是谁"。在普通方案里,每个 handler 都得自己解析 token。t3code 把 session 注入到 tRPC 的 context 里,protectedProcedure 直接拿到用户 ID,干净利落。

第四,样板代码过多。CRUD 接口的 request validation、错误处理、成功响应包装,这些代码每家公司写的都不一样,但本质上都是重复劳动。tRPC 内置 zod 校验、统一错误传播,能省掉一大半模板。

1.3 谁适合用这套方案

如果你是个人开发者做全栈应用、创业团队快速验证 MVP、或者公司内部工具类系统,t3code 是我目前见过的性价比最高的起点。它不需要你分别维护前端项目和后端项目,部署时一个应用就搞定了,对服务器资源也很友好。

反过来,如果你们的团队前端和后端完全分属不同仓库、由不同人维护,或者接口要被多个第三方应用消费,那 t3code 的优势会减弱。毕竟 tRPC 的强绑定更适合"自己人调用自己人",开放 API 场景还是 REST 或 GraphQL 更合适。这不是说 t3code 不好,而是工具要选对场景。

2. 技术选型拆解:为什么是这四件套加两件套

2.1 Next.js 当骨架:全栈而不失灵活

Next.js 在这套组合里承担的是"宿主"角色。它的 API routes 可以托管 tRPC 的 HTTP handler,页面组件可以直接在服务端取数,同时还能享受文件路由、静态优化、图片优化等生态红利。选它而不是 Vite + Express,核心原因是"单体应用"带来的部署简单:一个 Node 服务同时渲染页面和处理接口,不需要在 Nginx 里做复杂的路径转发。

App Router 出来之后,很多人纠结要不要用。create-t3-app 现在默认给的是 App Router,但我个人的建议是:老项目用 Pages Router 别急着迁,新项目直接 App Router。App Router 的 Server Components 配合 tRPC 的 server caller,可以在服务端直接查数据再渲染,体验很好。不过要注意,"use client"组件里用 tRPC 的 hook 是没有问题的,服务端组件则要走 server caller 的方式,别混用。

2.2 tRPC 是这套方案的灵魂

tRPC 是 t3code 最容易被低估的部分。它做的事看起来简单——让你在 client 端像调用本地函数一样调用后端 procedure——但背后是完整的类型导出机制。你写一个postRouter.list的 query,client 端api.post.list.useQuery()拿到的 data 类型就自动和后端返回类型一致,连返回值里的嵌套对象都给你推断得明明白白。

我第一次给同事展示的时候,他们第一反应是"这不就是个 RPC 框架吗"。但真正用起来区别很大:tRPC 不需要生成 schema 文件,不需要 codegen 步骤,类型直接从 server 代码推导到 client。改代码、保存、另一侧类型立刻生效,这种"零编译等待"的体验在开发效率上非常关键。配合 zod 做输入校验,一个 procedure 从参数校验到权限控制到业务逻辑,代码可以控制在二十行以内。

2.3 Prisma 把数据库变成了类型安全的 ORM

老实说,Prisma 不是唯一的 ORM 选择,Drizzle 现在也很火。但 create-t3-app 默认选 Prisma 是有道理的:它的 schema 文件非常直观,迁移工具成熟,对新手友好程度高。你用model Post { ... }声明表结构,然后npx prisma migrate dev生成迁移文件,数据库结构和 TypeScript 类型一次搞定。

Prisma 的 client 在 tRPC 里调用起来尤其顺手。因为 tRPC 的过程本身就是异步函数,你可以在里面直接ctx.db.post.findMany(),不需要额外包一层 repository。很多团队上来就套三层架构,反而把 Prisma 的简单性搞没了。以我的经验,先用 Prisma 直连,等业务复杂到需要复用查询逻辑时,再加 service 层不迟。

2.4 NextAuth.js 把认证做得更省心

认证是每个全栈应用绕不开的环节。自己实现登录、session、OAuth 回调,短则两三天,长则一周,还容易踩安全问题。NextAuth.js 提供了 Google、GitHub、邮箱密码等多种 provider,默认把 session 管理、CSRF 防护、回调逻辑都封装好了。在 t3code 里,它的 session 对象被注入 tRPC context,protectedProcedure就是"必须登录才能调用"的关卡。

要注意的是,NextAuth v4 和 v5(Auth.js)配置方式差了不少。create-t3-app 当前版本用的是 v4 的稳定方案,API 文档多。如果你自己集成新版,注意auth()的调用方式变化。我在实际项目里用 GitHub provider 居多,配置只需要GITHUB_ID和GITHUB_SECRET两个环境变量,开发时本地跑非常省事。

2.5 Tailwind CSS 的取舍

Tailwind 是这套组合里争议最大的一块。喜欢的人觉得原子类写起来快、不用切文件;不喜欢的人觉得 HTML 里堆一堆 class 很难看。我的看法是,T3 选 Tailwind 更多是因为它和组件开发模式的契合度——Next.js 的组件化天然适合原子化 CSS 的"组合"思路,而且 Tailwind 的 JIT 编译在 Next.js 里开箱即用,不需要额外配 PostCSS。

如果你实在不喜欢 Tailwind,完全可以不用它。create-t3-app 可以取消勾选,不影响其他部分。裁掉样式方案后,用 CSS Modules 或者 styled-components 都可以。组合的灵活性就在这里,不是强绑定。

3. 从零搭建:初始化到跑通第一个接口

3.1 用 create-t3-app 初始化项目

我建议新建一个干净的目录来操作:

npm create t3-app@latest

交互式命令行会问你项目名,以及要启用哪些模块。对于标准 t3code 全栈应用,我会勾选 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma,认证按需选 NextAuth。如果你暂时不需要数据库,也可以只勾前端和 tRPC,后面再加 Prisma,但那样初始化文件会少一些,不如一开始选全。

初始化完成后,先看一眼生成的.env文件。DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL这些都在里面,默认值是假的,需要你改成真实配置。这一步最容易掉坑——有人改了 Prisma schema 却忘了数据库连不上,就是因为 DATABASE_URL 还是默认值。

3.2 目录结构到底怎么长

初始化后的 src 目录里,几个关键位置要记住:

  • src/server/api/trpc.ts:tRPC 的初始化文件,定义了createTRPCRouter、publicProcedure、protectedProcedure,以及 context 的构建方式。
  • src/server/api/routers/:所有业务 router 放这里,脚手架默认给了一个post.ts示例(或者example.ts,取决于版本)。
  • src/server/db.ts:Prisma client 的单例导出,全局只创建一次连接。
  • src/utils/api.ts:tRPC client 的封装,前端所有api.xxx调用入口。
  • src/pages/api/trpc/[trpc].ts或src/app/api/trpc/[trpc]/route.ts:把 tRPC 挂到 Next.js HTTP 层的桥接文件,一般不需要动。

理解了这几个文件的协作关系,你就掌握了 t3code 的地图:context 里带着 db 和 session,router 里写业务逻辑,client 端通过utils/api.ts调用,类型全程自动流转。

3.3 配置环境变量与数据库

先改.env里的DATABASE_URL。本地开发我习惯用 SQLite,因为零依赖,但 create-t3-app 默认倾向 PostgreSQL。如果你没有现成的 PostgreSQL 实例,先改成file:./dev.db也行,后面要上生产再切 PostgreSQL。切换数据库时只要改连接串,schema 里如果有@default(uuid())之类的字段要注意和数据库类型兼容。

接着运行:

npx prisma migrate dev --name init

这个命令会读取prisma/schema.prisma,在数据库里建表,并生成客户端类型。跑完之后如果node_modules/.prisma/client没有生成,试试npx prisma generate手动生成。我经常提醒团队:每次修改 schema 后,一定要重新 migrate 或 generate,否则 TypeScript 类型和数据库表结构对不上,运行时各种诡异报错。

3.4 写出第一个 tRPC 接口并调用

以最简单的"读一条公开信息"为例。在src/server/api/routers/post.ts里定义一个 procedure:

import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ hello: publicProcedure.query(() => { return { message: "hello from t3code" }; }), });

然后在src/server/api/root.ts(或routers/root.ts)里把它挂到总 router 上。前端页面里这样调用:

import { api } from "~/utils/api"; export default function Home() { const { data } = api.post.hello.useQuery(); return <div>{data?.message}</div>; }

保存文件,你会发现data的类型已经被推断为{ message: string } | undefined。这就是 t3code 最迷人的时刻——前后端类型在写代码的当下就连成一条线了。

4. 实战:做一个带认证的完整 CRUD 功能

4.1 定义数据模型

光跑通 hello 还不够,来一个真实场景:做一个带用户关联的博客文章 CRUD。先在prisma/schema.prisma里加模型:

model Post { id String @id @default(cuid()) title String content String? authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model User { id String @id @default(cuid()) name String? email String @unique posts Post[] }

NextAuth 的用户表默认也叫 User,如果你自己建了 User 模型,需要给 NextAuth 的 adapter 配置对应关系,否则 session 里拿不到数据库用户信息。create-t3-app 的模板通常会处理好这一点,但如果你手工集成,这是最常见的坑:登录成功了,数据库里却没有 user 记录,或者 session.user.id 是 undefined。

执行npx prisma migrate dev --name add-post把表建好。

4.2 编写业务 router

在src/server/api/routers/post.ts里写完整 CRUD:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); }), detail: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id }, include: { author: true }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const authorId = ctx.session.user.id; return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId }, }); }), update: protectedProcedure .input(z.object({ id: z.string().min(1), title: z.string().min(1).max(100), content: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post || post.authorId !== ctx.session.user.id) { throw new Error("无权修改该文章"); } return ctx.db.post.update({ where: { id: input.id }, data: { title: input.title, content: input.content }, }); }), delete: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post || post.authorId !== ctx.session.user.id) { throw new Error("无权删除该文章"); } return ctx.db.post.delete({ where: { id: input.id } }); }), });

这里有几个设计点值得解释。list用publicProcedure,因为文章列表允许游客浏览;create用protectedProcedure,保证只有登录用户能发文章;update和delete在业务代码里做"资源归属校验",这是比登录校验更深一层的权限控制。很多人只做了登录校验就以为安全了,其实"是不是本人"这道检查同样重要。

4.3 页面调用与数据刷新

列表页组件这样写:

import { api } from "~/utils/api"; export default function PostList() { const { data, isLoading, refetch } = api.post.list.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => refetch(), }); const deletePost = api.post.delete.useMutation({ onSuccess: () => refetch(), }); if (isLoading) return <div>加载中...</div>; return ( <ul> {data?.map((post) => ( <li key={post.id}> <h2>{post.title}</h2> <p>作者:{post.author?.name ?? "匿名"}</p> <button onClick={() => deletePost.mutate({ id: post.id })}>删除</button> </li> ))} </ul> ); }

useMutation的onSuccess回调里调用refetch,这是 tRPC 数据刷新的标准模式。如果你有多个查询依赖同一份数据,可以考虑用utils的setQueryData做乐观更新。我个人倾向于先在onSuccess里 refetch,简单直接,等性能有瓶颈再优化乐观更新。

4.4 输入校验与错误处理

zod 的校验在 tRPC 里是"就近原则"——每个 procedure 的 input 声明就是校验规则。z.string().min(1).max(100)会在请求到达业务逻辑之前拦截非法输入,前端不需要重复写校验逻辑,但为了用户体验还是可以先用 HTML 的 required 顶一下。

错误处理方面,tRPC 的惯例是抛 Error,client 端在onError里统一处理。比如上表的"无权修改该文章",前端可以这样:

const updatePost = api.post.update.useMutation({ onError: (err) => { alert(err.message); }, });

这里要注意:生产环境下 tRPC 默认会隐藏错误堆栈,只把 message 暴露给前端。如果你希望把内部错误细节传给前端调试,要配置isServer相关的逻辑,但生产环境我建议保持默认,避免泄露内部信息。

5. 常见问题、踩坑记录与调优心得

5.1 类型不同步的经典场面

我见过最多的问题就是:改动 Prisma schema 或 router 后,前端类型没更新,编辑器报错说data上不存在某个属性。绝大多数情况是忘了npx prisma generate,或者 dev server 缓存了旧类型。解决顺序是:

  1. 先跑npx prisma generate,确认node_modules/.prisma/client已更新。
  2. 重启 Next.js dev server,让它重新编译 tRPC 类型。
  3. 如果还不行,删掉.next目录再跑npm run dev。

这里插一句,Windows 上偶尔会遇到类型文件锁定的情况,关掉编辑器或 IDE 的 TS server 再重启,一般就解决了。

5.2 数据库连接与迁移问题

本地 SQLite 一切正常,上了 PostgreSQL 后报P1001(连不上)或者P2002(唯一约束冲突),先别急,按这三步排查:

  • .env里DATABASE_URL是否带了正确的用户名密码、主机和端口;PostgreSQL 的连接串格式是postgresql://user:password@host:5432/dbname,少了斜杠或者端口写错都很常见。
  • 生产环境如果用连接池,Prisma 需要pgbouncer=true或者?connection_limit=1之类的参数配合,否则连接数一多就报错。
  • 迁移文件冲突:如果多人开发,建议用prisma migrate dev而不是prisma db push。前者会生成可审查的迁移历史,后者只同步表结构,团队协作时容易失控。

5.3 部署时容易翻车的地方

部署到 Vercel 是 Next.js 的天然主场,但 t3code 几个细节要注意。第一,NEXTAUTH_URL必须改成生产环境的域名,否则 OAuth 回调会一直失败。第二,Vercel 的 Serverless 环境默认无状态,Prisma 每次冷启动创建连接,如果数据库连接数有限,记得开启连接池或减少冷启动频率。第三,如果你的 procedure 在边缘运行时(Edge Runtime)里跑,Prisma 目前不支持,需要在文件顶部加上export const runtime = "nodejs"。

如果部署到自己的 Node 服务器,npm run build之后用npm run start启动即可。注意把.env文件放到服务端,或者通过 PM2 等进程管理工具注入环境变量。我曾经因为漏了.env导致生产环境 DATABASE_URL 为空,Prisma 直接抛出P1001,排查了半天才发现是环境变量没进去,这种低级错误最浪费心情。

5.4 API 组织、性能与团队协作经验

最后分享几条我在实际项目里的体会。

第一,router 拆分按业务域,不按"操作类型"。很多新手习惯建一个apiRouter把所有 procedure 塞进去,过两周就乱了。我更推荐一个业务域一个文件,比如userRouter、postRouter、commentRouter,然后在 root router 里合起来。这样每个文件的职责清晰,权限配置也好管理。

第二,谨慎使用include做深嵌套查询。Prisma 的关联查询很方便,但嵌套太深会生成低效 SQL。比如文章列表里 include 作者,没问题;如果作者又 include 他所有的文章,就会造成查询膨胀。可以先select需要的字段,或者用findMany加take限制条数,避免一次拉回全表。

第三,tRPC 的 context 里别放太重的东西。每次请求都会执行 context 的构建,如果你在 context 里查数据库或做耗时操作,所有 procedure 都会被拖慢。session 读取这类轻量操作没问题,重逻辑放到 procedure 内部按需调用。

第四,团队新人上手成本比你想象的低。我一直担心 t3code"太魔法",但实际带人后发现,只要理解了"router 写后端、hook 调后端、类型自动传"这一句话,新人半天就能写 CRUD。反而是传统 REST 项目,新人要先搞明白接口文档、请求封装、类型定义,上手更慢。

如果你打算长期维护这个项目,我建议把 zod schema 抽出来统一管理,尤其是多个 procedure 共用同一套 input 校验的时候。这样既避免重复代码,也让校验规则成为团队可见的"接口契约"。不过别一开始就抽象,等真的出现重复再抽,早了只会增加理解成本。

我在做自己的 Side Project 时最深的感受是:t3code 最大的价值不是某个库多厉害,而是把"前后端类型安全"变成了默认选项。当你不需要反复确认接口字段名是否拼错、不需要为了一个类型定义在两个仓库里来回改的时候,写代码的注意力才能真正集中在业务本身上。这也是我到现在还愿意推荐它的原因。如果你正打算启动一个新项目,不妨给它一个周末的时间,亲手把 CRUD 跑通,再决定适不适合你。

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

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

立即咨询