首页 / 资讯中心 / 文章详情

基于T3 Stack的t3code全栈模板:从搭建到上线避坑指南

基于T3 Stack的t3code全栈模板:从搭建到上线避坑指南 ★ FEATURED ARTICLE
如果你经常混前端圈子大概率见过一堆以 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 Prismaschema 即文档但别被 ORM 惯坏Prisma 在这套组合里承担数据访问层。它的核心价值是schema.prisma文件数据模型一眼看完关联关系清清楚楚。对比之前写 Sequelize 或者手写 SQLPrisma 的迁移体验确实顺滑npx prisma migrate dev会自动生成迁移文件还能同步生成 TS 类型。但是这里我想泼一点冷水Prisma 用多了之后容易把 SQL 忘掉。它屏蔽了太多底层逻辑导致遇到复杂查询时你脑子里只剩include和where写不出高效的 SQL 来兜底。我的建议是用 Prisma 可以但项目快慢查询、索引设计这些 DBA 基本功不要丢否则一旦流量上来Prisma 生成的 N1 查询会教你做人。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类型基础必留tRPCAPI 层端到端类型小团队必留多端团队慎选PrismaORM数据访问复杂查询多则考虑换 SQLTailwindUI 样式方案偏好型可替换NextAuth鉴权登录单机内部工具可去掉3. 搭起 t3code 的第一版从初始化到第一个读写闭环3.1 初始化命令与目录结构解读初始化非常无脑一条命令npx create-t3-applatest 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 servernpm run dev重新跑。如果你用 VS Code还会遇到 TS Server 缓存按CtrlShiftP执行 “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_limit5pool_timeout5, }, }, });如果你的数据库是 Postgres比如 Neon、Supabase建议再确认是否支持连接池。如果连接池不稳更稳妥的方案是给 Prisma 开启加速层让查询请求走远程缓存避免每个函数实例都直连库。这个方案会引入额外成本但对流量不稳定的小中型应用很有效。5.2 哪些查询不该走数据库t3code 做模板用了一段时间后我发现列表页开始有点慢一查才发现每次渲染都拉全量数据。其实类似“标签总数”“公开的灵感数”这种聚合型数据完全没必要每次查库。tRPC 的缓存在此刻是好朋友。tRPC v11 提供unstable_httpBatchStreamLink和相关缓存配置对查询类接口可以设置cacheTime。更直接的办法是把需要长缓存的数据挪到 React Server Component 里手动 fetch或者在查询里配置staleTimeapi.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 模型想清楚把权限边界确认好上线后你会省下很多像“为什么我调不通接口”这种自我怀疑。先用一个真实的小需求把这套流程完整走一遍比背十篇教程都管用。
阅读完成 · 觉得有帮助?
咨询建站