T3 Stack全栈开发实战:用tRPC与TypeScript打造类型安全应用

发布时间:2026/10/28 7:17:53
T3 Stack全栈开发实战:用tRPC与TypeScript打造类型安全应用

"t3code" 是我最近一个月一直在折腾的一个全栈项目代号。如果你也刷到了这个词,大概率是对T3 Stack这套技术栈感兴趣——Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth 这六件套的组合。这年头前后端分离早就是主流了,但我恰恰是在用 t3code 做实际项目的过程中,越来越觉得"全栈类型安全"这套思路值得单独拎出来聊聊。

先说 t3code 到底是个什么定位:它不是某个框架的官方项目,而是我把一套实际业务系统从零搭建到上线的完整代码沉淀,用了 T3 Stack 全家桶。这篇文章适合那些已经会写 React、知道 TypeScript 基本语法,但还没真正用 tRPC 完整跑通一个全栈项目的开发者。我会从选型决策讲到目录结构,从数据库建模讲到部署时的坑,全程用真实项目的代码说话。


1. t3code 的选型逻辑:为什么是这六件套而不是别的组合

1.1 技术栈的四块基石各自扮演什么角色

t3code 用到的东西其实不多,核心就是四个:Next.js、TypeScript、Tailwind CSS、tRPC,另外再加上 Prisma 和 NextAuth 这两个辅助位。我先把每个工具在项目里的真实职责说清楚,这样你后面看代码才不会懵。

Next.js是应用的骨架,负责页面渲染和 API 路由。在 T3 Stack 里它更多是充当 App Router 容器,tRPC 的 router 挂在 Next.js 的 API 路由上,服务端渲染和客户端交互都通过它承接。选 Next.js 而不是独立的 Express 或 Fastify,核心原因就一个:我们想要一个框架同时搞定前后端,减少部署和运维的目标数。

TypeScript是整个 t3code 的"粘合剂"。T3 Stack 最大的卖点就是端到端类型安全:数据库表结构、API 入参出参、前端调用代码,全部共享同一套类型定义。只要你把 Prisma Schema 写好了,后面从数据库到 UI 的类型推导都是自动的。这里给一个小建议:如果你的团队还没做好全员用 TS 的准备,T3 Stack 会非常痛苦,因为类型报错会在每个环节出现。

Tailwind CSS负责样式。它和 T3 生态并没有强绑定关系,用不用看个人习惯。我选它的原因很朴素:原子化 class 在多人协作时不用来回切文件看样式名,而且刷 UI 的速度确实快。如果你更习惯 styled-components 或 CSS Modules,完全可以用自己的方案替换,不影响其他部分。

tRPC是让我决定长期用 T3 Stack 的哪个原因,后面我专门开一节细说。

1.2 为什么 2024 年还要选"全家桶"方案

现在很多人一谈全栈就想到 Next.js 自己干所有事,或者 Next.js 做前端、NestJS 做后端、Swagger 做接口文档。t3code 之所以坚持 T3 Stack,是因为它在中小型项目的开发效率上确实强。

说个真实对比。我之前维护过一个单体仓库,前端 Next.js,后端 Express,接口用 OpenAPI 文档维护。每次后端改接口返回结构,前端都要先看文档再手写类型,还要担心文档过期。后来加了 codegen 自动化拉类型,但每次生成完还要手动处理 namespace 冲突。这个链路在 t3code 里直接不存在:前端调 tRPC 的 query,输入输出的类型就是后端函数签名里的类型,改一处,另一处立刻报错。

当然,T3 Stack 不是万能药。团队超过二十人、业务域边界明显、需要独立扩展后端的时候,tRPC 的单体模式就会变成瓶颈。这个我后面在合适的地方会展开。


2. tRPC 这个被低估的核心:类型安全的本质价值

2.1 从 REST 到 tRPC:接口联调到底变了什么

REST 接口的工作方式你现在肯定很熟:前端写一个 fetch 请求到某个 URL,后端返回到 JSON,前端对着接口文档写类型定义。问题在于,类型的真伪只能靠人肉保证。请求参数改了,后端起名字改了,前端完全无感知,直到运行时才发现字段 undefined。

tRPC 的工作方式则完全不同。你先把所有业务操作定义成一个个"过程"比如getPost、createUser。前端调用时像调用本地函数一样:

const result = await trpc.post.getPopular.query({ limit: 10 });

这里的result类型就是从后端推断出来的。后端如果改了返回字段名,前端在编译阶段立刻报错,根本轮不到运行时调试。这种静态检查带来的安全感,用习惯了以后真的回不去。

2.2 一个最小 tRPC 路由的完整拆解

在 t3code 里,tRPC 的初始化流程很简单。先在server/api/trpc.ts里创建实例:

import { initTRPC } from "@trpc/server"; const t = initTRPC.create(); export const router = t.router; export const publicProcedure = t.procedure;

然后在server/api/routers/post.ts里定义路由:

