
t3code这个名字起初是我给一个内部工具项目起的代号后来发现它意外地贴合核心含义T3是TypeScript、Tailwind CSS、tRPC三个T开头技术的组合code就是代码本身。如果用一个词概括这个项目我会说这是一套把“类型安全”贯彻到全栈每一个角落的Web应用实践样板。它能解决的是很多团队从后端到前端反复定义接口、维护文档、联调时来回确认字段的痛点适合所有正在选型全栈框架、或者想把Next.js项目做得更干净的人参考。说实话这些年做前后端分离项目我最大的感受是“联调”两个字听起来简单做起来全是坑。后端改了个字段名前端不知道前端口误把userId拼成user_id接口直接404Swagger文档写得很完整但没人更新最后文档和代码各说各话。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的N1问题小团队上手门槛不低。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 tRPCAPI层的类型安全是怎么做到的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 classNamew-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-applatest 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 createTRPCNextAppRouter({ 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 18T3依赖的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: demoexample.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注册进appRouterexport 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_URLhttp://localhost:3000 NEXTAUTH_SECRETyour-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日志打开遇到类型推导不出的地方第一时间按CtrlShiftP重启TypeScript服务。很多类型问题都是编辑器缓存导致的重启之后往往就消失了。趁手的工具加上严格的规范t3code这套架构才能真正发挥价值。