如果你在技术社区待得够久一定见过那种“麻雀虽小五脏俱全”的全栈项目——数据库、认证、API、样式、部署全都要管但很多人最后都栽在“类型不统一”和“环境配置地狱”上。t3code 就是我做的一个以 T3 Stack 为核心的全栈项目代号它把 Next.js、TypeScript、tRPC、Tailwind 这套组合真正揉在了一起用一套代码贯通数据库到浏览器前后端共享类型写完接口立刻能在页面上拿到提示。这篇文章不是官方文档复读而是我完整跑完这个项目后的复盘从技术选型为什么是 T3到目录怎么拆、认证怎么接、部署踩了哪些坑全部摊开讲中间会穿插大量可以直接抄走的配置和代码片段。适合正在做全栈项目、或者准备从后端 MVC 模式转向类型安全开发模式的同学参考。1. 项目定位与技术栈的取舍1.1 t3code 到底是个什么项目t3code 的定位很直白一个带用户认证、数据持久化、后台管理面和响应式前端的完整业务脚手架。它不是某个具体产品而是我为了验证“类型安全到底能不能贯穿全栈”而搭的一个基准工程。项目里包含用户注册登录、资料编辑、内容列表的分页查询、以及一套基础的权限控制逻辑所有数据流都经过 tRPC 的 mutation 和 query 暴露给前端没有一条传统 REST 路由。这个定位决定了它不需要微服务拆分也不需要 Kafka 这类重型中间件但这不代表它简单。恰恰相反正因为业务足够典型才能把全栈项目里最容易翻车的几个环节——认证状态同步、数据库访问、类型复用、服务端渲染——全部暴露出来。如果你正在头疼“后端写好了接口前端却不知道字段名到底叫什么”那 t3code 的这套链路会让你有一种“代码终于跟我说实话”的感觉。另外一个隐藏的定位是“模板化”。项目跑通之后我把所有业务无关的代码整理成了目录模板后续新项目直接从这套骨架复制起步省掉大量初始化时间。这也是为什么我会在目录结构上花那么大篇幅来设计它直接决定了模板的可复用性。1.2 为什么锁死 T3 这套技术栈很多人问我为什么不是 Next.js 加 Express 再加 React Query为什么不是 Nuxt我的回答很简单因为 t3code 想解决的核心问题只有一个——类型安全。T3 Stack 的四个核心件各司其职缺一个都不舒服。Next.js 负责应用框架和服务端渲染它让页面可以按需选择 SSG、SSR 还是 CSRTypeScript 负责全链路类型基础没有它后面的一切都无从谈起Tailwind 负责样式而且它没有侵入 JavaScript 逻辑层只在 CSS 层面做工tRPC 是这套组合里最有趣的一个它让你在后端写一个函数前端直接像调用本地函数一样调用它并且输入输出的类型完全自动推导。这里我要特别解释一下 tRPC 的价值。传统前后端联调后端写 REST 接口前端用 axios 请求再用 interface 手动声明响应类型。字段一多手写类型就容易和实际接口脱节而且没有任何机制保证它俩一致。tRPC 的思路是把后端函数像本地模块一样导出前端通过类型安全代理调用后端返回类型变了前端编译立刻报错不存在“接口文档更新了但前端不知道”这个问题。当然T3 这套也不是万能药。如果你的项目有独立的移动端 App需要多端共用一套 API那 tRPC 的 End-to-End 类型安全优势会打折扣因为非 TypeScript 客户端无法直接享受类型推导。我在项目初期就确认过这个问题t3code 的定位是纯 Web 端所以这个取舍是划算的。1.3 我对 Next.js App Router 还是 Pages Router 的最终选择这可能是初始化项目前最纠结的一个决定。t3code 一开始我用的是 Pages Router后来完整迁移到了 App Router。两个方案我都写过真实业务可以给出非常主观但足够具体的对比。Pages Router 的优势是理念简单文件即路由getServerSideProps 和 getStaticProps 清晰直白团队上手快。但它有几个让我不舒服的地方比如布局复用要靠自定义 _app.tsx 和嵌套组件手动完成数据获取逻辑分散在每个页面的 props 方法里页面一多容易变成一团乱麻。App Router 则把布局、加载态、错误态都做成了文件约定layout.tsx、loading.tsx、error.tsx 各司其职。最直观的好处是一个页面的不同区域可以各自独立请求数据而 Pages Router 里一个页面的 getServerSideProps 必须等所有数据就绪才能渲染。在 t3code 的详情页里用户资料和内容列表是两个互不依赖的请求用 App Router 的 Server Component 后列表区域甚至可以单独流式加载体验提升非常明显。不过迁移的代价也不小比如中间件改名、getServerSideProps 要改成 async server component、动态路由的 params 变成了 Promise 等等。这些坑我在后面踩坑章节会详细列出。2. 工程化搭建与目录结构设计2.1 从 create-t3-app 起步的正确姿势t3code 没有从零手写 webpack 配置而是直接用了 create-t3-app 脚手架。很多偏执的人觉得脚手架不“硬核”但我的观点是脚手架的价值在于把社区验证过的最佳实践固化下来你真正应该投入精力的是业务代码而不是困在 tsconfig 的 strict 选项里纠结三天。初始化命令非常简单但我会额外做三件事。第一初始化后立刻把默认的 git 仓库重新初始化避免把模板的提交历史带进新项目第二检查 .env 文件里的所有占位值create-t3-app 会生成一份注释详尽的环境变量模板但很多人直接复制过去就忘了改第三把 package.json 里的依赖版本锁定到一个已知可用的组合因为 T3 技术栈更新频率非常高几周前兼容的版本可能已经出现 breaking change。有一个细节值得注意create-t3-app 安装时会询问你是否开启 NextAuth、Prisma、Tailwind、tRPC 等模块。如果已经有明确规划建议只勾选需要的模块不要贪多。t3code 就只选了 Prisma、NextAuth、Tailwind 和 tRPC没选 ESLint 之外的额外内容保持依赖最小化。每次多一个模块就意味着每轮升级时多一批需要兼容测试的依赖。2.2 目录结构与模块划分目录结构是一个全栈项目的骨架我花了非常多心思在这上面。t3code 最终采用的目录划分原则是按“模块”而不是按“技术类型”划分。很多老项目喜欢建一堆文件夹叫 components、utils、api、types所有页面共享最后变成谁也不知道某个组件是给哪个页面用的。t3code 的 src 目录下主要分这么几块app/Next.js App Router 的路由和布局文件只包含页面骨架不写业务逻辑。server/服务端专属代码包括 tRPC router 的根定义、Prisma 客户端实例、认证配置。trpc/tRPC 的客户端封装和服务端调用工具react query 的 provider 也在这附近。components/可复用的展示型组件和业务组件按照页面维度建立子目录。lib/不绑定具体框架的纯函数比如日期处理、格式化、校验规则。styles/全局样式和 Tailwind 的入口文件。这个结构的核心逻辑是“依赖方向单一”。app 层依赖 componentscomponents 可以依赖 lib但任何一层都不允许反向去依赖 server 里的数据库逻辑。tRPC 的 router 是唯一允许同时被 server 和 client 逻辑引用的桥梁。这样划分后当我需要把某个功能拆分为独立包时边界非常清晰。还有一个小习惯把 Prisma schema 和迁移文件放在项目根目录的prisma/下这没什么好说的关键是不要在多个地方创建 Prisma Client 实例。t3code 里专门写了一个server/db.ts来导出单例避免开发模式下热重载创建几十个数据库连接。2.3 环境变量与配置管理环境变量是新手最容易翻车的地方。t3code 的环境变量分两类客户端可暴露的和服务端私有的。Next.js 用 NEXT_PUBLIC_ 前缀区分客户端变量会直接打包进浏览器 bundle所以绝对不能放密钥。t3code 的环境变量我做了严格的约束变量名归属端用途是否可暴露DATABASE_URLServerPrisma 连接数据库否NEXTAUTH_SECRETServer加密 session 的密钥否NEXTAUTH_URLServerNextAuth 回调地址否NEXT_PUBLIC_APP_NAMEClient前端展示应用名是为了防止“少配了变量导致运行期才报错”我在src/env.mjs里用了 zod 对 process.env 做运行时校验。这个文件由 create-t3-app 默认生成但很多人在初始化后把它删了我认为这是最不该删的文件之一。没有它你会经常遇到“本地好好的部署就报错”的诡异问题——其实只是部署环境忘了加某个变量。我会在部署章节再展开环境变量的坑这里先记住一个原则所有环境变量必须集中在一个文件里校验并且运行时启动时立即执行而不是在业务代码里到处 process.env。这样一旦缺失进程会直接拒绝启动而不是带着破损配置跑到半夜才炸。3. 核心链路认证、数据库与类型安全3.1 NextAuth 认证体系落地t3code 的认证选型是 NextAuth.js这里面有一个关键决策账号体系只做了邮箱密码加 OAuth 两种方式没有做短信验证也没有自研 Session。NextAuth 最值钱的地方是它把 Session 管理和 JWT 策略封装好了你不用自己设计 cookie 加密逻辑。在配置 NextAuth 时最难理解的是 JWT session 和数据库 session 的区别。t3code 最终选的是 JWT 策略原因很简单数据库 session 每次请求都要查一次数据库性能开销大而且 t3code 的 user 表里的敏感字段密码 hash根本不应该出现在 session 回调里。JWT 方案里服务端只需要用 NEXTAUTH_SECRET 验证签名的有效性就能确定用户身份。密码登录部分的实现我直接用了 NextAuth 的 Credentials Provider。这里有一个很多人忽视的安全点Credentials Provider 默认不受 CSRF 保护的白名单限制所以必须保证登录接口只走 NextAuth 暴露的规范路由不要自己包一层 api/auth/login否则等于把认证口子开在了安全机制外面。为了方便前后端共享用户态我在server/auth.ts里封装了一个getServerAuthSession函数服务端组件直接调用它获取 session。然后在 tRPC router 的根 context 里把 session 注入进去后续每个受保护的业务函数都能通过 ctx.session 判断当前用户。前端页面需要登录态时通过useSession()拿到同步状态整个认证信息流是单向且类型明确的。3.2 Prisma 模型设计与数据库迁移数据模型是整个业务的地基。t3code 的 Prisma schema 里最核心的是 User 和 Post 两张表。User 里包含 id、name、email、emailVerified、image 和 accounts/sessions 关系这是 NextAuth 的 Prisma adapter 要求的标准结构。我没有直接在 schema 里写密码字段因为使用 Credentials Provider 的时候密码 hash 需要存到一个独立的字段里。这里有个小陷阱Prisma 的默认模型名 Account 在实际表里会自动映射成 Account但很多数据库里 account 是保留字所以我在 schema 里通过map(user_account)和map(user_session)把表名显式映射了一遍避免迁移时撞关键字。Post 表我设计了 title、slug、content、published、authorId 和 createdAt其中 slug 加了unique约束这比用 id 拼 URL 更友好。为了列表查询性能我给 published 和 authorId 建了复合索引。很多人在小项目里忽略索引但一旦数据量涨到几十万行一次全表扫描就够你把性能优化一整天。数据迁移我直接在开发环境跑prisma migrate dev生产环境用prisma migrate deploy。每次修改 schema 后必须检查生成的迁移文件是否正确特别是删除字段或改类型时Prisma 有时会生成先 drop 再 create 的破坏性迁移需要人工确认数据安全。3.3 tRPC 路由与端到端类型安全tRPC 是 t3code 的核心亮点。我定义了一个根 router里面按业务域拆了几个子 routeruserRouter、postRouter、adminRouter。每个子 router 由一个个 query 和 mutation 组成。query 拉数据mutation 写数据理念上很像 GraphQL 的 query 和 mutation但实现方式完全是 TypeScript 函数。做一个最简单的例子。用户要创建一个 post后端就是这样的代码createPost: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(10), })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, slug: slugify(input.title), content: input.content, authorId: ctx.session.user.id, }, }); }),前端调用的时候不需要任何 fetch、axios也不用手写响应类型const utils api.useUtils(); const createPost api.post.createPost.useMutation({ onSuccess: () { utils.post.getAll.invalidate(); }, }); createPost.mutate({ title: 标题, content: 正文内容 });你会发现一个很爽的点后端 input 用了 zod 校验前端在调用 mutate 时TypeScript 编译器就会告诉你参数该是什么结构。字段名拼错、类型不匹配全部在编译期暴露而不是请求发出去之后返回 400 才醒悟。这是 t3code 项目里让我最“回不去”的三个字类型安全。protectedProcedure 是我自定义的一个过程包装器它在后端中间件里做了 session 校验未登录用户直接抛 UNAUTHORIZED 错误。类似的还有 adminProcedure会额外检查用户角色。整个过程器的链路非常克制没有过度设计但把权限控制收敛到了统一位置。4. 前端交互与样式实践4.1 Tailwind 的原子化样式组织样式方案我没有犹豫过直接选了 Tailwind CSS。t3code 里的设计是大多数页面直接写在 className 里少数高频复用的视觉模式抽成组件级 class。这里我不建议无脑把所有 class 都抽成 apply 指令封装成业务组件类那本质上又回到了传统 CSS 的命名空间管理和 Tailwind 的初衷背道而驰。我在项目里遵循一个粒度原则跨页面复用的才抽组件页面内只出现一次的样式就放心大胆写在行内。比如文章卡片的样式在首页和列表页都出现我抽了一个PostCard组件而某个详情页独特的标题装饰我就直接写在那个页面对应的组件里。Tailwind 2 升级到 3 之后最让我省心的是 JIT 模式。写再多动态拼接的类名构建时都会精准生成用到的 CSS不会像老版本那样整包输出导致文件巨大。t3code 的生产 CSS 压缩后只有不到 20KB这对用户体验的提升非常明显。暗色模式我也用 Tailwind 的 dark: 变体做了唯一要注意的是不要直接用系统偏好而是在根组件里加一个 class。我写了一个 ThemeProvider通过 context 管理 dark 类再配合 localStorage 持久化用户选择。这个逻辑大概 100 行比引入一堆主题库值多了。4.2 React Hook Form 与 Zod 的表单校验t3code 的表单前后端共享了同一个 Zod Schema。这是 T3 全栈最爽的体验之一后端用同一个 schema 校验 tRPC 的 input前端用同一个 schema 驱动表单校验一套规则两处生效绝不会出现“前端必填、后端忘了校验”的经典漏洞。具体实现上我用 react-hook-form 配合 hookform/resolvers 的 zodResolver。关键在于从src/trpc/router/post.ts里导出 zod schema而不是复制一份到前端文件。这样后端加了新字段约束前端表单校验的 TypeScript 类型会立刻跟着变化。一个性能相关的细节表单的 onChange 模式默认每个输入都会触发校验输入量大会有轻微卡顿。我的做法是把 mode 设为onTouched也就是用户离开输入框时才校验减少渲染频率。至于提交前的最终校验交给 mutate 时后端的二次校验兜底。4.3 组件拆分与服务端渲染策略组件拆分上我没有搞“原子设计”那套复杂理论就用一种朴素的标准一个组件只做一件事数据来源越纯越好。展示型组件全部接收 props不主动调用 tRPC只有页面的顶层容器才会主动发起数据请求。这样做的最大好处是组件测试和故事书维护起来非常轻松也方便以后切换到 React Server Components。t3code 的页面渲染策略是这样权衡的首屏要求快的页面用 Server Component比如首页的文章列表交互性非常强的管理后台则用 Client Component配合 React Query 做前端缓存。App Router 里一个页面可以同时包含 Server 和 Client 组件二者通过 props 传数据边界清楚这比 Pages Router 时代“非黑即白”的整页方案合理得多。这里有一个很关键的体验点用 Server Component 渲染列表时尽量不要把列表组件标记为use client否则会把整个列表的数据请求逻辑拖到客户端失去服务端渲染的意义。正确做法是Server Component 直接调用服务端的 tRPC caller把数据作为普通 props 传给子组件。子组件只需要“接收数据并展示”完全不知道数据来自哪个服务器。5. 部署、性能与稳定性5.1 部署方案选型t3code 部署我用了 Vercel主要有三个原因和 Next.js 的适配深度最好零配置支持 App Router自带 Preview Deployment每个 PR 都能拿到独立域名平台级 HTTPS 证书管理完全托管省去在 Nginx 里配证书的麻烦。如果你有私有化交付需求也可以选择 Docker。t3code 的 Dockerfile 我会采取多阶段构建先安装依赖再构建 Next.js standalone 输出最后用一个轻量 Node 镜像运行。需要特别注意的是生产容器里如果跑 Prisma必须在启动阶段执行prisma generate因为二进制文件不会打进普通 node_modules 的镜像里否则容器一启动就报找不到 Prisma Client。不管用哪种部署方式我都强烈建议把环境变量和代码分开存储。Vercel 的环境变量面板、GitHub Actions 的 secrets、Docker 的 .env 文件至少选用一种方案永远不要直接把 DATABASE_URL 和 NEXTAUTH_SECRET 写死进代码仓库。这个原则我在 t3code 里严格遵循因为只要泄露一次后面所有用户数据都等于裸奔。5.2 性能优化与缓存策略性能优化的第一优先级是数据库查询。我使用 Prisma 的select和include做了按需取数绝不写 select * 再在代码里过滤。比如 Post 列表只取 id、title、slug、createdAt不取 content 全文这个优化在列表页效果立竿见影。第二优先级是前端数据缓存。tRPC 基于 React Query天然支持缓存和失效机制。我针对 Post 列表设置了staleTime: 30_00030 秒内重复进入列表页不会重新请求页面切换如丝般顺滑。新增或修改 post 时手动调用 invalidate 使缓存失效。这个模式下用户操作的反馈远比传统 REST 里全量 refetch 来得更快。第三是 Next.js 自身的缓存能力。静态生成的页面用generateStaticParams做 SSG动态用户资料页则用revalidate做 ISR每隔一段时间让页面在后台增量更新。至于客户端水合体积我用了next/dynamic对重型组件做动态导入比如富文本编辑器只有真正打开那个页面才加载对应 JS。5.3 监控与错误追踪很多人把项目部署完就结束了但错误追踪才是项目上线后最重要的一环。t3code 集成了 Sentry同时监控前端运行时异常和后端 API 错误。我在 tRPC 的全局 errorFormatter 里统一把错误序列化包含 code、message 和可选的 zod 字段错误这样前端可以直接拿到结构化错误提示而不会看到 React Query 里那一长串难懂的 JavaScript 堆栈。日志记录我也做了统一封装。服务端任何 tRPC 调用都打了一条结构化日志包含用户 id、procedure 路径、执行时长和状态码。排查问题的时候只要看一条日志就能还原完整请求链路。比黑盒式“我猜这里可能有问题”高效太多。6. 常见问题与排查技巧实录6.1 环境变量导致的构建失败t3code 刚部署到 Vercel 时遇到过构建报错错误信息是 “Invalid environment variable: DATABASE_URL”。排查后发现是环境变量面板里没有新增这个变量。因为 env.mjs 的逻辑是启动即校验所以构建直接失败了。这个机制虽然会让第一次部署的人感到恐慌但仔细想想这是最好的结果——它把所有配置问题暴露在构建阶段而不是生产环境运行到一半才崩溃。建议大家在部署前自己列一个环境变量检查清单本地的 .env 是否和生产环境一致、NEXTAUTH_SECRET 是否足够随机、NEXTAUTH_URL 是否指向了最终域名。特别是 NEXTAUTH_URL如果填了 localhost生产环境的回调地址就会对不上导致登录后跳转无限循环。6.2 tRPC 响应类型与序列化问题用 tRPC 返回 Date 类型时前端确实能享受到类型推导但底层传输时 Date 会被序列化成字符串如果直接把它当作 Date 对象使用调用toISOString()时会报错。这是因为 tRPC 基于 JSON 传输JSON 本身不支持 Date。解决思路有两个。一个是在 schema 里就把它定义成z.string()存 ISO 字符串展示时再格式化为本地时间另一个是用 superjson 作为 tRPC 的 transformer这是 create-t3-app 默认开启的。如果你一开始按模板来的基本不会遇到这个问题但如果是手工搭的项目务必记得在 tRPC 服务端和客户端同时配置相同的 transformer否则会出现“客户端解析失败”的诡异现象。6.3 认证中间件与路由保护App Router 里做路由保护有两种方式中间件和布局里的服务端校验。我在 t3code 里给后台管理路径加了一个 Next.js 中间件匹配/admin/:path*。中间件里做的事情很简单从请求 cookie 里解析 session如果没有登录直接重定向到 /login。但别忘了中间件的校验粒度不能替代后端真正的权限控制。中间件里的 session 解析依赖页面访问时的 cookie如果恶意用户伪造请求直接调 tRPC 的 adminProcedure中间件是拦截不住的。所以我在每个受保护的 tRPC mutation 里都做了服务端身份和角色校验。中间件只是提升体验真正的安全边界在服务端函数内部。6.4 数据库连接与连接池开发环境热更新时最常见的一个问题是 Prisma Client 实例被反复创建导致数据库连接数飙满。解决方式是在开发模式下使用全局单例代码也很简单const globalForPrisma globalThis as unknown as { prisma: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }生产环境连接数的问题同样不能忽视。我一开始用的是 serverless 部署每个函数实例都会创建一个数据库连接流量一大数据库连接池就爆了。后来我在连接串里加了connection_limit5参数并且用 PgBouncer 做了连接池代理这才压住。这个参数在 Prisma 的 Postgres 连接串里是支持的加入后对服务器压力改善非常明显。写在最后我这套组合拳打下来最真实的感受如果你问我 t3code 最大的价值是什么我一定会说是“一整套链路跑通后形成的肌肉记忆”。从数据模型设计、认证接入、类型安全 API到样式系统和部署监控每一步都有“为什么这么做”的明确答案而不是盲目跟随模板。个人体会最深的一点是类型安全不是银弹但它在代码渐趋复杂时给了我极大的重构信心——改一个字段名所有引用处一起报错远胜于在运行时从报错堆栈里反推数据问题。最后再分享一个小技巧每次完成一个里程碑记得把依赖整体升级一次并跑完整链路测试。这个项目里我被 Next.js 从 13 升级到 14 的时候折腾得不轻但升级完成后性能和类型体验都上了一个台阶。后续我打算把 t3code 的模板继续扩展加入文件上传、消息通知这些企业级常见能力同时把测试覆盖率补起来这些都是一步步迭代出来的硬功夫没有任何捷径。