☰
T3 Stack实战:用TypeScript与tRPC构建端到端类型安全的全栈应用
2026/10/26 17:16:56 网站建设 项目流程

1. 标题越短坑越深:为什么t3code最后选了T3这套组合

先解释一下项目名字。“t3code”不是T3架构的三层代码,而是我基于T3生态(TypeScript + Tailwind CSS + tRPC)写的全栈应用代称。名字起得短,纯粹是个人项目,想用一个好记的仓库名,但技术选型的过程一点都不短。

做这个项目之前,我手上维护着两个不算小的业务系统:一个用纯Next.js写页面,数据层单独拆了个Node服务,前后端各维护一套类型定义;另一个更古老,Express + 前端手写any类型。两个项目都有一个通病——联调成本极高。改一个接口字段,后端要改,前端要改,类型文件要改,文档还不一定记得改。每次上线前光对齐字段定义就能耗掉半天,这种技术债累积起来非常痛苦。

所以我决定单开一个项目练手,目标很明确:第一,端到端类型安全,前端调接口时后端返回什么类型,前端直接推导出来,改一个地方联动错误;第二,少维护一套后端类型定义,不要再出现“前后端各有一份interface”的情况;第三,上手成本不能太高,团队里不全是全栈高手,学习曲线陡峭的方案直接排除。

按这个标准筛了一圈,候选方案有四条:MERN全家桶、Ruby on Rails、纯Next.js加后端、T3 Stack。前两个先被我划掉:MERN的类型安全基本靠自觉,Mongoose的文档模型和前端DTO之间没有任何关联性;Rails虽然开发效率高,但团队里没人熟悉Ruby,为一个小项目换语言代价太大。最后在纯Next.js和后端方案之间纠结了比较久。

其实纯Next.js加API Routes也能满足大部分需求,而且不用引入额外概念。但我的项目里有相当多的数据操作是高度定制化的,比如同一个页面要同时拿用户信息、项目列表、权限状态,还要做乐观更新。API Routes写起来仍然停留在“定义URL、定义请求体、定义返回JSON”的传统模式,路由一多,类型照样散落各处。

tRPC在这里提供了一个完全不同的思路:不定义URL,直接调用函数。后端写一个router,前端import对应的hooks,类型自动跟着走。它把HTTP层抽象掉了,你关心的只是数据本身。这一点对个人项目和中小团队来说,节省的沟通成本比想象中大得多。再加上Prisma做ORM、NextAuth做认证、Tailwind做样式,整套组合下来,类型语言贯穿全栈,我几乎找不到比这更适合当前目标的方案。

于是就有了t3code。需要提前声明:T3不是银弹,它不适合所有项目。如果你的场景是开放API给第三方、规范化REST接口对外售卖,或者团队对函数式调用极度排斥,那T3反而会让你别扭。但作为内部业务系统或快速迭代的产品原型,它确实把“类型安全”和“开发效率”两个最大痛点同时解决了。

2. 初始化t3code时被我改掉的东西:create-t3-app不只是脚手架

大多数人用create-t3-app,都是按默认模板走一遍,生成什么用什么。我这次特意没有直接跑,而是先把这个脚手架会做什么拆了一遍。

2.1 初始化命令与选项取舍

初始化用的是:

pnpm create t3-app@latest

选型阶段我做了几个和默认不同的决定:

  • 包管理器选pnpm。T3官方默认支持pnpm,实际跑下来安装速度和磁盘占用确实比npm好不少,而且Prisma的postinstall在pnpm下表现更稳定。
  • 只选了NextAuth、Prisma、tRPC、Tailwind。没有勾ESLint吗?勾了,这个必须勾,后面写tRPC router时有个能实时报错的lint规则很重要。
  • experimental features选了server components吗?没有。我需要在客户端做乐观更新,keep it simple。

2.2 目录结构里藏着的信息

