ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

T3 Stack实战:用tRPC和Prisma打造端到端类型安全的全栈应用

T3 Stack实战:用tRPC和Prisma打造端到端类型安全的全栈应用

有种偏见我必须要纠正一下:很多前端团队觉得“全栈项目”就等于“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 Component
tRPC端到端类型安全 RPC替代 REST/GraphQL,连接前后端
Tailwind CSS原子化 CSS 框架页面样式,快速迭代 UI
PrismaTypeScript 原生 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会自动生成迁移 SQL,npx prisma studio可以直接可视化查看数据,查询 API 的返回类型是自动推导出来的,真正实现了“数据库是 TS 类型系统的一部分”。

相比直接写 SQL 或者用 TypeORM,Prisma 对类型安全的贯彻最彻底。你写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-app@latest 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.json

src/server/api/trpc.ts是整个 tRPC 链路的心脏,里面定义了一个createTRPCContext,用来把session和db注入到每个请求的 context。然后定义publicProcedure和protectedProcedure,后者会先检查 session 是否为空,为空直接抛UNAUTHORIZED错误。这个文件一般不需要大改,但你要看懂它,因为后面写业务 router 时,所有 procedure 都从这里引。

3.3 环境变量与数据库配置

.env文件里最关键的就是数据库连接串和 NextAuth 配置:

# PostgreSQL 连接串 DATABASE_URL="postgresql://postgres:password@localhost:5432/t3code?schema=public" # NextAuth 会话加密密文,跑 npx auth secret 可以生成 NEXTAUTH_SECRET="your-secret" # 正式部署时改成线上域名 NEXTAUTH_URL="http://localhost:3000"

如果本地没装 PostgreSQL,可以用 Docker 快速起一个:

docker run --name t3code-db -e POSTGRES_PASSWORD=password -e POSTGRES_DB=t3code -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 <div>Loading...</div>; return ( <div className="space-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的推导结果就是最准确的。

创建和删除,对应useMutation:

const 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 generate
tRPC 前端类型不更新.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_ENV=production COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public EXPOSE 3000 CMD ["node", "server.js"]

对应的docker-compose.yml:

services: 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: "standalone",COPY --from=builder /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把骨架跑起来,跟着这篇里的业务逻辑思路把你平时熟悉的功能迁移过去,体会一下改类型后编译器给出全链路错误提示的感觉。等你真正理解这套数据流之后,再回过头去看传统前后端分离,就会发现:原来大部分衔接层面的复杂度,本可以不存在的。

RELATED READING

延伸阅读

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