如果你经常混前端圈子,大概率见过一堆以 t3 命名的东西。我手里这个t3code不是什么神秘组织代号,而是我基于create-t3-app搭出来的一套全栈应用模板。它解决了我最头疼的问题:每次开新项目都要在 Next.js、TypeScript、数据库、鉴权、样式这些模块之间反复拉锯,等配置写完,热情也凉了一半。t3code 就是那个让我从“准备开发”直接跳到“开始开发”的起点。
这篇文章不聊抽象概念,我会从为什么选 T3 Stack、怎么搭一个完整读写闭环、再到上线前踩过的几个真实坑,逐层拆开。如果你也打算开一个带登录、带数据库、前后端类型完全打通的项目,这篇可以直接照着抄。我拿它做了一个轻量的“灵感收集器”,但思路可以平移到内容管理、工单系统、个人记账,任何 CRUD 类全栈应用都行。
1. t3code 到底是什么:我给脚手架项目起的代号
1.1 从 create-t3-app 说起
t3code 这个名字是我自己起的,代表“一套基于 T3 Stack 的启动代码”。T3 Stack 最早是 Theo 提出的一个组合,核心是三件套:TypeScript、Tailwind、tRPC,后面又吸收了 Next.js、Prisma、NextAuth,变成一套官方脚手架create-t3-app。它和普通脚手架最大的区别不是技术多新,而是理念很明确:与其让你在几十个选项里做选择,不如先给你一个默认正确的组合,把常见问题在架构层面直接消掉。
我第一次用它的感受是:目录结构干净得不像一个全栈项目。没有 v1、v2、utils、helpers 这种乱七八糟的顶层文件夹,而是src/app放页面、src/server放服务端逻辑,前后端边界非常清晰。再加上环境变量校验、Prisma 模型、tRPC 路由都预置好了,新建完项目第一眼就知道每份代码该放哪。
1.2 我拿 t3code 做了什么
为了验证这套模板能不能经得起真实使用,我用它做了一个“灵感收集器”。功能很简单:登录后把偶然想到的产品点子、文章选题、代码片段存下来,按标签分类,之后可以按标签筛选。听起来像备忘录,但要真正做到好用,涉及到的能力并不少:
- 用户体系:注册、登录、会话保持;
- 数据库:用户、内容、标签、多对多关联;
- API 层:增删改查、权限校验;
- 前端:表单、列表、筛选、加载状态;
- 部署:环境变量管理、数据库迁移、线上构建。
这些刚好覆盖了一个全栈应用的所有核心环节。做完之后我把这个项目当成固定模板,后续再开新项目就 clone 一份改改模型,省掉重复劳动。
1.3 适合当模板的场景
我判断一个脚手架值不值得长期用,就看三件事:新项目从零到能跑要多长时间、CRUD 功能的开发效率、出问题后能不能快速定位。t3code 在这三件事上的表现在我这里可以打 8.5 分。适合它的项目有这些特征:
- 前后端要频繁联动:表单提交、列表筛选、详情页跳转,都是典型的 CRUD;
- 需要做用户隔离:每个人只能看到自己的数据;
- 需要快速上线:不追求微服务、不搞多端并行,就一个前端 + 一套 API;
- 团队已经有 TypeScript 基础:这点很关键,虽然模板本身能省事,但 tRPC 和 Prisma 的类型体系如果你不熟悉 TS,遇到类型报错还是有点懵。
不适合的也有:纯内容展示型站点、需要复杂搜索排序的电商后台、要和十几个外部系统对接的中台。T3 Stack 的舒适区是“一个数据库 + 一套页面 + 一种业务”,别硬撑。
2. 选型复盘:T3 全家桶里哪些值得留、哪些可以按需删
2.1 tRPC:把类型安全从数据库一路推到按钮
这一层是我觉得整个 T3 Stack 最上头的部分。以前写全栈,后端定义好 REST 接口,前端要靠手写或者工具生成接口类型,模型一变两边都要跟着改。tRPC 的做法是:你在服务端写一个procedure,前端直接引入同一个 client 调用,类型自动跟着走。新增一个字段、改一个参数名,编辑器立刻报错,根本不给运行时报错留机会。
很多人对 tRPC 的质疑是:它不就是一个不用 OpenAPI 的 RPC 吗?实际体验下来区别很大。OpenAPI 解决的是“多端多语言”的协议问题,tRPC 解决的是“单团队单代码库”的开发效率问题。如果你没有移动端、没有第三方商户要对接,tRPC 少掉的 schema 维护成本是实打实的。
我把这套思路用在灵感收集器上,效果是:改 Prisma 模型 → 跑prisma generate→ tRPC router 里的类型自动变 → 前端组件里的ctx.db也跟着变。整个链路没有任何手写接口文档的工作量。
2.2 Prisma:schema 即文档,但别被 ORM 惯坏
Prisma 在这套组合里承担数据访问层。它的核心价值是schema.prisma文件,数据模型一眼看完,关联关系清清楚楚。对比之前写 Sequelize 或者手写 SQL,Prisma 的迁移体验确实顺滑:npx prisma migrate dev会自动生成迁移文件,还能同步生成 TS 类型。
但是这里我想泼一点冷水:Prisma 用多了之后容易把 SQL 忘掉。它屏蔽了太多底层逻辑,导致遇到复杂查询时,你脑子里只剩include和where,写不出高效的 SQL 来兜底。我的建议是,用 Prisma 可以,但项目快慢查询、索引设计这些 DBA 基本功不要丢,否则一旦流量上来,Prisma 生成的 N+1 查询会教你做人。
2.3 NextAuth:一键登录背后的三个隐藏坑
T3 Stack 默认集成了 NextAuth(现在叫 Auth.js)。做用户体系时它确实省力,接入 GitHub 或 Google 登录只要配好环境变量就行。但“省力”只限于最初级的使用,实际跑起来有三个坑我建议你提前知道:
- 环境变量名不是你想的那样:不同版本、不同部署平台,需要的变量名可能不一样。
NEXTAUTH_SECRET在旧版叫这个,新版可能要用AUTH_SECRET。变量名错了,本地跑没事,线上必崩; - Session 策略选择:默认用
jwt还是database,关系到你能否在服务端拿到最新的用户数据。JWT 方案会把用户信息打包进 token,用户改名后不会同步刷新,数据库方案则相反; - 中间件鉴权的边界:NextAuth 自带的
middleware.ts只能保护页面级路由,保护 tRPC 的 mutation 必须自己在 procedure 里写protectedProcedure,两套系统要明确分工,不然就会出现页面能打开、API 一调就 401 的割裂感。
如果你只是内部工具,不想接三方登录,也可以直接去掉 NextAuth,自己用 Prisma 存用户表、用 JWT 在 header 里传。T3 的create-t3-app初始化时提供选项,选的时候想清楚。
2.4 Tailwind:效率导向的取舍
Tailwind 的争议一直很大,但在这个模板里我举双手赞成。原因很简单:它让样式代码和组件代码待在一起,改布局不用来回切文件。灵感收集器的界面很简单,一个左列表右详情的布局,Tailwind 的flex、grid、gap几个类一组合就完事了,没有设计系统负担。
真正让我对 Tailwind 改观的是它的响应式写法和状态修饰符。hover:、disabled:、sm:这些前缀直接在类名上表达状态,省掉了一堆 CSS 类名的命名纠结。如果你实在不喜欢,T3 也允许不用 Tailwind,换成 CSS Modules 完全可行。模板是死的,人是活的。
| 技术 | 我的定位 | 保留建议 |
|---|---|---|
| Next.js | 应用框架,SSR + 页面路由 | 必留 |
| TypeScript | 类型基础 | 必留 |
| tRPC | API 层,端到端类型 | 小团队必留,多端团队慎选 |
| Prisma | ORM,数据访问 | 复杂查询多则考虑换 SQL |
| Tailwind | UI 样式方案 | 偏好型,可替换 |
| NextAuth | 鉴权登录 | 单机内部工具可去掉 |
3. 搭起 t3code 的第一版:从初始化到第一个读写闭环
3.1 初始化命令与目录结构解读
初始化非常无脑,一条命令:
npx create-t3-app@latest t3code跟着交互提示选 TypeScript、Tailwind、tRPC、Prisma、NextAuth、App Router,等待依赖装完即可。装完之后的目录结构是这个样子,我会在括号里标注每层的作用:
src/ ├── app/ # 页面层(App Router) │ ├── api/auth/[...nextauth]/ # NextAuth 回调端点 │ ├── layout.tsx │ └── page.tsx ├── server/ │ ├── api/ │ │ ├── root.ts # tRPC 根路由,聚合所有子路由 │ │ ├── trpc.ts # procedure 基础定义 │ │ └── routers/ # 具体业务路由 │ ├── auth.ts # NextAuth 配置 │ └── db.ts # Prisma 客户端实例 ├── env.js # 环境变量校验 ├── styles/ └── trpc/我建议新接触的人先只看routers、db.ts和app这三个目录,其他暂时不用管。
3.2 灵感收集器的数据库模型
我的字段设计刻意保持简单,但把“多对多”和“关系过滤”这两个常见场景包含进去:
model User { id String @id @default(cuid()) name String? email String? @unique ideas Idea[] } model Idea { id String @id @default(cuid()) title String content String status String @default("open") tags Tag[] authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String ideas Idea[] }这一步的核心不是字段设计,而是理解@relation之后的数据库外键行为。Prisma 会根据authorId自动建立外键,你在 tRPC router 里写include: { author: true }就能顺手把关联用户捞出来。如果你之前没有用过 ORM,这里最值得先跑一次npx prisma migrate dev --name init看看生成的 SQL,你会更快理解表之间的约束。
3.3 用 tRPC 写第一个 mutation
先看根路由src/server/api/root.ts,它长这样:
export const appRouter = createTRPCRouter({ idea: ideaRouter, tag: tagRouter, });业务逻辑全在routers/idea.ts中。下面是一个创建灵感的 mutation,同时做了权限校验和输入校验:
export const ideaRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ title: z.string().min(1).max(80), content: z.string().min(1), tags: z.array(z.string()).default([]), })) .mutation(async ({ ctx, input }) => { const idea = await ctx.db.idea.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { connect: input.tags.map((name) => ({ name })), }, }, include: { tags: true }, }); return idea; }), });注意这里的protectedProcedure,它的定义里已经包含ctx.session的校验逻辑。如果用户没登录,请求会在进入业务函数前直接抛错。这是 T3 模板里给你设好的一道安全防线,自己写项目时一定不要把它绕过。
3.4 页面接入:服务端调用与客户端 mutation 的分工
App Router 下有两种调用 tRPC 的方式。服务端组件直接用api实例调 query,数据随着 SSR 直接渲染进 HTML,适合首屏列表;客户端组件则用useUtils()或直接调用 mutation,适合表单提交和交互回填。
// app/ideas/page.tsx(服务端组件) import { api } from "~/trpc/server"; export default async function IdeasPage() { const ideas = await api.idea.all(); return <IdeaList initialIdeas={ideas} />; }// components/idea-form.tsx(客户端组件) "use client"; import { api } from "~/trpc/react"; export function IdeaForm() { const utils = api.useUtils(); const createIdea = api.idea.create.useMutation({ onSuccess: () => utils.idea.all.invalidate(), }); // 表单逻辑省略 }关键在于onSuccess里的invalidate(),它会让服务端的idea.all()缓存失效并重新拉取。这个机制替代了以前 REST 模式里“提交成功后手动刷新列表”的繁琐操作,也是 tRPC 顺手把数据同步问题解决的典型场景。
4. 实测踩坑记录:类型安全在什么时候会“骗”你
4.1 Prisma 模型变更后 tRPC 类型不刷新
第一个坑出现在我往Idea模型加status字段之后。我改完 schema,跑了npx prisma migrate dev,然后回到 router 里准备引用input.status,编辑器居然一直报类型错误。
排查链路是这样的:先确认prisma generate是否跑成功,跑一下npx prisma generate再重启 dev server,问题仍在。接着检查tsc --noEmit,发现报错信息指向的是node_modules/.prisma/client的旧类型。最终定位到,create-t3-app 生成的db.ts里缓存了 Prisma Client 的实例,dev server 热更新不会主动感知 schema 变更。解决方案是改完 schema 后强制重启 TypeScript 的 dev server(npm run dev重新跑)。如果你用 VS Code,还会遇到 TS Server 缓存,按Ctrl+Shift+P执行 “TypeScript: Restart TS Server” 能救一半的报错。
这个坑的本质是“类型生成滞后”而不是类型不安全。它告诉你一个事实:端到端类型依赖的是一整套生成链路,任何一个环节缓存住了,编辑器显示的“安全”就是假象。
4.2 NextAuth 会话在 tRPC 中间件里对不上号
第二个坑更隐蔽。我在protectedProcedure里通过ctx.session.user.id去关联当前用户创建数据,本地测试一切正常,部署到线上后,所有需要登录的操作全部 401。
排查路径比较长:先查 NextAuth 回调日志,发现登录本身是成功的,session 也能创建;再给 tRPC 的 error handler 加日志,发现报错是“invalid signature”;最终定位到NEXTAUTH_SECRET不一致。
原因非常经典:本地.env和线上环境变量不一致,而 JWT 加密的 secret 一旦不匹配,服务端从 cookie 里解析 session 时签名验证必然失败。解决方案是讲究的:
- 本地、预览环境、生产环境各用自己的 secret;
- 不要手动复制生成值,用
openssl rand -base64 32一次性生成再注入平台; - 好一点的平台会自动注入,但如果你跨平台部署,一定要检查环境变量的作用域。
在 T3 模板里还有个容易漏的点:src/server/auth.ts中导出的authOptions如果被appRouter和 NextAuth API route 各自引用时,得保证是同一份配置对象。我在整理代码时发现有人会不知不觉 import 出两份相似的配置,导致 cookies key 不匹配,这是另一个 401 的来源。
4.3 环境变量缺失导致的隐性失败
create-t3-app 自带src/env.js做环境变量校验,这是我很喜欢的设计,但也会带来理解成本。它基于 Zod,所有变量在启动时校验,每个环境变量都严格区分server和client作用域。
我在一次 clone 模板时只复制了.env.example没改DATABASE_URL,启动后页面能打开,但一调用数据库查询就报错。加了日志才发现,连的是一台不存在的数据库。这里有两条经验:
- 凡是 Prisma 连接串、NextAuth secret、公共 API key,都放 env 校验里做 required,让启动时直接报错,而不是运行时再炸;
- 在 CI 里跑一次
npm run build前先做env校验,尽早发现变量缺失,避免每次都要等到部署后才看日志。
4.4 两端日期序列化的坑
最后一个坑是日期。Idea的createdAt在数据库里是DateTime,通过 tRPC 序列化传给前端后,变成 ISO 字符串,而不是前端友好的Date对象。我在详情页显示时间时用了.toLocaleDateString(),第一次渲染直接报“toLocaleDateString is not a function”。
排查时先检查了组件里拿到的数据,打印出来才确认是字符串类型。这个现象的本质是 JSON 序列化只保留原始值,tRPC 不会额外把字符串转回 Date。我的处理方案是写一个formatDate工具函数:
function formatDate(input: string | Date) { const date = typeof input === "string" ? new Date(input) : input; return date.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric", }); }另外服务端返回时如果涉及时区,最好统一用 UTC 存储、前端本地化展示,避免不同时区的用户看到不一致的时间。
5. 让 t3code 真正能上线:性能、部署与体验细节
5.1 服务端环境下的数据库连接池问题
T3 项目最常用的部署平台是 Vercel,而它默认是 serverless 环境,每个函数实例是独立的。Prisma 每次冷启动新实例都会尝试建立数据库连接,如果连接数没管控好,数据库很快会被打满。
一个务实的做法是在prisma.ts里增加连接池配置:
const prisma = new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL + "?connection_limit=5&pool_timeout=5", }, }, });如果你的数据库是 Postgres(比如 Neon、Supabase),建议再确认是否支持连接池。如果连接池不稳,更稳妥的方案是给 Prisma 开启加速层,让查询请求走远程缓存,避免每个函数实例都直连库。这个方案会引入额外成本,但对流量不稳定的小中型应用很有效。
5.2 哪些查询不该走数据库
t3code 做模板用了一段时间后,我发现列表页开始有点慢,一查才发现每次渲染都拉全量数据。其实类似“标签总数”“公开的灵感数”这种聚合型数据,完全没必要每次查库。tRPC 的缓存在此刻是好朋友。
tRPC v11 提供unstable_httpBatchStreamLink和相关缓存配置,对查询类接口可以设置cacheTime。更直接的办法是,把需要长缓存的数据挪到 React Server Component 里手动 fetch,或者在查询里配置staleTime:
api.idea.all.useQuery(undefined, { staleTime: 1000 * 60 * 5, });这行代码意味着五分钟内,相同参数的查询不会重新执行,直接读上一次的缓存。对不常变化的标签列表这种数据非常实用。
5.3 部署选择:Vercel 还是自托管
如果只部署一个项目,我强烈建议无脑上 Vercel,少操心。前提是你把环境变量配齐,数据库使用托管 Postgres。T3 模板甚至会自动识别是否在 Vercel 环境,构建时多了层保障。
自托管适合这些情况:数据不能出内网、团队已有容器化基建、或者 Vercel 流量费用失控。自托管时注意的是:
- 用 Node.js 20+ 运行,build 阶段需要环境变量;
- 数据库需要单独跑 migration,不能等应用启动时再迁移;
- 反向代理要把 WebSocket 支持打开,否则 tRPC 的订阅会被切断。
5.4 我为这个模板补充的三个体验细节
模板能跑通是一回事,好用是另一回事。我在 t3code 里加了三个小改动,都针对实际使用频率最高的场景:
第一,Form 的 loading 状态必须单独处理。用useMutation的isPending字段,在按钮上直接禁用并更换文字,防止用户重复提交。看起来小,但体验差异很大。
第二,列表页用骨架屏替代全屏 Spinner。服务端组件先渲染标题和布局,客户端再填数据,骨架屏只是占位;不要做一个居中转圈,页面一黑一闪很劝退。
第三,错误信息要面向人,而不是面向代码。tRPC 默认把 Zod 错误堆栈原样返回,你可以在 error formatter 里统一映射成可读文案,避免用户看到 “Expected string, received undefined” 这种话。
这些东西不复杂,但它们在细节上拉高了“正式项目”和“demo”之间的距离。
如果你准备拿 t3code 做模板,我最后再补一句真实感受:模板只能帮你解决“起步”,真正让你稳定的,是对类型链路和边界条件的理解。把 Prisma 模型想清楚,把权限边界确认好,上线后你会省下很多像“为什么我调不通接口”这种自我怀疑。先用一个真实的小需求把这套流程完整走一遍,比背十篇教程都管用。