生成完以后,典型的T3项目目录是这样的:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── pages/ │ │ ├── api/ │ │ │ ├── auth/[...nextauth].ts │ │ │ └── trpc/[trpc].ts │ │ └── index.tsx │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ │ │ │ ├── post.ts │ │ │ └── user.ts │ │ ├── db.ts │ │ ├── auth.ts │ │ ├── api/root.ts │ │ └── api/trpc.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── client.ts │ ├── styles/ │ └── env.js ├── tailwind.config.ts └── tsconfig.json

这里有一个很多人忽略的点:src/server/和src/trpc/的分离。server目录下的东西永远只能跑在服务端,trpc目录下的client.ts会暴露给浏览器。如果只是做个小项目,很容易随手把Prisma Client放到任意路径下然后到处import,但这个区分对防止“服务端代码被打进前端包”非常关键。我见过有人把数据库直接明文密码写进env.js然后客户端打包时泄露的案例,职责分清楚能少很多事。

2.3 环境变量的校验:env.js的价值

T3脚手架里带了一个env.js,很多人不知道它干什么。它本质上是把环境变量从“字符串靠自觉”变成“类型系统参与校验”,依赖的是@t3-oss/env-core。

import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; export const env = createEnv({ server: { DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), NEXTAUTH_URL: z.string().url().optional(), }, client: { NEXT_PUBLIC_APP_NAME: z.string().min(1), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, // 注意:Next.js只会把NEXT_PUBLIC前缀的变量打进客户端 NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, }, });

这套东西在部署的时候救过我一次。有一次我把DATABASE_URL拼写成了DATABASE_UR,应用启动直接报错,而不是等到页面加载才炸出“Cannot read properties of undefined”。对于依赖环境变量极重的全栈项目,这种启动时校验是必要的。

3. tRPC是t3code的灵魂:从router定义到前端调用的类型全链路

很多刚接触tRPC的人会问同一个问题:“这不就是把函数直接暴露给前端吗?和RPC有什么区别?”区别在于类型推导,不是传输方式。它是通过TypeScript的类型系统,把后端的函数签名直接“翻译”成前端hook的参数类型和返回类型,中间不需要手写任何DTO、校验器或序列化层。

3.1 先定义好context

tRPC的context是每个请求的初始数据。在src/server/api/trpc.ts里,默认模板会合并两个东西:数据库实例和当前session。

import { initTRPC, TRPCError } from "@trpc/server"; import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; import { getServerAuthSession } from "@/server/auth"; import { db } from "@/server/db"; export const createTRPCContext = async (opts: CreateNextContextOptions) => { const session = await getServerAuthSession({ req: opts.req, res: opts.res }); return { db, session, }; }; const t = initTRPC.context<typeof createTRPCContext>().create();

重点在session这一项。它让后端每个procedure都能直接拿到当前登录用户,不需要在请求体里再传一次token。很多人会在前端header里手动塞token,在tRPC里完全没必要,NextAuth的session本身就是请求上下文的一部分。

3.2 procedure和输入校验

我项目中有一个获取用户项目列表的procedure:

import { z } from "zod"; export const projectRouter = createTRPCRouter({ listMine: protectedProcedure .input( z.object({ status: z.enum(["active", "archived"]).optional(), limit: z.number().min(1).max(100).default(20), }) ) .query(async ({ ctx, input }) => { const projects = await ctx.db.project.findMany({ where: { userId: ctx.session.user.id, ...(input.status ? { status: input.status } : {}), }, take: input.limit, orderBy: { updatedAt: "desc" }, }); return projects; }), });

前后端的交互过程是这样的:

  • 前端调用api.project.listMine.useQuery({ status: "active" }),issue数据会自动推导出{ status?: "active" | "archived"; limit?: number },传错类型编译直接报错。
  • 返回的projects自动是Prisma查出来的Project[]类型,前端不用再写interface。
  • 输入校验交给zod,Zod的数据结构天然和TypeScript类型同源。

这个流程解决了我在文章开头提到的“改一个字段三处都要改”的痛点。前端想改返回结构,只需要改后端Prisma查询返回的字段,前端调用的地方会立刻发现类型不匹配。

3.3 中间件的实际用法

tRPC的middleware很像Express的中间件,但作用在逻辑层。我这里写了一个protectedProcedure:

const isAuthenticated = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session } }); }); export const protectedProcedure = t.procedure.use(isAuthenticated);

