搞全栈开发的人,最近两年如果没听过 T3 Stack,大概会觉得自己错过了什么。这个以 TypeScript、Tailwind CSS、tRPC 三个“T”为核心的组合拳,把前端、后端、样式和类型系统拧成了一股绳,而 t3code 正是我基于这套技术栈搭建的一个全栈实战项目代号。简单说,t3code 就是“用 T3 技术栈写出来的完整应用代码”,它解决的问题非常直接:让从前端页面到后端接口再到数据库查询,全程都有类型安全保障,写错字段名、传错参数类型这类低级错误,在编译期就被拦下,而不是等到线上炸了才去排查。这篇文章面向的是想真正动手把 T3 用起来的人,不管是第一次接触 tRPC 的新手,还是已经在 Next.js 里摸爬滚打一段时间的老手,都能在项目里找到可以直接抄走的东西。
1. t3code 是什么:一套让“类型安全”从后端贯穿到前端的全栈方案
1.1 T3 三个字母的真正含义
T3 这个名字看起来像是某个营销缩写,实际上它背后有一个很实在的约定。三个 T 分别代表 TypeScript、Tailwind CSS、tRPC。很多人会纠结第三个 T 到底是 tRPC 还是 Tailwind,因为这两个都是 T 开头,官方给出的定义是:TypeScript 打底,Tailwind 负责样式,tRPC 负责接口通信。三个工具单独拎出来都不是什么新鲜东西,但组合在一起,产生了一种其他方案很难复制的开发体验——端到端类型安全。
我在 t3code 项目里最直观的感受是,写后端接口就像在写本地函数,前端调用后端方法的时候,IDE 能直接弹出完整的入参提示,返回值的结构也一目了然。这种体验并不是魔法,而是 TypeScript 在前后端之间架起了一座桥梁,tRPC 则是桥上的传送带。配合 Next.js 的 API 路由和 React Server Components,整条数据链路几乎不需要手动定义任何接口文档,类型本身就是文档。
1.2 t3code 能解决的实际问题
很多团队做全栈项目,最常见的痛苦来源是前后端接口对齐。后端定义了一个返回结构,前端拿到数据之后通常要再写一堆 interface 去“猜”字段类型,一旦后端改了字段名,前端马上报错或者干脆渲染出 undefined。t3code 把这一层的摩擦几乎降到了零,因为后端 router 是直接暴露给前端类型引用的,前端不用再去维护一套手工的 DTO 定义。
另一个被很多人忽略的好处是流程简化。传统的前后端分离开发,你至少需要维护两套工程、两套部署管道和一遍联调环境。t3code 把代码放在同一个 Next.js 应用中,API 层、数据模型、页面逻辑、样式文件全部在一个仓库里管理。对于中小型项目来说,这种“单体但分层”的模式能省掉大量沟通成本,部署也退化为一次构建、一次上线。不是说这种方式适合所有场景,但至少在你不需要独立扩展后端服务的情况下,它是性价比最高的选择。
2. 技术选型背后的取舍逻辑
2.1 为什么用 tRPC 而不是 REST 或 GraphQL
如果你之前写过 REST API,应该知道它的核心是“资源”和“动作”,一个 URL 对应一种资源,通过 HTTP 方法区分操作。这个模型足够通用,但问题也很现实:前端要自己拼 URL、处理状态码、解析错误结构,后端要操心路由设计、参数校验、接口文档。GraphQL 把查询能力拉满,但引入 schema、resolver、缓存这些概念之后,复杂度也随之水涨船高,小团队有时候会被它反噬。
tRPC 的思路完全不同,它不定义一个通用的 HTTP 语义层,而是直接把后端函数暴露成可调用方法。前端写api.user.getById.useQuery({ id }),本质上就是在调用一个远程函数。因为 TypeScript 的类型推导覆盖了整个过程,所以前端拿到的返回值天然是后端返回类型的子集,不需要中间人,不需要手写 OpenAPI 规范,更不需要在前后端之间维护一个“契约文件”。
我在 t3code 项目里选用 tRPC 还有一个实际考量:迭代速度。业务需求经常变,今天接口要加一个字段,明天查询要多一个过滤条件,这些改动如果用 REST,可能需要同时改路由、改文档、改前端调用代码,而用 tRPC,后端加一个字段,前端类型立刻同步,改完就能跑通。对于一个人开发或者两三个人协作的项目来说,这种效率提升比任何性能优化都明显。
2.2 Tailwind:把样式从“全局命名”里解放出来
很多没有实际用过 Tailwind 的人,第一反应是“class 名好长好丑”。我一开始也是这个态度,直到我在 t3code 里跑了两个完整页面,才真正理解 Tailwind 的设计逻辑。它不是一个给你预置好组件的 UI 库,而是一个约束化的样式工具,所有间距、颜色、字体、阴影都遵循一套设计令牌,你直接在类名里组合,而不是去styles.css里起一个.card-wrapper之类的全局类。
这个选择的意义在于,你在编写组件时,视觉样式和结构代码是放在一起的,不用反复在 HTML 和 CSS 文件之间跳转。更重要的是,Tailwind 会自动剔除未使用的样式,生产环境的 CSS 体积被压得很低。t3code 的页面里我用了大量响应式类,比如grid-cols-1 md:grid-cols-3 gap-4,这种写法在移动端和桌面端之间的切换非常直观,省掉了写 media query 的环节。
说到底,Tailwind 不是让 CSS 消失了,而是把 CSS 的组织方式从“文件系统”变成了“组合系统”。如果项目足够复杂,你还可以通过@layer和@apply把重复模式提炼成自己的工具类,它并没有锁死你写自定义样式的能力,只是给了你一套更快的默认路径。
2.3 TypeScript 严格模式到底带来了什么
t3code 从第一行代码就活在strict: true的世界里。很多人对严格模式又爱又恨,恨的是它总在报错,爱的也是它总在报错。严格模式把所有可能为null或undefined的情况摊在明面上,逼着你处理边界条件。
举个例子,从数据库查一个用户,在非严格模式下,前端的类型可能会被推导成User,但实际上这个字段完全可能不存在。你直接访问user.name,编译不会报错,运行时直接挂掉。而在严格模式里,类型会被推导成User | null,你必须在代码里处理为空的可能,这就是所谓“让错误无处可藏”。
在 t3code 里,TypeScript 的作用不仅仅是类型标注,它本身就是设计工具。当我写一个 tRPC 的input定义时,用 zod 声明校验规则,TypeScript 会直接推导出入参类型,前端调用时连参数提示都对上了。这听起来好像只是省了一点重复劳动,但放到一个几十个 router 的真实项目里,节省的时间和避免的低级错误是非常可观的。
3. 从零初始化:环境准备与项目脚手架
3.1 初始化命令与依赖选择
t3code 的项目初始化非常标准,用的是官方脚手架create-t3-app,一条命令就能拉起整个结构。
npm create t3-app@latest执行之后,交互式命令行会问你要启用哪些模块。我当时选了 Next.js、Tailwind CSS、tRPC、Prisma、NextAuth 五件套,因为这是 T3 生态里最完整的默认组合,覆盖了从数据库到认证再到 API 的全链路。如果你只是先试水,也可以只选 Tailwind 和 tRPC,后面再手动补 Prisma 或 NextAuth。
项目结构生成之后,映入眼帘的是几个关键目录:
src/ app/ # Next.js App Router 页面 components/ # 通用组件 server/ api/ routers/ # tRPC 路由定义 trpc.ts # tRPC 初始化与 context 创建 db.ts # Prisma Client 实例 trpc/ server.ts # 服务端 tRPC 封装 client.ts # 客户端 tRPC 封装 prisma/ schema.prisma # 数据模型这个目录划分是 T3 官方推荐的,核心思路就是把“服务端逻辑”和“客户端逻辑”严格分开,所有 Node.js 相关的依赖都不允许被前端代码直接引用,避免泄漏到浏览器端。我在实际开发中也一直遵守这个约定,宁可多写一层中间封装,也不贪图方便把数据库查询直接写进组件里。
3.2 数据库模型与 Prisma 配置
t3code 是一个任务管理场景的示例,所以数据模型很简单,核心就两张表:用户和任务。Prisma schema 如下:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String? @unique name String? image String? tasks Task[] accounts Account[] sessions Session[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Task { id String @id @default(cuid()) title String completed Boolean @default(false) createdAt DateTime @default(now()) userId String user User @relation(fields: [userId], references: [id]) }这个 schema 里有两个容易被新手忽略的点。第一是@default(cuid()),cuid 相比自增 ID 更适合分布式场景,而且生成的 ID 是字符串,天然避免了一些前端把 ID 当数字处理的坑。第二是updatedAt @updatedAt,Prisma 自动帮你维护更新时间,不用在业务代码里手工赋值。
初始化完 schema 之后,需要执行一次迁移,把模型同步到数据库:
npx prisma migrate dev --name init这个命令会生成迁移文件,同时自动生成 Prisma Client。生成后,src/server/db.ts里导出的db实例就能直接用了。我在第一次跑项目的时候,在prisma migrate dev这一步卡了很久,原因只是本地的 PostgreSQL 版本和 Prisma 客户端不兼容,后来换了官方推荐的 PostgreSQL 16 镜像,一次就过了。
4. t3code 核心模块:认证、路由与类型推导
4.1 NextAuth 集成与 Session 类型扩展
t3code 里认证用的是 NextAuth,它在 Next.js 生态里属于“默认选项”,因为 SSR 场景下的 session 管理、OAuth 登录、JWT 回调都内置好了。T3 脚手架会在初始化时自动生成一份能跑通的认证配置,你需要做的只是填入对应的 Provider 密钥。
不过这里有个非常关键的细节:NextAuth 的 session 类型默认只有user、expires这些基础字段,如果你在 tRPC 的 context 里访问session.user.id,TypeScript 会报错。解决办法是在项目里声明一个类型扩展文件:
// src/types/next-auth.d.ts import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }这个扩展是我后来才补上的,一开始我在 tRPC 路由里强行用session.user.id,编译期一直报属性不存在的错误,查了一圈才发现是 NextAuth 的类型默认值里没有 id 字段。建议所有用 T3 + NextAuth 的项目,第一步就把这个声明写掉,省得后面每个路由都在跟类型博弈。
4.2 tRPC 路由结构与 context 传递
tRPC 的核心并不在于传输协议,而在于router和procedure这两个抽象。一个router就是一组方法的集合,而procedure定义了每个方法可被调用的条件和入参规则。在 t3code 里,taskRouter 是这样写的:
// src/server/api/routers/task.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const taskRouter = createTRPCRouter({ getAll: protectedProcedure.query(({ ctx }) => { return ctx.db.task.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1, "任务标题不能为空"), description: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, description: input.description, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const task = await ctx.db.task.findUnique({ where: { id: input.id }, }); if (!task || task.userId !== ctx.session.user.id) { throw new Error("无权操作该任务"); } return ctx.db.task.update({ where: { id: input.id }, data: { completed: !task.completed }, }); }), });这段代码里用了protectedProcedure而不是publicProcedure,这是一个非常关键的区别。protectedProcedure会在执行前先检查 session 是否存在,不存在就直接抛错,省去了在每个查询里手动判断登录态。这种通过 procedure 级别控制访问权限的模式,比在每个方法里写 if-else 清晰得多,也更安全。
context 的传递也是 tRPC 的一大亮点。在trpc.ts里,所有信息都会被融合进一个 context 对象,然后注入到每个 procedure 中。t3code 的 context 配置大致长这样:
export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, ...opts, }; };这个 context 里的db和session会同步到每个路由里,前端调用时不需要显式传入,因为它们是从请求上下文中自然获取的。这也是 tRPC 和普通 HTTP 调用之间最大的体验差异——你不需要关心 token 怎么带、session 怎么取,框架在内部都处理好了。
4.3 服务端调用与前端调用链路
t3code 的前端页面是典型的 React 写法,用api这个代理对象直接调用后端路由。以任务列表为例:
// src/app/page.tsx "use client"; import { api } from "~/trpc/react"; export default function HomePage() { const utils = api.useUtils(); const { data: tasks, isLoading } = api.task.getAll.useQuery(); const createTask = api.task.create.useMutation({ onSuccess: async () => { await utils.task.getAll.invalidate(); }, }); if (isLoading) return <div>加载中...</div>; return ( <div className="mx-auto max-w-2xl space-y-4 p-8"> <h1 className="text-2xl font-bold">我的任务清单</h1> <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); const title = String(formData.get("title") ?? ""); createTask.mutate({ title }); }} > <input name="title" className="w-full rounded border p-2" placeholder="输入新任务标题" /> </form> <ul className="space-y-2"> {tasks?.map((task) => ( <li key={task.id} className="flex items-center gap-3 rounded border p-3"> <input type="checkbox" checked={task.completed} onChange={() => toggleTask.mutate({ id: task.id })} /> <span className={task.completed ? "line-through opacity-50" : ""}> {task.title} </span> </li> ))} </ul> </div> ); }这里最实用的一个功能是invalidate。当你创建完一条数据后,调用utils.task.getAll.invalidate(),tRPC 会自动重新拉取getAll的结果并刷新页面,不需要手动管理状态。这种“变更后自动同步”的机制,写起来非常顺手。
另外一个容易被忽略的能力是服务端调用。在 Next.js 的 Server Component 里,你可以直接调用同一个 tRPC 路由,因为整套逻辑都跑在 Node.js 环境里。这意味着首屏渲染可以直接拿到数据库数据,再辅助 Client Component 做交互,兼顾了 SEO 和用户体验。t3code 的任务详情页就是这么做的,静态部分走服务端查询,动态交互走客户端 mutation,两套调用链路共享同一份类型定义。
5. 部署与上线:从开发到生产要过的几道关卡
5.1 环境变量管理与多环境配置
t3code 里最容易被忽视的部分是环境变量。本地开发还好,DATABASE_URL、NEXTAUTH_SECRET随便填都能跑,但到了部署阶段,任何一项没配齐都会让你寸步难行。
常见的环境变量组合如下:
# 数据库连接 DATABASE_URL="postgresql://user:password@host:5432/dbname" # NextAuth 相关 NEXTAUTH_SECRET="一串足够长的随机字符串" NEXTAUTH_URL="https://你的域名" # OAuth Provider 密钥 GITHUB_CLIENT_ID="..." GITHUB_CLIENT_SECRET="..."这里要特别提醒的是NEXTAUTH_SECRET,千万不要用明文写在代码里,至少也要用openssl rand -base64 32生成随机值。部署到云端时,建议直接把环境变量配置在托管平台的控制面板里,而不是写进.env文件然后提交到仓库。我见过太多因为.env被误提交导致密钥泄密的案例,T3 的默认.gitignore会忽略.env文件,这个习惯值得保留。
5.2 用 Docker 打包 Next.js 应用
t3code 的部署我选择用 Docker 镜像,这样可以保证生产环境和本地环境完全一致,不用在服务器上单独装 Node.js 版本、pnpm 这些工具链。一个可用的 Dockerfile 长这样:
# 依赖安装阶段 FROM node:20-alpine AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile # 构建阶段 FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN npx prisma generate RUN pnpm build # 运行阶段 FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/public ./public COPY --from=builder /app/package.json ./package.json EXPOSE 3000 CMD ["pnpm", "start"]这个 Dockerfile 用了多阶段构建:第一阶段只装依赖,第二阶段跑 Prisma generate 和应用构建,第三阶段只保留运行所需的文件。这样镜像体积能缩小不少,启动速度也会快一些。如果服务器上不方便装 Docker,也可以直接用pnpm build && pnpm start跑 Node.js 进程,配合 PM2 做进程守护。
5.3 数据库迁移与连接池参数
部署阶段最容易踩的坑是数据库迁移。本地开发时跑prisma migrate dev会把 schema 直接同步到本地库,但生产环境不能用 dev 模式,要改用:
npx prisma migrate deploy这个命令只执行迁移文件,不会做任何 schema 变更,所以你需要在 CI 流程里提前生成并按顺序提交迁移文件。如果你加了字段或者改了索引,确保新的迁移文件已被prisma generate重新生成过再部署。
连接池参数也值得关注。如果你的数据库托管服务商限制了连接数,Prisma 默认的连接池配置可能不够用,尤其是 Serverless 环境下,冷启动时可能会瞬间打出大量连接。解决办法是在DATABASE_URL里加上?connection_limit=5这样的查询参数,控制单实例的连接数量。这个参数虽然不起眼,但在高并发场景下能救你一命。
6. 实际操作中的常见坑与排查思路
6.1 类型报错:泛型推导失败怎么破
t3code 开发过程中,我遇到最多的一类问题就是 TypeScript 泛型推导失败。典型场景是在 tRPC 的 mutation 里,input 的类型在路由定义里没问题,但前端调用时突然提示参数类型不匹配。
这个问题的根源通常是 zod 校验规则写得不够严格,或者中间层包装函数改变了输入类型。比如我在某个 router 里直接定义z.object({ id: z.string() }),但实际传入的是一个 custom class 实例,TypeScript 推导出来的类型和运行时并不一致。解决办法并不复杂,尽量让 zod schema 成为类型的唯一来源,不要在业务代码里再手工定义一套重叠的 interface。如果你发现类型在两端对不上,优先检查 router 的 input schema,其次检查 context 是否多包了一层。
6.2 tRPC context 为空的诡异情况
另一个让我折腾了很久的问题是,tRPC 的 context 里 session 字段有时候是 null,页面却明明已经登录了。排查到最后发现,问题出在 NextAuth 的getServerAuthSession()函数只适用于服务端上下文,而在某些边缘渲染模式下,请求头里的 cookie 没有正确传递。
解决办法分两步。第一步,确保 tRPC 的 context 创建函数里用的是headers(),而不是从组件里手动传入的 headers。第二步,在trpc/react.ts的客户端封装里,把url指向/api/trpc,并打开credentials: 'include',让浏览器在请求时自动带上 cookie。我这里说的是 NextAuth 默认的 JWT session 方式,如果你换成数据库 session,还需要确认数据库表结构完整,别因为缺了Session表导致初始化失败。
6.3 Prisma 迁移顺序引发的数据不一致
Prisma 的迁移文件是按时间顺序编号的,如果团队协作时两个人各自在本地生成了迁移,冲突几乎是必然的。t3code 这种个人项目还好,但只要你开始和人协作,就会体验到合并迁移文件的酸爽。
规避办法是,在每一个功能分支开始前,先同步一次主分支的迁移状态。如果发现迁移文件存在冲突,不要手动改迁移内容,直接删掉其中一份并重新生成。因为迁移文件一旦跑过,数据库里的_prisma_migrations表会记录执行状态,你手工修改已有的迁移文件,会导致后续部署时校验失败。一句话总结:迁移顺序宁可多花十分钟整理,也不要图省事跳过。
6.4 部署后白屏的排查流程
如果部署后打开页面是白屏,别急着怀疑 tRPC,先从浏览器控制台的报错开始排查。常见原因有三个。第一,Next.js 的静态资源路径不对,需要在 Dockerfile 里设置ENV NEXT_PUBLIC_BASE_PATH或在 next.config 里配置assetPrefix。第二,环境变量没有在生产环境生效,NEXT_PUBLIC_开头的变量会被打进浏览器 bundle,如果你在构建阶段没有注入,运行时没法补充。第三,数据库端口不通,后端查询直接抛错,前端页面渲染失败。
我的排查顺序一般是:先看浏览器控制台,确认是网络错误还是渲染错误;再看服务端日志,确认是 API 报错还是页面组件报错;最后看数据库连接状态。大多数白屏问题都能在这三步里定位到根因,而不是盲目地在代码里加 console.log。
6.5 auth 跳转逻辑的隐藏坑位
如果你把会话过期时间设得很短,用户在填写一个长表单时可能突然被踢回登录页,而且表单数据全部丢失。这个问题在 t3code 里出现过一次,原因是 NextAuth 的过期策略默认基于 JWT 过期时间,没有做滑动续期。解决办法是自定义jwt回调,在每次请求时刷新过期时间:
// src/server/auth.ts callbacks: { jwt({ token, user }) { if (user) { token.id = user.id; } return token; }, session({ session, token }) { if (session.user) { session.user.id = token.id as string; } return session; }, }这段配置既解决了 session.user.id 的类型问题,也在每次请求时让 JWT 保持续期。如果你不想让用户频繁登录,这个自定义回调基本是必须写的。
7. 一点个人体会
t3code 这个项目做完之后,我对全栈开发的理解发生了一点变化。过去我总是习惯把“前后端分离”当成默认选项,觉得前端和后端本来就该是两套工程、两拨人、两个值得维护的部署单元。T3 栈让我重新审视了这个惯性——当项目的规模和团队结构并没有复杂到需要拆分的时候,把类型安全贯穿到底,反而是一条更轻松的路径。类型系统不是给你找麻烦的,它是来帮你在凌晨两点避免“明明看着没错,上线就是炸”的那种篝火旁式痛苦的。
如果你正准备用一个新项目练手,我的建议是别急着堆功能,先用 t3code 这种最小结构跑通“数据库读一条数据 -> tRPC 暴露接口 -> 前端页面渲染出来”这条链路,然后再逐步加认证、加权限、加部署。很多时候不是框架不够好,是你第一次接触时被一堆概念淹没了。把链路走通,剩下的都是细节问题。祝你在 T3 的世界里少踩坑、多写代码。