import { router, publicProcedure } from "../trpc"; import { z } from "zod"; export const postRouter = router({ getPopular: publicProcedure .input(z.object({ limit: z.number().min(1).max(50).default(10) })) .query(async ({ input }) => { return prisma.post.findMany({ take: input.limit, orderBy: { viewCount: "desc" }, }); }), });

最后一个步骤把 router 挂到 Next.js 的 API Route 上:

// app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "@/server/api/root"; export const GET = (req: Request) => fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter }); export const POST = GET;

这里的核心点在input()方法调用了 zod 校验。请求参数的类型和运行时校验绑在了一起,前端传入的数据会在这一层被拦住,不会带着错误结构深入业务逻辑。你写前端调用代码的时候,TS 也能直接推导出limit字段必须是一个数字。

2.3 什么时候该用 tRPC,什么时候别硬上

tRPC 最大的限制是它需要前端和后端共享同一个 TypeScript 类型环境。如果你做的是一个纯粹的后端 API,要服务小程序、App、第三方商家,那 tRPC 的"无缝体验"就发挥不出来了。这个时候 OpenAPI + codegen 依然是最成熟的选择。相反,如果你的产品只有一个 Next.js 前端和一个后端,或者项目体量允许前端后端代码放同一个 monorepo,tRPC 能帮你省掉大量接口文档维护成本。


3. 从零复刻 t3code 的核心模块:一次完整的全栈开发记录

3.1 初始化的正确姿势和版本选择

t3code 的起点用的是社区维护的脚手架create-t3-app,一条命令生成整套项目结构:

npx create-t3-app@latest t3code

初始化的时候会问你选哪些模块,我的建议是除了 Tailwind 之外,Prisma、tRPC、NextAuth 全选上。这里特别提示一下版本坑:2024 年中的时候,Next.js 15 和 tRPC v11 都有一些破坏性变更,脚手架生成的项目模板版本并不一定是最新的。真遇到问题,先看官方仓库的 README,别急着搜解决方案。

生成后的目录结构长这样:

prisma/ schema.prisma src/ server/ api/ routers/ trpc.ts auth.ts db.ts app/ api/trpc/[trpc]/route.ts page.tsx trpc/ server.ts client.ts

这个结构看起来很模式化,但它其实清楚区分了两个域:src/server下的代码只会在服务端执行,src/trpc/client.ts里的是前端的 React Query 相关封装。你写业务代码的时候注意别把两者混在一起,尤其是不要在前端组件里直接 importserver/db.ts去查数据库。

3.2 数据库建模与 Prisma Schema 设计要点

t3code 里我设计了一个比较典型的业务场景:文章与标签的多对多关系,带用户收藏的中间表。直接看 Schema 片段:

model Post { id String @id @default(cuid()) title String content String @db.Text viewCount Int @default(0) tags Tag[] savedBy UserSavedPost[] createdAt DateTime @default(now()) } model Tag { id String @id @default(cuid()) name String @unique posts Post[] } model UserSavedPost { userId String postId String savedAt DateTime @default(now()) user User @relation(...) post Post @relation(...) @@id([userId, postId]) }

Prisma 的三点经验分享给你。

第一,主键尽量用 cuid 而不是自增 id。自增 id 在并发写入场景容易暴露业务量,而且分库分表时几乎必然要重写。cuid 是字符串,前端不用关心生成规则,后端数据库也无压力。第二,多对多关系的中间表要自己显式定义才方便加额外字段,比如上面的savedAt。用 Prisma 的隐式多对多关系确实写起来省事,但后续想加字段就得重构。第三,@db.Text这种字段类型标注一定要做,大型文本内容不用的话,后期数据量上来,查询性能会很受影响。

定义完 Schema 后执行迁移:

npx prisma migrate dev --name init

这个命令会创建 SQL 迁移文件,同时根据 Schema 生成 TypeScript 客户端类型。后面每次改 Schema,都要重新执行一次 migrate 并重启 dev server,否则 Prisma Client 的类型不会更新。

3.3 身份认证与 session 处理:NextAuth 的配置细节

t3code 用了 NextAuth 做 GitHub OAuth 登录加 Credentials 登录混合方案。NextAuth 和 tRPC 集成时有个关键点:tRPC 的 context 里要能拿到 session,但 session 的获取不要让每个 query 都去查数据库。

我当时的做法是在trpc.ts里这样组织 context:

import { getServerSession } from "next-auth"; import { authOptions } from "../auth"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { session, prisma, headers: opts.headers }; };

前端调用受保护的流程时,用 protectedProcedure 包一层:

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

这里值得注意的细节是userId从 session 里取出来后要透传给下层 procedure。这样业务逻辑就只关心ctx.userId,不用每次判断 session 是否存在。看起来是小优化,但写起来代码会简洁很多。


4. 部署与上线:t3code 从本地到生产环境的完整链路

