
t3code是我最近一直在维护的一个全栈项目代号说直白点就是用T3 Stack搭起来的一套应用骨架兼代码资产库。去年接手这套东西的时候团队里的前端、接口、数据库脚本还散落在好几个仓库里每次加个功能要同时改三四处光联调就能耗掉半天。后来我把技术栈统一换成Next.js TypeScript tRPC Prisma这一套所有代码收敛到一个仓库里前后端类型全部打通开发效率提升得非常明显。如果你也在做全栈项目的框架选型或者正在纠结要不要上tRPC甚至只是想知道一个“看着很酷”的技术组合在真实项目里到底怎么落地这篇文章应该能给你说清楚。1. t3code是什么用一套技术栈把所有代码收进同一个仓库1.1 我最初为什么放弃微服务回到单体仓库先说个背景。t3code刚开始并不是一个“设计出来”的项目而是被逼出来的。团队早期做内部系统时按“前后端分离”的惯用套路拆成了两个仓库前端用Vue后端用Node Express数据库脚本单独放在第三个仓库里。理论上挺好的实际跑起来全是麻烦事前端要的字段后端没给后端改了字段名前端不知道文档永远处于“刚写完就过期”的状态。最难受的是新人入职光是把三个仓库的运行环境跑起来就得花一上午。后来我决定把项目全部收敛到一个仓库里采用Next.js做全栈框架。这个决定看起来像是“倒退”——毕竟大家都在拆服务但对我们这种几十个页面、几十张表、十几个开发者的内部项目来说单体仓库才是最合理的形态。代码就在同一个目录里改后端模型和改前端页面可以发生在同一个PR里Code Review的时候能一次性看清改动全貌不用在两个仓库之间来回切换验证。t3code这个名字说白了就是“T3栈的代码资产”既是这个项目的代号也是我后续所有内部项目的起手模板。1.2 为什么选T3 Stack类型安全到底省了多少钱T3 Stack说的是Next.js TypeScript tRPC Prisma Tailwind这套组合名字来自Theo的T3 for a Reason。其他技术都好理解核心争议在tRPC它把服务端路由变成函数前端直接调用而不是走REST URL。我选择它的最大理由只有一个端到端类型安全。用REST的时候前端拿到的是一个any类型的响应体需要自己写interface去猜后端返回什么结构猜错了运行时才报错。用tRPC的时候服务端定义的query和mutation就是函数签名前端导入后自动获得完整的类型推导参数、返回值、错误信息全部能在编辑器里看到。改后端一个字段前端编译直接报错根本不会出现“联调时才知道字段名抄错了”的尴尬。拿生活里的例子说REST像是你在餐厅点菜只能看纸质菜单上菜后跟你要的不一样只能找服务员理论tRPC则像是打开小程序点单每个菜都带图片和价格点错了当场就提示。少了这层“猜测”成本开发体验提升是实打实的。1.3 t3code适合谁用解决什么具体问题这套项目模板最适合三类人一是做内部管理系统、后台工具的开发团队这类项目功能重复度高、页面多端到端类型安全能极大减少低级错误二是想学习全栈开发的新人整个链路清晰不需要分别部署前端和后端跑一个命令就能看到完整页面三是需要快速产出MVP的个人开发者t3code把所有基建都配好了可以直接从业务代码开始写。它解决的问题也都很具体类型割裂带来的联调成本、接口文档维护不及时导致的前后端扯皮、新项目初始化时还得重新搭一堆脚手架等等。投一次资后面所有项目都受益。2. 项目结构设计让新人凭直觉就能找到代码2.1 目录划分与职责边界t3code的目录结构我调整过好几版最终定型如下个人觉得是目前最清晰的一套t3code/ ├── prisma/ │ └── schema.prisma ├── public/ ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── user.ts │ │ │ └── root.ts │ │ ├── db.ts │ │ └── auth.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── client.ts │ ├── env.mjs │ └── styles/ ├── app/ │ ├── page.tsx │ ├── layout.tsx │ └── api/trpc/[trpc]/route.ts ├── .env.example ├── package.json └── tsconfig.json这里有个关键约定src/server目录下的代码绝不能被前端组件直接引用前端只能通过src/trpc/client.ts暴露的hooks来调用。这个约定一开始不强制后来有人为了图省事直接import了server端的db把数据库连接串泄露到浏览器端产物里我才意识到边界规则必须写进README里。所有数据库访问、鉴权逻辑、业务核心算法都放server目录所有页面和组件都只访问tRPC的客户端代理这样安全性和可维护性都有了保障。2.2 数据模型设计先想清楚关系再写代码Prisma是t3code的数据访问层我用它来定义数据库表和关系。项目初期最关键的步骤不是写页面而是把prisma/schema.prisma里的模型定义好。我举个例子这是一个典型的博客场景model User { id String id default(cuid()) name String? email String unique posts Post[] createdAt DateTime default(now()) } model Post { id String id default(cuid()) title String slug String unique content String? published Boolean default(false) authorId String author User relation(fields: [authorId], references: [id]) tags Tag[] createdAt DateTime default(now()) } model Tag { id String id default(cuid()) name String unique posts Post[] }这里几个细节值得说道。主键用cuid()而不是自增id原因是分布式环境下生成id不需要查数据库且cuid不会暴露数据量。所有关联字段都要显式声明外键列比如authorId不能只写关系对象否则后续做原生查询时字段名很容易搞混。多对多关系用隐式中间表Prisma会自己维护我从不手动建联接表除非中间表本身需要额外字段。模型定义完后运行npx prisma migrate dev生成迁移文件本地数据库自动同步结构。这个命令建议每次都写清楚迁移名称比如npx prisma migrate dev --name add_post_tags这样git历史里每个迁移的意图一目了然回滚的时候也知道哪个对应哪个。2.3 模型与路由的映射关系t3code在路由组织上遵循一个简单原则一个业务域对应一个router一个router对应一个或几个Prisma模型。比如post.ts只管文章相关的查询和写入user.ts只管用户相关逻辑。这个映射关系简单到新人看一眼就能理解也是我刻意保持的风格——项目大了以后最怕的就是那种“什么都能干”的巨型service类找代码全靠搜索。router内部的procedure命名也有一套约定能查多个用list查单个用byId或bySlug写操作用动词开头比如create、update、delete。命名一致之后前端调用方的变量名几乎可以由路由名直接推导比如trpc.post.list.useQuery()对应返回的posts数组完全不需要额外的文档说明。3. 核心模块开发与实操记录3.1 tRPC路由从0到1搭一个接口按t3code的惯例我们看一个最小可用的postRouter怎么写import { z } from zod; import { createTRPCRouter, publicProcedure, protectedProcedure } from ../trpc; export const postRouter createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ where: { published: true }, include: { author: true }, }); }), bySlug: publicProcedure .input(z.object({ slug: z.string().min(1) })) .query(async ({ ctx, input }) { return ctx.db.post.findUnique({ where: { slug: input.slug }, include: { author: true, tags: true }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().optional(), slug: z.string().regex(/^[a-z0-9](?:-[a-z0-9])*$/), })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, slug: input.slug, authorId: ctx.session.user.id, }, }); }), });三个细节值得展开。第一publicProcedure和protectedProcedure的区别前者是所有人可访问的查询后者会先执行ctx.session的校验没有登录直接抛异常。这个鉴权能力来自NextAuth配置在src/server/auth.ts里。第二所有输入都用Zod做运行时校验。别小看这一步TypeScript的类型检查只存在于编译期运行时用户传过来的数据永远不可信。Zod同时承担了“类型定义”和“校验逻辑”两份工作一个schema前后共用让前端调用方拿到的类型和实际校验规则完全一致。第三Mutation里必须从ctx.session.user拿当前用户ID绝对不允许让前端把userID作为参数传进来。这个是我在项目里反复强调的安全底线否则任何一个登录用户都可以冒充别人发文章。3.2 前端页面接入useQuery和useMutation服务端路由定义好之后前端的接入过程简单得让人上瘾。以文章列表页为例use client; import { trpc } from ~/trpc/client; export default function PostList() { const { data: posts, isLoading, error } trpc.post.list.useQuery(); const createPost trpc.post.create.useMutation({ onSuccess: () { // 自动刷新列表 utils.post.list.invalidate(); }, }); if (isLoading) return div加载中.../div; if (error) return diverror.message/div; return ( ul {posts?.map((post) ( li key{post.id} h2{post.title}/h2 p{post.author.name}/p /li ))} /ul ); }这里有个概念叫“invalidate”翻译成大白话就是“让这个查询失效并重新拉取”。传统REST里更新完数据之后前端得手动调用列表接口或者用全局状态管理去同步。tRPC把这一步简化成了utils.post.list.invalidate()它知道列表数据依赖哪些查询自动做精确刷新。在进去实际项目之前我一直担心tRPC是不是只适合小项目因为它的调用方式看起来和“标准化REST接口”差异很大。但跑了一年多下来外部客户需要开放接口的场景毕竟少数内部系统的数据更新频率也没那么高这种“函数式调用”带来的效率提升远远超过了“不够REST”的理论缺憾。3.3 环境变量与开发流程t3code的环境变量校验用的是Zod配置写在src/env.mjs里import { z } from zod; const envSchema z.object({ DATABASE_URL: z.string().min(1), NEXTAUTH_SECRET: z.string().min(1), NEXTAUTH_URL: z.string().url().optional(), // 其他业务变量 }); export const env envSchema.parse(process.env);这个做法很值得复制应用启动时如果环境变量缺失或格式不对会直接在启动阶段报错而不是运行到某个页面时才蹦出“undefined is not a function”这种让人摸不着头脑的异常。.env.example里只保留变量名和注释真正的密钥放在.env.local确保不被提交到git。本地开发流程就是一行命令pnpm dev。数据库迁移、类型检查、ESLint都是独立命令我建议把postinstall钩子加上prisma generate这样任何人拿到代码后第一次installPrisma Client就会自动生成省去一个常见的新人坑。4. 部署、鉴权与常见问题排查4.1 部署方案的选择与实际配置t3code的部署不复杂但有几个细节容易踩坑。我实际用的是Node服务器部署方案本地先执行pnpm build产物是.next目录和server入口用PM2守护进程跑起来。如果你习惯用容器部署把pnpm build的输出复制到镜像里即可数据库连接串通过环境变量注入。有一个容易被忽略的点Next.js的独立输出模式需要在next.config.mjs里加一行output: standalone这样构建产物会包含一个精简版的Node server体积更小、启动更快特别适合容器环境。没有这行配置的话部署时需要把整个node_modules带上去镜像体积夸张启动也慢。部署环境里初始化数据库的命令要分开跑prisma migrate deploy只负责应用迁移不执行seedprisma db seed只在第一部署时跑一次。我用一个简单脚本把这两个步骤串起来顺序固定避免重复seed导致主键冲突。4.2 鉴权接入NextAuth的细节t3code的鉴权默认用NextAuthProvider我优先选OAuth式的账号体系。核心配置在src/server/auth.ts里这里最常出的问题就是Session里的用户信息不够用。默认情况下JWT里只包含sub、email等基础字段但我们的数据库主键是cuid业务代码需要拿到user.id。解决方案是在callbacks里把token中的sub映射成数据库用户记录再塞进sessioncallbacks: { jwt({ token, user }) { if (user) token.id user.id; return token; }, session({ session, token }) { if (session.user) session.user.id token.id as string; return session; }, }如果没有这段配置前端拿到的session.user.id永远是undefined然后所有关联查询全部失败还很难排查。我最初踩这个坑时花了半天时间后来把这段代码注释写得特别详细每周都有同事来感谢我留下的注释。4.3 常见报错速查表下面这张表是我在项目群里被问得最多的问题汇总建议直接收藏报错或现象根本原因解决方法PrismaClient is not defined开发模式下Prisma Client被热更新多次实例化用globalThis缓存PrismaClient实例服务端改了router前端类型没变tRPC类型生成没刷新重启dev server或执行tsc --noEmit排查Zod校验报“Invalid input”前端传了多余或缺失字段看报错中的路径定位字段避免用any绕过登录后session里没有user.idNextAuth回调未配置token.id按4.2节的callbacks配置补上查询返回N1条SQL关联数据未使用include检查是否循环访问了posts.author之类的关系部署后页面500环境变量缺失确认.env里的DATABASE_URL和NEXTAUTH_SECRET4.4 一个真实调优案例t3code里有个数据看板页面初始版本直接在前端列表组件里逐个调用trpc.tag.byId去查每篇文章的标签页面加载时发了大量请求接口平均耗时从200ms飙到2秒多。后来我把查询改成一次include把tags带出来N1消失耗时降到300ms以下。这个案例没什么高超技巧但值得强调的是Prisma的include很强大但在列表场景里要注意返回体积。像文章列表这种只需要标题和作者的场景我用select而不是include只拿需要的字段数据库传输量和JSON序列化耗时会小很多。优化这件事永远别等产品经理提上线前把基本查询都检查一遍选对select和include收益即时可见。5. 代码规范与协作提效5.1 ESLint、Prettier与Git钩子t3code在代码规范这块直接用了create-t3-app的默认配置但我额外补了Prettier、Husky和lint-staged。Prettier专门负责格式化和ESLint做静态检查分工明确避免两套工具在“该不该加分号”这种问题上打架。Husky的配置重点在pre-commit钩子逻辑是提交前只对暂存区文件跑lint和格式化而不是全仓检查这样速度飞快不会让开发者因为commit太慢而偷偷绕过钩子。pre-push钩子再跑一遍全量tsc --noEmit类型错误在推到远端之前就被拦下了。有一次我忘了配置pre-push结果同事把带类型错误的代码推到CI整条流水线红了大半天从那以后我就把这两道关卡写进了新人必读文档。5.2 Git提交规范与Code Review清单t3code的git提交信息强制用Conventional Commits格式feat、fix、docs、refactor、perf、test、chore。不要小看这个格式配合lint-staged的commitlint校验可以自动生成清晰的CHANGELOG也能让git bisect定位问题时快得多。Code Review时我的检查清单比较固定每次过一遍能拦住大部分问题新加的input有没有写Zod校验没有就驳回。查询有没有无谓的N1相关的关联字段够不够用mutation里涉及的权限有没有校验到用户能不能操作别人的数据有没有往client组件里引server端代码新表有没有建立必要的索引尤其是unique约束和WHERE条件要用的字段这套清单的最大价值是“无需讨论照章办事”。新人不用怕被review时挑刺因为问题都是公开的、可预期的改起来也很快。5.3 文档策略注释只写why不写whatt3code的代码里注释不多但每一条都很有价值。我定了个原则注释只写“为什么这么做”不写“这段代码做了什么”。比如路由里的回调配置注释会写“NextAuth默认不把user.id放进session需要在callbacks里手动映射”而不是写“获取用户id”。后者代码本身已经说明了前者才是同事真正想知道的信息。对于复杂业务规则我不建议写大段注释不如把逻辑抽成命名清晰的功能函数把规则写在函数名和参数名里。代码库本身就是文档只有那些牵一发动全身的设计决策才需要额外说明。6. 踩坑总结与个人体会6.1 我建议你直接照抄的几个决定如果让我重新搭一遍t3code有几个决定我一定会原样保留。第一是“前后端同仓库”。对于内部中后台项目这真的是效率最优解不要为了“架构好看”把简单问题复杂化。第二是“所有输入一律Zod校验”这是防止数据污染系统的最廉价手段。第三是“server目录严格隔离”既保安全又明确职责任何想跨界的冲动都要立刻拦住。第四是“新路由先命名再实现”先想清楚procedure的名字和参数再动笔写逻辑比边写边想更容易保持一致性。6.2 接下来我打算怎么扩展t3code目前还处在“好用”阶段离“通用”还有距离。我下一步准备做三件事一是把上传能力整合进去目前文件上传还是单独的服务体验割裂二是加一层基于Redis的查询缓存把一些高频只读接口的响应时间再压下去三是把项目里的通用工具函数抽成独立的npm包方便其他项目复用。但扩展之前我会先把项目的docker compose环境补完整让新成员一个命令就能起一套包含数据库、本地鉴权服务、应用实例的完整开发环境。基建做扎实了后面加功能才不会慌。最后分享一个我个人的实操体会代码资产这件事真正拉开团队差距的从来不是某个技术“够不够新”而是在每一次小改动里是否愿意顺手把规范、注释和自动化补上。t3code之所以能成为我后续所有项目的起手模板不是因为T3 Stack本身有多玄妙而是因为它让我在加新功能时几乎不用想“基建”——所有精力都能花在业务逻辑上。如果你也想搭一套自己的全栈骨架我的建议很简单选定一个组合后别急着写业务先把目录、命名、校验、部署、钩子这些基础设施一次配齐然后每条坑都记录成文档三个月后你回头看一定会感谢自己当时的投入。