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 <div>error.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,这样任何人拿到代码后第一次install,Prisma 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只负责应用迁移,不执行seed;prisma db seed只在第一部署时跑一次。我用一个简单脚本把这两个步骤串起来,顺序固定,避免重复seed导致主键冲突。
4.2 鉴权接入NextAuth的细节
t3code的鉴权默认用NextAuth,Provider我优先选OAuth式的账号体系。核心配置在src/server/auth.ts里,这里最常出的问题就是Session里的用户信息不够用。
默认情况下,JWT里只包含sub、email等基础字段,但我们的数据库主键是cuid,业务代码需要拿到user.id。解决方案是在callbacks里把token中的sub映射成数据库用户记录,再塞进session:
callbacks: { 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.id | NextAuth回调未配置token.id | 按4.2节的callbacks配置补上 |
| 查询返回N+1条SQL | 关联数据未使用include | 检查是否循环访问了posts.author之类的关系 |
| 部署后页面500 | 环境变量缺失 | 确认.env里的DATABASE_URL和NEXTAUTH_SECRET |
4.4 一个真实调优案例
t3code里有个数据看板页面,初始版本直接在前端列表组件里逐个调用trpc.tag.byId去查每篇文章的标签,页面加载时发了大量请求,接口平均耗时从200ms飙到2秒多。后来我把查询改成一次include把tags带出来,N+1消失,耗时降到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校验?没有就驳回。
- 查询有没有无谓的N+1?相关的关联字段够不够用?
- mutation里涉及的权限有没有校验到?用户能不能操作别人的数据?
- 有没有往client组件里引server端代码?
- 新表有没有建立必要的索引,尤其是unique约束和WHERE条件要用的字段?
这套清单的最大价值是“无需讨论,照章办事”。新人不用怕被review时挑刺,因为问题都是公开的、可预期的,改起来也很快。
5.3 文档策略:注释只写why,不写what
t3code的代码里注释不多,但每一条都很有价值。我定了个原则:注释只写“为什么这么做”,不写“这段代码做了什么”。比如路由里的回调配置,注释会写“NextAuth默认不把user.id放进session,需要在callbacks里手动映射”,而不是写“获取用户id”。后者代码本身已经说明了,前者才是同事真正想知道的信息。
对于复杂业务规则,我不建议写大段注释,不如把逻辑抽成命名清晰的功能函数,把规则写在函数名和参数名里。代码库本身就是文档,只有那些牵一发动全身的设计决策才需要额外说明。
6. 踩坑总结与个人体会
6.1 我建议你直接照抄的几个决定
如果让我重新搭一遍t3code,有几个决定我一定会原样保留。
第一是“前后端同仓库”。对于内部中后台项目,这真的是效率最优解,不要为了“架构好看”把简单问题复杂化。第二是“所有输入一律Zod校验”,这是防止数据污染系统的最廉价手段。第三是“server目录严格隔离”,既保安全又明确职责,任何想跨界的冲动都要立刻拦住。第四是“新路由先命名再实现”,先想清楚procedure的名字和参数,再动笔写逻辑,比边写边想更容易保持一致性。
6.2 接下来我打算怎么扩展
t3code目前还处在“好用”阶段,离“通用”还有距离。我下一步准备做三件事:一是把上传能力整合进去,目前文件上传还是单独的服务,体验割裂;二是加一层基于Redis的查询缓存,把一些高频只读接口的响应时间再压下去;三是把项目里的通用工具函数抽成独立的npm包,方便其他项目复用。
但扩展之前,我会先把项目的docker compose环境补完整,让新成员一个命令就能起一套包含数据库、本地鉴权服务、应用实例的完整开发环境。基建做扎实了,后面加功能才不会慌。
最后分享一个我个人的实操体会:代码资产这件事,真正拉开团队差距的从来不是某个技术“够不够新”,而是在每一次小改动里是否愿意顺手把规范、注释和自动化补上。t3code之所以能成为我后续所有项目的起手模板,不是因为T3 Stack本身有多玄妙,而是因为它让我在加新功能时几乎不用想“基建”——所有精力都能花在业务逻辑上。如果你也想搭一套自己的全栈骨架,我的建议很简单:选定一个组合后别急着写业务,先把目录、命名、校验、部署、钩子这些基础设施一次配齐,然后每条坑都记录成文档,三个月后你回头看,一定会感谢自己当时的投入。