t3code 这个代号,是我当时给一个全栈 Web 应用随手起的仓库名。t3 指的不是数字三,而是前端圈里传得很广的那套 T3 Stack:TypeScript、Tailwind CSS、tRPC,再让 Next.js 当胶水把前后端串起来。项目本身是一个内部用的小型内容管理后台,页面不算多,但要求类型安全、迭代快、以后好维护。我先试过传统的“REST API + 前端手动维护接口类型”那套,也考虑过 NestJS 独立服务,最后用 create-t3-app 初始化了一个代号叫 t3code 的项目。一路做下来,最初担心的 tRPC 学习成本反而是最低的,真正埋坑的地方都在配置、认证、部署这些边缘环节。如果你正准备用 T3 栈起新项目,或者已经在 create-t3-app 里折腾,这篇应该能帮你绕开好几个我花了一段时间才想明白的问题。
1. t3code 的技术选型:为什么是 T3 栈而不是另一套全栈方案
1.1 当时摆在面前的三条路
先交代背景。t3code 要做的事情本身不复杂:一个后台界面,几张列表页,几个表单,加上登录、权限、搜索、分页这类常规功能。真正让人纠结的是技术选型,因为这类项目一旦中途换栈,等于白干。
我当时认真考虑过三套方案。
第一套是 Next.js API Routes + React Query。思路很朴素,后端用 Next.js 的接口路由暴露 REST API,前端用 React Query 发请求。这套方案的好处是认识的人多、资料多,网上随便一搜就是一堆 demo。但问题也很明显:请求和响应没有真正的类型关联,接口字段改了以后前端感知是滞后的,经常要跑到浏览器 Network 面板里看返回数据才发现字段名变了。
第二套是 NestJS + Vite 前后端分离。NestJS 结构清晰,适合做大型系统,但为了一个不到二十个接口的后台,要额外维护一套服务端、一套部署流程、两套代码仓库,成本明显偏高。再加上 CORS、认证、部署域名这些前后端分离特有的繁琐配置,对一个小而美的内部项目来说有点杀鸡用牛刀。
第三套就是 T3 Stack,也是 t3code 最后走的路。它的核心特点是 TypeScript 从数据库到 UI 全程贯通,tRPC 代替 REST 后,前端调用后端就像调用本地函数,类型推断自动完成。对比下来,这套方案对“一个人要维护全栈项目”的场景太友好了。
| 维度 | REST + React Query | NestJS 前后端分离 | T3 Stack |
|---|---|---|---|
| 端到端类型安全 | 弱,需要手写类型或生成器 | 中等,需要额外工具 | 强,tRPC 天然推导 |
| 前后端代码距离 | 较近 | 远,跨进程通信 | 非常近,代码内联 |
| CRUD 场景开发速度 | 中等 | 慢,样板代码多 | 快,模板代码少 |
| 大团队协作 | 还行 | 好 | 一般,适合小团队 |
| 长期维护成本 | 中 | 高 | 低,但耦合较紧 |
1.2 create-t3-app 初始化后的目录结构
选型定了以后,实际搭建比想象中顺利。官方脚手架 create-t3-app 会主动问你几件事:要不要用 NextAuth、要不要用 Prisma、要不要用 Tailwind、要不要用 tRPC。我不建议全选,而是按项目实际需求来。t3code 里我只勾选了 NextAuth、Prisma、Tailwind、tRPC,ESLint 和 Prettier 是默认的。
初始化命令很简单:
npm create t3-app@latest项目名填t3code,它生成的结构是这个样子:
src/ pages/ server/ api/ root.ts routers/ trpc.ts auth.ts db.ts styles/ env.js这里有个容易被忽略的细节:env.js里做了一套 zod 校验,程序启动时如果缺少必要的环境变量,会直接崩给你看。刚开始很多人觉得烦,觉得就是调个接口为什么要搞这么多门禁。但用过一段时间后你会感激它,因为它把“环境变量拼写错误”这类问题在启动阶段就拦住了,而不是等到线上某个功能突然不可用才去排查。
1.3 为什么“类型安全”在这种规模下是真正的效率
我听到过一种说法:小项目不需要类型安全,写快点把功能跑通就行。这个观点在纯前端页面里可能成立,但放到全栈项目里就是反的。
t3code 里最典型的场景是改数据库模型。以前用 REST 接口,改一个字段名,得去改数据库、改后端 DTO、改前端类型、改页面引用,漏掉任何一环都要靠线上 bug 才能发现。用了 T3 栈以后,Prisma schema 一改,tRPC 的 router 返回值类型跟着变,前端useQuery拿到的数据立刻长出新的类型,TS 在编译阶段就开始报错。这种感觉就像原来两个人靠对讲机沟通,信号不好还容易听岔,现在直接共用一份提词器,你看到什么就是什么。
所以我说,t3code 类型安全的收益不是“写代码时少敲几个字符”,而是“改需求时少踩几个雷”。
2. 初始化后的第一关:那些默认配置背后的 why
2.1 TypeScript 严格模式不是刁难,是漏掉 bug 的安全网
create-t3-app 生成的项目默认开启了非常严格的 TypeScript 配置。说实话,刚上手那几天我有点被烦到:strict模式下不允许隐式 any,noUncheckedIndexedAccess要求数组索引都要考虑 undefined,verbatimModuleSyntax逼着你用import type区分类型导入和值导入。
举个具体例子。从数据库查回来的列表可能为空,这在严格模式下会被明确标记成undefined可能值:
const posts = await db.post.findMany(); // 严格模式下,posts[0] 的类型是 Post | undefined // 以前写代码时经常直接 posts[0].title,现在会被编译器拦住一开始觉得麻烦,但真正跑起来才发现,这些检查拦住的全是我以前会在半夜被叫起来修的问题。拿空数组索引这种问题来说,后端没数据时前端直接白屏的 bug,以前要复现半天才能查出来,现在编译器直接不让过。所以建议不要为了少写几个类型断言就关掉严格模式。
2.2 路径别名、环境变量与 env.mjs 的校验
t3code 里默认用@/作为源码根目录的别名,比如@/server/db对应src/server/db.ts。这个不是花架子,它让深层引用的代码可读性好很多。
环境变量是另一个重点。T3 栈的约定是:
- 所有变量写在
src/env.mjs中,用 zod 定义运行时校验; - 本地复制
.env.example为.env,补上真实值; - 真实
.env必须加入.gitignore,避免密钥泄漏。
t3code 里的几个变量名称是这样的:
DATABASE_URL="mysql://user:password@localhost:3306/t3code" NEXTAUTH_SECRET="dev-secret-change-me" NEXTAUTH_URL="http://localhost:3000"注意NEXTAUTH_URL在本地和生产环境值不同,部署平台一般会单独配置,最好不要硬编码进代码里。
2.3 包管理器与 Node 版本:保持一致性
这个坑比较隐蔽。T3 栈对包管理器没有硬性要求,npm、pnpm、yarn 都行。但你既然用了 Prisma,就必须留意一件事:不同包管理器生成的 prisma client 缓存位置不同,团队成员如果混用,很容易出现“我这边没问题你那边报错”的经典场景。
t3code 我统一用了 pnpm。原因比较简单:它速度快,磁盘占用小,更重要的是严格模式下不会让你悄悄引入未声明的依赖。为了防止团队里有人用错,我在根目录的package.json里加了:
{ "packageManager": "pnpm@9.0.0", "engines": { "node": ">=20.0.0" } }这个写法在 pnpm 下会自动检查,装依赖时如果版本不匹配会直接提示。属于“一次性配置,长期省心”的投入。
3. 接入 tRPC:从远程调用到本地函数的类型安全体验
3.1 tRPC 的运行逻辑与清晰心智
很多人第一次听到 tRPC 会想:这是不是又发明了一套新协议?其实没有。它的本质仍然是 HTTP 请求,只是把请求路径、入参、出参的类型推到编译期,让前后端之间不存在“文档漂移”的问题。
t3code 里一个最简单的查询会长这样:
// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ page: z.number().default(1) })) .query(async ({ ctx, input }) => { const posts = await ctx.db.post.findMany({ skip: (input.page - 1) * 10, take: 10, orderBy: { createdAt: "desc" }, }); return posts; }), });前端调用的代码:
const { data } = api.post.list.useQuery({ page: 1 });注意,这里的api.post.list不是浏览器里跑的请求函数,而是从trpc导出的类型化 hooks。你不需要手动写fetch、不需要手动处理URLSearchParams、不需要在后端改了字段后去前端同步类型,因为类型已经自动对齐了。
我的建议是先建立这个心智:tRPC 不是魔法,它只是把 HTTP 细节封装起来,让你专注业务本身。想确认具体发了什么请求,可以直接打开浏览器 Network 面板看POST /api/trpc/post.list这样的记录。
3.2 Router 拆分:不要把所有接口堆进一个文件
小项目最容易犯的毛病,就是把所有 query 和 mutation 都放在rootRouter一个文件里。t3code 管理后台页面一多,接口数很快超过三十个,如果全堆一起,改一个接口要翻几百行代码,非常难受。
我的做法是按业务域拆 router:
src/server/api/routers/ auth.ts post.ts comment.ts user.ts然后在root.ts里合并:
import { postRouter } from "./post"; import { userRouter } from "./user"; import { commentRouter } from "./comment"; import { createTRPCRouter } from "../trpc"; export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, comment: commentRouter, });这样一来,前端调用路径天然带有业务域前缀,例如api.post.list、api.comment.add。命名即文档,维护起来舒服很多。
3.3 Query 与 Mutation 的缓存更新
tRPC 底层跑的是 React Query,所以它继承了缓存、请求去重、自动重试这些能力。但也正因为有缓存,mutation 执行完后如果不主动刷新,页面会一直显示旧数据。
t3code 里最常见的更新方式是:
const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { // 让 post.list 相关的所有 query 失效并重新请求 utils.post.list.invalidate(); }, });一个实操心得是:invalidate的范围要精准。如果你只改了某个 id 的数据,最好用invalidate({ postId })这种精确匹配方式,而不是直接把所有 list 缓存全部清掉。无脑 invalidate 在小项目没事,到接口变多、列表变复杂以后,会白白产生大量并发请求,影响体验。
3.4 tRPC 不擅长的事情:别忘了它的边界
tRPC 确实方便,但它不是万能的。t3code 早期我想把图片上传也直接塞进 tRPC,后来发现很别扭。
tRPC 的入参和返回值本质上是 JSON 序列化,二进制文件、超大 payload、需要流式处理的场景根本不适合走这条路。真正做文件上传,还是老老实实用 Next.js 的 API Route,或者直接上传到对象存储服务,让前端拿到预签名 URL 去直传。tRPC 只负责记录元数据,比如文件名、大小、路径,这样各干各的,反而轻松。
这个边界认知非常重要。TypeScript 全栈再好,也只是一个工具,用错场景一样翻车。
4. Tailwind 和 UI 组件:样式方案真正省时间的地方
4.1 为什么 Tailwind 在这个项目里比 CSS Modules 更顺手
t3code 的管理后台页面结构相对统一:顶部导航、侧边栏、内容区。刚开始我用 CSS Modules,每个组件都单独建一个.module.css文件,命名要费脑子,样式多了以后组件和样式文件的对应关系开始混乱。
换成 Tailwind 以后,情况好了很多。不是因为它写出来的样式更美,而是它能让我在一个文件里同时看到结构和样式,不用来回跳转。像“卡片阴影 + 圆角 + 内边距”这种组合,在 Tailwind 里直接写:
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">一行类名就把整套视觉方案定下来,改起来也只动当前文件。
Tailwind 做的其实不是帮你少写 CSS,而是消灭“为类名起名字”这层心智负担。这对小团队、快速迭代的 Web 应用尤其有价值。
4.2 动态类名与类名冲突:cn() 这个细节
Tailwind 真正的坑不在基础使用,而在动态类名。t3code 里我一开始犯过一个错误:根据状态拼接类名时,写成了模板字符串:
// 这样写是有问题的 className={`bg-${color}-500 text-white`}Tailwind 的 JIT 编译器是在构建时扫描源码里的完整类名来生成样式的,它不会去解释运行时变量。上面这种写法,编译器只看到bg-开头的半截字符串,无法生成对应的工具类,前端就永远拿不到颜色样式。
正确做法是提供完整的类名清单,让 Tailwind 能在源码里直接发现:
const colorMap = { red: "bg-red-500 text-white", green: "bg-green-500 text-white", blue: "bg-blue-500 text-white", } as const;另一个常见问题是 class 冲突。渲染一个按钮时,既有基础样式又有外部传入的 className,两边的px-*可能会打架。t3code 里我引入了一个小组件工具函数,底层用 tailwind-merge 合并且让后传入的类名覆盖前面的:
import { twMerge } from "tailwind-merge"; import { clsx, type ClassValue } from "clsx"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }以后凡是组件要接收外部 className,都优先用这个cn()加工一遍。这个习惯能省掉大量样式互相覆盖的问题。
4.3 与 shadcn/ui 搭配时的主题配置
t3code 的 UI 我直接用了 shadcn/ui 那套,因为它生成的组件代码是放在项目里的,可以随便改,不会被顶层库限制。但 shadcn/ui 默认的样式靠 CSS 变量驱动,需要你在globals.css里定义主题色:
:root { --background: 0 0% 100%; --primary: 240 5% 10%; } .dark { --background: 240 10% 4%; --primary: 0 0% 98%; }然后在tailwind.config.ts里映射成 Tailwind 的颜色 token。最开始我漏了.dark变量定义,导致切到暗色模式时,背景直接变成默认白底黑字,看起来又刺眼又突兀。
另外一个建议是:别频繁更新 shadcn/ui。这类代码生成式组件的每个版本都可能会调整内部类名和结构,你升级一次,可能就要跟着改一次业务代码里的样式引用。锁定在某个稳定版本,等真的需要新组件再手动更新,比无脑拉最新版稳得多。
5. 认证接入:NextAuth 与 tRPC 的权限边界
5.1 为什么选 NextAuth 而不是自建 Session
管理后台必须做登录认证。t3code 里我没有自己写 Session 方案的另一个原因,是很容易在 Cookie 安全、加密签名、回调地址这些细节上栽跟头。自建认证系统不是不行,而是对小型项目来说性价比太低。NextAuth 提供了成熟的 OAuth 流程、JWT/Session 策略,还直接兼容 Prisma adapter,和 tRPC 一起用非常顺。
它的基本配置是放在pages/api/auth/[...nextauth].ts:
import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { db } from "@/server/db"; export default NextAuth({ adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, }), ], session: { strategy: "jwt", }, });有人可能会问,为什么用 Prisma 还要把 session 策略设为 jwt?这是为了避免每次请求都去数据库查 session 记录,减少对数据库的依赖。管理后台对会话的实时撤销要求不高,JWT 方案完全够用。
5.2 把 Session 放进 tRPC Context,让 tRPC 替你守门
tRPC 的 Context 是每个请求进入时先执行的一段逻辑。t3code 里我在这里把当前用户的 Session 对象塞进去,后续所有 procedure 都能直接访问当前用户信息。
在createTRPCContext中:
import { getServerAuthSession } from "@/server/auth"; export const createTRPCContext = async (opts) => { const session = await getServerAuthSession(); return { session, db, ...opts, }; };然后在trpc.ts里定义一个受保护的 procedure:
export const protectedProcedure = publicProcedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { user: ctx.session.user }, }); });这个设置的好处是,路由级别就能做权限控制,不需要在每个接口里重复写“先检查登录状态”。接口的权限边界一目了然。
比如用户中心接口:
export const userRouter = createTRPCRouter({ me: protectedProcedure.query(async ({ ctx }) => { return ctx.db.user.findUnique({ where: { id: ctx.user.id }, }); }), });未登录用户连me这个 query 都执行不到,在最外层就被拦住了。
5.3 生产环境常见的登录失败原因
这部分是我踩坑最多的环节。t3code 上线前,登录功能在本地一切正常,部署到服务器就出问题。我后来总结出几个高频原因。
第一个是NEXTAUTH_URL配错。本地通常配http://localhost:3000,生产环境必须改成真实的对外域名,而且不要写带尾斜杠的地址。它一旦不对,OAuth 回调地址就会对不上,第三方登录直接报 redirect_uri 错误。
第二个是自定义域名的环境不一致。如果你用localhost和127.0.0.1分别登录,浏览器会把它们视为不同的站点,Session Cookie 是不互通的。所以本地开发最好固定一个入口,别一会儿 localhost 一会儿 127.0.0.1。
第三个是回调地址白名单。NextAuth 在 JWT 策略下,默认会把当前站点地址写入 token,部署到新环境必须重新生成NEXTAUTH_SECRET,否则用户会一直登录失败或频繁退出。
顺便说一句:如果项目放在反向代理后面,还需要确认 Next.js 能正确识别代理传递的协议和主机头,否则它判断回调地址时容易构造出错误的链接。这个坑不热门,但排查起来很费时间。
6. 构建部署阶段:t3code 上线前我重新检查的清单
6.1 环境变量在本地、预览、生产保持一致
T3 栈项目部署时最大的风险往往不是代码逻辑,而是环境变量在不同环境下不一致。t3code 早期试过把DATABASE_URL在本地、预览分支、生产分支分别配置成不同的值,结果预览环境跑出来的数据和生产完全不同,排查了一个下午才发现只是环境变量没同步。
我的建议是:
- 本地只维护一份
.env,所有环境变量首次在这里配齐; - 部署平台(比如 Vercel)的 Preview 与 Production 环境变量尽量保持一致,差异仅保留真正的敏感配置或数据库地址;
- 每次新增环境变量,同步更新
.env.example和部署平台的配置,并在提交说明里写一句“需要更新环境变量”。
不要在代码仓库里提交任何真实密钥。用 zod 校验环境变量的项目,启动失败会直接给出生动报错,这反而比“静默降级”好排查得多。
6.2 数据库迁移与构建缓存
t3code 用的是 Prisma,这里有个很典型的构建陷阱:有些页面会直接在服务端渲染时查数据库,如果部署平台在构建阶段跑next build,而数据库结构还没迁移,构建就会失败。因为 Prisma 客户端按 schema 生成后,真正访问数据库时才更早暴露迁移状态问题。
更稳妥的顺序是:
- 先执行数据库迁移:
prisma migrate deploy; - 再执行
next build; - 最后启动服务。
另外,不要把prisma migrate dev用在生产环境。dev模式会自动重置数据库风险极高,生产环境请用migrate deploy只应用迁移记录。
构建缓存同样是个隐性坑。Next.js 12+ 默认启用强缓存,文章列表这种页面如果走了静态生成,内容更新后线上可能长时间不刷新。t3code 后台页面我是故意把读取频率高的接口标成动态渲染,避免缓存盖过真实数据。
6.3 模拟生产环境:best 最容易被跳过的验证步骤
本地开发和线上环境差异最大的一点,实际上是 Next.js 的构建期优化。很多在next dev下正常的功能,到next build && next start之后表现完全不同。
所以 t3code 在部署前,我都会先在本地跑一套完整生产构建:
pnpm build pnpm start然后用浏览器把核心流程走一遍:登录、发请求、查数据、退出登录。这个过程不用花太多时间,但它能提前暴露好几个问题:比如某个环境变量没配置导致构建失败、某个组件在服务端渲染时调用了浏览器 API、某个接口在静态导出后变成了空壳。
可以说,这是我所有部署准备里最值钱的一步。
6.4 日志和错误监控:别等用户告诉你系统坏了
后台系统最怕的不是故障,而是故障发生以后你完全不知道。t3code 早期没有接任何错误监控,有一次接口因为数据库连接数打满直接 500,我是在第二天用户反馈时才发现的。后来我加了简单的结构化日志,每次请求记录请求路径、状态码、耗时,再用统一错误上报服务做告警。
不需要一开始就上特别复杂的东西。能把错误堆栈和现场数据留存下来,再配合定时健康检查,就足以覆盖绝大多数内部项目的运维需求。
我自己用下来的感受是,T3 Stack 对内部工具、中小型全栈项目来说,确实能把“类型安全”和“开发效率”两者同时拉满,但它的快是建立在规则之上的。第一次用的人,最好把配置、认证、部署这三个环节单独留出半天来理解,而不是急着写业务代码。等这几关过了以后,后面真的一马平川。