1. 为什么是 t3code:一个全栈项目的选型起点
大概两个月前,我的个人项目里出现了一个绕不开的岔路口:要做一个带用户登录、数据展示、后台管理的小型全栈应用,但前后端技术栈到底怎么选,让我纠结了很久。纠结的核心不是“做不出来”,而是“做完之后还要不要人”,我自己维护一个项目,最怕的是写完当天很兴奋,一周后打开代码库却因为类型割裂、接口文档过期、调试又要开两个终端而彻底失去维护的兴趣。
所以当 t3code 这个项目的代号落在纸上时,我的目标就很明确了:不是再搭一个“能跑的 Demo”,而是把一个代码库当成长期产品来设计——前端、后端、数据库、认证全部收拢在一个 TypeScript 体系里,让类型从一个数据库表一路流到 React 组件的 props 上,中间不掉链子。t3code 的含义其实很直白,就是 T3 Stack 实践库。T3 是社区里对 TypeScript、Tailwind CSS、tRPC 这一组工具链的简称,再加一个 Next.js 作为框架底座,正好组成一个“从数据库到 UI 全链路类型安全”的现代全栈组合。很多开发者在微博和技术群里讨论过这套组合,但真正把它完整落地成项目、跑通认证、部署上线的并不多。我这篇文章就想把 t3code 从零到上线的整个过程梳理一遍,包括为什么这么选型、目录怎么分、数据模型怎么设计、tRPC 路由怎么写、部署时踩过哪些坑,全部用实际代码和真实操作说话。
如果你也想做一个自己的全栈项目,或者正在 React / Vue 单页应用和 Next.js 全栈方案之间犹豫,t3code 的复盘过程应该能给你一个比较完整的参考。我会尽量少讲抽象概念,多放能直接抄走的代码和命令,同时把每一步背后的理由也讲清楚,免得你搭完一个项目却不知道下一个项目该怎么改。
2. 目录结构与数据模型:先搭骨架再写业务
2.1 按模块划分代码库的实验
第一次用 create-t3-app 脚手架时,生成的默认目录已经帮你分好了src/pages、src/server、src/trpc这些基础区域。但一旦开始写真实业务,你会发现默认目录只适合“跑通”,不适合“生长”。我在 t3code 里做的第一件事,就是按照业务模块重新调整目录。原则只有一条:让一个功能的所有代码尽量待在同一个垂直切片里,而不是按技术类型把所有 components 堆在一起、所有 api 堆在一起。
这里我踩过一个挺典型的坑:早期我把所有表格组件挂在src/components下,所有查询函数挂在src/server/api/routers下,表面上各归各位,但当一个表格要修改字段时,我得来回跳三个目录才能改完。后来改成按 feature 划分,结构变成这样:
src/ features/ projects/ components/ # 项目的表格、表单、详情卡片 hooks/ # 项目相关的客户端状态与 hooks api.ts # tRPC 查询入口,收敛本 feature 的所有 API 调用 members/ components/ hooks/ api.ts server/ auth.ts # NextAuth 配置 db.ts # Prisma Client 单例 api/ routers/ post.ts user.ts root.ts # 汇总所有子路由 shared/ schemas/ # zod 校验 schema,服务端和客户端共用 utils/ # 格式化、权限判断等通用函数这样调整之后,开发体验提升非常明显。要改 projects 模块时,我只需要进src/features/projects,不用在整个项目里大海捞针。同时shared/schemas里面的 zod 校验规则既能在服务端入参校验时用,又能在前端表单注册时用,一套规则两边走,把“前后端各写一遍校验”这个经典重复劳动直接消灭了。
2.2 Prisma 模型设计的三个心得
数据层是 t3code 的骨架,我用的是 Prisma + SQLite。很多人觉得 SQLite 只适合本地开发,其实对个人项目、小团队内部工具来说,SQLite 的单文件部署反而省心——不用单独维护数据库服务,备份就是拷贝一个文件。线上真到了并发量上来,Prisma 的 provider 改成 PostgreSQL 也只需要改一行配置加一次迁移,迁移成本完全可以接受。
在设计数据模型时,我特别注意三件事。第一,所有关联字段都要建立索引,尤其是一对多关系的另一侧。第二,尽量用时间戳字段记录创建和更新时间,但默认值必须交给数据库生成,而不是在应用代码里用new Date()插入,这样才能保证时区一致性。第三,业务字段尽量加枚举约束,比如项目状态用 PROJECT_STATUS 枚举,而不是一个自由文本字段。
下面是 t3code 项目里实际用到的简化模型,你可以直接参考这个模式:
model Project { id String @id @default(cuid()) name String slug String @unique status ProjectStatus @default(ACTIVE) ownerId String owner User @relation(fields: [ownerId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([ownerId, status]) } enum ProjectStatus { ACTIVE ARCHIVED DELETED }这里特别说明一下@@index([ownerId, status])这个复合索引。很多 Prisma 新手都忽略索引,觉得数据量小不在乎。但项目一旦跑起来,列表页最常见的查询就是where ownerId = ? and status = ?这种过滤,没有复合索引的时候,全表扫描的代价会随数据量线性增长,等到几百上千条数据时就会明显卡顿。我因为这个问题重构过一次表结构,所以建议你从一开始就加上。
2.3 环境变量的无头坑
我是吃了环境变量大亏才意识到要专门讲它。t3code 刚跑起来的时候一切正常,部署之后所有登录请求全部报 500,查了整整一天,最后发现是AUTH_SECRET没设置到生产环境变量里面。T3 栈里把环境变量写进.env只是第一步,部署平台(Vercel、Docker 容器)通常有自己独立的环境变量面板。我的做法是建一个.env.example放在仓库里,把所有需要的环境变量列好,注释清楚用途,部署时照着它逐项填。
同时要注意 Next.js 环境变量的前缀规则。只有以NEXT_PUBLIC_开头的变量才会被打包进浏览器端代码,服务端变量像DATABASE_URL、AUTH_SECRET必须避免这个前缀,否则会有泄露风险。最好再用 zod 对process.env做一次运行时校验,启动时缺哪个变量就直接报错,而不是等到运行到某个页面才炸。这也是 T3 脚手架默认附带的做法,很多文档没解释为什么,我理解下来就是:让配置错误尽早暴露,别把问题留到用户面前。
3. 从认证到 API:tRPC 路由的设计细节
3.1 为什么直接选 NextAuth 而不是自己写登录
登录认证是很多全栈项目的第一个分水岭。自己实现一套 session + cookie 体系也不是不行,但安全性细节非常多——密码哈希算法选型、token 刷新机制、CSRF 防护、会话失效清理,每一条都值得单独写一篇博客来覆盖。对 t3code 这种个人项目来说,我不觉得从头造轮子是明智的选择,所以我直接用了 NextAuth 的 Credentials 和 GitHub OAuth 双方案。
GitHub OAuth 的配置非常顺滑,只需要在 GitHub OAuth Apps 页面创建应用,把Client ID和Client Secret填进环境变量。Credentials 方式(邮箱 + 密码)则需要自己做好密码哈希,我推荐你使用 bcrypt 或者 argon2。bcrypt 是社区里验证特别多的方案,虽然性能不如 argon2 激进,但兼容性和文档优势非常明显。注册接口里取到密码后,先await bcrypt.hash(password, 10),再存进数据库。登录时用bcrypt.compare验证密码。
这里有一个我后来才注意到的细节:NextAuth 的 secret 千万不要在代码里 hardcode,而且生产环境的 secret 一定不能和开发环境共用。一旦 secret 泄露,攻击者可以伪造 session token,后果非常严重。
3.2 tRPC 路由的模块化思路
tRPC 是 t3code 项目里我体验最好的部分。它本质上是一个 RPC 框架,客户端直接调用服务端定义的函数,整个调用过程像在操作本地函数一样,而且类型自动推导,不需要像 REST 那样手动维护接口类型定义。
我用的是initTRPC创建一个实例,然后通过createCallerFactory做成服务端调用,或者createTRPCNext接在前端。路由文件按业务模块拆开,每个文件导出一个createTRPCRouter对象。比如 projects 路由:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { updateProjectSchema } from "~/shared/schemas/project"; export const projectRouter = createTRPCRouter({ list: protectedProcedure .query(async ({ ctx }) => { return ctx.db.project.findMany({ where: { ownerId: ctx.session.user.id, status: "ACTIVE" }, orderBy: { createdAt: "desc" }, }); }), update: protectedProcedure .input(updateProjectSchema) .mutation(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { id: input.id }, }); if (!project || project.ownerId !== ctx.session.user.id) { throw new TRPCError({ code: "UNAUTHORIZED", message: "您没有权限修改该项目", }); } return ctx.db.project.update({ where: { id: input.id }, data: { name: input.name, status: input.status }, }); }), });注意protectedProcedure,这是我在 tRPC 上下文里加了 session 判断后产生的专用 procedure。未登录用户直接调用这些接口会立刻收到 UNAUTHORIZED 错误,不用在每个路由里手动检查一遍 session。这样做的好处是安全逻辑收敛在了一个地方,后续新增接口时只要选对 procedure,默认就是安全的。
3.3 服务端渲染与客户端调用的边界
T3 栈里有个容易混淆的点:tRPC 既可以服务端调用,也可以客户端调用。tRPC 的createCallerFactory能在 Next.js 的 Server Component 里直接取数,这样首屏数据是在服务端渲染的,对 SEO 友好;另一方面,React Query 把客户端缓存、重新验证、乐观更新都接好了。
我的原则是:页面初次加载需要的数据尽量走服务端取数,用户交互之后才需要的数据走客户端调用。举个例子,项目列表页默认数据直接在服务端用createCallerFactory拿到再传给页面组件,而用户搜索、筛选、新增项目等交互操作则走客户端trpc.project.list.useQuery()。这样兼顾了首屏速度和交互及时性,也不容易出现服务端和客户端状态不同步的水合问题。
tRPC 的类型推导还省掉了大量重复工作。服务端修改了一个查询返回的字段类型,前端所有调用的地方立刻会出现类型错误提示,开发阶段就能发现接口签名不匹配的问题。相比以前写 REST 接口要单独维护一份 OpenAPI/Swagger 文档、再手动生成客户端类型,t3code 的类型完全是从代码里长出来的,不需要额外劳动,也不会出现文档和实现漂移。
4. 部署、监控与常见坑
4.1 线上部署的两种路径
t3code 这类 Next.js 全栈项目部署非常方便。我个人试过两条路径,都完全可行。
第一条是 Vercel。把 GitHub 仓库连进去,Vercel 会自动识别 Next.js 项目,构建命令都不用改。唯一要做的就是在 Vercel 的项目设置里把环境变量填好,然后部署即可。SQLite 数据库文件怎么处理是一个麻烦点——Vercel 的函数是无状态的,写文件系统不稳定,所以线上我直接换成了 PostgreSQL,用 Neon 或者 Supabase 这类托管服务,给一个连接字符串就行。Prisma 换库只需要改datasource db的 provider 和连接字符串,再跑一次prisma migrate deploy。
第二条是自建服务器 Docker Compose。t3code 线上版我现在就是跑在一台 Linux 服务器上的,用 Docker 打包 Next.js 应用,再用 Compose 启动一个 PostgreSQL 容器,配合 Nginx 做反向代理和 HTTPS 证书。Dockerfile 我用的是多阶段构建,第一阶段安装依赖并next build,第二阶段只拷贝构建产物和node_modules里的生产依赖,镜像体积从 1.5GB 降到了不到 300MB。
4.2 我踩过的五个坑,按痛苦程度排序
先列一张表,把 t3code 开发上线过程中最有代表性的问题整理出来,后面再逐个讲排查思路。
| 症状 | 根因 | 解决方式 |
|---|---|---|
| 刷新后页面闪现登录状态丢失 | saveSession 未开启或 token cookie 同站点设置问题 | 检查 NextAuth 配置,设置session.strategy: "database"或确认 cookie secure 属性 |
| API 偶尔 500,重启恢复 | SQLite 连接被并发写占用 | 开发环境线程池调小,或直接迁移 PostgreSQL |
| 字体/样式在首屏闪烁 | Tailwind CSS 的darkMode配置在服务端和客户端不一致 | 确认darkMode: ["class"]且根组件同步挂载 class |
| 上传图片一直 413 | Nginx 默认请求体大小只有 1MB | client_max_body_size 10m; |
| 项目列表查询越来越慢 | 缺少复合索引 | 按业务筛选条件补复合索引 |
第一个坑在开发阶段还不太明显,部署到线上后,用户刷新页面时前端拿到一个新的 NextAuth session 请求,如果浏览器 cookie 的SameSite属性和部署域名的 HTTPS 配置不对,cookie 不会被正确带上,已经登录的用户也会被判定为未登录。解决方法是确保NEXTAUTH_URL设置正确,且在 NextAuth 配置里把 cookie 的 secure 选项打开。
第二个坑是 SQLite 在真实并发场景下的瓶颈。SQLite 适合单进程读写,当 Next.js 无服务器函数以多实例并发执行时,数据库文件经常被多个进程同时写入,就会偶尔报database is locked。t3code 开发版本里我继续保留 SQLite,好处是零配置,但线上版必须换 PostgreSQL,否则很容易在流量稍微上来一点时就出问题。
第三个坑很隐蔽。我用 Tailwind CSS 配置暗色模式时,用media策略直接跟随系统,但这种策略在服务端渲染时拿不到用户偏好,导致服务端认为亮色、客户端认为暗色,页面挂载时就出现闪烁。换成class策略之后,我在根布局组件里读取用户偏好并显式渲染darkclass,服务端和客户端就完全一致了。
第四个坑在自建服务器上几乎必踩。Nginx 默认的client_max_body_size是 1M,用户上传一张稍大的图片就直接 413。排查方法很简单,先看 Nginx 错误日志,如果看到client intended to send too large body,就说明是请求体大小限制。加上一行配置就行。
第五个坑前面已经提到,加复合索引解决。它能排在最后一个不是说不严重,而是绝大多数性能问题只要建好索引,查询速度都能立刻恢复,算是性价比最高的修复。
4.3 为什么坚持严格类型检查
T3 栈给你搭好了全链路类型安全的地基,但如果不坚持下去,这些优势会被一点点蚕食。我在 t3code 里开的是 TypeScriptstrict: true,并且把 eslint 的no-explicit-any作为 error 级别规则。刚写的时候会觉得约束太多,尤其是一些第三方库类型定义不全的时候,想要as any糊弄过去。但两个月跑下来,感受非常明显:因为不能轻易any,每次遇到类型报错我都会回到数据源头去看 schema,这反而帮我提前发现了三个字段命名不一致的问题——前端传createdAt,Prisma 返回createTime,这类 bug 如果放在任意类型的缝隙里,可能要等到 UI 上数据全空白才能发现。
严格类型检查还有一层好处:重构时安全感拉满。我把 projects 模块的数据返回结构调整过一次,服务端改了查询返回的字段,前端所有用到旧字段的地方全部在编辑器里标红,我照着红圈改了十几分钟就完成了整个重构,不用手动搜索“哪里可能用到了”。这种体验一旦适应,就再也回不到纯 JavaScript 时代了。
5. 把 t3code 跑通之后,我又优化了什么
5.1 查询性能优化:分页与缓存
项目列表数据量增长后,全量查询迟早会卡。我建议所有列表类接口都做游标分页,而不是传统的 offset 分页。为什么用游标?因为 offset 翻页到深页时,数据库还要扫描前面所有被跳过的行,性能会逐步下降;游标分页用where id > cursor的方式取下一页,每条记录只被扫描一次,数据量再大也相对平稳。
t3code 里我封装了一个通用的分页逻辑,核心就是用一个cursor参数记录上次返回的最后一条记录的id,配合take限制条数。前端 React Query 会自动把游标状态缓存下来,按“加载更多”按钮时把上次游标传给后端。具体代码比较长,这里给一个简化的核心思路:
const list = await ctx.db.project.findMany({ where: { ownerId: session.user.id }, take: 20, ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), orderBy: { createdAt: "desc" }, });注意skip: 1,因为游标记录本身也满足查询条件,要跳过它。这个细节我一开始漏了,结果每页加载都出现第一条数据重复。
缓存方面,React Query 默认有 staleTime 机制。查询一个不常变的列表时,把staleTime调到 30 秒甚至 1 分钟,可以显著减少后端请求。t3code 的详情页我把staleTime设为 5 分钟,配合服务端取数,页面几乎秒开,用户体验好了很多。
5.2 权限管理:从“能登录”到“能分角色”
原型阶段只要有人登录就行,但真实使用一周后,权限不足的问题就浮出来了。我加了简单的角色系统,User 模型里加role枚举字段,取值为 ADMIN / USER。然后在 tRPC 层做授权分离:protectedProcedure只保证已登录,adminProcedure在它基础上再判断角色,非管理员直接抛出 FORBIDDEN。在 Server Action 和 API 层都复用这套逻辑,避免前端按钮隐藏但后端接口裸奔的情况。
角色权限设计不用一开始就上 RBAC 那套大的模型,对于个人项目或小团队,一个枚举字段加几个专用 procedure 就够用了。等真的出现多角色、多资源、细粒度操作权限需求,再考虑引入@casl/ability之类的权限库也不迟。过早抽象反而会让代码难以理解。
服务端数据校验同样重要。t3code 里的所有 mutation 入参都走 zod schema,表单数据在后端接口入口再完整校验一遍。不是为了和前端过不去,而是防止绕过前端直接调 API 传入脏数据。type-safe 的 API 层让你容易忽略这件事——总觉得类型都对,但类型只保证格式,不保证业务合法性,比如字符串长度、枚举范围、时间先后关系都还要靠 zod 兜底。
5.3 测试与监控:给项目上保险
t3code 早期完全没有测试,直到一次改数据库 schema 时不小心把某个字段名拼错了,页面静默出错,我才意识到测试的重要性。加了 vitest 和 React Testing Library,覆盖核心路由和几个关键组件。
写测试这事不需要一步到位。我先给最核心的三个数据流写了测试:认证逻辑、项目 CRUD 路由、权限校验。这三个部分一旦出问题整个应用都没法用,属于“优先级最高”的测试。UI 组件的测试则是后面有空再逐渐补,毕竟组件变动频率高,写得太细反而会频繁返工。
服务器侧我用的是 Sentry 做错误监控,前端Sentry.init,后端通过 Next.js 的 instrumentation 上报 unhandled error。上线后的第一周就抓到两个线上 bug,一个是在某个特定操作下session.user.id为空的空指针,另一个是第三方服务超时导致的请求挂起。没有监控的情况下,这些错误大概率是用户来骂了才知道,现在则是报警先来,修复完用户还没察觉。
6. 如果重做一次 t3code,我会怎么做
这篇文章写到这,基本把 t3code 从选型到上线、再到优化加固的完整过程都过了一遍。如果要给一个总结性的回答,我不会说技术选择有多么先进,反而会想说一个更朴素的体会:全栈项目最容易失控的地方,不是某一项技术你不会,而是前后端之间的类型、校验、权限、状态同步这些问题总是在缝隙里捣乱。t3code 用的这一套工具组合,本质上就是把这些缝隙用 TypeScript 填平,让开发者的精力主要花在真正的业务逻辑上。
如果现在从头再做一个类似项目,我也会毫不犹豫地再走 T3 这条路,但仍然有一些调整。第一,数据库会直接上 PostgreSQL,而不再从 SQLite 迁移,省掉一次换库折腾;第二,一开始就要建索引和加测试,而不是等问题浮出水面再补;第三,权限模型先用最简单的方式跑通,但接口安全校验永远不放低标准。这些经验看起来都很简单,但都是我在这段真实项目过程中一点点踩出来的。
写这篇复盘最想表达的一件事其实是:项目代号叫什么并不重要,重要的是它是否能够长期健康地被维护。t3code 这个代码库现在还在持续更新,日常记录、数据导入、权限调整都在里面进行。它不庞大,不复杂,但它足够清晰,让我任何时候打开都能快速定位问题,修改起来也不怕打碎其他部分。如果你也在纠结全栈项目怎么搭,不妨按我上面的流程试一次,把类型安全和模块划分做扎实。相信我,两个月后你会感谢当时认真铺路的自己。