很多人第一次看到 t3code 这个命名都会下意识认为是某个第三方库或者在线工具。其实它是我的一个个人全栈项目代号一套基于T3 Stack搭建的、以“类型安全”为第一原则的现代 Web 应用模板。T3 Stack 的核心组合是 Next.js、tRPC、Prisma、Tailwind CSS再配合 NextAuth 做认证。t3code 本质上就是把这一套东西固化下来做成一个开箱即用的起点帮我自己和团队省掉每次新建项目时重复的环境搭建和胶水代码。这篇文章就把我在搭建 t3code 过程中踩过的坑、验证过的方案、以及每个设计取舍背后的思考完整记录下来给想上手 T3 Stack 或者准备自建全栈模板的朋友一个可复现的参考。先说结论如果你追求的类型安全不是“嘴上说说”而是真的想让后端接口类型直接贯穿到前端组件里那 T3 Stack 是我目前用下来最舒服的组合。t3code 这个名字可以理解为 T3 生态的实践代码集它不是一个框架而是一套约束。它用create-t3-app初始化按官方约定维护但我在此基础上加了新的目录规范、权限封装、部署脚本和一整套排查笔记。下面从项目定位开始一步步拆解。1. T3 Stack 与 t3code 的项目定位1.1 T3 Stack 到底是什么T3 Stack 不是一个新框架而是四个成熟工具的固定搭配Next.js前端和后端路由、tRPC端到端类型安全 API、Prisma数据库 ORM、Tailwind CSS样式方案以及可选的 NextAuth认证。这套组合最核心的理念是“类型安全贯穿前后端全链路”。举个生活中容易理解的例子传统前后端对接就像两个各说各话的人前端要拿接口文档猜后端返回什么字段后端也要小心维护一份文档防止参数被传错。tRPC 的存在等于给这两个人配了一个既懂前端又懂后端的翻译官前端调后端接口时代码提示里直接出现后端定义的参数名和类型。如果后端改了字段名前端编译阶段就直接报错而不是等运行时才发现。既然单个工具各自都很成熟组合起来的价值主要在于减少概念切换成本。我不需要为 API 层单独设计一套 RESTful 资源路径不需要手写 Swagger 注解也不需要前端再定义一份重复的 interface。这种体验在中小型项目里尤其明显很多类型定义写一遍就够了维护成本直线下降。1.2 t3code 要解决哪些实际问题我做 t3code 之前团队里每次启动新项目都要花大半天配置Next.js 脚手架、ESLint 规则、Prisma 连接、认证回跳、API 路由结构……配置方式五花八门不同老哥甚至用不同的目录规范交接时非常痛苦。t3code 的定位就是把这些散落的约定固化成一个模板。具体解决的问题有三类项目启动成本一个命令拉下来环境变量填好数据库 migrate 完项目直接能跑。类型一致性问题后端写好的 schema 和 API 类型前端自动继承不会再出现“明明后端改了字段前端文档没同步”的情况。权限与认证的重复劳动把登录、会话、受保护接口、角色判断全部封装完成新业务只需要关心自己那部分。适配人群方面如果你是一个准备学习全栈开发的个人开发者或者一个小团队想统一技术栈t3code 里这套思路可以直接借鉴。但如果你是大型组织需要微服务拆分、多团队独立部署这种单体重型的 T3 Stack 可能就不太适合模板的构建思路还是可以参考。2. 技术选型与工程化配置2.1 使用 create-t3-app 初始化项目t3code 的脚手架是用官方提供的create-t3-app生成的。我刻意选择默认推荐选项而不是全选所有插件。因为模板的第一目标是稳定不是功能堆叠。初始化命令如下npx create-t3-applatest t3code交互式选项里我选了 Next.js 14 的 App Router、tRPC、Prisma、Tailwind CSS、NextAuth。没有选的是 Docker 相关、Drizzle ORM 和 Playwright。虽然 Drizzle 在 TypeScript 类型推导上确实比 Prisma 更激进但 Prisma 的生态成熟度、迁移工具链和可视化 Studio 对团队协作更友好所以保守起见用了 Prisma。目录结构上默认脚手架已经分配好了主要区域src/ app/ # Next.js App Router 的页面和路由 server/ # 服务端逻辑包括 tRPC router、Prisma 客户端、认证配置 trpc/ # tRPC 的客户端和服务端桥接代码新增业务代码时我的习惯是页面与布局放在app里数据模型和数据库访问逻辑统一收敛到server侧不要在页面文件里直接写 Prisma 查询。这样做的好处是业务路由和服务端逻辑解耦将来如果要把某个模块抽离成独立服务只需要把server内对应的 router 暴露出去不需要改页面代码。2.2 环境变量体系与约束t3code 的环境变量统一由 Zod 做运行时校验这可能是很多人忽略但非常重要的一点。初始化完成后env.js文件里会针对每个环境变量定义 schema。比如DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL如果缺失或格式错误项目启动会直接抛出明确异常。这一点在团队协作里特别有意义新同事 clone 项目后如果只是照着.env.example复制漏掉了某个变量会在启动时立刻看到错误而不是等到登录接口调不通才排查。我可以把这种校验机制当成一种“配置即文档”。实际项目中我还在.env.example里加上了每项配置的注释说明它从哪里获取。例如GITHUB_ID来自 GitHub OAuth Apps 的 Client ID。实际使用时本地开发环境用一个本地 URL生产环境换成正式域名。这里踩过的坑就是NEXTAUTH_URL如果忘记设置NextAuth 有时候会自动推断本地地址部署到线上后登录回调就会错误指向 localhost所以现在一律显式配置。2.3 约定优于配置t3code 从脚手架继承了一个非常推荐的约定页面组件默认是 server component只有确实需要前端交互的地方才加use client。这意味着大部分数据获取可以直接在服务端进行再用 props 传给子组件。tRPC 在这套架构里有两种用法的选择但我个人更偏向在需要“实时客户端状态变更”的场景用 tRPC hooks比如写评论、点赞、表单提交。如果只是首次加载页面数据就在 server component 里调用 tRPC 的 server caller避免往返。这两种方式在 t3code 模板里都有对应的示例后面实操部分会展示。3. 核心功能实现数据库、API 与前端界面全链路3.1 用 Prisma 定义数据模型以 t3code 内置的“文章 Post 用户 User”这个最小闭环为例数据模型如下model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) updatedAt DateTime updatedAt index([authorId]) }这里特意把authorId暴露为普通字段而不是仅存在关系字段author是为了让 tRPC 查询时写起来更清晰。Prisma 里关系字段与标量字段可以并存但如果你不在 schema 里显式声明authorId它会自动生成但不可被select语句直接引用这会让排查问题时多一层困扰。执行迁移命令npx prisma migrate dev --name init这里的dev模式会自动生成一个增量迁移文件同时触发 TypeScript 类型重新生成。写过数据库迁移的人都懂最怕的是不同环境间表结构漂移。所以我在 t3code 的文档里明确要求所有 schema 变更必须以迁移文件为准禁止手改数据库。一个小建议主键选择cuid()而不是自增整数。在全栈项目里cuid 字符串可以避免“猜测资源数量”的安全风险也能让 ID 直接在客户端生成不会每次都要等数据库返回自增 ID。劣势是索引体积稍大但这一点对绝大多数业务来说完全可忽略。3.2 tRPC Router 与受保护接口tRPC 的结构在脚手架里已经分层好了root.ts汇总所有子路由trpc.ts定义procedure类型。t3code 在这个基础上增加了一个关键约定所有需要鉴权的业务接口都显式声明从protectedProcedure继承。示例代码如下import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; import { z } from zod; export const postRouter createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, take: 20, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(120), content: z.string().min(1).max(10000), }) ) .mutation(async ({ ctx, input }) { const post await ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); return post; }), });这里有几个细节值得展开。create接口用了protectedProcedure意味着 tRPC 会在调用 handler 之前先检查会话是否存在。由于 NextAuth 的 session 数据已经在中间件里被写入到上下文所以 handler 里可以直接用ctx.session.user.id。这比在每一个业务函数里手写“if (!ctx.session) throw ...”要干净得多。input用 Zod 做运行时校验。Zod 的好处是解析后的数据会带着完整的类型传给 handler配合 TypeScript 静态类型前端传参如果类型不对直接编译期报错手写接口文档完全多余。关于错误提示的坑如果你在create里不校验title长度数据库层也会有约束兜底但 Prisma 抛出的错误信息是英文且比较底层用户在前端看到的可能就是“字段 title 过长”之类的原始错误。所以用 Zod 做第一道防线还是很有必要的。你可以在 Router 里对错误做二次包装但最基本的输入约束必须在源头做好。3.3 前端调用与自动类型推导tRPC 最有感知的优势在 React 组件里体现。以前写 REST 接口前端要手写fetch(/api/posts)再.then(res res.json())而后端改字段名之后前端往往悄无声息地挂掉。t3code 里的写法如下use client; import { api } from ~/trpc/react; export function PostList() { const { data: posts, isLoading } api.post.list.useQuery(); if (isLoading) return div加载中.../div; return ( div classNamespace-y-4 {posts?.map((post) ( article key{post.id} h2{post.title}/h2 p{post.content}/p /article ))} /div ); }注意api.post.list这部分api对象后面是直接跟着后端 router 路径的鼠标悬停可以看到list的入参和出参类型。如果我把Post模型的title字段改成titleText前端这里编译立刻报红我都来不及刷新浏览器就发现了问题。useQuery返回的data类型是自动推断的无需手写 interface。这在业务迭代很快的项目里太省事了改数据库模型意味着改 schema → 迁移 → 前端类型自动跟随三步搞定完全没有重复劳动。错误处理tRPC 的 error 对象带有状态码。我倾向于在useQuery的onError回调里判断error.data.httpStatus如果是 401 就引导用户重新登录如果是 403 就提示无权限其余情况统一展示通用错误提示。不要在默认错误处理里把服务端错误原封不动显示出来很容易暴露内部实现细节。4. 认证与权限控制4.1 集成 NextAuth 与 OAuth 登录t3code 默认内置了 GitHub 和 Google 两种 OAuth provider并保留 Credentials provider 的扩展位。实际配置时在.env里填入对应的 Client ID 和 SecretGITHUB_CLIENT_IDxxx GITHUB_CLIENT_SECRETxxx NEXTAUTH_SECRET随意生成的长随机字符串 NEXTAUTH_URLhttp://localhost:3000NEXTAUTH_SECRET可以用openssl rand -base64 32生成。它负责加密 session token没有它 NextAuth 根本起不来。这个 key 的保管要像密码一样严格一旦泄露任何人都可以伪造 session。在auth.ts中我覆盖了session回调把用户 ID 注入到 session 对象里callbacks: { session({ session, user }) { if (session.user) { session.user.id user.id; } return session; }, }这一步很关键。因为默认的 session 只携带子集字段不额外加 id 的话后续 tRPC 里拿不到当前用户的主键权限判断就只有 email 没有 id查库还要多一步转换。t3code 里把 id 直接挂到 session.user 上让后续 router 开发时可以快速拿用户标识。4.2 前端守卫与服务端守卫的一致性权限控制有一个很容易犯的错误前端靠“隐藏按钮/路由”来限制操作。真实的安全边界一定在服务端。t3code 里我做了两层前端判断session.status来决定渲染登录按钮还是评论区后端用protectedProcedure保证接口本身有会话校验。举个例子页面上评论区的“提交”按钮没有登录时我是直接隐藏的。但就算有人绕过前端直接调用 tRPC 的post.createmutation服务端也会把他拦下来因为protectedProcedure会检查 session。曾经测试过这段逻辑未登录调用接口返回的是UNAUTHORIZED错误码并不是 500说明链路是对的。关于 owner 校验只做登录校验还不够。如果要实现“只能删除自己的文章”不能只判断用户是否登录还要比较post.authorId是否等于ctx.session.user.id。这一步最好封装成一个工具函数比如assertCanDelete(ctx, postId)返回明文错误码。后续如果要做管理员角色也可以在同一层扩展。5. 部署与性能优化5.1 团队协作中的检查链条t3code 沿用 create-t3-app 自带的脚手架检查脚本type-check、lint、format。这三个操作是接入 git hooks 的。比如在package.json里{ scripts: { build: next build, type-check: tsc --noEmit, lint: eslint ., format: prettier --write ., precommit: lint-staged } }我对这套机制体会很深一个全栈项目前后端类型是联动的如果某个人改了 schema 没有重新生成类型就提交上去其他人拉代码后编译就会报错。所以类型检查放在前置流程里等于强制所有人都按同一套流程走。团队协作中清晰的错误比“悄悄跑不通过”要友好得多。5.2 数据库索引与迁移管理Post 模型的index([authorId])不是随便加的。对于一个以用户维度查询文章的应用WHERE authorId ?如果没有索引数据库会把整个表扫一遍。索引在这个场景里比多加一台服务器还管用。Prisma 迁移会自动创建这个索引所以不要省略这个声明。迁移管理的心得在团队里最大的噩梦是两位同事在各自分支上分别新增了迁移导致迁移文件冲突。我现在用两个策略缓解第一所有迁移文件必须进代码审查第二尽量把一次性变更拆成多个小迁移避免一个巨大的 schema 变更。如果迁移真冲突了prisma migrate diff可以从 schema 直接生成 SQL再用prisma db execute手工修正虽然麻烦但可解。5.3 打包部署与运行时注意项部署方面我用的是 Vercel 平台因为 Next.js 本身就是它家的零配置体验确实好。但就算上 Vercel还是有几件事必须处理环境变量NEXTAUTH_URL在生产环境必须是正式域名不能留 localhost。Prisma 迁移我用的方案是在 CI 流水线里先执行prisma migrate deploy再跑next build。这里刻意用deploy而不是dev因为deploy不会自动创建新的迁移而是按序应用已有迁移更安全。构建缓存Next.js 在 Vercel 上的缓存是开着的但如果你升级了 Prisma 版本别忘清除构建缓存否则会出现生成器和运行时版本不匹配的问题。血泪教训遇到莫名其妙的 Prisma 启动报错先看看缓存。Node.js 版本约定建议锁定在.nvmrc或 Vercel 的项目设置里指定 Node 20。不同 Node 版本对 OpenSSL 和 Prisma engine 的行为有差异统一版本能减少很多“本地好好的线上挂了”的诡异情况。6. 常见问题与排查实录6.1 NextAuth 回调地址错误现象本地登录没问题部署后点击登录浏览器地址栏跳到了localhost:3000/api/auth/callback/...页面直接无法访问。原因忘了设置NEXTAUTH_URL或设置错误。解决生产环境显式配置完整回调域名并确保 OAuth provider 后台的 Redirect URI 里也包含这个地址。这个问题在这套模板里遇到得最频繁所以现在在.env.example里特别标注了“此字段默认为空时 NextAuth 会自行推断生产环境务必填写”。6.2 Prisma 连接报错Environment variable not found现象启动项目时 Prisma 提示DATABASE_URL不存在。原因.env文件不在项目根目录或者环境变量名与schema.prisma里的env(DATABASE_URL)不一致。解决检查文件位置确认.env在根目录且未被.gitignore忽略。注意如果你的项目是用 monorepo 管理的Prisma 读环境变量的路径会受 schema 文件位置影响这时需要显式指定prismaSchemaFolder或调整.env路径。6.3 tRPC 返回 500且日志里是序列化错误现象接口在浏览器里返回 500服务端日志显示Error serializing .data。原因我把 Prisma 查询出来的Date对象直接塞到了 query 返回数据里而 tRPC 对 JSON 序列化有严格要求Date 对象默认是不能直接序列化的。解决要么在返回前把createdAt转换为 ISO 字符串要么用superjson作为 tRPC 的 transformer。create-t3-app 模板其实默认就带 superjson所以如果你移除过或没用它记得在trpc.ts里重新开启import superjson from superjson; export const transformer superjson;6.4 Tailwind 样式无效现象新增了一个classNamebg-red-500浏览器里没效果。原因绝大多数情况下是 Tailwind 的内容扫描配置没覆盖到该文件。Tailwind v3 依靠扫描源码文件来生成样式如果文件路径没有出现在tailwind.config.ts的content数组里类名就不会被编译。解决确认内容数组包含./src/**/*.{ts,tsx}或按实际目录调整。v4 里自动扫描规则变了升级时记得重新看文档。6.5 构建时 TypeScript 报错但本地正常现象next build在 CI 里失败本地却正常。原因常见情况是本地的.env中存在 CI 没有的变量比如某种 provider 的 Key导致类型检查时条件分支不同。另一个可能是本地 node_modules 和 CI 的依赖树不一致。解决确保 CI 的环境变量齐全并锁死package-lock.json如果还是不行清理缓存后重试。另外补充一个小经验tRPC 中如果遇到Failed to parse或Invalid input错误不要只看 message一定要在开发模式下打开ata或者查看error.shape.data那里面的 zodError 会精确告诉你哪个字段不满足规则。这个信息在前后端联调时特别有价值。7. 维护 t3code 之后的个人心得项目模板维护了大半年最大的感受是工具只能解决一部分问题真正让项目可持续的是“约束”。t3code 把类型检查、prisma 迁移、登录鉴权、样式规范都沉淀成默认动作开发者不需要每次做决定而是把精力花在业务逻辑上。因为有这些约束新来的同事第一次提交代码就能通过 type-check这在我见过的大部分项目里都算难能可贵。最后分享一个小技巧如果你也想维护自己的模板建议把“更换新依赖”的流程固定下来像盯安全公告一样盯 Prisma 和 Next.js 的 major 版本更新。不要看到一个新版本就立刻升级先在旁边分支试运行一周跑过完整的登录、数据库迁移、构建部署链路后再合并回主分支。这套谨慎程度比任何代码风格规范都更能保护项目的长期健康。