1. 这套叫t3code的代码组合到底在说什么
如果你最近在GitHub上搜过全栈项目模板,大概率会撞见T3 Stack这个名字。我理解的t3code,就是围绕这套技术栈沉淀下来的一整套实战代码——它不是某个单一框架,也不是一个官方库,而是一种“用TypeScript把前后端一整条链路焊死”的写法和配套工程结构。上周我正好用官方脚手架create-t3-app重写了一个团队内部的会议室预约系统,从数据库字段到浏览器里的按钮,全程走类型推导。这篇就借这个真实项目,把t3code这类项目的搭建过程、类型穿透链路、认证接入、样式组织和Serverless部署从头盘一遍。
先说清楚这套组合里最关键的四块东西:Next.js负责应用骨架和路由,TypeScript统一语言,Tailwind CSS处理样式,tRPC替代传统REST接口层。再往后还有Prisma管数据库,NextAuth管登录认证。很多第一次接触的人以为tRPC是个冷门库,实际上它才是T3 Stack的灵魂——它让“前端直接调用后端函数”成为可能,而且调用时的参数类型、返回类型全部自动从服务端推导到客户端,不需要手写一份接口文档,也不需要像GraphQL那样维护Schema。
1.1 三个T的含义与tRPC的核心价值
T3 Stack全称是“The T3 Stack”,三个T分别指TypeScript、Tailwind CSS、tRPC。最早由Theo在社区里推广,核心思想是:既然项目里前后端都写TypeScript,为什么不能让类型从数据库一路穿透到UI?
举一个最直观的对比。传统REST API的典型流程是这样的:后端定义路由GET /api/resource,写接口文档,前端拿着文档封装fetch函数,再手动定义interface Resource {...}。一旦后端改了字段名,前端可能要过很久才发现,运行时报错才暴露问题。
tRPC的做法完全不同。你在服务端写一个查询函数:
// server/api/routers/resource.ts import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const resourceRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.resource.findMany(); }), });前端直接写:
// app/resources/page.tsx(部分代码) const { data } = api.resource.getAll.useQuery();api对象上的方法名、参数类型、返回值类型都是从服务端resourceRouter自动推导出来的。如果后端把getAll改成list,前端编译直接报错——这个特性在重构时尤其救命。
1.2 create-t3-app的“按需启用”原则
官方脚手架叫create-t3-app,它和其他脚手架最大的区别是“按需启用”。初始化时它会问你用不用NextAuth、Prisma、Tailwind、tRPC、Docker。你完全可以只选其中两三项。这种设计思路很符合实际工程:技术栈是服务于人的,不是人服务于技术栈。我这次初始化会议室预约系统时,就选了全套——因为内部工具需要登录、需要数据库、需要极快的搭建速度,这套组合正好。
但也正因如此,很多人初次拉下来会困惑:“这么多文件都是干嘛的?”下一节我就把初始化后的骨架拆开讲。
1.3 这套组合的适用边界
任何技术选型都有边界,T3 Stack尤其明显。它最舒适的场景是:单人全栈或两三人小团队、前后端代码在同一个仓库、项目规模中等偏小、没有对外提供公共API的需求。反过来说,如果你要做开放API平台、要同时服务Web和多个移动端App、或者要把后端逻辑独立拆成微服务,tRPC就不是好选择了——它的类型穿透优势恰恰建立在“前后端同仓”这个前提上。
2. 五分钟拉起一个可复现的T3项目骨架
2.1 初始化命令与交互选项
拉一个新项目非常简单,我用的命令是:
npx create-t3-app@latest t3code执行后进入交互式界面,这里有几个关键选项值得认真选:
| 选项 | 建议 | 说明 |
|---|---|---|
| Use TypeScript | 是 | 不用选No,这个栈的灵魂就是TS |
| Use Tailwind CSS | 是 | 如果需要自己控制样式体系就选,组件库需要它 |
| Use tRPC | 是 | 本项目的核心通信方式 |
| Use NextAuth | 按需 | 需要登录选Yes,纯公开站可以No |
| Use Prisma | 按需 | 需要数据库选Yes,纯无状态项目可以不选 |
| Use Docker | 否 | 本地开发不需要容器,CI里有需要再补 |
| Import alias | 推荐~/ | 保持默认,路径更简洁 |
如果你只是想快速实验,直接一路回车也行,默认就是全套启用。
2.2 初始化后的目录结构速览
初始化完成之后,src目录下会分成两大阵营:app是UI层(Next.js App Router的页面和组件),server是服务端逻辑层。这两个目录的边界划分是整个项目能保持清晰的关键。
src/ ├── app/ │ ├── api/ │ │ ├── auth/ │ │ │ └── [...nextauth]/ │ │ │ └── route.ts │ │ └── trpc/ │ │ └── [trpc]/ │ │ └── route.ts │ ├── layout.tsx │ ├── page.tsx │ └── _components/ ├── server/ │ ├── api/ │ │ ├── routers/ │ │ │ ├── post.ts │ │ │ └── root.ts │ │ ├── trpc.ts │ │ └── root.ts 集合所有router │ ├── auth.ts │ └── db.ts ├── trpc/ │ ├── react.tsx │ └── server.ts └── styles/ └── globals.cssserver/db.ts里初始化Prisma Client,server/auth.ts里初始化NextAuth,server/api/trpc.ts创建tRPC实例和procedure封装,server/api/routers/放各个业务模块的router。前端trpc/react.tsx负责把tRPC客户端和React Query桥接起来,trpc/server.ts给Server Component用的服务端调用入口。
这个分层很清晰:任何业务代码要么属于app(页面展示),要么属于server(数据处理),中间通过trpc目录里封装的客户端/服务端调用桥接。
2.3 跑起来前必改的配置与环境变量
直接npm run dev大概率会报错,因为你还没填环境变量。根目录有个.env.example,复制成.env后至少要改三项:
DATABASE_URL:Prisma连接数据库的字符串。我这次用的是PostgreSQL(云数据库),格式大致是postgresql://用户名:密码@主机:端口/库名。AUTH_SECRET:NextAuth签名Session用的密钥。可以直接用openssl rand -base64 32生成一个。AUTH_GITHUB_ID和AUTH_GITHUB_SECRET:如果用GitHub登录,需要去GitHub OAuth Apps页面注册一个应用拿Credentials。回调地址填http://localhost:3000/api/auth/callback/github。
这里有个很容易忽略的点:AUTH_SECRET如果为空,NextAuth在开发模式会随机生成一个,导致第二次npm run dev后登录态失效。我吃过这个亏,后来一律在.env里写死。
3. 类型穿透全链路:以会议室预约系统为例
这一部分是本篇的核心。我用“会议室预约”这个业务场景走一遍完整链路:定义数据库模型、写服务端查询、前端消费类型。跟着走一遍,你就能理解为什么T3 Stack能被这么多人推荐。
3.1 用Prisma模型定义数据库源头
会议室预约系统至少需要两张表:会议室(Resource)和预约记录(Booking)。在prisma/schema.prisma里写:
model Resource { id String @id @default(cuid()) name String location String capacity Int @default(4) bookings Booking[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Booking { id String @id @default(cuid()) resourceId String resource Resource @relation(fields: [resourceId], references: [id]) userId String startAt DateTime endAt DateTime note String? createdAt DateTime @default(now()) }写完Schema后执行迁移:
npx prisma migrate dev --name init这个命令做了两件事:生成SQL迁移文件并应用到数据库;重新生成Prisma Client的类型定义。此时ctx.db.resource和ctx.db.booking都带上了完整的类型信息。
3.2 通过tRPC把服务端类型导给前端
接下来在server/api/routers/resource.ts里写两个查询。一个用于获取会议室列表,一个用于查询某个会议室在未来某段时间是否已被预约。
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const resourceRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.resource.findMany({ orderBy: { name: "asc" }, }); }), isOccupied: publicProcedure .input(z.object({ resourceId: z.string().min(1), startAt: z.date(), endAt: z.date(), })) .query(async ({ ctx, input }) => { const conflict = await ctx.db.booking.findFirst({ where: { resourceId: input.resourceId, startAt: { lt: input.endAt }, endAt: { gt: input.startAt }, }, }); return conflict !== null; }), });注意input的校验用了zod。tRPC对输入参数的处理远比REST严格:前端传进来的数据会先经过zod校验,类型不对在编译期就报错,数据不合法在运行时被拦截。这样你根本不需要在业务代码里再写一遍if (!id) throw ...这类防御逻辑。
最后在root.ts里把resourceRouter挂载到总router上。之后前端拿到的api.resource就是这里导出的对象。
3.3 前端组件用useQuery消费类型
页面代码长这样:
"use client"; import { api } from "~/trpc/react"; export function ResourceList() { const { data, isLoading, refetch } = api.resource.list.useQuery(); if (isLoading) return <div>加载中...</div>; return ( <ul> {data?.map((item) => ( <li key={item.id}> {item.name} - {item.capacity}人 </li> ))} </ul> ); }data的类型由服务端findMany的返回类型自动推导。你在组件里写item.name,编辑器会提示你有哪些字段可选,写错字段名立刻飘红。这在协作时特别好用:后端改了模型,前端编译不过,问题在跑起来之前就暴露了。
useQuery背后是TanStack Query(React Query),它自动帮你处理了请求缓存、加载态、错误态、重新请求。比如预约成功后想刷新列表,直接refetch(),不用自己写状态管理。
3.4 最容易破坏类型链路的几个习惯
类型穿透这么爽,但也别高兴太早。我在团队代码评审里见过不少让链路“断裂”的写法:
第一,用any接收tRPC返回值。一旦用any,后端改字段前端就没提示,等于亲手丢掉了这套栈最大的优势。
第二,手动定义接口类型。前端不去api.xxx.useQuery()推导,而是自己写interface ResourceDTO {...}。这看起来没毛病,实际上等于把类型变成了“两份”,后端一改,前端这份就失忆了。
第三,在Server Component里用api.xxx(服务端tRPC调用)拿到数据后,再通过props传进Client Component。这个流程本身没问题,但传的时候如果手动给props标注了“简化类型”,链路也会断。
正确做法是尽量复用inferRouterOutputs这类工具从router上抽取类型:
import type { AppRouter } from "~/server/api/root"; import type { inferRouterOutputs } from "@trpc/server"; type ResourceListResult = inferRouterOutputs<AppRouter>["resource"]["list"][number];这样你定义的任何业务类型都是从同一个源头推出来的,不会出现“两台对不上台账”的情况。
4. 认证接入:NextAuth与Prisma Adapter的实战细节
内部工具几乎都要登录。T3 Stack默认集成了NextAuth,和Prisma Adapter配合,把登录态持久化到数据库。下面我分享这次配置中的完整流程和最重要的几个坑。
4.1 认证配置的完整流程
初始化之后,server/auth.ts是认证模块的主文件。默认支持GitHub Provider,需要做三步:
首先是数据库适配器:
import { PrismaAdapter } from "@auth/prisma-adapter"; import { db } from "~/server/db"; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), providers: [GitHub], // ... });其次在.env里填GitHub OAuth的ID和Secret。最后在app/api/auth/[...nextauth]/route.ts里把请求转发给Auth的handler。整个链路跑通后,用户的登录Session会存到数据库的Session表里,Prisma会自动维护这几张由Adapter生成的基础表。
4.2 在tRPC里区分公开与受保护路由
业务上,查询会议室列表可以是公开的,但创建预约必须登录。tRPC封装了这个能力:
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const bookingRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ resourceId: z.string().cuid(), startAt: z.date(), endAt: z.date(), })) .mutation(async ({ ctx, input }) => { return ctx.db.booking.create({ data: { resourceId: input.resourceId, userId: ctx.session.user.id, startAt: input.startAt, endAt: input.endAt, }, }); }), });protectedProcedure发源于trpc.ts里的一个封装:
export const protectedProcedure = createTRPCRouter({ // ... }).procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: ctx.session, }, }); });前端调用受保护路由时如果未登录,会收到一个标准的UNAUTHORIZED错误,你可以统一在React Query的错误处理里跳转登录页。
4.3 自定义用户字段后TypeScript类型同步问题
这是我最想提醒的一个点。默认生成的User模型只有id、name、email、emailVerified、image几个字段。但内部工具往往需要自定义字段,比如工号、部门、角色。我在schema.prisma里加了两列:
model User { id String @id @default(cuid()) name String? email String? @unique employeeNo String? // 工号 role String @default("member") // admin / member // ...其他NextAuth相关字段 }迁移后,数据库表确实多了两列,但ctx.session.user的类型还是老的——因为NextAuth的Session类型是从独立的类型定义推导的,不会自动感知Prisma模型的改动。
解决方法是在项目任意位置加入module augmentation:
// types/next-auth.d.ts import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; employeeNo?: string; role: string; } & DefaultSession["user"]; } }加上之后,ctx.session.user.role就能在tRPC route里提示出来了。但注意,这个d.ts必须被tsconfig include到。如果加完还是报错,先检查tsconfig.json里是否声明了types字段,把next-auth加进include范围。
5. 样式层与组件层的选型:Tailwind之外还要想清楚的事
T3 Stack默认带Tailwind,但它不止于“写样式”。真正开发中还要处理组件库选择、Client Component边界、样式主题等一堆问题。
5.1 Tailwind在App Router下的配置要点
初始化后你会发现tailwind.config.ts里已经生成好了content扫描路径:
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],这个配置意味着src下所有tsx文件都会被扫描,包括Server Component和Client Component。这里有个细节:如果你把样式类名动态拼接,比如`bg-${color}-500`,Tailwind是扫描不到的,因为JIT只会扫描字面量完整的类名。解决方式是多写几个分支或者用CSS变量。
另外,T3 Stack默认的暗色模式策略是class,需要你在layout.tsx里挂一个处理dark类的脚本。我没用全局切换,而是让每个内部工具页面按业务场景自己决定是否用暗色。
5.2 shadcn/ui这类组件库和T3的配合
Tailwind本身只是原子类,没有按钮、表单、对话框这些复合组件。实际开发还是需要组件库。我这次选了shadcn/ui,原因有三个:
- 它不是运行时组件库,而是“复制源码到你项目中”,这样组件CSS完全可控。
- 它和Tailwind天然共生,主题切换通过CSS变量实现。
- 它是Headless UI那一层,组件逻辑和样式彻底分开,能自由覆盖。
用法很简单:
npx shadcn@latest init npx shadcn@latest add button dialog form input添加完组件后,直接在页面里import { Button } from "~/components/ui/button"就行。注意shadcn/ui默认把组件放在components/ui下,和T3 Stack的src/components结构能无缝衔接,不需要额外调整路径别名。
5.3 Server Component与tRPC的边界划分
Next.js App Router引入了Server Component(RSC),很多人会问:数据到底用RSC直接查Prisma,还是走tRPC?我给一个实用判断标准:
初始加载、不依赖用户登录态的数据,用RSC直接在服务端查数据库。这样页面首屏不发起额外请求,体验最干净。比如会议室列表这种所有人看到都一样的数据,完全可以在page.tsx里直接await db.resource.findMany(...),把结果作为props传给子组件。
用户登录后、依赖Session或需要持续刷新的数据,用tRPC。比如“我的预约列表”“预约是否冲突”这类个性化数据,走tRPC的useQuery,借助React Query的缓存和重新请求能力。这条划分规则可以避免很多“RSC首次渲染报错”“Cookies读不到”的诡异问题。
6. 部署Serverless时的环境变量、迁移与坑
项目开发完总要部署。T3 Stack的大多数用户会把Next.js部署到Vercel这类Serverless平台。在Serverless环境下,有几个细节跟本地完全不同,要特别留心。
6.1 环境变量在多环境下的管理
Vercel支持生产、预览、开发三套环境变量。我的做法是:
- 生产环境单独建一个PostgreSQL实例,和预览环境数据库物理隔离,避免有人跑了迁移把生产表结构改坏。
AUTH_SECRET在三个环境分别设置不同的值,不要复用。- GitHub OAuth回调地址也要根据环境区分:生产回调填
https://你的域名/api/auth/callback/github,预览环境回调填https://你的预览域名/api/auth/callback/github。GitHub OAuth只允许配置一个回调地址,所以预览环境的登录可能会受限制,通常的做法是在GitHub后台每套环境注册一个OAuth App。
6.2 Prisma迁移在Serverless环境的正确姿势
本地开发时,prisma migrate dev很爽——自动生成迁移、自动应用。但这个命令在Serverless部署时不能直接用。正确做法是:
- 本地开发阶段用
npx prisma migrate dev --name xxx生成并应用迁移。 - 把生成的
prisma/migrations目录提交到Git。 - 在Vercel的构建命令里设置:
prisma generate && prisma migrate deploy && next build。
migrate deploy只负责把迁移文件里还没应用到数据库的部分执行一遍,不会重新生成文件,天然适合CI环境。如果你是团队协作,注意提醒每个人在数据库结构变更后都要把迁移文件提交上来。
另外有个很常见的坑:Serverless环境是分布式运行的,Prisma Client初始化要避免每次请求都重新new一个实例。T3 Stack生成的server/db.ts里用了全局缓存模式:
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;这个模板代码不是模板废话,它防止在热更新和Serverless冷启动时创建过多数据库连接导致连接池耗尽。千万不要改成每次用的时候new PrismaClient()。
6.3 我实测中遇到的高频报错与排查思路
最后实际运行阶段有几个报错,几乎每个用T3 Stack的人都会遇上,我整理了一张排查表:
| 报错现象 | 根本原因 | 解决办法 |
|---|---|---|
PrismaClientInitializationError | DATABASE_URL没设置或数据库IP未加入白名单 | 先查.env是否正确,再检查云数据库的网络访问策略 |
Type error: Property session does not exist on type ... | NextAuth类型定义未覆盖自定义字段 | 按4.3节做module augmentation |
| 页面水合失败,HTML不匹配 | Client Component缺少"use client"指令 | 确认交互组件的文件顶部有该指令 |
| tRPC请求返回500,但本地正常 | Serverless平台地域与数据库地域跨网,或请求体超限 | 将数据库实例和Vercel区域调到同一地区,检查JSON大小 |
AUTH_SECRET缺失导致登录态丢失 | 生产环境未设置该变量 | 在Vercel控制台补上,重新部署 |
排查时我的固定顺序是:先看环境变量,再看类型编译,最后抓运行时日志。T3 Stack的错误大多数能从这三个层面里找到根源,因为链路里每一层都有明确的TypeScript类型约束可查。
最后聊几句实际感受。用了这类T3代码结构小半个月,最大的体会是“重构不再可怕”。以前改一个后端字段,前端接口文档、mock数据、联调全要跟着动;现在改成类型后,界面描述和接口逻辑完全同步,改完模型跑一遍编译就知道哪里需要动。而且因为类型穿透,我做会议预约冲突检测这种业务逻辑时,几乎没有写过任何“对字段”的胶水代码。这套技术栈当然不是银弹,开放接口、多端消费的场景它并不擅长,但如果你和我一样做的是中小规模全栈产品,它带来的效率提升是实实在在的。