T3 Stack实战:t3code全栈脚手架搭建与踩坑指南

发布时间:2026/10/26 23:23:57
T3 Stack实战:t3code全栈脚手架搭建与踩坑指南 最近有朋友问我t3code 这个名字到底是怎么来的是不是又一个“套壳脚手架”。我干脆把整个项目从头到尾复盘了一遍写在这篇长文里。t3code 是我维护的一个全栈项目脚手架核心理念来自 T3 Stack把一个现代 Web 应用最常用的技术组合通过一条命令变成一个可以直接开发、直接上线的基础工程。这篇文章会从需求背景、技术选型、完整搭建过程到踩坑排查把你可能遇到的问题都过一遍适合正在选型全栈技术栈、想用 Next.js 起项目、或者单纯想搞明白 T3 Stack 为什么这么设计的开发者参考。1. 为什么叫“t3code”先想清楚这到底要解决什么问题1.1 T3 Stack 不是“三个技术”那么简单很多人第一次听到 T3 Stack以为它就是“TypeScript 三个框架”的拼盘。我第一次接触时也这么想后来才意识到它的核心不是技术堆叠而是一套选型哲学。T3 里的 T指的是 TypeScript3 则是三条原则类型安全、全栈一体、务实主义。也就是说凡是能在编译期解决的问题不要让用户留到运行期去猜。t3code 这个项目名本质上是把这套哲学落成一个代码仓库。它不是给你一堆 Hello World 组件而是给你一套完整的工程底座数据库模型、用户鉴权、API 路由、前端页面、部署配置全部打通。你需要做的不是“重新发明轮子”而是往上添加自己的业务代码。我见过太多项目死在初始化阶段前后端分离API 文档靠手写类型定义两边不同步改一个数据库字段要改五个文件。t3code 要避免的正是这种局面。它把数据层、逻辑层、界面层放在同一个 TypeScript 项目里让类型从数据库一直贯穿到按钮点击事件从源头消除“字段拼错了但没人发现”的问题。1.2 t3code 与 create-t3-app 的关系如果你查过资料一定知道官方有个 create-t3-app它已经是社区里很成熟的脚手架了。那 t3code 和它是什么关系简单说t3code 不是重复造轮子而是基于 create-t3-app 的核心理念做了一套更偏向个人工程实践的封装。create-t3-app 给你的是一个最简选项Next.js、TypeScript、Tailwind、tRPC、Prisma、NextAuth你按需勾选。但真正进入业务开发时你会发现还缺很多东西统一错误处理、权限校验封装、日志与埋点、环境变量规范、Docker 部署配置。t3code 的目标是把我这些年写业务项目时积累的默认约定直接放到模板里。你可以把 t3code 理解为“create-t3-app 的实战增强版”。我自己的使用场景很透明接外包项目、做内部工具、给团队做新项目初始化。每开一个新项目如果都要从头搭一遍鉴权和数据库访问层效率太低了。有了 t3code我初始化完一个项目大概半小时就能把基础的用户登录、数据表格、增删改查跑通剩下的时间全部留给业务开发这个节奏是让我坚持维护它的主要原因。1.3 我为什么不用“前端 后端”分目录很多传统全栈项目喜欢拆成client和server两个目录甚至拆成两个仓库。这样做的好处是部署独立、团队边界清晰但代价也很大你定义了接口比如GET /api/user/list然后前端要维护一份对应的 TypeScript 类型后端也要维护一份。数据库字段一变两边漏改一个运行期就出一个神秘的 undefined。t3code 选择的是把所有代码放进同一个 Next.js 应用里通过文件规范来区分边界而不是通过物理目录来隔离。这样做最直接的好处是类型共享变成零成本。tRPC 的 Router 定义好了前端 page 组件里直接import { api } from ~/utils/api调用一个函数返回值的类型就是后端精确推断出来的。我不用再为“TypeError: Cannot read properties of undefined (reading name)”这类问题浪费一个小时。你说这是不是一种退步对于超大型团队独立微服务可能更合适。但如果你也像我一样主要做中小型业务系统、内部工具、独立开发产品单体全栈应用才是开发和维护成本最低的方案。t3code 就是为这种场景设计的它让你先专注业务逻辑等到确实需要拆分服务时再拆而不是一开始就背着分布式的大炮去打蚊子。2. 技术选型拆解每一层都在解决什么痛点2.1 Next.js 14把“全栈容器”这个位置坐稳Next.js 在这套技术栈里的角色很容易被低估。很多人只把它当成 React 框架用来做前端页面这是对它的浪费。Next.js 14 的 App Router 带来的服务端组件、路由处理器、中间件能力让一个项目里可以同时存在服务端逻辑和客户端交互并且它们共享一套组件树和类型系统。我在 t3code 里依赖的核心能力有三个。第一服务端组件默认不把 JavaScript 发送到浏览器页面初始加载更快这对后台系统来说体验提升很明显。第二Route Handlers 用来承载一些非 tRPC 的接口比如 Webhook 回调、文件上传这些场景不适合走 RPC 协议但 Next.js 可以直接在同一个应用里给你提供能力。第三Middleware 可以在请求到达页面之前做拦截像是简单的登录态跳转我不用在每个页面组件里重复写判断。选 Next.js 还有一个很务实的原因部署生态成熟。Vercel、Docker、Node.js 裸机都能跑。t3code 的模板默认支持next build next start这种最普通的方式这意味着你随便找一台服务器就能部署不需要为框架本身引入额外的运维负担。2.2 tRPC在 REST 和 GraphQL 之间我选了第三条路先讲清楚 tRPC 是什么它不定义传输层协议而是让客户端像调用本地函数一样调用服务端函数。底层走的还是 HTTP POST数据序列化默认用 JSON但类型是端到端的。你不需要手写 API 文档不需要手动定义接口类型Router 就是唯一的真相。相对于 RESTtRPC 最大的优势是开发效率。REST 要求你先设计资源路径、动词、状态码然后再为每个接口写请求函数和类型定义。这些工作在项目初期看起来没什么但当你有 50 个接口时维护成本就上来了。tRPC 不逼你做这些形式主义事情你写一个函数定一个inputschema前端就能直接调我甚至觉得它比 GraphQL 更适合中小型项目。GraphQL 是另一条路。它的查询灵活性和协议标准化确实很好但这套体系需要你维护 Schema、Resolver、客户端缓存策略对团队要求很高。t3code 不排斥 GraphQL但从“两个人三天做完一个功能模块”的角度看tRPC 的学习成本和实现成本都低得多。这里有一个反面教训我曾在自己的项目里先用 REST后来因为每个接口都要重复定义参数类型切到 tRPC 后代码量直接减少约三成且再也没出过“前端说字段名不对”的接口联调问题。2.3 Prisma 与 NextAuth安全区和最后一公里Prisma 解决的是数据访问层的痛点。你不用写 SQL 拼接不用手动维护模型类只需要一个schema.prisma文件定义数据模型然后运行迁移命令数据库表和 TypeScript 类型就同时生成了。这在 t3code 里是“数据库模型即类型定义”的关键一环详细的案例在第三章讲。NextAuth 则解决鉴权“最后一公里”问题。它的好处是不需要自己设计密码找回、Session 存储这些通用流程接入 OAuth 只需要一个 Config 对象。t3code 默认使用 Credentials 登录和数据加密同时在authOptions中配置 Session 策略这样 app router 的 server side 和 tRPC 都能共享同一套会话状态。我选它的另一个原因是安全性成熟。框架对 Session 注入、CSRF 防护、密码哈希策略都有默认处理。当然它也不是万能的第三方的 OAuth 配置里很容易踩坑这块我会放在第四章排查记录里细说。3. 从零搭出一个 t3code完整实操记录3.1 环境准备与初始化命令我建议你准备一个比较干净的 Node.js 环境Node.js 18.17.0 或更高版本包管理器用 pnpm 或 npm 都可以。t3code 本身不需要特殊依赖但 Next.js 14 对 Node 版本有要求版本低了构建会直接报错。初始化 t3code 项目最简单的方式直接用官方 create-t3-appnpm create t3-applatest my-t3code-app初始化时会让你选择需要包含的模块。我的建议模块是否勾选原因Next.js必选全栈容器不解释TypeScript必选类型安全的第一道保障Tailwind CSS推荐样式效率高模板里已经配好tRPC必选t3code 的核心通信层Prisma推荐数据库建模和迁移是刚需NextAuth按需没做登录需求可以后续再加Drizzle可选如果更偏好 SQL 优先的 ORM 可以选它替代 Prisma初始化完进入目录安装依赖cd my-t3code-app pnpm install pnpm db:push pnpm devdb:push是 Prisma 在没有迁移文件时直接把当前 schema 同步到数据库的命令。开发阶段非常方便但它不会生成迁移历史所以团队协作或生产环境请使用prisma migrate dev。3.2 目录结构哪些文件是 t3code 的灵魂初始化完成后第一件事是打开项目把目录结构读明白。很多人初始化完就直接改代码只改了几分钟就把类型搞乱了。我建议你花十分钟按下面的顺序看文件prisma/schema.prisma # 数据模型定义 src/server/db.ts # Prisma Client 单例 src/server/api/root.ts # tRPC 根 Router聚合所有子 Router src/server/api/routers/ # 每一个业务模块的 Router 文件 src/server/api/trpc.ts # tRPC 上下文和中间件定义这里定义了 protectedProcedure src/server/auth.ts # NextAuth 配置 src/app/api/trpc/[trpc]/route.ts # 前端请求 tRPC 的入口 src/app/page.tsx # 首页,演示了客户端调用 tRPC src/utils/api.ts # 前端用到的 tRPC createTRPCNext 适配器对初学者来说最容易忽略的是trpc.ts。它做了两件关键事情第一把当前请求的 Session 塞进上下文这样你每个 Router 都能拿到当前用户第二定义了一个protectedProcedure任何需要登录才能访问的接口都走这个中间件。这里有一个典型的开发场景你新建了一个routers/user.ts想实现“返回当前用户信息”。如果你直接用publicProcedure没登录的人也能调用。正确的做法是用protectedProcedure。这个定义不只是一个约定更是类型层面的约束你可以在后面的代码里直接感受到。3.3 关键代码实现Router、鉴权、数据模型的写法第一步数据模型。打开prisma/schema.prisma先建立两张最基础的表User 和 Task。Task 属于某个用户形成一对多关系。model User { id String id default(cuid()) name String? email String unique passwordHash String? createdAt DateTime default(now()) tasks Task[] } model Task { id String id default(cuid()) title String completed Boolean default(false) userId String user User relation(fields: [userId], references: [id]) createdAt DateTime default(now()) }这里要注意email加了unique这是很多应用的基础假设否则后面查询会出错。passwordHash不能是必填因为 OAuth 登录的用户可能没有密码。第二步定义 tRPC Router。新建src/server/api/routers/task.tsimport { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const taskRouter createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) { return ctx.db.task.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const task await ctx.db.task.findUnique({ where: { id: input.id } }); if (!task || task.userId ! ctx.session.user.id) { throw new Error(任务不存在或无权操作); } return ctx.db.task.update({ where: { id: input.id }, data: { completed: !task.completed }, }); }), });这段代码有三个关键点。第一所有操作都带上了ctx.session.user.id从数据库层面就锁死了“只能操作自己的数据”这是防止越权的第一道防线。第二create里的input(z.object(...))是 zod 校验。如果前端传了一个空字符串标题这个请求根本不会进入数据库层在 tRPC 入口就会被拦截。这种“传入数据即验证”的思路比在服务端函数体里逐行if判断要干净得多。第三toggle里我先findUnique再判断 userId再执行 update。这一步看似多了一个查询但避免了“条件更新没命中却返回成功”的尴尬也更安全。第三步接入根 Router。打开root.ts把子 Router 装配起来import { createTRPCRouter } from ~/server/api/trpc; import { taskRouter } from ./task; import { userRouter } from ./user; export const appRouter createTRPCRouter({ task: taskRouter, user: userRouter, }); export type AppRouter typeof appRouter;第四步前端调用。在page.tsx或任意客户端组件中use client; import { api } from ~/utils/api; export function TaskList() { const { data: tasks, refetch } api.task.list.useQuery(); const createTask api.task.create.useMutation({ onSuccess: () refetch(), }); return ( ul {tasks?.map((task) ( li key{task.id} span{task.title}/span span{task.completed ? 完成 : 未完成}/span /li ))} /ul ); }这里api.task.list.useQuery的类型是直接从AppRouter推断出来的。你在taskRouter 定义里写返回Task[]前端拿到的就是完整的Task[]类型。这就是端到端类型安全最直观的体现也是我推荐你把 t3code 当脚手架底座的核心理由。3.4 鉴权流程配置NextAuth 与 tRPC 共享 SessionNextAuth 在 t3code 里的配置统一放在src/server/auth.ts。我需要把它和 tRPC 的上下文打通具体在trpc.ts里实现import { getServerAuthSession } from ~/server/auth; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db, session, ...opts, }; };这样每个 tRPC 请求都会去读取当前请求的 Session。前端登录后Session 的 Cookie 会自动带上后端上下文里就能拿到 user 信息。接下来看 Credentials 登录怎么配import Credentials from next-auth/providers/credentials; import bcrypt from bcryptjs; export const authOptions { session: { strategy: jwt }, providers: [ Credentials({ credentials: { email: {}, password: {} }, authorize: async (credentials) { const user await db.user.findUnique({ where: { email: credentials.email }, }); if (!user) return null; const isValid await bcrypt.compare(credentials.password, user.passwordHash); if (!isValid) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ], callbacks: { jwt: ({ token, user }) { if (user) token.id user.id; return token; }, session: ({ session, token }) { if (session.user) session.user.id token.id; return session; }, }, };这里的bcrypt.compare比直接用明文密码安全一个数量级。我在真实项目里发现很多人习惯把密码校验写在自己写的登录接口里但在 t3code 里你直接复用 NextAuth 的 authorize 回调即可没必要重复造轮子。注意上面用了session: { strategy: jwt }这是为了让 Session 数据不落数据库对无状态部署更友好。4. 常见问题与排查实录4.1 tRPC 类型报错为什么服务端改了类型前端还是旧类型t3code 日常开发中我最常遇到的问题是服务端 Router 改了返回值结构但前端useQuery拿到的数据还是老结构甚至直接报类型错误。原因有两类。第一类是 t3code 的客户端类型没有热更新干净需要重开开发服务器或者重启next dev。这个问题在 Next.js 14 tRPC 10 里偶尔出现解决方法是关掉进程重新pnpm dev通常会恢复。第二类是前端 import 路径写错没有从~/utils/api引入api而是自己创建了另一个 tRPC 客户端实例导致泛型无法正确绑定 AppRouter。排查技巧先从root.ts开头确认appRouter里已经挂了该子 Router再看utils/api.ts的createTRPCNext泛型是不是传了AppRouter最后检查引入路径。你只要保证整个应用只有一个api实例这个报错基本就能解决。4.2 Prisma 与数据库的同步问题为什么字段改了但查询不到我踩过一个大坑在schema.prisma里给 Task 模型加了priority字段然后直接pnpm db:push前端组件也开始引用task.priority结果数据库一直报“column does not exist”。原因很简单db:push在开发时确实能快速同步 schema 到数据库但它不会重新生成完整的迁移记录而且如果你本地开发用的DATABASE_URL和生产环境不是同一个库就会把生产库漏掉。正确做法是开发环境用prisma migrate dev --name add_priority生成正式的迁移 SQL提交到代码仓库里然后在服务器上跑prisma migrate deploy。另外一个更隐蔽的问题Prisma Client 的生成类型是缓存在node_modules/.prisma/client里的。修改 schema 后如果没有触发prisma generateTS 类型可能还是旧的。遇到“明明定义了字段但 TS 报错”的情况先执行一次pnpm prisma generate不要急着怀疑框架。4.3 NextAuth 会话在 tRPC 上下文里一直是 null这个问题最典型的场景前端已经登录成功但调用 tRPC 的protectedProcedure仍然返回 UNAUTHORIZED或者ctx.session是 null。我需要先排查NEXTAUTH_SECRET环境变量。NextAuth 在开发模式如果没设置它虽然能登录但在重启服务后会丢失 JWT 签名Session 校验就会失败。t3code 那个.env文件里默认生成了一段随机字符串你不用改它但要注意把它也配到生产环境。另一个点如果调用 tRPC 的请求来自不同域名比如前端跑在 3000 端口后端接口在 3001 端口Cookie 的跨域设置就会让 Session 丢失。t3code 默认是单体应用所以没这个问题但如果你把 tRPC 拆出去需要配置 NextAuth 的cookies策略和前端请求的credentials: include。在单体模式下请务必保持前端页面的 Host 和 API 请求的 Host 一致。还有一个我犯过的低级错误在middleware.ts里写全局登录拦截但没有放行/api/trpc路径导致登录接口本身都被拦截了Session 永远建立不起来。类似情况可以在中间件的 matcher 里明确排除export const config { matcher: [/((?!api|_next/static|_next/image|favicon.ico).*)], };4.4 Tailwind 样式不生效或类名顺序不对Tailwind 在 t3code 里是默认配好的但如果你新开了页面发现类名没有生效大概率是文件路径没有被 content 匹配到。打开tailwind.config.ts确认content里包含你的页面路径content: [./src/**/*.{js,ts,jsx,tsx,mdx}],另一个小知识如果你用了apply指令要确保它在全局 CSS 文件里而不是在某个组件的 CSS 模块里。Tailwind 的apply只识别它能扫描到的内容局部的 CSS Module 有时解析不到导致构建报错。5. 后续扩展从 t3code 长出自己的业务代码5.1 让模板为业务服务的三个技巧脚手架能帮你提速但它不能替你思考业务。我在 t3code 上跑过三个不同类型的项目后总结出三个实用的扩展技巧。第一给所有protectedProcedure加上权限校验中间件。默认的protectedProcedure只校验“用户是否登录”但真实系统里角色权限很重要。你可以在trpc.ts里继续封装adminProcedure比如export const adminProcedure protectedProcedure.use(async ({ ctx, next }) { if (ctx.session.user.role ! ADMIN) { throw new Error(需要管理员权限); } return next({ ctx }); });这样管理后台的接口全部挂adminProcedure普通用户即使伪造请求也无法触碰。权限校验集中在一个中间件文件夹里而不是散落在各个 Router 中。第二规划好routers/目录的模块划分。一个业务模块一个文件文件内的 query 和 mutation 数量控制在一个可读的范围。如果某个文件超过 200 行我会拆成task.ts、taskComment.ts、taskAttachment.ts这类细粒度 Router再在root.ts里聚合。这样类型推断和调试都更直观。第三给复杂查询写 tRPCinput校验时尽量把 zod schema 抽出来。比如列表查询的筛选条件可能在多个 mutation 里复用。定义一个taskFilterSchema然后组合成create/list的 input能减少重复并且让接口的约束一目了然。5.2 我最后想说的三个心法心法说明开发期靠 db:push发布期必须靠 migrate我吃亏过才知道快速同步和正式迁移要分开一个项目只保留一个 api 实例多个 tRPC 客户端会导致类型混乱别自找麻烦权限校验一律走中间件不要在 20 个 Router 里各写一遍判断t3code 这个项目对我而言不是终点它更像一套我反复验证过的“默认值”。每次起新项目我不需要再纠结用什么数据库、怎么处理鉴权、接口类型怎么同步这些已经沉淀在模板里了。我也把里面积累的错误处理规范、代码组织习惯、环境变量约定慢慢磨进了日常的开发流程中。如果你也想少踩一些初始化阶段的坑我建议你直接拿它起一个项目试试跑一个完整流程下来你的收获会比看十篇文章都大。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询