写的时候有个细节值得提:不要在middleware里直接读数据库查user表。如果每一步请求都查一次用户详情,数据量上来后数据库压力很大。NextAuth的session本身已经带了user基础信息,如果确需全量用户信息,再单独写一个me查询接口,而不是每次请求都全量加载。

4. 数据建模和Prisma:t3code的表结构是这样一点一点长出来的

4.1 建模思路:从页面反推Schema

我建模的习惯是先画出页面需要什么,再倒推数据库结构。t3code涉及的核心业务是“用户管理多个项目,每个项目下有若干任务”,先设计成三张表:

model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] projects Project[] } model Project { id String @id @default(cuid()) name String description String? status Status @default(active) userId String owner User @relation(fields: [userId], references: [id], onDelete: Cascade) tasks Task[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum Status { active archived } model Task { id String @id @default(cuid()) title String done Boolean @default(false) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) }

三个关键取舍:

  • 主键用cuid而非自增id。暴露给前端的ID如果连续自增,会有信息泄露风险,比如/project/1很容易被人遍历到/project/2。cuid没有顺序,破解成本高一点。
  • 加了onDelete: Cascade。删除项目时连带删除任务,这里顺手在Prisma里声明外键行为,省得业务层写一堆删除逻辑。
  • updatedAt交给数据库自动更新,避免了手动更新时间的遗漏问题。

4.2 Prisma Client接入tRPC的细节

在src/server/db.ts里,官方模板用的是globalThis缓存PrismaClient:

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

这里解决的是Next.js开发模式下的热更新导致PrismaClient重复实例化问题。不在production下缓存,每次文件变更都可能new一个连接池,轻则告警,重则把开发数据库连接数打满。

4.3 迁移与种子数据

T3项目里跑数据库迁移直接用:

pnpm prisma migrate dev

我强烈建议schema每改一次就生成一次迁移,不要用db push代替。db push适合原型快速验证,但它不会留下迁移历史,将来多人协作时数据库结构说不清楚是怎么演进的。migrate dev会生成SQL文件,即使你不上生产,也相当于给自己留了操作审计。

种子数据我用prisma/seed.ts,配合tsx直接执行:

import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const user = await prisma.user.create({ data: { email: "demo@t3code.dev", name: "Demo User", projects: { create: [ { name: "示例项目A", status: "active" }, { name: "示例项目B", status: "archived" }, ], }, }, }); console.log("seed done:", user.email); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect());

有关种子数据,最深的体会是:别在seed里写死太多用户相关外键。如果你的业务里外键关联了用户,但用户模型来自NextAuth,那seed时最好先通过email查或创建用户,再挂载业务数据,否则每次环境重建都要手工调整。

5. NextAuth与受保护路由:认证环节踩得最狠的几个坑

认证是我在这个项目里耗时最长的部分。NextAuth整体不难,但细节配合tRPC时容易出问题。

5.1 配置流程中的关键点

src/server/auth.ts里配置Provider、callbacks和Prisma适配器:

import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "@/server/db"; export const authOptions = { adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID ?? "", clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "", }), ], callbacks: { session({ session, user }) { if (session.user && user.id) { session.user.id = user.id; } return session; }, }, };

5.2 最坑的坑:session.user.id不存在

默认情况下,NextAuth的session.user只有name、email、image,没有id。你想在tRPC的procedure里直接ctx.session.user.id,会直接拿到undefined。

正确做法是加session callback,在登录时把数据库用户ID写入session。我一开始没加这层,结果所有受保护接口都报“user id undefined”,排查了好久才定位到是session类型不完整的问题。

这种错误之所以隐蔽,是因为typescript不会报错,运行时undefined又不会立刻让页面崩溃,等到你在tRPC里用它查询时,查询条件变成userId: undefined,Prisma会默认忽略这个过滤条件,从而可能返回不属于当前用户的数据。这是安全漏洞级别的问题,不是简单功能缺陷。所以我在session callback之外,又在tRPC的protectedProcedure中加了二次校验,确保ctx.session.user.id一定存在。

