
1. t3code的选型逻辑为什么我把筹码押在了T3技术栈上年初的时候朋友找我帮忙搭一个小型团队管理系统需求倒不复杂——成员管理、任务跟踪、数据看板。真正难受的是他们那套老系统前端Vue2、后端Java、接口文档靠口头约定改一个字段能拉扯半小时。我那时候就在想能不能用一个统一的技术标准把这套链路整个收编了。后来我花了两周时间基于T3技术栈从头搭了一套完整的全栈样板工程名字就叫t3code。先解释一下t3是什么。T3 Stack这个概念来自create-t3-app核心是三个字母TypeScript、TailwindCSS、tRPC加上Next.js作为框架底座。它不是一个官方框架而是一套社区认可的、强调端到端类型安全的技术组合。最早是Theo Broussard在社区里推起来的主张用最小依赖、最少样板代码把前后端类型安全做到极致。t3code这个名字没有官方出处它是我给自己这套工程起的代号——T3标准下的完整可复用代码库你可以把“t3”理解为技术底座“code”理解为业务代码和工程实践合在一起就是一套能吃透、能复制、能上生产的全栈模板。为什么选这套组合而不是其他方案我当时的对比维度有几个类型安全覆盖的深度、上手成本、生态成熟度、以及部署维护的复杂程度。如果你用传统方案比如Next.js配REST API、再配一坨手写的类型定义前后端类型不联动已经是常态——后端改了字段前端编译期毫无感知等联调跑起来才发现崩了。如果上GraphQL类型联动是解决了但引入Schema、Resovler、Codegen一整套额外工具链,对于一个小团队来说成本不低。而tRPC这套思路更朴素你直接在服务端定义函数客户端像调用本地函数一样调用它类型推断从服务端函数签名直接流向客户端中间没有任何手写契约。这是我选T3最核心的理由——类型安全不是靠规范约束出来的而是靠架构设计出来的。除了技术合理性我还考虑了工程可持续性。Next.js作为React全栈框架Vercel商业化运营多年文档、生态、社区支撑都足够稳定TypeScript做静态类型、Prisma做ORM、Tailwind做样式——每一层都有明确的替代方案和不低的迁移成本但它们组合在一起就是一个非常顺滑的开发体验。t3code这套代码跑起来之后我从零开始实现业务模块到部署上线整体体感是“路径依赖非常强”——需求一进来我就能立刻猜到数据模型该长什么样、代码文件该落在哪个目录、接口该在哪个router里声明。这种确定性对日常开发效率的提升是实打实的。需要提醒的是t3code并不追求什么高深复杂的技术创新它更像一个“把正确技术在一套工程里落到位”的样板。适合的读者有两类一是想快速搭一套全栈应用、又不想被前后端接口联调拖慢的独立开发者二是团队打算统一技术栈、想有参考模板的前端或全栈工程师。后面我会把t3code从初始化、数据建模、业务实现、到部署上线的完整过程和踩坑记录拆开来讲。2. 工程初始化create-t3-app之后我做了什么调整2.1 初始化命令和模块选择t3code的第一步是用create-t3-app建的壳。命令很直接npm create t3-applatest t3code执行之后交互式界面会问你选哪些模块。我的选择是Next.js、TypeScript、Prisma、tRPC、TailwindCSS、ESLint。没勾NextAuth——原因是t3code这套样板在多数场景下只需要一个简单的会话身份标识NextAuth对于内部小工具来说配置负担偏重我后面会单独用一套基于JWT或Cookie的轻量鉴权方案。也没勾Docker因为初期部署目标是Vercel或单台云主机容器化不是刚需等项目确实要迁K8s了再补也不迟。这里有一个容易忽略的细节create-t3-app初始化出来的项目已经有非常完整的目录结构、依赖版本和ESLint配置但它默认装了ts-rest没有装任何数据请求库React Query是后来以tRPC依赖形式带进来的。如果你平时习惯在项目里push一堆自己的工具函数建议先别——保持t3code初期“只用官方模板给的”这个原则跑顺了再逐步自定义。2.2 环境变量的类型校验初始化之后模板会在.env.example里给你列出所有环境变量比如DATABASE_URL、NEXTAUTH_SECRET如果选了NextAuth。t3code做了一个我认为最值得保留的做法用zod校验环境变量。模板里有个src/env.mjs文件它会读取process.env、按你定义好的zod schema做解析和类型推断让所有环境变量在使用时都是类型安全且经过校验的。import { z } from zod; const envSchema z.object({ DATABASE_URL: z.string().min(1), NODE_ENV: z.enum([development, test, production]), }); const env envSchema.parse(process.env); export default env;我当时改动了几处一是把NODE_ENV的枚举补上了test方便跑单元测试二是新增了一些业务侧环境变量例如OSS的Bucket名称、Redis连接串等。这个文件是t3code的“环境变量总闸门”后续所有新增变量必须先在这里登记否则编译直接fail——团队的同事一开始不习惯这种“强约束”但习惯之后他们自己都承认不再出现那种生产环境少配了一个key导致半夜告警的事故了。2.3 目录结构的落地约定create-t3-app的模板目录已经很清晰t3code在这之上做了一套更偏业务的分层src/server/api/routers/所有tRPC路由按领域拆分比如user.ts、article.ts、dashboard.tssrc/server/db/Prisma client实例和数据库相关工具src/server/auth.ts自定义的鉴权上下文逻辑src/components/页面级的UI组件按业务模块建子目录src/pages/或src/app/Next.js路由层取决于用Pages Router还是App Router模板默认已切到App Routersrc/trpc/服务端和客户端的tRPC初始化逻辑。我给团队定的“铁律”是业务卡片只能出现在两个位置——src/server/api/routers/后端逻辑和src/components/前端渲染其他位置的代码尽量只承接数据流转和复用能力。这样做的原因是当需求需要改动时你从UI所在目录能倒推出对应的router代码检索路径非常短。后期t3code增加了几个模块同事说最爽的体验就是“改一个字段能从数据库到页面全程用IDE跳转追完不需要全局搜索”。3. 数据库建模与迁移Prisma Schema设计的真实链路3.1 数据模型规划t3code里我设计了一套贴近“团队任务管理”场景的数据模型。以核心的两张表为例User用户id、email、name、avatarUrl、role、createdAt、updatedAtTask任务id、title、description、status枚举pending/doing/done、assigneeId关联User、creatorId、priority、dueDate、createdAt、updatedAt。在写Prisma Schema之前我用表格把这套关系梳理了一遍关系类型模型说明一对多User 1-N Task一个用户可以负责多个任务一对多User 1-N Taskcreator创建者和负责人可能是不同人多对多Task - Tag任务打多个标签标签可复用Schema里的任务关联用了relation(Assignee)和relation(Creator)两种命名关系来区分用户与任务之间的两个语义维度。model User { id String id default(cuid()) email String unique name String? tasks Task[] relation(Assignee) createdTasks Task[] relation(Creator) } model Task { id String id default(cuid()) title String description String? status Status default(PENDING) assignee User? relation(Assignee, fields: [assigneeId], references: [id]) assigneeId String? creator User relation(Creator, fields: [creatorId], references: [id]) creatorId String dueDate DateTime? createdAt DateTime default(now()) updatedAt DateTime updatedAt }这个设计看似简单但里面有个值得注意的点assigneeId是可选creatorId必填。因为任务可以先被创建出来、暂时不指派而创建者必须存在。字段可空性和业务语义是一一对应的不要为了省事全设成必填或全设成可选那会导致业务边界模糊。3.2 索引与查询路径的对齐很多人写Schema容易漏掉索引。t3code里我明确要求每个relation的外键字段、以及高频查询的过滤字段都必须建索引。Prisma Schema中默认会为unique字段建索引但普通字段不会。比如Task的assigneeId如果没有索引那么按负责人拉任务列表时数据库就需要全表扫描。数据量小的时候没啥感觉到了几万条记录就明显变慢。我在Schema里补了几个复合索引model Task { // ... index([assigneeId, status]) index([creatorId, createdAt]) }index([assigneeId, status])直接覆盖了最典型的查询某个用户当前负责的各种状态任务列表。复合索引的字段顺序很重要——左侧的字段用于等值过滤右侧的用于范围或排序如果顺序反了这个索引基本就废了。为这事我还吃过亏早期给Task建过index([status, assigneeId])结果查WHERE assigneeId ? AND status ?时索引失效后来排查执行计划才发现顺序问题。3.3 迁移与种子数据的实操细节Schema写完之后跑迁移npx prisma migrate dev --name init这个命令会做两件事生成一个新的SQL迁移文件并重新生成Prisma Client。本地开发阶段建议每次调整Schema后都跑一次让迁移历史保持线性的可追溯状态。注意一个常见坑如果你在团队协作中别人改了Schema而你本地没拉迁移文件就启动服务Prisma Client版本和数据库状态不匹配会直接报错这时先git pull再跑npx prisma migrate dev即可。种子数据我用了一个独立的TS脚本配合tsx运行// prisma/seed.ts import { PrismaClient } from prisma/client; const prisma new PrismaClient(); async function main() { const user await prisma.user.create({ data: { email: devt3code.dev, name: T3 Developer, }, }); await prisma.task.create({ data: { title: First task, status: PENDING, creatorId: user.id, }, }); } main().finally(() prisma.$disconnect());种子脚本的价值不只是造几条假数据而是给前端的列表、详情、状态切换等交互提供真实的联调素材。我建议种子数据尽量贴近真实业务——字段值该长则长、该短则短比如任务描述里可以故意放几段超长文本顺便验证UI的截断与换行表现。跑种子的命令npx prisma db seed数据库里立刻有一组可以直接展示的数据省去一个个手填的时间。4. tRPC路由设计与边界校验类型安全是如何穿透全栈的4.1 路由拆分与命名规范tRPC在t3code里的组织方式是整套工程的心脏。模板默认在./src/server/api/routers/下有post.ts和root.ts两个示例文件我把它们清空后按自己的业务域建了三个routeruserRouter、taskRouter、dashboardRouter。业务域的拆分标准不是“数据库表越多越好”而是“调用方视角的一致性”。比如dashboard虽然底层要聚合任务、成员、统计三类数据但我仍然单独建了一个router——因为它面向的是“看板页面”这一套UI需求聚合逻辑放在一个入口里前端调用更加直观类型返回也更加可控。每个router内部我定义了明确的procedureexport const taskRouter createTRPCRouter({ list: protectedProcedure .input(z.object({ userId: z.string().optional(), status: z.enum([PENDING, DOING, DONE]).optional() })) .query(async ({ ctx, input }) { return ctx.db.task.findMany({ where: { assigneeId: input.userId, status: input.status, }, orderBy: { createdAt: desc }, include: { assignee: true, creator: true }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), description: z.string().max(1000).optional(), assigneeId: z.string().optional(), dueDate: z.date().optional(), })) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { title: input.title, description: input.description, assigneeId: input.assigneeId, dueDate: input.dueDate, creatorId: ctx.session.userId, }, }); }), });对于routers的命名我自己有一条“一眼看懂”的规则router文件名用单数名词且与数据库模型名保持一致taskRouter、userRouter下层procedure用动词或工业界习惯的动宾结构list、create、update、delete、stats。debug的时候看到路由名就能猜到作用减少沟通成本。4.2 Zod输入校验服务端边界的第一道防线tRPC的输入校验默认接zod这也是t3code类型穿透的关键一环。在procedure上挂一个input定义类型会自动推断到前端调用处的参数类型。比如task.create的入参类型是{title: string; description?: string; assigneeId?: string; dueDate?: Date}前端调用时如果传入title: 123TS编译期直接报错不经任何运行时判断。但这里有一个zod用得不好就容易踩的坑日期类型。如果你用z.date()校验dueDate前端传JSON序列化时经常变成ISO字符串运行时zod会直接拒绝。实际项目中更稳妥的做法是定义成z.iso.datetime()或先z.string()再在service层转换。t3code里我用的是z.iso.datetime().pipe(z.coerce.date())把ISO字符串解析成Date对象兼顾了传输格式和存取格式的一致性。还有一点值得展开校验不只是判空而是要“把约束写进代码”。t3code中我给每个procedure的输入校验都定义了min/max长度比如title最多100字、description最多1000字。这不仅仅是为了用户体验也是为了数据库层面的安全——Postgres虽然对text类型没有限制但超长内容会导致存储膨胀和索引失效。校验写在入口处比在下层每个查询里做防御要省事得多。4.3 受保护的Procedure与鉴权上下文t3code里所有需要登录态的procedure都用了protectedProcedure。它在模板的src/server/api/trpc.ts里定义逻辑很直白请求进来先走一个中间件检查ctx.session是否存在用户ID不存在则抛UNAUTHORIZED错误存在就放行。export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user?.id) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session }, }); });这里的ctx是怎么注入的在createContext里t3code使用Next.js的getServerSession或自定义的cookie解析读取请求中的会话信息。我之前自己写过一套基于JWT的方案核心是在登录接口里签发token前端每次请求带上cookie服务端解析后把userId塞进session。因为t3code内部项目不依赖OAuth的社交账号体系这套轻量方案比引入NextAuth更直接。鉴权上下文还承担了一个职责把“会话身份”变成“业务查询的隐式过滤条件”。例如task的list接口理论上传入任意userId就能看别人的任务但实际中绝大多数场景都是“查看当前登录用户的任务”。t3code里如果入参没传userId默认会用ctx.session.userId作为过滤条件这样前端调接口少传一个参数又不会因为疏漏泄露全量数据。这是控制访问范围的一个低成本手段。4.4 客户端调用链路上的真实调试体验t3code前端调用的模式相比传统REST有一个很大的体验差不需要维护任何API URL、请求方法、参数拼接。React组件里直接用api.task.list.useQuery({status: PENDING})类型、返回值、loading状态全部自动推导。这个“无URL”的开发体验我用了一个多月之后再回去写REST接口竟然有点不习惯。但也不是没有坑。最常见的调试问题有两个第一服务端抛错后前端的错误信息非常笼统。tRPC默认会把error序列化成TRPCClientError如果你在服务端抛了自定义message前端只能拿到一个message字段堆栈和状态码容易丢。建议在中间件里做一层错误格式化把code、httpStatus、path都带上function errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, stack: process.env.NODE_ENV development ? error.stack : undefined, }, }; }第二React Query的缓存策略。useQuery默认是stale-while-revalidate即数据不是实时刷新而是后台更新。如果你在任务创建成功之后立刻查询列表很可能拿到的是旧缓存。正确做法是使用utils.invalidate()手动让列表失效const utils api.useUtils(); const createTask api.task.create.useMutation({ onSuccess: async () { await utils.task.list.invalidate(); }, });这两个小坑是t3code实际开发中前三天必踩的项目之一提前知道能省半天时间。5. 部署与上线前的最后检查从构建优化到生产配置5.1 部署平台的选择t3code的目标部署环境取决于项目规模。如果是给团队内部用的小工具我建议优先Vercel——因为它和Next.js天然契合推送代码即部署环境变量在面板上配置即可。如果项目涉及内网数据库、或数据不能出网那就迁到一台云主机上自托管跑next start或Docker化部署。两种方式的取舍我列一个简单的对比维度Vercel自托管云主机部署速度秒级分钟级需构建重启静态资源CDN自带需自己接CDN数据库连通性需公网访问可走内网运维成本低几乎为零高需自己守护进程、日志、备份适用场景对外demo、SaaS企业内网、私有数据t3code最终在Vercel上跑了两个星期后来因为数据库在内网迁回了云主机。迁移过程很简单——把生产环境变量配到.env.production然后npx prisma migrate deploy注意生产环境不能用dev命令它会在数据库变更时要求交互确认再构建启动。5.2 生产环境变量与Prisma迁移的正确姿势这里有一个很多人踩过的坑直接在云主机上跑npx prisma migrate dev。这个命令是面向开发环境的会在你不知情时重置数据如果有冲突千万不要在生产环境用。正确做法是本地生成迁移文件并提交到Git仓库部署时在生产目录执行npx prisma migrate deploy它只应用尚未执行的迁移文件不触碰已有数据启动服务前确认Prisma Client版本和迁移历史里的版本一致。还有一点生产环境的DATABASE_URL尽量使用连接池串例如用PGBouncer配套的?pgbouncertrue参数Prisma官方推荐。直接用裸连接串在高并发场景下会打满Postgres的连接数表现为间歇性超时。这个问题在一台小机器上不明显但一旦压测到100并发就暴露得很彻底。5.3 构建体积与页面性能Next.js默认的构建输出已经在做路由级代码分割。t3code里我再做了三件具体的事来压体积所有UI组件库组件改为按需导入不整包引radix-ui/*在next.config.mjs里开启reactStrictMode和compiler的removeConsole生产环境剔除console.调用使用next/dynamic懒加载非首屏的大组件比如图表模块ECharts体积较大。// next.config.mjs const nextConfig { reactStrictMode: true, compiler: { removeConsole: process.env.NODE_ENV production, }, };用next build之后观察产物首屏JS从默认模板的180KB压到了130KB左右gzip后对于t3code这种中后台工具来说已经够用了。Lighthouse的Performance跑下来桌面端在95分以上移动端因为首屏包含表格和图表分数稍微低一点约80分。核心瓶颈反而是图片和字体——如果后续要追求更高分得换CDN图片和预加载字体这属于锦上添花了。5.4 上线后的监控闭环t3code上线后我在监控上做了三件事第一接入了日志上报。服务端在errorFormatter里把TRPCError的堆栈输出到标准输出前端加了window.onerror和unhandledrejection将错误POST到一个自定义的日志接口存到一张app_log表。虽然原始但比完全盲跑好得多。第二数据库慢查询日志。Postgres的log_min_duration_statement设成500ms超过500ms的SQL都会打到日志里。上线第一天就抓到了两个没走索引的查询顺手在Schema里补了索引。第三定期检查任务积压数。因为t3code的业务核心是任务状态流转我写了一个简单的dashboard接口统计每个状态的task数量每5分钟跑一次如果pending数量持续上涨说明业务处理有积压。这个“业务健康指标”比单纯看CPU和内存更贴合这个项目的实际情况。6. 给后来者的复盘清单t3code踩过的坑和对新框架的控温建议我在t3code这个项目上踩过的坑不少挑几个有代表性的复盘一下给打算做类似T3全栈工程的朋友提个醒。第一个坑是tRPC的错误处理链路比想象中长。刚开始我以为类型安全能挡住大部分错误后来发现类型安全挡的是“字段结构错误”挡不住“业务状态冲突”。比如任务从DONE状态不能直接回退到PENDING这类规则需要在service层单独校验zod只能解决入参形状解决不了状态机的约束。后来我用一个专门的validateStatusTransition函数处理状态流转才把这类问题收口。第二个坑是React Query缓存失效的时机。T3生态里tRPC和React Query是深绑定的useQuery的缓存确实香但如果你在多个页面共享同一个query key在A页面改了数据B页面的缓存不会自动失效。t3code里我研发了一套约定所有写操作mutation的onSuccess回调里必须invalidate对应领域的所有query宁可多失效也不留脏数据。刚开始同事觉得麻烦后来发现不写invalidate的后果更麻烦——页面上数据显示陈旧用户刷新了才好体验非常割裂。第三个是数据库索引别照着前端查询写。早期t3code的索引设计完全按“前端可能传什么参数”来建比如给status和priority分别建了独立索引。后来发现实际查询往往是组合条件单列索引发挥不出作用。调整成复合索引之后慢查询数量降了一个数量级。这里我的经验是索引设计必须基于真实的查询模式而真实查询模式要通过日志和慢查询分析来确认不要靠猜。第四个是关于“新框架热情”的控温建议。T3这套组合非常顺滑顺滑到有时候你会有一种“所有问题都被解决了”的错觉。但实际上Prisma强在CRUD和类型安全弱在复杂聚合tRPC强在端到端类型但如果你要做公开API给第三方调用它反而没有REST或GraphQL直观因为第三方不一定用TypeScript。所以我在t3code里留了一条设计原则只要是面向内部系统的接口优先用tRPC如果有朝一日要对外暴露接口可以考虑在tRPC之上再包一层REST路由或者单独抽一个API服务。技术选型永远是在边界内做权衡而不是追着某个框架跑。最后说一下我的个人体会。t3code这套样板真正改变我日常开发习惯的不是某个框架或工具而是“类型安全从数据库一路穿透到页面”这个体验。后端改了Schema、前端连字段名都能跟着变不需要对着接口文档憋半天新增一个查询、调整几个过滤条件IDE的补全直接把可选项列出来。对这种“少了一堆琐碎联调”的爽感一旦习惯了就很难回去了。不过我也要提醒t3code适合做内部工具、中小型全栈应用如果业务复杂度上去、团队人数多了还是需要在上层引入更完整的流程约束。工具能帮你省掉一部分精力但它不会替你思考业务边界。