ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

T3 Stack全栈实践:从Prisma到tRPC的端到端类型安全建设

T3 Stack全栈实践:从Prisma到tRPC的端到端类型安全建设

这两年写前端项目,我最烦的就是"前后端联调时类型全靠猜"——后端改个字段名,前端跑起来才报错;数据库模型动了,API文档没跟着动,整个团队全靠口口相传。今年我抽时间做了一个全栈项目,代号就叫t3code,意思很直白:基于T3这套技术栈,把类型安全从数据库一路贯穿到页面组件。项目是个面向小团队的内部知识库工具,包含用户认证、文档管理、按空间隔离的权限体系和全文搜索,前后端代码加起来不到一万行。如果你也在纠结全栈技术栈选型,或者想看看"端到端类型安全"到底长什么样,这篇实战记录应该能给你一个比较完整的参考。

做这个项目之前,我在Next.js、NestJS、Remix之间反复横跳过。最后选T3这套组合,不是因为它新,而是因为它把"类型安全"这根线串得最紧——从数据库表结构到API输入输出,再到前端拿到的数据,全程不靠手写接口文档。这半年踩了不少坑,也改了不少自以为是的写法,下面把设计思路、核心配置、实战细节和排查经验都捋一遍。

1. 为什么叫T3Code:全栈类型安全这件事本身的价值

1.1 从"写得不爽"到T3Code的由来

做t3code之前,我上一个全栈项目还是"Next.js + Axios + 自建Express"。那会儿最难受的不是写代码,而是改代码:后端加一个字段,前端要翻网络面板看返回值;Prisma模型加一张关联表,接口层和页面层的类型定义要手动同步。改一个老功能,光是找类型定义就要花半天,改完还不敢保证没漏。

后来我接触了T3 Stack,核心思路很简单——把TypeScript的泛型推导做到极致,让数据库、服务端、客户端共享同一套类型系统。你写完Prisma模型,数据库字段类型自动推导到tRPC路由的输入输出;前端调用tRPC时,拿到的数据天然带完整类型提示,字段改名后在编译期就报错。t3code这个项目名,其实就是"T3 + Code"的拼写:我用这套技术栈写出来的代码,理论上不该有"后端字段和前端对不上"这种低级问题。

1.2 T3到底解不解决"痛点"

T3 Stack常被简单说成"TypeScript + Tailwind CSS + tRPC",但真正让这套组合起飞的,是它把Next.js、Prisma、NextAuth这些成熟工具焊在了一起。它的核心价值我总结为三件事:

第一,类型推导的链路完整。从数据库到API再到组件,中间不打断。你不需要在API层重新定义一遍DTO,不需要在前端再写一遍interface,类型能从Schema自动流到页面。

第二,开发心智负担小。tRPC的router写法接近本地函数调用,不需要学习RESTful路由命名规范,也不用纠结GraphQL的field和fragment设计。对中小型项目的CRUD场景来说,这是实打实的效率提升。

第三,退化路径清晰。T3 Stack只是"默认推荐组合",你完全可以只取其中一部分:不想要Prisma可以换Drizzle,不想要NextAuth可以自己对接session。它是脚手架,不是枷锁。

当然它也有代价。类型系统越强,心智负担其实越往后越重——tRPC的泛型报错信息经常长得像天书,服务端组件调用tRPC的方式跟客户端还不一样。这些坑后面我细说。

2. 项目初始化与基础配置细节

2.1 脚手架搭建和目录结构设计

t3code是用create-t3-app初始化的,命令很简单:

pnpm create t3-app@latest t3code

交互式选项里我勾了Next.js App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。数据库选了PostgreSQL。说实话,如果从零手动搭这套东西,光版本匹配就要搞半天,脚手架帮你省掉了这一步,但代价是你得接受它默认的模板结构。

初始化之后的目录,我做了两处调整。第一,把所有业务代码按模块放在src/server/和src/app下,Procurement不再用pages目录,统一走App Router的app/目录;第二,单独建了src/env.mjs管理环境变量,把数据库地址、认证密钥、站点URL这些配置集中起来,方便本地和线上切换。模板自带的目录已经很清晰,我保留了大框架,只在components/ui里放了一堆自己写的通用小组件。

2.2 TypeScript严格模式与全栈类型联动的两个关键配置

T3模板默认开了strict: true,这个没商量,建议任何项目都别关。但有两个配置是t3code里真正影响"全栈类型联动"的,值得单独提。

第一个是moduleResolution和module的配置。App Router下我用的moduleResolution: "Bundler",搭配module: "ESNext"。它能正确识别@/components/...这类路径别名,也能让tRPC客户端和服务端的类型推导不被打断。如果配置不对,最常见的现象是import { type RouterOutputs } from "~/server/api/root"时会报模块解析错误。

