
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-applatest t3code执行后进入交互式界面这里有几个关键选项值得认真选选项建议说明Use TypeScript是不用选No这个栈的灵魂就是TSUse Tailwind CSS是如果需要自己控制样式体系就选组件库需要它Use tRPC是本项目的核心通信方式Use NextAuth按需需要登录选Yes纯公开站可以NoUse 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 Clientserver/auth.ts里初始化NextAuthserver/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_URLPrisma连接数据库的字符串。我这次用的是PostgreSQL云数据库格式大致是postgresql://用户名:密码主机:端口/库名。AUTH_SECRETNextAuth签名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 QueryReact 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 inferRouterOutputsAppRouter[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}-500Tailwind是扫描不到的因为JIT只会扫描字面量完整的类名。解决方式是多写几个分支或者用CSS变量。另外T3 Stack默认的暗色模式策略是class需要你在layout.tsx里挂一个处理dark类的脚本。我没用全局切换而是让每个内部工具页面按业务场景自己决定是否用暗色。5.2 shadcn/ui这类组件库和T3的配合Tailwind本身只是原子类没有按钮、表单、对话框这些复合组件。实际开发还是需要组件库。我这次选了shadcn/ui原因有三个它不是运行时组件库而是“复制源码到你项目中”这样组件CSS完全可控。它和Tailwind天然共生主题切换通过CSS变量实现。它是Headless UI那一层组件逻辑和样式彻底分开能自由覆盖。用法很简单npx shadcnlatest init npx shadcnlatest 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 ComponentRSC很多人会问数据到底用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的人都会遇上我整理了一张排查表报错现象根本原因解决办法PrismaClientInitializationErrorDATABASE_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数据、联调全要跟着动现在改成类型后界面描述和接口逻辑完全同步改完模型跑一遍编译就知道哪里需要动。而且因为类型穿透我做会议预约冲突检测这种业务逻辑时几乎没有写过任何“对字段”的胶水代码。这套技术栈当然不是银弹开放接口、多端消费的场景它并不擅长但如果你和我一样做的是中小规模全栈产品它带来的效率提升是实实在在的。