4.1 环境变量、迁移脚本与构建流程

本地开发时,.env文件直接写数据库连接串、OAuth 密钥。生产环境一般用托管平台的 secret 管理,不要放进代码仓库。

构建流程里有一步经常被忽略:生产环境部署前要跑prisma migrate deploy,而不是prisma migrate dev。dev 命令会做很多开发态的检查,生产环境不该跑它。另外,构建镜像时要把prisma generate也放进流程,因为 Prisma Client 是按平台生成的,换环境不重新生成会导致查询报错。

我在 t3code 的部署脚本里写的顺序是这样:拉代码 → 安装依赖 →prisma generate→prisma migrate deploy→ 执行 Next.js 构建 → 启动进程。如果数据库有 seed 数据,记得单独跑 seed 脚本,不要放在 migrate 阶段。

4.2 常见部署平台的配置差异

以 Vercel 和普通的 Node 服务器为例说明。Vercel 上部署 T3 Stack 项目非常顺滑,因为 Next.js 本身就是他们的主场。但要注意 serverless 环境下,每个请求的数据库连接不能被长期缓存。Prisma 官方推荐的使用方式是初始化单例并利用 serverless 环境的连接池,你在server/db.ts里可以这样写:

import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

这段代码的核心思路是:开发环境热更新会导致 PrismaClient 被反复实例化,必须做全局单例;生产环境 serverless 冷启动时也应复用连接。如果你用的是普通的 VPS 或 Docker,连接池策略又会不一样,更需要关注数据库的最大连接数限制。

4.3 上线后的性能检查单

t3code 上线后我给自己列了一个检查清单,分享出来供你参考:

  • tRPC 的 query 能否正确利用 React Query 的缓存,避免每次切页都重新请求
  • 数据库查询有没有 N+1 问题,Prisma 的include和select有没有合理裁剪字段
  • 图片资源是否走了 Next.js 的 Image 组件,而不是裸 img 标签
  • Next.js 的静态页面有没有正确标记dynamic,避免不必要的数据拉取
  • 有没有给高频查询接口加上 zod 的输入限制,防止恶意大参数请求拖垮数据库

这五项看着基本,但真实项目里总能发现至少一两个没做好的点。


5. 真实项目里最容易翻车的几个地方:我的排查经历

5.1 tRPC 请求超时与序列化问题的根因

用 tRPC 最隐蔽的一个坑是:query 的返回值里如果有 Date 对象或 bigint,JSON 序列化的时候会出问题,或者类型被悄悄吞掉。Prisma 查出来的DateTime字段在传输层会变成字符串,tRPC 的 superjson 序列化器能处理 Date、Map、Set 这些特殊类型。所以初始化 tRPC 时,记得在 transformer 里配置 superjson,否则日期字段会在前端变成纯字符串,本地模拟看不出问题,一部署到生产就出现时间错乱。

另一个常见问题是超时设置。tRPC 默认没有请求超时限制,但浏览器和反向代理都有自己的超时策略。我遇到过前端 React Query 的请求明明还在挂起,但 Nginx 已经掐断了连接的情况。后来在fetchRequestHandler外面包一层自定义 timeout 中间件,才把这个问题控制住。

5.2 数据库连接池与热更新冲突

开发模式下,Next.js 热更新触发频率非常高,如果每次编译都重新创建 PrismaClient,连接池会不停建立新连接,最终把数据库连接数打满。这个问题早期的症状是:项目跑着跑着数据库突然报Too many connections,重启 Next.js 进程就好了,但跑一阵又犯。解决方法就是我上面写的全局单例。这个坑不踩一次你可能很难体会它有多烦。

5.3 类型定义"看起来没问题"但编译不过的经典死局

在 t3code 开发过程中,我有一段时间经常遇到一个诡异情况:数据库表里明明有某个字段,前端调用 tRPC 时 TS 也提示有这个字段,但运行起来返回值里就是没有。排查了半天,最后发现是查询语句里用了select白名单,字段没被选进去,但类型上由于宽松的推断,TS 没有报错。

解决办法其实很简单:永远不要手动写返回类型来掩盖 Prisma 的类型推断。让 Prisma 从select条件自动推断出精确返回值类型,前端就无法引用到不存在的字段。凡是遇到"类型和实际返回值对不上"的情况,优先怀疑是不是有手动标注类型的地方在作祟。


最后再分享一个小体会。t3code 这个项目做到上线之后,我最深的感受是:T3 Stack 真正省时间的点不在于省掉了写接口文档这一步,而在于它让前后端之间的沟通成本变成了编译器的错误提示。这个小生态并非没有缺点,但对独立开发者或小团队来说,带来的安全感远超学习成本。

如果你也在折腾 t3code 相关的项目,建议从一个小模块开始跑通 tRPC 全链路,再逐步扩展到业务核心。踩坑之后你会和我一样,彻底喜欢上这种"类型即契约"的开发方式。