如果你在技术社区待得够久,一定见过那种“麻雀虽小五脏俱全”的全栈项目——数据库、认证、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 还是 CSR;TypeScript 负责全链路类型基础,没有它,后面的一切都无从谈起;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 层依赖 components,components 可以依赖 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_URL | Server | Prisma 连接数据库 | 否 |
| NEXTAUTH_SECRET | Server | 加密 session 的密钥 | 否 |
| NEXTAUTH_URL | Server | NextAuth 回调地址 | 否 |
| NEXT_PUBLIC_APP_NAME | Client | 前端展示应用名 | 是 |
为了防止“少配了变量导致运行期才报错”,我在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,里面按业务域拆了几个子 router:userRouter、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_000,30 秒内重复进入列表页不会重新请求,页面切换如丝般顺滑。新增或修改 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_limit=5参数,并且用 PgBouncer 做了连接池代理,这才压住。这个参数在 Prisma 的 Postgres 连接串里是支持的,加入后对服务器压力改善非常明显。
写在最后:我这套组合拳打下来最真实的感受
如果你问我 t3code 最大的价值是什么,我一定会说是“一整套链路跑通后形成的肌肉记忆”。从数据模型设计、认证接入、类型安全 API,到样式系统和部署监控,每一步都有“为什么这么做”的明确答案,而不是盲目跟随模板。个人体会最深的一点是:类型安全不是银弹,但它在代码渐趋复杂时给了我极大的重构信心——改一个字段名,所有引用处一起报错,远胜于在运行时从报错堆栈里反推数据问题。
最后再分享一个小技巧:每次完成一个里程碑,记得把依赖整体升级一次并跑完整链路测试。这个项目里我被 Next.js 从 13 升级到 14 的时候折腾得不轻,但升级完成后性能和类型体验都上了一个台阶。后续我打算把 t3code 的模板继续扩展,加入文件上传、消息通知这些企业级常见能力,同时把测试覆盖率补起来,这些都是一步步迭代出来的硬功夫,没有任何捷径。