t3code这个名字,起初是我给一个内部工具项目起的代号,后来发现它意外地贴合核心含义:T3是TypeScript、Tailwind CSS、tRPC三个T开头技术的组合,code就是代码本身。如果用一个词概括这个项目,我会说:这是一套把“类型安全”贯彻到全栈每一个角落的Web应用实践样板。它能解决的是很多团队从后端到前端反复定义接口、维护文档、联调时来回确认字段的痛点,适合所有正在选型全栈框架、或者想把Next.js项目做得更干净的人参考。
说实话,这些年做前后端分离项目,我最大的感受是“联调”两个字听起来简单,做起来全是坑。后端改了个字段名,前端不知道;前端口误把userId拼成user_id,接口直接404;Swagger文档写得很完整,但没人更新,最后文档和代码各说各话。t3code这个项目想做的,就是从技术栈层面把这些沟通成本直接砍掉。
1. 设计思路:t3code为什么选择T3 Stack这套组合
1.1 T3 Stack到底解决了什么问题
先说清楚T3 Stack是哪几个东西的组合:TypeScript、Tailwind CSS、tRPC,再加上Next.js、Prisma、NextAuth这套打底。这套方案的核心思想不是“加更多框架”,而是让类型检查成为前后端之间的契约。
传统项目里,前后端的数据交互靠什么保证?靠文档。后端写好接口文档,前端照着文档写请求代码,然后联调、对着字段吵架。T3的思路反过来:后端用TypeScript定义的输入输出类型,前端直接通过tRPC“继承”过来,编译器帮你兜底。字段改错了本地就报错,不用等到运行时。
打个比方,以前前后端协作就像两个人在电话里报菜名,一个说“红烧茄子”,另一个听成“红烧鸡翅”,菜上错了还得扯半天。T3的做法是直接把菜单打印出来贴在墙上,两个人看着同一份菜单,谁也错不了。
1.2 为什么不用GraphQL或传统REST方案
我在做t3code之前,认真对比过GraphQL和REST。GraphQL确实解决了“过度获取”和“请求多轮”的问题,但它有个隐形成本:schema要维护、要写resolver、要处理data loader的N+1问题,小团队上手门槛不低。REST就更直接了,它本身不携带类型信息,前后端始终要靠文档和人肉同步。
t3code选tRPC的核心原因是:零Schema定义,纯TypeScript类型推导。服务端写一个router,客户端调一个函数,编译器自动推导出参数和返回值类型。没有代码生成步骤,没有额外配置文件,类型就摆在那里。
下表是我做技术选型时的对比记录:
| 方案 | 类型安全 | 学习成本 | 运行时开销 | 生态成熟度 |
|---|---|---|---|---|
| REST + OpenAPI | 中(依赖代码生成) | 低 | 低 | 非常高 |
| GraphQL | 高(依赖Schema) | 中高 | 中(需解析GraphQL) | 高 |
| tRPC | 高(纯TS推导) | 中(需理解TS泛型) | 极低(本质是RPC) | 中等偏上 |
从我实际使用的感受看,tRPC的上手曲线比GraphQL平缓很多,一旦习惯“直接调用函数”的写法,再回去写REST会觉得很折磨。
1.3 这套技术栈适合什么场景
t3code整体方案最适合全栈型应用:管理后台、内部工具、SaaS产品、内容管理系统。这类场景的特点是业务逻辑集中在服务端,前端主要做交互展示,数据模型相对结构化,天然适合类型贯穿。
如果你要做纯静态营销页、或面向海量匿名用户的开放API,那T3 Stack的优势会打折扣。比如做一个完全公开的API平台,你需要的是开发者文档、版本管理、限流策略,这时传统REST或GraphQL可能更合适。我的建议是:先想清楚你的产品形态,再决定上不上这套组合。
2. 核心细节解析:t3code背后的五个关键技术点
2.1 tRPC:API层的类型安全是怎么做到的
tRPC的原理其实不玄乎:服务端定义一个router对象,包含各种procedure(查询或变更操作),客户端引入这个router的类型定义,TypeScript就能推导出每个procedure的参数和返回值。
举个例子,服务端这样定义一个获取用户信息的接口:
// server/api/routers/user.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const userRouter = createTRPCRouter({ getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.user.findUnique({ where: { id: input.id }, select: { id: name: true, email: true, avatarUrl: true }, }); }), });客户端调用时,tRPC会自动把返回值的类型带过来:
// app/user/page.tsx import { api } from "~/utils/api"; const UserPage = ({ id }: { id: string }) => { // result.data 的类型自动推导为 User & { name: string; ... } | null const { data } = api.user.getById.useQuery({ id }); if (!data) return <div>加载中...</div>; return ( <div> <h1>{data.name}</h1> <p>{data.email}</p> </div> ); };这里最妙的一点是:后端select了哪些字段,前端的类型就只会出现哪些字段。你无法拿到未返回的字段,也无需担心undefined字段。这种约束让我在t3code开发过程中省下了大量“字段不存在”导致的低级bug。
2.2 Next.js:用什么模式组织页面
t3code默认使用Next.js作为应用框架。我建议直接用App Router模式,而不是老的Pages Router。App Router提供了Server Component、Nested Layout、Loading UI这些机制,配合React 18的并发特性,对全栈应用的组织更清晰。
有一个容易忽略的点:凡是涉及用户登录态的页面,尽量用Server Component加载初始数据,把tRPC调用放在Server端执行。这样既省了客户端请求,又能避免“首屏先空白再加载”的体验问题。等用户需要交互时再用Client Component配合tRPC调用。
这里补充一个tRPC和Server Component配合的技巧:在Server Component里,直接用createCaller创建服务端调用实例,不走HTTP,没有网络开销。
// app/dashboard/page.tsx import { createCaller } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; export default async function DashboardPage() { const ctx = await createTRPCContext({ headers: await headers() }); const caller = createCaller(ctx); const stats = await caller.dashboard.getStats(); return <StatCard data={stats} />; }这样前端无需二次请求,首屏直接拿到数据,速度体感极佳。
2.3 Prisma:数据库层的类型定义与迁移
数据库层我用Prisma,原因简单:schema即类型。在schema.prisma里定义好模型,运行prisma migrate后,客户端代码会自动生成对应的TypeScript类型,这些类型再通过tRPC传到前端,三层打通。
实践时应注意几个细节:
- 默认值设置:数据库字段的默认值尽量交给Prisma,比如
createdAt用@default(now()),不要在前端传。 - 软删除设计:用
deletedAt DateTime?字段做软删除,避免物理删除导致关联数据丢失。 - 索引规划:根据查询条件提前设计复合索引,不然数据量上来后查询会拖垮接口。
我在t3code里设计过一个任务系统的数据模型,简化版如下:
model User { id String @id @default(cuid()) name String? email String @unique password String? projects Project[] tasks Task[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Project { id String @id @default(cuid()) name String ownerId String owner User @relation(fields: [ownerId], references: [id]) tasks Task[] createdAt DateTime @default(now()) } model Task { id String @id @default(cuid()) title String done Boolean @default(false) projectId String project Project @relation(fields: [projectId], references: [id]) assigneeId String? assignee User? @relation(fields: [assigneeId], references: [id]) createdAt DateTime @default(now()) }2.4 Tailwind CSS:样式工程化而非“野蛮工具类”
Tailwind常被误解为一堆原子类堆砌,但t3code里的使用方式不是这样。我把设计令牌(设计变量)统一配置在tailwind.config.ts里,比如颜色、间距、圆角、阴影,团队写页面时只能从令牌里取值,不会出现三个人用三种蓝色的问题。
另一个重要实践是组件化。我用cn工具函数结合clsx和tailwind-merge,让组件支持类名合并:
// lib/cn.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }这样按钮组件可以这样用:
<Button className="w-full md:w-auto">提交</Button>外部传入的类名会智能覆盖默认值,不会再出现两个宽度类互相打架的情况。
2.5 认证授权:NextAuth与受保护路由
认证我选用NextAuth.js,配合Credentials Provider或者OAuth都是顺手的事。t3code里涉及用户登录态的地方,我用tRPC的protectedProcedure统一做保护:未登录直接抛错,前端跳到登录页。
// server/api/trpc.ts export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这里有个细节:middleware里返回的ctx会覆盖原上下文,所以业务router拿到的ctx.session.user一定是登录用户,不会误判。权限相关的逻辑都集中在这一层,就不会在业务代码里到处写“是不是管理员”的判空。
3. 实操记录:从初始化t3code到上线的完整流程
3.1 初始化项目:create-t3-app交互式配置
t3code项目开始时,直接用官方脚手架创建,命令很简单:
npx create-t3-app@latest t3code这个命令会问你几个选项:是否包含NextAuth、Prisma、Tailwind、tRPC。我的建议是全部勾选,哪怕是暂时用不到。原因很现实:后面加这些配置比自己手动接入麻烦得多,脚手架生成的是经过测试的默认集成,比自己东拼西凑稳得多。
初始化的目录结构大概是这样的:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── api/ │ ├── components/ │ ├── pages/ │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── trpc.ts │ │ │ └── routers/ │ │ └── auth.ts │ ├── styles/ │ ├── types/ │ └── utils/ │ └── api.ts ├── .env ├── next.config.mjs ├── tailwind.config.ts └── tsconfig.json注意看utils/api.ts这个文件,它是前后端类型贯穿的“桥”。脚手架会自动生成:
// utils/api.ts import { createTRPCNext } from "@trpc/next"; import { httpBatchLink } from "@trpc/react-query"; import type { AppRouter } from "~/server/api/root"; export const api = createTRPCNext<AppRouter>({ links: [ httpBatchLink({ url: "/api/trpc", }), ], });这里的关键是AppRouter类型,它在构建时被引用,浏览器端的代码因此能“知道”服务端有哪些查询和变更。
3.2 数据库初始化与设计任务模型
初始化完项目后,第一件事是在schema.prisma里补上业务模型。我以任务管理为例,写清楚User、Project、Task的关系后,运行迁移命令:
npx prisma migrate dev --name init这个命令会生成SQL迁移文件并执行,同时在node_modules/.prisma/client里生成最新的Client代码。注意:改完schema后,务必重启开发服务器,否则Prisma Client不会加载新类型,写代码时会发现类型还是旧的。
还有一个环境问题:如果你用SQLite开发、PostgreSQL生产,迁移语句会有些许差异,建议直接在生产环境的数据库类型上开发,避免上线前出现迁移冲突。
3.3 实现tRPC路由与前端调用
业务router的实现,我以“创建任务”为例:
// server/api/routers/task.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const taskRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1, "标题不能为空"), projectId: z.string(), assigneeId: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.task.create({ data: { title: input.title, projectId: input.projectId, assigneeId: input.assigneeId, }, }); }), });前端调用时只需要这样写:
const createTask = api.task.create.useMutation({ onSuccess: () => { utils.task.getByProject.invalidate(); }, }); createTask.mutate({ title: "写周报", projectId: "cly123" });注意onSuccess里的invalidate(),它会自动重新拉取该查询的数据,不用手工写刷新逻辑。这是tRPC配合React Query最丝滑的体验。
3.4 部署上线:Vercel与Naval环境变量
t3code部署我用的是Vercel,简单且免费。部署前需要做的事:
- 在
.env里配置好DATABASE_URL,线上设置为生产数据库地址。 - 配置
NEXTAUTH_SECRET,用openssl rand -base64 32生成一个随机密钥。 - NextAuth的登录回调地址要记得配置
NEXTAUTH_URL,本地开发如果是http://localhost:3000,线上就是你的域名。
Vercel构建时,默认环境是Node 18+,T3依赖的React 18、Prisma 5都兼容。有个坑必须提醒:如果你的数据库在防火墙后,需要把Vercel的出口IP加入白名单,不然部署后接口全都报连接超时。
另一个容易踩的坑是Prisma Client在服务器函数里的生成时机。部署前几天我遇到过一个报错,说是“Prisma Client could not be generated”,最后发现是构建脚本里少了prisma generate。在package.json的postinstall脚本补上一行:
{ "scripts": { "postinstall": "prisma generate" } }这个坑网上讨论不少,提前写进脚本能省去很多麻烦。
3.5 数据初始化与预置种子数据
业务上线前,我习惯写一套种子数据脚本,方便本地开发和演示。用Prisma可以直接写一个prisma/seed.ts:
// prisma/seed.ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const user = await prisma.user.create({ data: { email: "demo@example.com", name: "演示用户", projects: { create: [{ name: "示例项目" }], }, }, }); console.log(`Created user ${user.id}`); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect());运行npx tsx prisma/seed.ts即可。种子数据的价值是让团队新成员clone项目后立刻有数据可看,不用先手动造数据。
4. 常见问题与排查技巧实录
4.1 tRPC在前端报类型不匹配
t3code开发中最常见的问题是:修改了后端router的输入输出后,前端还是旧类型。排查顺序是:
- 确认开发服务器已重启(tRPC类型是编译期推导,不会热更新)。
- 确认
tsconfig.json里没有把@trpc/server和@trpc/client版本混在一起。 - 在项目根目录运行
npx tsc --noEmit,看具体报错位置。
有一次我自己踩坑,是因为在root.ts里没有把新router注册进appRouter:
export const appRouter = createTRPCRouter({ user: userRouter, task: taskRouter, // 忘了加这个 });结果前端访问api.task.create直接报“找不到属性”。记住:新增router后必须注册到根router,这是一个不起眼但影响全局的细节。
4.2 Prisma客户端与本机时间不一致
还有一次遇到诡异的问题:创建的数据在数据库里看时间不对,早了好几个小时。排查后确认是Prisma默认会用UTC存储时间,而前端展示时用的是本地时区。
解决办法是在前端渲染时间时统一转换为本地时区:
function formatDate(date: Date | string) { const d = new Date(date); return d.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }); }如果项目里涉及多个时区,我更推荐用dayjs配合utc插件统一处理,避免每个页面的时间格式不一致。
4.3 NextAuth回调地址报错
在本地开发时,如果登录后跳转到http://localhost:3000/api/auth/callback然后报错“Invalid callback URL”,基本是NEXTAUTH_URL没配置,或配置的地址和当前访问地址不一致。
解决办法是在.env里显式设置:
NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET=your-random-secret如果部署到Vercel,记得在Vercel的环境变量里也同步配置这两项。有些朋友只配了本地的.env,部署后登录功能就挂,原因就在这里。
4.4 React Query缓存穿透问题
tRPC默认使用React Query管理客户端缓存。有一个常见陷阱:某个查询的参数很多,产生大量缓存条目,内存占用飙升。对t3code这种中后台应用来说,通常不会到达瓶颈,但如果你做的是数据密集型页面,记得给查询设置:
api.task.getByProject.useQuery({ projectId }, { staleTime: 5 * 60 * 1000, // 5分钟内不强刷 gcTime: 30 * 60 * 1000, // 30分钟内回收缓存 });staleTime控制数据多久后过期,gcTime控制内存中缓存的生命周期。合理设置这两个值,能把性能和实时性校准到合适区间。
4.5 目录权限导致的Prisma生成失败
在企业版Windows环境或容器里,偶尔会遇到Prisma Client无法生成的权限问题。此时先在项目目录里手动运行:
npx prisma generate如果还是失败,检查node_modules目录是否有写权限。一个偏门但有效的办法是删掉node_modules和package-lock.json后重新安装依赖。这个现象不多见,但一旦遇到排查起来很头疼,浪费了我一下午。
5. 基于t3code还能扩展的方向
5.1 实时协作与WebSocket接入
t3code目前的服务端是请求-响应模式,如果要加实时协作(在线评论、共同编辑),就需要接入WebSocket。tRPC原生支持subscription,配合Prisma的watch机制可以做简易版实时同步。
订阅端:
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const liveRouter = createTRPCRouter({ onTaskUpdate: publicProcedure.subscription(async function* ({ ctx }) { while (true) { const task = await waitForUpdate(); // 等待变更事件 yield task; await delay(1000); } }), });实际生产我建议直接用WebSocket网关(如基于Socket.io的独立服务),tRPC的subscription更适合原型验证,在大流量生产场景还需要细致的重连与事务处理设计。
5.2 多租户与权限边界设计
如果将t3code做成SaaS,需要为不同租户隔离数据。我推荐的做法是:在User表增加tenantId,把租户ID挂到ctx中,在Prisma的where条件里强制带上tenantId,形成数据隔离的默认安全边界。
一个简单的中间件:
export const tenantProcedure = protectedProcedure.use(async ({ ctx, next }) => { const tenantId = ctx.session.user.tenantId; if (!tenantId) throw new TRPCError({ code: "FORBIDDEN" }); return next({ ctx: { tenantId } }); });这样业务代码里写查询时,天然能拿到租户上下文,不会漏掉隔离条件。
5.3 团队协作中的代码规范与约束
最后聊聊团队协作。t3code这样的架构,类型贯穿能力很强,但代码规范必须跟上。我的经验是:
- 所有业务方法必须用tRPC router集中管理,不允许在页面里直接写fetch调用第三方API。
- zod input必须写在router内部,不允许在页面里自建校验逻辑。
- 查询返回的数据尽量用select限定字段,避免整个对象被序列化到客户端。
- 提交代码前跑
eslint和prisma generate,用CI检查类型错误。
这些看起来都是“小事”,但考虑到t3code的核心优势就是类型安全,一旦有人绕过这套规范,类型链路就会断掉一个口子,后面所有含这种口子的代码都会失去编译期保护。
结语:一点真实的心得
t3code从立项到上线,我最大的收获不是学会某个框架,而是理解了“让编译器成为你的同事”这句话。以前我写代码需要记很多“约定”:这个接口返回什么字段、那个错误码什么意思、session里存了哪些信息。现在这些约定都变成了类型定义,写错的地方编译器会直接告诉你,而不是等线上用户踩雷。
如果你准备用T3 Stack做一个全栈项目,我最想强调的一点是:不要试图同时学所有技术,先跑通一个最小闭环。用脚手架创建一个项目,实现一个“登录-创建数据-展示数据”的流程,观察类型如何贯穿三层,再逐步增加复杂功能。走过一遍这个流程,你对这套架构的理解会远超看十篇教程。
最后再分享一个小技巧:开发时打开package.json中的dev脚本,把tsx watch日志打开,遇到类型推导不出的地方,第一时间按Ctrl+Shift+P重启TypeScript服务。很多类型问题都是编辑器缓存导致的,重启之后往往就消失了。趁手的工具加上严格的规范,t3code这套架构才能真正发挥价值。