t3code这个代号,最开始只是我给自己一套全栈代码模板起的仓库名。后来发现,顺着这个名字往下搜,撞进的是T3技术栈那一大片社区讨论——Next.js加TypeScript加Tailwind加tRPC,数据层挂一个Prisma,鉴权再配NextAuth。这个组合在中小型全栈项目里出镜率越来越高,核心卖点就一个:让类型安全从数据库一路贯穿到浏览器。
这篇文章是我从零搭起t3code到上线半年多的完整复盘,包括每个选型背后的理由、关键代码怎么落、以及真正投产时踩过的那些文档里不会写的坑。适合正在调研全栈脚手架的人,也适合被前后端接口类型不一致折磨到想换技术栈的人。整篇讲的不是某个商业产品,是我自己基于T3技术栈沉淀的一套代码实践。如果你想快速跑一个带登录、带数据库、带全栈API的原型,这里的每一步几乎都能直接抄。
1. 我为什么会把t3code当项目代号:技术选型复盘
1.1 被接口文档折磨后的第一个念头
上一份工作做内部管理系统,前后端严格分离:后端同学维护接口文档,前端同学维护Mock数据,我负责中间那层胶水代码。听起来很正规,实际体验是——后端改一个字段类型,文档忘了同步;前端对着旧文档写了三天的页面,联调时发现一半字段对不上;联调阶段被产品催,于是大家开始互相甩锅。最崩溃的一次,一个数组字段从string[]改成object[],全项目十几个调用点,因为没有类型检查,报错在运行时才炸出来。
我当时的结论是:小团队做全栈项目,再走"文档驱动联调"这条路就是自虐。需求明明很简单,真正的成本全耗在"让两个代码库理解同一个业务对象"上。所以当我看到T3技术栈的核心理念——用TypeScript的推断能力把后端类型直接暴露给前端,不需要接口文档,不需要手写类型定义,改一处全链路同步——几乎没犹豫就决定自己试试。
1.2 T3技术栈六件套到各干各的什么活
T3这个叫法来自社区里t3.gg那套主张,封装在create-t3-app脚手架里。我的t3code项目选的是最经典的一组组合,这里把分工讲清楚:
| 组件 | 解决什么问题 | 我理解的角色 |
|---|---|---|
| Next.js | 路由、SSR、API层托管 | 整个应用的骨架 |
| TypeScript | 静态类型、编译期报错 | 贯穿全链路的类型基底 |
| Tailwind CSS | 原子化样式,避免CSS文件失控 | 写UI不换脑子 |
| tRPC | 前后端"函数直接调用",类型端到端安全 | 粘合剂 |
| Prisma | ORM、迁移、数据库访问 | 数据层的守门员 |
| NextAuth | 登录会话、OAuth | 权限边界 |
Tailwind在T3里的定位值得多说一句。很多人觉得它只是"快速写样式",但在这个组合里,它的价值是让UI层不引入单独的CSS架构概念。配合TypeScript,类名也可以被摇树优化和类型检查,页面组件保持纯粹的数据驱动。tRPC和GraphQL都做类型安全,但GraphQL需要写schema、维护resolver和query的映射,tRPC直接让TypeScript的类型推断替你干这个活,心智负担小一个量级。
1.3 不是所有项目都适合穿这身装备
t3code组起来之后,陆续有同事问我能不能直接拿去用。我的回答通常是先泼冷水:这套组合适合的是全栈小团队(一到五个人)、MVP验证、B端内部工具、以及Next.js为主的交付型项目。反过来,如果你们已经有独立后端团队在维护REST或GraphQL服务,前端只是消费方,那tRPC这层基本用不上;如果项目是给iOS和Android同时供数据的移动端业务,也不会选Next.js做服务端;纯展示型官网更没必要上数据库和鉴权。
还有一个容易忽略的条件:团队里所有人必须接受TypeScript。T3的体验建立在类型推断之上,如果谁图省事到处写any,tRPC的优势瞬间归零。我在t3code的README里写的第一条约定就是:禁止在业务代码里出现any,除非能说出三个理由。
2. 用create-t3-app把t3code跑起来:脚手架与目录结构
2.1 初始化命令与交互选项
第一步不用我重新发明轮子,直接用create-t3-app官方脚手架。执行:
npm create t3-app@latest my-t3-app交互过程会问几个选项:是否用TypeScript(这已经是强制的)、是否启用Tailwind CSS、是否启用tRPC、数据库ORM选Prisma还是Drizzle、鉴权选不选NextAuth、import别名用默认的~/*还是改成@/*。我第一次自己搭的时候,看到这么多问题一度犹豫,但后来发现这正是T3的好处——脚手架逼你在写第一行业务代码之前,先把技术边界想清楚。
需要提醒的是,命令执行时要保证Node.js版本够新(我建议至少18以上),npm版本也是新版,不然安装依赖容易有兼容性报错。这一步如果卡住,大概率不是代码问题,去查环境版本。
2.2 生成之后必须想明白的三个问题
脚手架跑完,项目能启动了,但我强烈不建议立刻去写页面。先用半小时想清楚下面三件事:
第一,数据库用什么。create-t3-app默认让你配Prisma,但连接串得你自己给。本地开发我建议直接用PostgreSQL的Docker容器:
docker run --name t3-postgres -e POSTGRES_PASSWORD=devpass -p 5432:5432 -d postgres:16然后.env里写DATABASE_URL="postgresql://postgres:devpass@localhost:5432/t3code?schema=public"。如果只想要零配置体验,SQLite也可以,但生产环境我会劝你换掉。
第二,登录怎么接。NextAuth的Provider配置需要提前准备。我用GitHub登录时,先去GitHub OAuth应用设置里把回调地址填成http://localhost:3000/api/auth/callback/github,本地开发才不会白屏。接三方登录最烦的不是代码,而是这些外部服务的回调配置。
第三,路由模式。新项目无脑选App Router。它和tRPC在Server Component里配合得很好,而且Next.js官方已经把所有新功能都压在App Router上,没必要逆势而为。
2.3 目录结构里哪些文件是真正的主心骨
脚手架生成的结构初看有点密,但真正的核心文件就这几个:
t3code/ ├── prisma/ │ └── schema.prisma # 数据模型,唯一的数据真相 ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts # tRPC的HTTP入口 │ │ └── layout.tsx # SessionProvider和TRPCProvider │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts # 组装所有子router │ │ │ ├── trpc.ts # context、procedure定义 │ │ │ └── routers/ # 各业务模块的接口路由 │ │ ├── auth.ts # NextAuth配置 │ │ └── db.ts # Prisma客户端单例 │ └── trpc/ │ ├── react.tsx # 前端hook和Provider │ └── server.ts # 服务端直接调用caller刚开始我的直觉是"这么多封装太绕了",但用了三个月后理解了这个分层的意义:src/server下面全是只能跑在服务端的代码,src/trpc是给前端消费的客户端桥接层,src/app只负责路由和UI。边界划清楚了,就不会出现服务端代码被浏览器打包进去的蠢事。
src/server/db.ts里有一个细节我建议保留并说清楚:因为Next.js开发模式会热重载,如果不做全局单例缓存,PrismaClient会被反复实例化,导致连接数爆炸。脚手架生成的模板里已经有globalThis缓存方案,别自己手贱改成"简洁"版本,那是踩坑高发区。
3. tRPC路由与类型安全链路:t3code最核心的代码逻辑
3.1 tRPC如何把"调用后端"变成"调用本地函数"
tRPC的原理通俗讲是这样:后端定义一堆procedure(查询用query,写操作用mutation),每个procedure的参数和返回值都由TypeScript自动推断;前端拿到router的类型定义后,在组件里写出来的调用代码,从函数名到参数类型到返回字段,全部被编译器盯着。REST接口要维护文档和类型定义,GraphQL要维护schema,tRPC把这三份工作压缩成了"只写一份后端函数"。
有人担心tRPC是不是只能在Next.js里用,其实它本身框架无关,但当你已经选了Next.js,tRPC能直接在Server Component和Server Action的体系里工作。相比手写fetch封装,它省掉的不是几行代码,而是整整一类"接口契约同步"问题。
3.2 从router定义到前端useQuery的完整链路
我在t3code里最常用的一块业务是文章发布,先把router定义写出来,感受一下类型推断的威力:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ latest: publicProcedure .input(z.object({ limit: z.number().min(1).max(50).default(10) })) .query(async ({ ctx, input }) => { return ctx.db.post.findMany({ take: input.limit, orderBy: { createdAt: "desc" }, select: { id: true, title: true, content: true, createdAt: true }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional() })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });前端组件里调用:
"use client"; import { api } from "~/trpc/react"; const { data: posts, isLoading } = api.post.latest.useQuery({ limit: 10 }); const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.latest.invalidate(); }, });注意create用了protectedProcedure,所以传入的authorId来自当前会话而不是前端表单——这是权限设计上的一个细节,后面会说。两个关键收益:第一,前端写{ limit: 10 }时,如果多传了字段或少传了必填字段,编译期直接报错;第二,后端改了返回结构,前端所有用到这个字段的地方,编译器会像侦探一样把所有调用点挖出来。
3.3 登录态接口:protectedProcedure与中间件的正确姿势
t3code里所有需要登录才能调用的接口,都必须走protectedProcedure,它本质上是一个中间件:
import { TRPCError } from "@trpc/server"; export const protectedProcedure = publicProcedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } }, }); });中间件在进入真正的业务逻辑前检查会话是否存在,不存在直接抛401。这样业务代码里就不需要每个handler都写一遍判断,更不会出现"忘记判断登录态"的低级漏洞。
这里我踩过一个大坑:中间件里拿不到session。排查了半天,最后发现是src/server/auth.ts里NextAuth的配置没返回完整的session对象,tRPC context里自然就是undefined。所以如果遇到"明明登录了但接口一直401",先去查auth配置和context组装,而不是查中间件。另外,中间件抛错时,TRPCError的message不要拼上用户输入,防止把内部信息通过错误详情暴露出去。
4. 数据层选型:t3code里的Prisma定义与迁移
4.1 schema建模时的取舍
Prisma在T3里承担的是"数据库唯一真相"的角色。我第一次建模时犯过一个典型错误:把用户表里能想到的字段全塞进去,结果关联关系写得一团糟。后来学乖了,先只建最少字段,跑通链路再加。t3code里文章模型长这样:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? @unique posts Post[] } model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id]) }两个容易忽略的点:一是@updatedAt字段,Prisma会在每次更新时自动维护,不用自己写时间戳;二是如果用了NextAuth的数据库适配器,还需要补Account、Session、VerificationToken这几张表,否则登录会话写不进数据库。脚手架不会替你想清楚这些关联,你得对着auth适配器的要求手动补。
4.2 迁移命令与团队协作流程
团队协作中,数据库结构变更最容易出乱子。我的流程是:先改schema.prisma,再本地生成迁移文件,然后代码评审时把迁移文件一起提交,最后走CI时在测试环境验证。
npx prisma migrate dev --name add_post这条命令会检测schema和数据库的差异,生成迁移SQL并直接应用。本地没问题后,生产环境用:
npx prisma migrate deploy这里强烈建议不要把prisma db push用在生产库上。db push是直接同步schema,不经过迁移文件,一旦出错,改历史表结构的痕迹全丢了。migrate deploy是按顺序回放迁移,团队每个人执行同一批文件,数据库结构才能保证一致。
4.3 查询输出控制:select与include的实战差别
新手最容易写出的查询是include大法:为了拿文章作者名字,就把整个author对象都查出来。结果前端把不该拿到的邮箱、哈希值全暴露在浏览器网络面板里,既浪费流量又有隐私风险。
我的习惯是查询一律显式select:
return ctx.db.post.findMany({ where: { published: true }, select: { id: true, title: true, createdAt: true, author: { select: { name: true } }, }, });这样做的好处是:第一,数据库返回的数据面最小,接口天然瘦;第二,配合tRPC的类型推断,前端拿到的对象类型就是后端select出来的精确形状,不会出现"文档写有email,实际返回没有"的乌龙。如果你希望"省事"和"安全"兼顾,可以定义DTO工具函数统一控制输出,但项目初期一个select就够用了。
5. 样式、鉴权与部署:t3code从本地到线上的临门一脚
5.1 Tailwind的主题化和类名组合
样式层面我尽量不去碰复杂方案,t3code直接用Tailwind。主题色、字体、间距都写在tailwind.config.ts里,业务组件不散落硬编码颜色:
import type { Config } from "tailwindcss"; const config: Config = { content: ["./src/**/*.{ts,tsx}"], theme: { extend: { colors: { primary: "#2563eb", surface: "#f8fafc", }, }, }, plugins: [], }; export default config;组件里经常需要根据条件切换类名,纯字符串拼接容易出重复类名互相覆盖的问题。我建议在工具函数目录放一个cn:
import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }这个函数能自动处理tailwind类名的冲突,比如同时传了p-2和p-4,它会保留后者而不是两个都渲染。写动态样式统一走cn,类名维护成本会低很多。
5.2 NextAuth与tRPC的会话衔接
登录链路的衔接,很多人卡在"为什么前端能看到用户信息,但tRPC接口拿不到"。原因通常是Provider挂载顺序或context没接对。t3code里的标准做法是两个Provider层层包住应用:
// src/app/layout.tsx import { TRPCReactProvider } from "~/trpc/react"; import { SessionProvider } from "next-auth/react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="zh-CN"> <body> <TRPCReactProvider> <SessionProvider>{children}</SessionProvider> </TRPCReactProvider> </body> </html> ); }服务端这边,createTRPCContext里把会话对象塞进去:
export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session }; };这样同一份session,既能在前端组件里用useSession()读,也能在tRPC的context里用,权限逻辑就统一了。额外提醒一下,NextAuth v4和v5(现在叫Auth.js)的环境变量命名不一样,v4是NEXTAUTH_SECRET和NEXTAUTH_URL,v5是AUTH_SECRET和AUTH_URL,升级时总有人漏改,然后登录状态各种诡异。
5.3 部署环境里最容易被忽略的几个设置
部署到云平台前,先检查三件事。第一,环境变量要全:DATABASE_URL、鉴权相关的SECRET、URL,一个都不能少。第二,数据库连接串要适配部署环境:Serverless平台默认连接数有限,直接暴露PostgreSQL端口很容易被打满,建议用连接池模式(比如连接串里带pgbouncer参数)。第三,执行npm run build的时间点,TypeScript编译错误和Prisma没有generate都会在这里暴露。
我个人遇到过最隐蔽的问题是Next.js的缓存。tRPC查询结果可能被Next.js路由缓存吃掉,导致创建新数据后页面不刷新。解决办法是在相关页面加export const dynamic = "force-dynamic",或者对Server Component用unstable_noStore。这些都写在文档里,但只有实际被缓存坑过一次的人才会记得翻。
6. t3code上线半年后的踩坑记录
6.1 跑通demo和真正养一个项目之间差了七件事
demo能跑起来和项目能长期维护,中间隔着一片雷区。我把半年来真正踩过的坑整理成清单,每一条都是文档不会主动告诉你但早晚会遇到的:
- context依赖顺序:tRPC的context组装如果依赖session,那么session初始化必须先于router执行,否则所有接口都拿不到session。检查顺序比检查代码更快。
- server-only边界:
src/server下所有代码默认只能在服务端运行,如果被客户端组件import了,打包时要么报错要么泄露数据库逻辑。我会在每个服务端模块顶部写import "server-only",从打包层面强制约束。 - 数据库连接池:本地连接串和生产连接串必须区分开。生产环境用连接池,否则并发一上来Prisma就会报连接数超限。
- Next.js路由缓存:创建/更新操作之后,用React Query的
invalidate把对应查询标记失效,同时注意页面级别的缓存策略。 - zod校验别写两遍:前端表单校验和后端tRPC入参校验共享同一份zod schema,不要前端一套后端一套,两套迟早会有偏差。
- 环境变量模板化:初始化项目就建一份
.env.example,把每个变量名和用途写清楚,新成员加入三分钟就能自己配完环境。 - 迁移流程固化:生产数据库绝不手改,所有变更走Prisma迁移文件,变更前先备份。半年来唯一一次差点事故,就是我图省事直接改了生产表字段。
这七条里,最让我记忆深刻的是第一条和第四条。第一次上线时,好几个接口间歇性报401,日志里session是undefined,查了两天才发现是context的调用顺序问题;后来某个列表更新后总不刷新,才意识到Next.js缓存和React Query两套缓存叠加,需要在正确的位置同时刷新。
6.2 组件库、路由约定与模板沉淀
t3code稳定运行后,我开始把可以复用的部分沉淀下来。UI组件库我选了shadcn/ui,它的思路不是给你一个封闭组件库,而是把代码直接铺到项目里,颜色和样式完全跟随Tailwind主题,改起来一点不别扭。配合我之前说的cn函数,组件的类名覆盖非常干净。
路由约定上,我固定了一套雏形:公开页面走/和/post/[id],需要登录的走/dashboard,管理功能走/admin并单独挂middleware.ts做守卫。业务router按模块拆:post、user、billing各自一个文件,root.ts只做组装。这样每次加新功能,思路都是固定的,不是每次重新设计。
另外,我会在每次踩坑后把解决方案写进项目里的docs/troubleshooting.md。这个文档现在已经成了团队真正的"字典"。新人遇到奇怪问题,先翻它,解决率比搜索引擎高很多。
6.3 我的最后一公里经验:先链路后样式,类型安全是复利
最后说一点个人体会。从我决定做t3code,到第一个能登录、能发文章、能展示列表的版本跑通,其实只花了一个周末。真正花时间的是后面半年的调优和踩坑修复。这个过程里我最大的感受是:把一条查询链路从数据库跑到浏览器页面,比把一百个页面堆出来更有价值。因为只要这条链路是类型安全的,后面每加一个页面、每改一个字段,编译器都会提前告诉你哪里会出错。
类型安全的复利体现在哪?体现在我三个月后回改一个model字段时,所有受影响的前端代码都被TypeScript标了红线,不需要靠测试用例去碰运气。体现在新同事入职第一天就能独立给前端加一个调用后端新接口的页面,因为他不需要等接口文档,IDE的自动补全就是文档。还体现在我重构目录结构时,编译器第一时间揪出了所有引用旧路径的模块,而不是上线后由用户来发现。
现在t3code这个名字对我来说,已经不止是代码模板了,它代表一套工作方法:先用技术选型消解沟通成本,再用类型安全换取重构勇气,最后用文档沉淀踩坑经验。如果你也在搭自己的全栈脚手架,别急着堆功能,先把那一条最核心的链路打通打通再考虑一切装饰。