5.3 NEXTAUTH_URL的隐蔽行为

本地开发时经常有人配NEXTAUTH_URL=http://localhost:3000,但部署到Vercel后忘记了,或者设置了之后没重启服务。NextAuth在有些版本下NEXTAUTH_URL不配置时会自动从request里推断,但这个推断在自定义域名、代理环境、内网部署告警很多。

我建议是:始终显式配置NEXTAUTH_URL,并且本地和线上最好各用一套env文件。别问为什么,问就是我曾在部署到staging环境时,因为NEXTAUTH_URL还指向本地localhost,导致OAuth跳转严重错乱,整个登录流程看起来“完全正常”但实际回调打了自己电脑。

5.4 中间件保护页面的简单做法

Next.js的middleware可以保护整个路径:

// src/middleware.ts export { default } from "next-auth/middleware"; export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"], };

这段代码的作用:未登录用户访问/dashboard或/settings下的任何页面都会被NextAuth踢到登录页。不过要注意,tRPC的API路由不能靠它保护,因为tRPC操作是通过/api/trpc/*来执行的,即便页面被中间件拦截了,API呼照样能打到后端。所以真正的防线还是tRPC里的protectedProcedure,页面middleware只是体验层面的拦截。

6. Tailwind在t3code里的实际姿势:从原子类风暴到可维护

6.1 原子类的爽与痛

Tailwind在t3code里用下来,最直观的爽点是开发新页面时不用写一行CSS文件,所有样式内联在className里。但项目跑了一个月后开始出现另一种焦虑:className越来越长,一个按钮的类名能绕屏幕两行,复用的时候只能复制粘贴。

这不是Tailwind的错,是我没做好抽象。后来我把高频组件抽出来,做成带默认样式但允许覆盖的半成品:

type ButtonVariant = "primary" | "secondary" | "ghost"; const buttonBase = "inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"; const variantClass: Record<ButtonVariant, string> = { primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500", secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-400", ghost: "bg-transparent text-gray-600 hover:bg-gray-100 focus:ring-gray-300", }; export function Button({ variant = "primary", className = "", ...props }: ButtonProps) { return ( <button className={`${buttonBase} ${variantClass[variant]} ${className}`} {...props} /> ); }

这种做法兼顾了Tailwind的原子化好处,又不至于每个地方都堆一坨重复类名。它配合cn工具函数(例如clsx + tailwind-merge)效果更佳,能让外部传入的className正确覆盖内部类名而不会冲突。

6.2 深色模式的暗雷

Tailwind的dark:变体默认是基于prefers-color-scheme的,但业务系统通常需要用户手动切换。我一开始没配class策略,导致系统明明支持深色模式,用户却永远切不过去。

正确做法是在Tailwind配置里设置:

// tailwind.config.ts import type { Config } from "tailwindcss"; export default { darkMode: "class", content: ["./src/**/*.{ts,tsx}"], theme: { extend: {} }, plugins: [], } satisfies Config;

然后在根组件里根据状态给html标签手动添加dark类,localStorage保存偏好。这个改动虽然小,但对用户体验提升非常直接——自动跟随系统主题的网页应用,在深色模式用户那里往往就是“反光刺眼”和“舒适”的差别。

6.3 服务端用Tailwind的几个注意点

如果你在RSC(React Server Components)里直接写Tailwind类名,注意不要用动态拼接字符串,比如className={p-${size}}。Tailwind的JIT扫描器只识别源码中完整的字面量类名,动态拼接的类名不会被生成到CSS里,表现出来就是样式偶尔生效、偶尔不生效,而且只在生产环境出现,因为本地开发时JIT大部分会把可能的组合都提前生成。这个坑非常隐蔽。我现在的习惯是:动态类名一律放进对象的key映射里,或者用cn()时确保传入的候选集是静态字符串数组。

7. 部署t3code时浪费的一天:环境变量、Prisma生成和Serverless内存

7.1 部署平台的选择