第二个是tsconfig里的customConditions或者叫moduleSuffixes。tRPC的server caller调用客户端组件边界时,会出现"服务端能跑但类型不对"的情况,原因就是App Router区分server-only和client-only代码,TypeScript默认不会自动识别这些条件导出的包。我后来在tsconfig里加了"customConditions": ["@t3-oss/env-core", "server"]这类条目,才让类型在server/client两个环境里各归各位。

这里提醒新手一句:别小看tsconfig的报错。t3code开发初期我为了少改几行配置,把strict关了半小时,结果tRPC的inferred type直接崩了,Prisma生成的类型也全变成any。类型安全这种雪崩效应,真的宁可信不过也不要自废武功。

2.3 Tailwind CSS的样式组织策略

Tailwind在t3code里承担的是"快速搭界面"的角色。我没有用什么CSS-in-JS方案,直接用Tailwind的utility class,配合一点点自定义CSS。样式这块最大的体会是:组件别写太长,不然class成堆看着头皮发麻。

我的策略是三层:基础层用@layer base定义全局的字体、背景色、默认边框;组件层用@layer components封装几个高频组件(比如btn、card、input样式);剩下的原子class直接写在JSX里。这样既避免了"utility class地狱",又没引入额外的组件库依赖。

另外,Tailwind和Prisma、tRPC配合时要注意HMR的坑。开发时改Prisma schema之后,临时生成的.prisma/client文件类型没更新,会导致页面刷新时报"Property does not exist on type"。解决方式就是改完schema后跑一次pnpm prisma generate,或者给它配一个watch命令。反正我后来直接在package.json里加了"dev": "concurrently \"next dev\" \"prisma generate --watch\"",省心很多。

3. 核心功能模块的实战实现

3.1 数据模型设计与Prisma迁移

t3code的核心数据模型围绕四个实体展开:用户(User)、空间(Space)、文档(Document)和成员关系(SpaceMember)。用户和空间是多对多关系,文档属于某个空间且由一个用户创建。

Prisma schema里最关键的几个模型定义如下:

model User { id String @id @default(cuid()) email String @unique name String? passwordHash String? role Role @default(MEMBER) memberships SpaceMember[] documents Document[] createdAt DateTime @default(now()) } model Space { id String @id @default(cuid()) name String slug String @unique members SpaceMember[] documents Document[] createdAt DateTime @default(now()) } model SpaceMember { id String @id @default(cuid()) space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) spaceId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String role SpaceRole @default(EDITOR) joinedAt DateTime @default(now()) @@unique([spaceId, userId]) } model Document { id String @id @default(cuid()) title String content String space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) spaceId String createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) createdById String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

这里有几个设计决策值得说道说道。角色字段我用枚举Role和SpaceRole区分"全局角色"和"空间内角色"——全局角色管能否登录、能否建空间,空间角色管空间里的读写权限。这是内部知识库工具的典型需求:一个用户在公司层面是普通成员,但在某个空间里可以是管理员。

数据库迁移我用的Prisma Migrate。开发环境改schema后执行pnpm prisma migrate dev,它会自动生成迁移文件并同步数据库。生产环境则用pnpm prisma migrate deploy,这个区别一定要记住:dev会提示你重置数据库,deploy只是把迁移应用到已有数据库,不会清数据。

3.2 tRPC路由设计与类型安全的API调用

tRPC是t3code的核心通信层。我建了三个路由文件:authRouter.ts、spaceRouter.ts、documentRouter.ts,每个文件导出对应的router,最后在root.ts里合并。

这里重点说documentRouter里的权限校验逻辑。每次在公司知识库里创建文档,都要先确认当前用户在该空间里有编辑权限,否则直接拒绝:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { TRPCError } from "@trpc/server"; export const documentRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ spaceId: z.string().min(1), title: z.string().min(1).max(200), content: z.string().default(""), })) .mutation(async ({ ctx, input }) => { const membership = await ctx.db.spaceMember.findFirst({ where: { spaceId: input.spaceId, userId: ctx.session.user.id, }, }); if (!membership || membership.role === "VIEWER") { throw new TRPCError({ code: "FORBIDDEN", message: "你在这个空间中没有编辑权限", }); } return ctx.db.document.create({ data: { title: input.title, content: input.content, spaceId: input.spaceId, createdById: ctx.session.user.id, }, }); }), });

类型安全在这段代码里体现得最明显:ctx.session.user.id的类型由NextAuth的session类型推导,input.title由zod schema推导,ctx.db.document.create的data字段由Prisma模型推导。三个来源的类型在编译期就被tRPC对齐了,前端调用时根本不用猜字段类型。

前端调用也非常直接:

