T3 Stack实战:用tRPC和Prisma打造端到端类型安全的全栈应用
发布时间:2026/10/26 17:02:37 锦皓数字建站

有种偏见我必须要纠正一下很多前端团队觉得“全栈项目”就等于“Node写接口 React写页面”前后端各写各的类型定义靠手敲、接口文档靠人传、联调靠看network面板猜字段。这也不怪谁REST 和 GraphQL 发展这么多年端到端类型安全始终是“理论上很美、实践很难”的状态。我最近把一个内部工具用 T3 Stack 重写了一遍项目名就叫t3code算是一个带用户系统的代码片段分享站。跑完整个过程之后我的感受很直接类型安全这件事在 T3 这套组合拳里是真能从头贯到尾的而且开发体验好到你上手之后就不想再退回传统前后端分离的方式。这篇东西不是教程式的文档堆砌是我基于t3code这个项目从零搭建到部署的完整实践记录包含了选型逻辑、核心代码、我自己踩过的坑和排查思路。想了解 T3 Stack、想做一个带认证和数据库交互的全栈应用、或者正处于“要不要从 REST 迁到 RPC”这个纠结阶段的朋友都可以参考参考。1. t3code 是什么一个全栈类型安全项目的完整样本简单说t3code是我个人的代码片段管理工具功能其实很朴素注册登录之后可以收藏一段代码打上标签设置公开或私密主页能看到所有人的公开片段。选这个项目来实践 T3 Stack是因为它麻雀虽小五脏俱全——有用户认证、有数据库关系、有增删改查、有列表筛选、有权限控制。一个小工具把 T3 的核心链路全走了一遍比纯 demo 能暴露更多真实问题。“T3 Stack”这个称呼最初是 Theo 带起来的T3 并不是什么三个字母缩写而是 “Tips, Tricks, and Troubleshooting” 的意思。它本质上是一套“最佳实践集合”核心成员是几个东西技术职责在 t3code 中扮演的角色TypeScript全栈静态类型前后端共享同一套类型语言Next.jsReact 框架 服务端能力页面渲染、API 路由、Server ComponenttRPC端到端类型安全 RPC替代 REST/GraphQL连接前后端Tailwind CSS原子化 CSS 框架页面样式快速迭代 UIPrismaTypeScript 原生 ORM定义模型、创建迁移、操作数据库NextAuth.js认证解决方案邮箱登录 session 管理这套组合的最终效果就是我在prisma/schema.prisma里定义一个模型然后从数据库、服务端调用、前端状态缓存到页面组件渲染的数据类型整条链路全部自动对齐。改一个字段哪里会报错TypeScript 会在编译期直接告诉你。这种体验放在传统 REST 手写类型 JS 前端的工作流里几乎不可想象。t3code解决的也不只是“类型安全”一个问题。它还顺带解决了全栈脚手架搭建、认证方案集成、数据库 schema 管理、权限校验分层这些每个业务项目都要重复做一遍的事情。如果你是第一次接触这套东西跟着做完一个这种带完整业务逻辑的项目基本就能摸清 T3 的骨架。2. 技术选型为什么是这套T3 Stack 的取舍逻辑刚接触 T3 Stack 的人常常有一个疑问别人用 Express React 也写得挺好为什么非要改成 Next.js tRPC Prisma 这套我是实际迁移完t3code之后才真正想明白——这套选型本质上是在给“团队协作过程中的沟通成本”做减法。2.1 TypeScript 作为全栈基石T3 对 TypeScript 的依赖不是“用了”而是“重度依赖”。前后端全部用 TS 只是一层真正的关键是用 TS 把 API 契约变成可推导的类型。在t3code里前端拿到的是GithubSnippet这种带完整类型的对象而不是一个“看起来像 JSON 的 any”。TS 在这里起的作用相当于一个“提前执行的测试”改接口字段、改模型关系、换参数名编译期全给你兜住。一个很典型的场景后端的procedure返回了{ id, title, content, createdAt }前端拿着这个对象做snippet.title.toUpperCase()如果哪天后端把title改成了name前端代码在编译时就会直接红色波浪线提示。这套链路对我这种“一人维护两个端”的小项目来说收益是切切实实的开发时间节省。2.2 tRPC 替代 REST 和 GraphQL 的理由REST 的痛点是契约维护成本你要写 OpenAPI 文档要手写类型定义要对齐日期格式稍不注意前后端对字段的理解就分叉了。GraphQL 解决了“按需取字段”的问题但带来了 Schema 编写、 resolver 拆分、缓存策略等一系列新复杂度。t3code这种内部工具根本没有“多端消费者”的需求用 GraphQL 属于杀鸡用牛刀。tRPC 的方案本质上是“把函数直接暴露给前端调用”。服务端定义一个protectedProcedure.createSnippet前端 import 同一个 router 类型之后直接trpc.snippet.createSnippet.mutate({...})参数和返回值全自动推导。没有 URL 拼接没有 JSON 序列化协议对齐没有手写类型。它在t3code里的实际体验就像是“调一个本地函数”一样丝滑。代价是它只适用于“前后端同一语言”的场景这也是 T3 特意选 Next.js 的原因之一。2.3 Prisma数据库层的类型安全t3code的表结构有三张用户、片段、标签以及一个多对多的关联表。用 Prisma 定义好之后数据库表结构就是 schema 文件的镜像。最实用的几个点prisma migrate dev会自动生成迁移 SQLnpx prisma studio可以直接可视化查看数据查询 API 的返回类型是自动推导出来的真正实现了“数据库是 TS 类型系统的一部分”。相比直接写 SQL 或者用 TypeORMPrisma 对类型安全的贯彻最彻底。你写db.snippet.findMany({ where: { userId: ctx.session.user.id } })返回数组元素的类型就是Snippet { tags: Tag[] }的推导结果完全不需要额外声明。schema 一变所有相关查询的类型全都跟着变。2.4 Tailwind CSS 的开发体验Tailwind 在 T3 里的地位我只从“开发效率”这一个角度讲。t3code的 UI 全是组件化的className直接写在 JSX 上不用来回切 CSS 文件不用想类名命名也没有样式隔离的烦恼。原子类的组合方式确实能在前期迭代阶段把 UI 开发速度拉满。有人担心 Tailwind 会造成 class 字符串很长、HTML 很乱的问题我的做法是给重复使用的样式抽小组件比如PageHeader、SnippetCard、TagBadge组件内部再堆 Tailwind 类这样既保持了可维护性又享受了原子化的效率。另外确认一下T3 默认集成的是 Tailwind v3如果你需要更严格的样式定制直接改tailwind.config.ts里的extend就行。2.5 NextAuth.js认证方案为什么放在这里t3code的认证用的是 NextAuth.js之所以在 T3 里推荐它主要是因为它和 Next.js 的 App Router、Server Component、中间件这些机制是原生集成好的。认证不再是一个孤立的登录页而是可以在中间件层做路由保护、在服务端拿 session、在客户端用 hook。配置方式也很 T3在src/server/auth.ts里导出统一的 auth 实例然后通过getServerSession在 tRPC 的 context 里注入这样每个protectedProcedure就能直接拿到当前登录用户。前端通过SessionProvider包裹后直接用useSession()拿登录状态。这种“认证信息贯穿全栈”的设计让权限控制从 UI 层一路下沉到数据访问层而不是只在页面上隐藏一下按钮。3. 从零搭建 t3code初始化与项目骨架这部分给想复现的朋友一条明确的路径。我用的是最新版 create-t3-app跑一条命令就能把整套骨架拉起来。3.1 脚手架初始化npm create t3-applatest t3code运行之后它会有几个勾选项我的选择如下Next.js勾选TypeScript勾选默认就是Tailwind CSS勾选tRPC勾选Prisma勾选NextAuth.js勾选ESLint/Prettier都给勾上所有选项选完脚手架会直接生成一套能够跑起来的完整项目。第一次npm install之后建议立刻跑npx prisma db push初始化数据库如果配置了本地 PostgreSQL再npm run dev验证首页能否访问。很多新手在这里容易犯的错是直接开始写业务代码结果数据库连接串还没配置好跑起来就报DATABASE_URLis missing。3.2 核心目录结构解读脚手架生成的目录结构是理解 t3code 的关键t3code/ ├── prisma/ │ └── schema.prisma # 数据模型定义 ├── src/ │ ├── pages/ │ │ ├── api/ │ │ │ ├── auth/[...nextauth].ts │ │ │ └── trpc/[trpc].ts │ │ ├── index.tsx │ │ └── snippet/[id].tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── rourers/ # tRPC 路由按业务模块拆分 │ │ │ ├── root.ts # 所有 router 聚合 │ │ │ └── trpc.ts # tRPC 实例、context、procedure 定义 │ │ ├── auth.ts # NextAuth 配置 │ │ └── db.ts # Prisma Client 单例 │ ├── styles/ │ ├── types/ │ └── utils/ │ └── api.ts # tRPC 客户端 hook 封装 ├── .env ├── next.config.mjs ├── tailwind.config.ts └── tsconfig.jsonsrc/server/api/trpc.ts是整个 tRPC 链路的心脏里面定义了一个createTRPCContext用来把session和db注入到每个请求的 context。然后定义publicProcedure和protectedProcedure后者会先检查 session 是否为空为空直接抛UNAUTHORIZED错误。这个文件一般不需要大改但你要看懂它因为后面写业务 router 时所有 procedure 都从这里引。3.3 环境变量与数据库配置.env文件里最关键的就是数据库连接串和 NextAuth 配置# PostgreSQL 连接串 DATABASE_URLpostgresql://postgres:passwordlocalhost:5432/t3code?schemapublic # NextAuth 会话加密密文跑 npx auth secret 可以生成 NEXTAUTH_SECRETyour-secret # 正式部署时改成线上域名 NEXTAUTH_URLhttp://localhost:3000如果本地没装 PostgreSQL可以用 Docker 快速起一个docker run --name t3code-db -e POSTGRES_PASSWORDpassword -e POSTGRES_DBt3code -p 5432:5432 -d postgres:16-alpine注意NEXTAUTH_SECRET在部署到生产环境时一定不要和本地共用同一个值也不要塞进代码仓库。我一般用.env.production单独维护并且交给环境变量管理而不是硬编码进 Dockerfile。配置好.env后先跑一次npx prisma db push或npx prisma migrate dev --name init。需要说明一下我自己习惯用 migrate 方式因为迁移文件能进版本管理后续团队协作时历史可以追溯。db push更适合开发期快速改 schema 验证效果生产环境不建议直接用。4. 核心功能实战代码片段的创建、展示与权限控制整个项目最核心的价值就在这一块。我会按“数据模型 → 服务端逻辑 → 前端调用 → 权限保护”的顺序拆开讲。4.1 Prisma Schema 设计prisma/schema.prisma里的定义直接决定了整个项目的类型边界。我的Snippet模型大概长这样model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] snippets Snippet[] } model Snippet { id String id default(cuid()) title String content String language String default(text) visibility String default(public) // public | private createdAt DateTime default(now()) updatedAt DateTime updatedAt author User relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String tags Tag[] index([authorId]) index([visibility, createdAt]) } model Tag { id String id default(cuid()) name String snippets Snippet[] }这里有几个细节值得说明。visibility我用字符串加默认值而不是布尔型isPublic因为后续如果要扩展成 “仅好友可见” 这类需求字符串枚举更好扩展。index([authorId])和index([visibility, createdAt])是两个关键索引前者优化“查某个用户的片段列表”后者优化公开列表的倒序分页。数据量小的时候感受不到差别但这类字段一开始就建好索引省得以后数据大了再做迁移。Tag 和 Snippet 的多对多关系想要纯 Prisma 自动生成的隐式关联表其实已经够用。但我另有打算所以自己建关联模型更稳以后可以在关联表上加createdAt。注意如果你加了显式多对多关联模型Prisma 原生的隐式写法就不成立了route 逻辑里要用tags: { connect: [{ id: tagId }] }这种语法。4.2 tRPC Router 的业务逻辑层src/server/api/routers/snippet.ts是业务的核心。先上一个能跑的版本import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const snippetRouter createTRPCRouter({ // 公开列表所有人可读 listPublic: publicProcedure .input( z.object({ language: z.string().optional(), cursor: z.string().nullish(), // 游标分页 take: z.number().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) { const items await ctx.db.snippet.findMany({ where: { visibility: public, ...(input.language ? { language: input.language } : {}), }, include: { author: { select: { name: true } }, tags: true }, orderBy: { createdAt: desc }, take: input.take 1, ...(input.cursor ? { cursor: { id: input.cursor } } : {}), }); let nextCursor: string | null null; if (items.length input.take) { const nextItem items.pop(); nextCursor nextItem?.id ?? null; } return { items, nextCursor }; }), // 创建片段需要登录 create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().default(text), visibility: z.enum([public, private]).default(public), tagIds: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, visibility: input.visibility, authorId: ctx.session.user.id, tags: input.tagIds.length ? { connect: input.tagIds.map((id) ({ id })) } : undefined, }, include: { tags: true }, }); }), // 删除仅作者本人 delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const snippet await ctx.db.snippet.findUnique({ where: { id: input.id } }); if (!snippet || snippet.authorId ! ctx.session.user.id) { throw new Error(FORBIDDEN); } await ctx.db.snippet.delete({ where: { id: input.id } }); return { ok: true }; }), });有几个点我在实践里特别有体会第一protectedProcedure保证了进到这里的人一定带了 session所以ctx.session.user.id可以放心用。第二删除的权限判断不能只依赖前端隐藏按钮服务端必须再查一次authorId做比对这才是真正的安全边界。第三游标分页我用了take 1来判断是否有下一页这是 GraphQL 时代就有的经典做法比skip/take在深分页场景下稳定得多。root.ts负责把所有子路由聚合export const appRouter createTRPCRouter({ snippet: snippetRouter, // 还可以挂 userRouter、tagRouter 等 }); export type AppRouter typeof appRouter;AppRouter这个类型是整个端到端安全的源头。前端所有 hook 的类型都从它推导后面你会反复看到typeof appRouter的身影。4.3 前端页面与类型安全调用前端调 tRPC 的体验我直接用src/utils/api.ts里生成的 hook 说明import { api } from ~/utils/api; export default function SnippetList() { // 这里类型完全自动推导 const { data, isLoading, hasNextPage, fetchNextPage } api.snippet.listPublic.useInfiniteQuery( { language: ts }, { getNextPageParam: (lastPage) lastPage.nextCursor } ); if (isLoading) return divLoading.../div; return ( div classNamespace-y-4 {data?.pages.flatMap((page) page.items).map((snippet) ( SnippetCard key{snippet.id} snippet{snippet} / ))} {hasNextPage ( button onClick{() void fetchNextPage()}加载更多/button )} /div ); }当你把鼠标悬停在snippet上时编辑器会告诉你这个对象是Snippet { author: { name: string | null }; tags: Tag[] }不是any。这就是端到端类型安全的直接体现——前端根本不需要 import 任何数据库实体类型AppRouter的推导结果就是最准确的。创建和删除对应useMutationconst createSnippet api.snippet.create.useMutation({ onSuccess: () { void utils.snippet.listPublic.invalidate(); }, }); function handleSave() { createSnippet.mutate({ title, content, language, visibility, tagIds: selectedTagIds, }); }第三方库react-query被 T3 藏在了api工具封装背后但它带来的缓存能力依然非常强。我特别推荐这套组合里的invalidate机制举个例子你创建失败后列表数据可以自动重新拉取避免自己手动刷新页面。如果你需要局部更新而不是整页刷新用utils.snippet.listPublic.setData手动把新数据塞进缓存即可这个 API 同样有完整类型不会瞎写。4.4 NextAuth 认证接入与权限保护t3code 的认证目前只用了邮箱登录不需要 OAuth provider 的前提下NextAuth 配置写在src/server/auth.ts里核心逻辑如下import { getServerSession, type NextAuthOptions } from next-auth; import EmailProvider from next-auth/providers/email; import { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ./db; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(db), session: { strategy: database }, providers: [ EmailProvider({ server: process.env.EMAIL_SERVER, from: process.env.EMAIL_FROM, }), ], callbacks: { session: ({ session, user }) ({ ...session, user: { ...session.user, id: user.id }, }), }, }; export const getServerAuthSession () getServerSession(authOptions);这里的sessioncallback 非常关键默认的session.user没有id字段但 tRPC 的protectedProcedure里直接用到了ctx.session.user.id不扩展类型的话编译根本过不了。你需要同时为Session类型加上user.id的定义这个坑我后面在常见问题里会展开。再从 tRPC context 里读 session// src/server/api/trpc.ts export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db, session, ...opts, }; };这样一来t3code的权限体系就是分层的页面层用useSession()控制 UI 展示路由中间件层用 middleware 拦截未登录者数据层用 tRPC 的protectedProcedure兜底。即便我不小心在某处漏了 UI 层判断服务端的protectedProcedure还是会把数据锁死。5. 工程化与部署实践常见问题、性能优化和上线细节功能写完了离“能上线”还有一段路。这段路我吃了不少苦头整理成清单分享给你能帮你少走弯路。5.1 常见问题速查表症状原因解决办法prisma/client类型报错schema 改了但没重新生成每次改 schema 后跑npx prisma generatetRPC 前端类型不更新.next缓存重启npm run dev必要时删除.next目录NextAuth 登录后拿不到user.idsession callback 未扩展在auth.ts的 callbacks 中注入id并扩展 Session 类型页面一直显示 404文件名大小写或路径写错App Router 的文件名区分大小写检查目录结构P1001数据库连不上DATABASE_URL配置错误或数据库未启动先用npx prisma db pull验证确认连接串格式部署后构建失败构建期缺少DATABASE_URL确认环境变量已注入构建流程Prisma generate 需要读连接串打出来的包过大ICD 路由全量打包检查是否在 Server Component 中意外引用了客户端库tRPC 报UNAUTHORIZED但不提示登录中间件未配置 matcher在 middleware.ts 中配置matcher需要受保护的路由5.2 性能优化要点t3code现在的性能表现我很满意首屏请求数少、交互几乎零等待。主要靠的是以下几个优化方向。查询粒度控制Prisma 的include要克制能select就select不要把整行全部捞上来。尤其列表页公开片段列表只需要作者名字和标签没必要查询author.email、author.image。React Query 缓存策略用staleTime控制数据刷新频率。对于公开 snippet 列表这种不经常变的数据把staleTime设成 30 秒以上避免用户一点返回就重新请求const utils api.useUtils(); utils.snippet.listPublic.invalidate; api.snippet.listPublic.useQuery(undefined, { staleTime: 30_000 });路由级代码分割Next.js 默认每个页面单独打包尽量保持页面组件“轻”别把所有组件都堆进一个大的layouts.tsx里。t3code里我把公共布局和页面组件分开了编辑页面才加载编辑器对应的重型依赖。开启 Standalone 输出在next.config.mjs中加一行output: standalone这样 Docker 镜像可以不携带完整 node_modules部署体积小很多。这个设置我用下来很值。5.3 Docker 与云服务器部署实践t3code我最终部署在云服务器上用 Docker Compose 管理同时挂了一个 PostgreSQL 容器。下面是一个可以直接抄的多阶段 Dockerfile# 构建阶段 FROM node:20-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npx prisma generate RUN npm run build # 运行阶段 FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENVproduction COPY --frombuilder /app/.next/standalone ./ COPY --frombuilder /app/.next/static ./.next/static COPY --frombuilder /app/public ./public EXPOSE 3000 CMD [node, server.js]对应的docker-compose.ymlservices: db: image: postgres:16-alpine restart: always environment: POSTGRES_USER: t3code POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: t3code volumes: - db_data:/var/lib/postgresql/data app: build: . ports: - 3000:3000 environment: DATABASE_URL: postgresql://t3code:${DB_PASSWORD}db:5432/t3code NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} NEXTAUTH_URL: https://your-domain.com depends_on: - db restart: always volumes: db_data:这里有几个必须注意的坑第一next.config.mjs如果没有配置output: standaloneCOPY --frombuilder /app/.next/standalone ./这行就会失败因为根本没有 standalone 目录。第二容器内DATABASE_URL的主机名必须写成db不能是localhost这个几乎是我每个部署项目都会踩一回的老坑。第三Nginx 反向代理至少要配置好 HTTP/2 和 SSE 支持因为 NextAuth 在 dev 模式下依赖 HTTP 长连接生产环境如果代理超时设太短登录请求会被切断。迁移文件在容器里怎么执行我建议不要在生产容器里跑prisma migrate而应该在部署流水线里先执行迁移再启动新版容器。最稳妥的方式docker compose run --rm app npx prisma migrate deploy执行成功后再docker compose up -d --build app。这样避免多实例同时启动时发生迁移竞争。写在最后的个人体会跑完t3code这个项目之后我的最大感悟是类型安全不是一种“格式上的炫技”它是开发效率实实在在的一部分。以前我在 REST 项目里花费大量时间在“接口文档、字段映射、类型定义”这种胶水代码上而这些恰恰是 t3code 里几乎不存在的环节。我把后端Snippet模型加了一个字段前端页面保存后重新拉数据TS 立刻帮我检查出了三处遗漏使用的地方这种体验已经回不去了。如果你打算自己做一个小工具或者中型业务系统我很推荐走一遍 T3 Stack。不要一开始就纠结“tRPC 是不是太新了”“NextAuth 会不会维护不稳”这类问题先用create-t3-app把骨架跑起来跟着这篇里的业务逻辑思路把你平时熟悉的功能迁移过去体会一下改类型后编译器给出全链路错误提示的感觉。等你真正理解这套数据流之后再回过头去看传统前后端分离就会发现原来大部分衔接层面的复杂度本可以不存在的。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。