项目主体用的Next.js,最顺手的部署平台当然是Vercel。对于t3code这种个人项目,Vercel + PostgreSQL(比如Neon或Supabase)几乎零成本。不过部署过程中有三个问题绝对值得写下来。

第一个是Prisma Client必须在构建阶段重新生成。很多框架会把prisma generate放到postinstall里,但如果构建缓存把node_modules整个缓存了,postinstall可能不会重跑,导致线上的Prisma Client和数据库schema不匹配。我的做法是在Vercel的build command里显式执行:

prisma generate && next build

第二个是Serverless环境下的Prisma连接池。Vercel的Serverless函数每个实例都会创建数据库连接,如果数据库连接数上限低(比如免费版Postgres),高并发时很容易把连接池打满。Neon这类支持连接池的数据库可以缓解这个问题。如果你用的是常规Postgres,至少把Prisma的connection_limit调低:

new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL + "?connection_limit=5", }, }, });

第三个坑是serverless内存限制。tRPC的批量查询如果一次加载太多数据,Prisma序列化大对象时可能造成内存溢出。我的任务列表接口早期一次性返回整棵项目树,部署后偶尔504或者OOM。后来改成分页和字段裁剪,问题消失。记住:Serverless环境不是让你把所有数据一次拉完的,就算数据库扛得住,函数内存也扛不住。

7.2 环境变量的部署版本

本地和线上的环境变量管理,我推荐的组合是:

  • 本地:.env文件,gitignore掉
  • 线上:Vercel Project Settings里配置,不要提交到仓库
  • 无需区别:NEXTAUTH_SECRET必须单独生成一个长随机串,不要用“changeme”之类的占位符

openssl rand -base64 32生成密钥。用默认模板自带的占位密钥上生产,一旦别人猜出来,你的session就能被伪造。

7.3 长任务的认知纠偏

tRPC适合普通CRUD,但不适合跑长任务。如果你有一个procedure要执行耗时超过30秒(比如批量导入Excel、生成PDF报告),在Serverless环境下大概率超时。这种任务正确解法是放到队列或独立worker里,tRPC只负责提交任务和轮询状态。我给t3code做导入功能时就因为没意识到这一点,在本地一切正常,上线后批量导入就一直超时。

后来我把导入流程拆成三步:前端上传文件到对象存储、后端消费队列逐行处理、前端轮询任务状态。tRPC只负责第一步和第三步,中间耗时逻辑全部旁路。

8. 项目做完后的维护心得:哪些设计真正省了事

这个项目跑到现在,重新回顾整个选型和落地过程,有几个当初不起眼的决策在后续维护里发挥了很大价值。

第一个是Zod成为前后端共享的校验规则源。tRPC的input校验和前端表单校验用的是同一套schema,改一处联动生效。不需要再维护“前端一个校验规则、后端一个校验规则”,两边不一致导致的问题(比如前端允许3个字符、后端必须5个字符)完全消失。

第二个是Prisma Studio的调试作用。pnpm prisma studio打开一个本地数据库可视化界面,排查数据问题比写SQL快得多。遇到“为什么这个用户看到别人的项目”这类问题,我都是先开Studio看数据,再回头看代码逻辑,比打印日志高效。

第三个是习惯性地为每个tRPC查询想清楚“该用户有没有权限看这条数据”。T3项目里最容易犯的错误就是写一个findUnique({ where: { id } }),全站通用,没加ownership判断。我后来给所有涉及用户私有数据的procedure都强制带上ctx.session.user.id过滤,这比一味依赖前端路由守卫靠谱得多。

如果你也在做一个需要快速迭代、又不想牺牲类型安全的全栈项目,t3code这套组合值得一试。它不完美,Serverless下长任务受限、tRPC的社区资源不如REST生态丰富,但就“一个人快速把一个想法从数据库到页面完整落地”这件事而言,它是我目前用过最顺手的组合。最后补一句从踩坑里换来的建议:无论用什么技术栈,生产环境一定要先跑通“从零部署”的完整流程再写业务代码,环境变量、数据库迁移和密钥配置这些必须先于功能准备好,否则你会被本末倒置的排错拖垮整个项目进度。

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

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

立即咨询