import { api } from "~/utils/api"; const mutation = api.document.create.useMutation({ onSuccess: () => { // 创建成功后的处理 }, }); mutation.mutate({ spaceId: "cm...", title: "研发周报", content: "# 本周进展", });

这种写法对前端同学来说几乎零学习成本,不用拼接URL、不用import一堆API类型定义,编辑器里敲api.就有完整的路由提示。

3.3 用户认证与授权(接入NextAuth)

认证这块我用了NextAuth.js。为了少接外部服务,t3code用的Credentials Provider,也就是邮箱+密码登录。密码用bcryptjs哈希后存passwordHash字段。

NextAuth核心配置在src/server/auth.ts里,几个关键点:

import { NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { compare } from "bcryptjs"; import { db } from "~/server/db"; import { env } from "~/env.mjs"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db), session: { strategy: "jwt" }, providers: [ CredentialsProvider({ credentials: { email: { label: "邮箱", type: "email" }, password: { label: "密码", type: "password" }, }, async authorize(credentials) { const user = await db.user.findUnique({ where: { email: credentials?.email }, }); if (!user?.passwordHash) return null; const ok = await compare(credentials!.password, user.passwordHash); if (!ok) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ], callbacks: { jwt({ token, user }) { if (user) token.id = user.id; return token; }, session({ session, token }) { if (session.user) session.user.id = token.sub!; return session; }, }, };

这里有两个容易踩的坑。一是session策略我选了"jwt"而不是"database"——因为Credentials Provider无法在数据库中创建session记录,用database策略会报错。二是authorize函数里如果用户不存在或密码错误,统一返回null,这样不会泄露"邮箱是否存在"这类信息。

权限隔离在t3code里分两层:页面级用NextAuth的getServerSession判断是否登录,操作级用tRPC的protectedProcedure拦截未登录请求。中间件我放在src/middleware.ts里,只做粗粒度的登录拦截,细粒度的空间权限全部放在tRPC路由里校验。

3.4 全文搜索与异步任务的处理细节

知识库工具少了搜索就是残废。刚开始我打算接一个独立的全文搜索引擎,但考虑到部署运维成本,最后用PostgreSQL自带的全文搜索顶上了。Prisma不支持原生的to_tsvector查询,我通过$queryRaw写了一段搜索:

search: protectedProcedure .input(z.object({ keyword: z.string().min(1) })) .query(async ({ ctx, input }) => { const results = await ctx.db.$queryRaw` SELECT id, title, excerpt FROM "Document" WHERE to_tsvector('simple', title || ' ' || content) @@ plainto_tsquery('simple', ${input.keyword}) LIMIT 20 `; return results; }),

这段代码工作得很好,但有个前提:数据库里得建好全文索引,否则数据量一上来性能会崩。我在迁移文件里手动加了一个GIN索引,t3code现在几千篇文档的搜索响应都在100ms以内,完全够用。

异步任务这块,我遇到的实际需求是"文档导出为Markdown文件并发送到指定邮箱"。起初直接在API路由里同步做,用户操作要等好几秒。后来改成把任务写入一张ExportTask表,用一个轻量的定时器轮询处理,前端轮询任务状态。虽然不够高级,但对内部工具来说稳定最重要。

4. 部署上线与性能优化实录

4.1 数据库连接与生产环境的Prisma配置

t3code部署在Vercel的Serverless环境,数据库用独立部署的PostgreSQL实例。这里有个比较隐蔽的问题:Serverless函数是冷启动的,每个函数实例启动时都会初始化Prisma Client,如果连接池配置不好,数据库连接数会被瞬间打满。

我在Prisma连接串里加了连接池参数:

DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=5&pool_timeout=10"

同时在src/server/db.ts里用了全局单例模式:

import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

不要小看这个全局单例。在Next.js开发模式下,每个文件的更新都会触发模块重新加载,如果没有这种保护,可能会创建一堆Prisma Client实例,连接数耗尽只是时间问题。

4.2 静态资源与图片优化

t3code里文档允许上传封面图,最开始是直接存到服务器磁盘,结果部署到无状态Serverless环境后图片根本存不住。后来改成了对象存储方案,图片上传走预签名URL,链路是:前端请求后端获取上传凭证——前端直传对象存储——数据库只存图片路径。

图片展示方面,我用了Next.js自带的next/image组件,配置了远程图片域名白名单。要注意的是,如果你的对象存储域名不在images.remotePatterns配置里,图片组件会直接报错。这个配置在next.config.mjs里:

images: { remotePatterns: [ { protocol: "https", hostname: "cdn.example.com" }, ], },

4.3 边缘运行时兼容性排查

上线前我把middleware.ts放到Edge Runtime跑,结果踩了一个大坑:NextAuth的getToken在Edge环境跟Node环境行为不太一样。如果你在middleware里读取session,建议直接用jose库解析JWT,别依赖next-auth/jwt的默认方法,否则在Edge模式下会报"Secret required"这类摸不着头脑的错误。

我的中间件逻辑最终只做了三件事:检查cookies里有没有session token、判断路径是否受保护、如果没登录就重定向到登录页。如果要做细粒度的权限判断,建议移到tRPC或Server Component里做,不要在Edge层过度处理。

另外,Prisma Client在Edge Runtime下默认不支持,这是个大坑。t3code的API路由是在Node运行时跑的,如果硬要在Edge Runtime里跑Prisma查询,要么用Data Proxy方案,要么就换运行时。我的解决方案是按路由区分:重逻辑走Node运行时,轻量跳转逻辑走Edge,别指望一套配置打天下。

5. 开发中遇到的坑与排查经验

5.1 服务端组件与tRPC调用的常见误区

tRPC在App Router里有两个入口:客户端组件通过TRPCReactProvider调用,服务端组件通过createCaller直接调。很多教程只讲了前者,导致你在Server Component里写api.document.list.useQuery()时会直接报"useQuery is not a function"。

正确做法是import服务端的caller:

import { createCaller } from "~/server/api/root"; import { authOptions } from "~/server/auth"; import { getServerSession } from "next-auth"; const session = await getServerSession(authOptions); const caller = createCaller({ session }); const docs = await caller.document.list({ spaceId });

这里有个很容易忽略的细节:服务端调用器的context要先构造成和HTTP请求一致的对象,否则session不完整,权限校验会误判。我一开始省掉了session传参,结果所有调用都变成未登录状态。

5.2 类型推导丢失与泛型陷阱

tRPC的类型推导依赖zod的输入输出类型,如果你在route里用了z.object().passthrough()或者给input加了any,整条调用链路上的类型提示就断了。还有一次我用useQuery时写错了泛型参数,TS直接推断成never,排查了半小时才发现是方法名拼错。

遇到类型丢失,我的排查顺序是:先看zod input是否写了as const——不需要加;再看root.ts里的createTRPCRouter是不是漏了mergeRouters;最后跑tsc --noEmit全量检查,让编译器告诉你哪里断了。别在编辑器里瞎点,类型推导问题是结构性的,不可能只修一个文件解决。

5.3 依赖版本升级的破坏性变更

t3code开发到中期,正好赶上Next.js 14到15、tRPC v10到v11的大版本切换。升级时我被坑得最惨的是tRPC的TRPCError导入路径变了,导致一堆文件报错。还有Prisma 5.x把@prisma/client的PrismaClient构造函数行为改了,本地连接池配置失效。

这种上游框架的破坏性变更没办法完全避免。我的经验是:t3code这类用最新脚手架的项目,千万别在开发中途频繁跨大版本升级。如果非要升,先看官方升级指南的迁移清单,然后改完依赖直接跑tsc和全量测试,别手动一个一个文件排查。

5.4 问题排查速查表

下面这个表是从t3code实际开发中整理出来的高频问题,省得大家再走一遍弯路。

常见问题现象原因与解决思路
Prisma模型改动后类型不更新页面报Property does not exist on type改schema后必须执行prisma generate,建议加watch
tRPC客户端类型断掉useQuery返回never检查zod schema是否显式声明了input/output类型,别用隐式推断
Server Component里调用tRPC失败报useQuery is not a function应该用createCaller创建服务端调用器,不能直接复用客户端hook
NextAuth在Edge环境解析session失败middleware里getToken返回nullEdge环境建议用jose库直接解析JWT
Serverless环境数据库连接爆满部署后请求大量超时给连接串加connection_limit,Prisma Client做成全局单例
图片组件报域名错误远程图片不显示在next.config.mjs里配置images.remotePatterns
搜索查询很慢文档多了以后搜索卡顿给数据库表建GIN全文索引,SQL里用to_tsvector和@@操作符
tRPC大版本升级后报导入错误TRPCError找不到模块按官方迁移文档改import路径,重点检查@trpc/server子路径

5.5 避坑技巧的补充

最后再补一条很多人不知道的细节:不要把NODE_ENV跟DATABASE_URL在.env里写死。开发环境、预览环境、生产环境应该各自有一套清爽的环境变量配置,最好通过src/env.mjs统一校验。t3code在一次部署中因为把本地数据库连接串误带到生产环境,导致线上数据全被dev环境覆盖,还好有自动备份才保住。生产环境数据库的写入操作,任何时候都要先在staging环境完整走一遍。

另外,生产环境部署新版本前,一定记得跑一次prisma migrate deploy,并且把迁移脚本放在构建流程里。如果代码已经部署了但数据库迁移没跑,线上页面会因为你用了新字段而直接500。我的做法是:在部署脚本里先执行迁移,再执行build,两条指令串起来,顺序绝对别反。

RELATED READING

延伸阅读

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