t3code 是我最近一个内部全栈项目的代号,技术栈选了 T3 Stack。如果你还没听说过这个词,T3 Stack 指的是由 Next.js、TypeScript、Tailwind CSS、tRPC 组成的一套现代全栈开发方案,常配合 Prisma 和 NextAuth 一起用。简单说,就是一套从数据库到前端 UI 全程类型安全、约定优先的开发模板。我在做 t3code 这个项目之前,团队已经受够了前后端接口文档漂移、字段类型对不上、联调全靠嘴的毛病。这次我决定用 T3 Stack 从头搭一套,把过去踩过的坑、积累的经验都沉淀下来,这篇文章就是完整的实战记录。
我会从为什么选这套技术栈开始,讲到脚手架初始化、类型安全数据链路的具体实现、认证与权限的落地细节、Tailwind 样式工程化、最后到部署上线的全过程,再附上排查实录。无论你是打算入坑 T3 Stack 的新人,还是已经在用但被某些细节卡住的老手,这篇文章都值得一看。
1. 为什么把项目代号定为 t3code:从一次技术栈混乱说起
1.1 团队之前的真实痛点
t3code 的起点其实很普通。我们接到的是一个内部协作平台的外包需求,功能不复杂,无非是账号体系、任务分配、进度跟踪,但团队内部技术栈早就乱成一锅粥:前端有写 Vue 2 的,有写 React 的,后端有人用 Express,有人用 Fastify,接口风格有人用 REST,有人直接甩个 JSON 就完事。最痛苦的是每次联调,前端问后端这个字段是什么类型,后端说你看文档,文档又早就过期了。改一个字段名,前端、后端、数据库三处都要改,漏一处就是线上事故。
这种问题不是某个人不努力,而是架构层面就没有约束。类型没有一个统一的来源,接口没有自动生成的能力,所有的"约定"都靠口头传递。我在复盘会上说得很直白:我们需要的是一个把类型作为唯一事实来源的技术栈,让前后端共享同一套类型定义,谁都不能单方面改接口。
1.2 T3 Stack 是什么,以及它凭什么能解决这类问题
T3 Stack 的核心思想可以用一句话概括:约定优先,类型贯穿。它把四个技术组合在一起,各司其职:
- Next.js:负责 React 前端和 API 路由,既能做 SSR 也能做静态生成;
- TypeScript:全链路类型系统的基石;
- Tailwind CSS:原子化 CSS 方案,写样式不离开 HTML;
- tRPC:替代传统 REST 接口的远程调用方案,让前端像调用本地函数一样调用后端逻辑。
如果说传统前后端开发像是在两栋楼之间靠对讲机通信,那 T3 Stack 就是把两栋楼连成同一栋,施工队和装修队看的是同一张图纸。类型定义只在数据库 Schema 里写一遍,Prisma 自动生成类型,tRPC 把后端函数暴露给前端,前端调用的时候 TypeScript 能直接推导出返回值的结构。改字段只需要改数据库模型,迁移之后类型自动更新,前端立刻就能感知到变化,或者编译报错提醒你,或者直接智能提示新字段。
我第一次用完整链路跑通一个"从数据库到前端按钮"的功能时,确实有点震撼:以前写一个列表接口要写控制器、路由、DTO、类型定义,现在这些全被自动推导出来了。这正是我决定把项目代号定为 t3code 的原因:T3 Stack 做代码,本身就是一套"少写代码、多写类型"的玩法。
1.3 方案对比:为什么不是 REST + Vue,也不是 GraphQL
有人会问,REST + OpenAPI 不也能生成类型吗?GraphQL 不也能做到类型安全吗?确实能,但都有代价。
REST + OpenAPI 的问题是维护成本。你要先写接口,再写 OpenAPI 注解或 YAML,再用 openapi-generator 生成前端类型,链路长且容易漂移。团队懒一点,文档就开始过期,跟之前没什么区别。
GraphQL 的类型安全做得很好,但引入 GraphQL 层意味着要学一套查询语言、要写 Resolver、要处理缓存和 N+1 问题。对于 t3code 这种内部平台,这些复杂度是多余的。
tRPC 的精妙在于它放弃了"通用接口"这个目标,直接让后端函数成为前端可调用的"过程"。不需要 URL 设计,不需要手动解析参数,不需要写接口文档,因为类型本身就是文档。我们只有两个后端服务,用 tRPC 刚刚好。如果未来业务膨胀要提供开放 API,再在 tRPC 外层加一层 REST 适配也不迟。
我整理了一张对比表,是当时在技术选型评审会上给团队看的:
| 方案 | 类型安全 | 维护成本 | 学习曲线 | 适合场景 |
|---|---|---|---|---|
| REST + OpenAPI | 一般 | 高(文档易漂移) | 低 | 对外公开 API |
| GraphQL | 强 | 中 | 高 | 多端查询需求复杂 |
| tRPC | 极强 | 低(类型驱动) | 低 | 内部全栈应用 |
选型结论很简单:t3code 是一个内部项目,没有开放 API 的需求,优先考虑的是开发效率和长期可维护性。T3 Stack 的每一环恰好都压在这个需求上。
2. 初始化与骨架搭建:create-t3-app 的实际体验
2.1 初始化命令与选项
T3 Stack 官方提供了一个脚手架工具 create-t3-app,我建议只要不是有极其特殊的定制需求,一律用它初始化。手搭一遍看起来能加深理解,但容易在版本兼容性上浪费大量时间。
pnpm create t3-app@latest t3code执行后会有交互式提问。我勾选了 NextAuth、Prisma、Tailwind、tRPC 这四个模块。这里有个小经验:如果项目初期还不确定要不要数据库,我建议仍然勾上 Prisma,因为 T3 Stack 的类型安全核心链路就是靠 Prisma 驱动的,后面再加虽然可行,但会打断一开始的"类型贯穿"体验。
包管理器我选了 pnpm。原因很简单:安装速度快,依赖隔离干净,磁盘占用小。特别是 T3 Stack 这种依赖树很深的项目,pnpm 的软链接机制能避免很多 node_modules 嵌套地狱。
2.2 生成后的目录结构拆解
创建完成后,目录结构大概是这样的:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── pages/ │ │ ├── api/ │ │ │ ├── auth/[...nextauth].ts │ │ │ └── trpc/[trpc].ts │ │ └── index.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ └── styles/ │ └── globals.css ├── .env └── package.json看到这个结构,最容易犯的错误是把src/pages/api/trpc/[trpc].ts当成业务代码去改。它只是 Next.js 的 API 路由入口,把 HTTP 请求转发给 tRPC 处理器,真正的业务逻辑都在src/server/api/routers/下。我不止一次看到新手在这个文件里写业务逻辑,然后发现类型推导全乱了。
另一个要重点理解的是src/server/这个目录下放的全是服务端代码。tRPC 的类型安全之所以能成立,是因为root.ts里导出的appRouter既是后端路由的真实实现,也是前端类型推断的唯一来源。前端代码里import type { AppRouter } from '@/server/api/root',TypeScript 会把它当成纯类型引用,编译时不会有任何服务端代码被打进前端 bundle。
2.3 版本组合与常见坑
创建成功那一刻,默认的版本组合是 Next.js 14(App Router 模式)、React 18、tRPC 10、Prisma 5、Tailwind 3。这套组合是官方持续测试过的,稳定性有保障。
我遇到过最典型的一个坑是:初始化后不写任何业务代码,直接pnpm dev启动,然后浏览器访问首页报错。排查下来发现是.env里DATABASE_URL还没配置,Prisma 客户端初始化失败。create-t3-app 虽然会生成.env,但数据库连接串是空的,需要你自己去填。这个不算 bug,但对第一次用的人是个不小的惊吓。
还有一点要提醒:不要在初始化后立刻升级所有依赖到最新版本。T3 Stack 的版本组合是经过匹配的,tRPC 11 出来的时候,很多人大手一挥升级,结果发现 NextAuth 的适配器不兼容,tRPC 的createTRPCNext也改了 API。我后来是老老实实退回 tRPC 10 才恢复。尝鲜要等生态跟上了再说,生产项目最怕的是依赖之间的隐形冲突。
3. 核心实现:类型安全数据链路是怎么跑通的
3.1 Prisma Schema:先把数据模型定下来
t3code 的核心业务是"任务分配",我需要三个基础模型:用户、项目、任务。在prisma/schema.prisma里写好后,执行迁移命令:
pnpm prisma migrate dev --name init_task_modelsSchema 的重点不在字段多少,而在关联关系的表达。我贴一段简化后的核心模型:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? tasks Task[] projects Project[] } model Project { id String @id @default(cuid()) name String ownerId String owner User @relation(fields: [ownerId], references: [id]) tasks Task[] createdAt DateTime @default(now()) } model Task { id String @id @default(cuid()) title String status String @default("todo") projectId String project Project @relation(fields: [projectId], references: [id]) assigneeId String? assignee User? @relation(fields: [assigneeId], references: [id]) createdAt DateTime @default(now()) }这里一个设计细节是:项目 owner 和任务 assignee 都用了cuid()而不是自增数字 ID。自增 ID 在内部系统看起来方便,但暴露业务量,而且分布式迁移时会撞主键。cuid 在生成时通过时间戳和随机因子保证全局唯一,TypeScript 侧是字符串类型,调试时也比 UUID 短一截,更友好。
prisma migrate dev执行后,Prisma 会自动生成PrismaClient的类型。记住:Prisma Schema 是类型真相的唯一来源。业务里想加一个字段,先改 Schema,再迁移,然后类型自动更新。这就是 T3 Stack 全链路类型安全的第一环。
3.2 tRPC Router:把后端逻辑变成类型安全函数
类型有了,接下来是把操作封装成 tRPC procedure。在src/server/api/routers/task.ts里,我写了第一个业务 Router:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { TRPCError } from "@trpc/server"; export const taskRouter = createTRPCRouter({ listByProject: protectedProcedure .input(z.object({ projectId: z.string().cuid() })) .query(async ({ ctx, input }) => { return ctx.db.task.findMany({ where: { projectId: input.projectId }, orderBy: { createdAt: "desc" }, include: { assignee: { select: { id: true, name: true } } }, }); }), create: protectedProcedure .input( z.object({ projectId: z.string().cuid(), title: z.string().min(1), assigneeId: z.string().cuid().optional(), }) ) .mutation(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { id: input.projectId }, }); if (!project || project.ownerId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } return ctx.db.task.create({ data: input }); }), });看懂这个文件,tRPC 的写法基本就掌握了大半。有几个关键点:
第一,protectedProcedure是脚手架内置的一个封装,会在进入 handler 前先校验 session。没登录的用户根本进不来逻辑层。相比 REST 里在中间件里做一堆 if 判断,这种声明式的保护方式让每个 procedure 的安全边界一眼可见。
第二,input用 zod 做运行时校验。zod 校验的对象和 TypeScript 类型是同一个来源,z.string().cuid()既保证前端传过来的是合法 cuid 格式,也让 TypeScript 自动推断出input的类型。很多初学者会忽略 input 校验,觉得类型安全就够了,但实际上 TypeScript 类型是编译期的,运行时恶意请求根本不吃这一套。zod 是运行时安全的关键,不能省。
第三,mutation 里的权限判断发生在 handler 内部,因为"项目 owner 才能新建任务"这种业务规则没法靠全局中间件表达。我见过有人在多个 procedure 里重复写同样的项目归属判断,正确做法是抽成工具函数,或者用 middleware 包一层。t3code 里我抽了个isProjectOwner的 middleware,代码量立刻下降一半。
3.3 前端调用:一行代码拿到类型安全数据
后端 Router 写好后,前端调用看起来像这样:
const { data: tasks, refetch } = api.task.listByProject.useQuery( { projectId }, { enabled: !!projectId } ); const createTask = api.task.create.useMutation({ onSuccess: () => { refetch(); }, });就这两行,完成了以前至少需要"类型定义 + 请求封装 + 状态管理 + 缓存策略"四件事才做完的功能。useQuery的data类型完全由后端的返回类型推导出来,你要是把task.title写成task.titles,TypeScript 编译直接报错。
useQuery 底层是 React Query,缓存和失效策略天然可用。这里我学到的一条硬经验是:mutation 成功后优先选择 invalidate 相关 query,而不是手动 refetch。
const utils = api.useUtils(); await utils.task.listByProject.invalidate({ projectId });invalidate 会让 React Query 标记缓存过期并重新拉取,而 refetch 只是主动触发一次请求。前者在多客户端、多列表页的场景下能保持全局数据一致,后者容易出现"这边改了那边还是旧数据"的尴尬。
3.4 这背后到底省了什么
我做一个粗略统计:t3code 里一共有 12 个业务实体、30 多个操作接口。如果走传统 REST + 手写类型定义,我至少要维护 30 个 API 路径、60 个 DTO 文件、一份接口文档、一份前端 API 请求层。用 tRPC 之后,这些全部消失了,只需要维护 12 个 Prisma 模型和 7 个 Router 文件。接口文档不再是交付物,因为类型定义就是活的文档。
更重要的是开发体验的变化。以前改接口,前后端必须同步部署才能联调,现在改完 Prisma Schema,跑一次迁移,前端编译器立刻提示哪里需要跟着改。以前是"改代码 + 对文档",现在是"改类型 + 编译器帮你看",这个转变对团队的交付节奏是质变。
4. 认证与会话:NextAuth 在 t3code 里的落地细节
4.1 为什么选 NextAuth 而不是自己写 JWT
认证方案几乎不用纠结。create-t3-app 默认集成 NextAuth,而且和 Prisma 有现成的适配器,直接复用现成的是最稳的选择。有同事提议自己写 JWT 签发和验证,我坚决反对。认证是最容易出安全漏洞的环节,自己实现签名算法、会话过期、刷新策略,任何一步考虑不周都是事故。NextAuth 经过大量生产环境验证,提供 Google、GitHub、邮箱等 OAuth 和 Credentials 登录方式,还能通过适配器把 Session 持久化到数据库。
4.2 数据库 Session 方案的模型
在prisma/schema.prisma里,NextAuth 标准的四个模型是 Account、Session、User、VerificationToken。脚手架初始化时,我记得schema.prisma已经有这些模型的骨架,但字段不全,需要对照 NextAuth 官方文档补齐。一个容易漏的字段是Session模型里的expires索引,不加上去的话,用户量大了以后,数据库查询过期 Session 会很慢。
Session 方案有两种选择:JWT Session 和 Database Session。t3code 选了 Database Session。区别在于:JWT Session 是服务端签发一个加密 token,客户端保存,缺点是一旦签发,无法主动踢用户下线;Database Session 把 session 记录存在数据库里,服务端任一时间都能撤销,适合作弊检测、权限回收这类需要强控制的场景。内部协作平台对权限控制要求高,所以我选数据库方案。
4.3 中间件与权限控制的坑
NextAuth 在 Next.js 里的接入方式是src/pages/api/auth/[...nextauth].ts。create-t3-app 默认生成的文件里已经把 auth 配置和 tRPC 的上下文打通了,核心代码在src/server/auth.ts。
这里我踩过一个比较疼的坑:把 NextAuth 回调函数写错作用域。NextAuth 配置里callbacks的jwt和session回调,一个负责生成 token,一个负责把 token 信息映射到 session。我在session回调里尝试访问ctx.db,结果发现回调里根本没有ctx。因为 NextAuth 回调是独立于 tRPC 上下文执行环境的,需要的数据得在jwt回调里先把字段塞进 token,再到session回调里取出来。看官方文档没细想,踩过一次就记住了。
页面和接口的权限保护,我分两层做。页面层在src/middleware.ts里做路径匹配,未登录访问/dashboard/*就重定向到登录页:
export { default } from "next-auth/middleware"; export const config = { matcher: ["/dashboard/:path*"], };接口数据层由 tRPC 的protectedProcedure把关。这两层缺一不可:middleware 保护的是页面访问,procedure 保护的是数据读写。只做页面层保护,用户照样可以通过浏览器网络面板直接调 API,前提是合法 Session;但是如果没有 procedure 层,登录用户就能访问不属于他的数据。所以在我们内部平台,业务级权限检查永远在 procedure 内做,而不是只靠页面隐藏按钮。
5. 样式工程化:Tailwind 与组件体系
5.1 设计令牌:从吐槽到统一
团队以前写 CSS 时,颜色变量各写各的,蓝色在 A 页面是#3b82f6,在 B 页面是#38bdf8,看似差不多,放一起就很脏。用 Tailwind 之后,第一件事是配置设计令牌。在tailwind.config.ts里扩展语义化颜色:
colors: { primary: { DEFAULT: "#2563eb", foreground: "#ffffff", hover: "#1d4ed8", }, surface: { DEFAULT: "#ffffff", muted: "#f8fafc", }, }语义化的好处是:业务代码里永远不出现裸色值,只写bg-primary、text-foreground。以后品牌换色,只改配置文件一个地方,全站自动更新。这个思路不只适合 Tailwind,任何设计系统都适用。
5.2 动态类名编译问题的踩坑
这是 Tailwind 新手最容易踩的坑。Tailwind 在构建时会扫描源码,把用到的类名提取出来生成 CSS,但它不会运行你的 JS 代码。如果你写className={bg-${color}-500},Tailwind 编译时看到的是模板字符串,不知道bg-blue-500和bg-red-500到底出现在哪,类名就不会生成,页面上背景色就是透明的。
解决方案是写完整的类名,或者用映射表让编译器能直接看到所有候选值:
const buttonColors = { primary: "bg-primary text-white", danger: "bg-red-500 text-white", ghost: "bg-transparent border border-gray-300", };这样 Tailwind 的扫描器能扫描到bg-primary、bg-red-500,类名就不会丢。这个坑的可怕之处是它不会报错,只会在运行时表现异常,而且只在生产构建里出现,因为开发环境有 JIT 全量编译,本地好好的,一打包就少了样式。
5.3 暗黑模式与主题切换
Tailwind 的暗黑模式默认是根据系统偏好来的,但我们业务平台希望提供手动切换。需要改darkMode: "class",然后在 html 根节点上挂dark类,剩下的就是通过上下文管理主题状态。
我试过用next-themes这个库来管理主题,它解决了一个关键问题:避免首屏闪烁。如果不用它,直接在 React 组件里读localStorage设置主题,页面水合之前背景色可能是白的,暗黑模式用户会看到短暂的白光闪烁。next-themes会在页面加载前通过内联脚本读取并设置主题,体验好了不少。
主题切换在 t3code 里不只是颜色变化,还涉及图表、地图等组件的重绘,所以我把主题状态放在了全局上下文里,而不是每个页面各自管理。这个决策在后来的页面扩展中省了很多事。
5.4 组件复用策略
Tailwind 虽然好写,但擅长把页面写得又长又重复。t3code 里的组件我用了一个简单不复杂的分层:基础 UI 组件(Button、Input、Card)统一封装,业务组件(TaskCard、ProjectSelector)组合基础组件。
基础组件封装时要注意:不要把 className 写死。要支持类名合并,推荐cn工具函数,内部用clsx+tailwind-merge处理冲突,让外部传入的类名能覆盖默认样式。tailwind-merge 解决的核心问题是px-4和px-6这种冲突类名,后者的样式会前者的覆盖,如果没有它,组件内部写死的px-4永远压不住外部传入的px-6,样式就改不动了。
6. 部署上线:从本地到生产环境的完整记录
6.1 数据库连接与迁移
t3code 的本地数据库用 SQLite,零配置好上手。但生产环境我换成了 PostgreSQL,原因很简单:并发写入和连接管理能力。SQLite 在内部平台的量级下或许也能跑,但它是单文件锁机制,多个请求同时写入时会报锁错误,而且我看不到连接池和慢查询的监控能力,心里不踏实。
生产库连接串长这样:
DATABASE_URL="postgresql://user:password@host:5432/t3code?schema=public"部署流程里最容易踩坑的是迁移时机。迁移要在新代码部署之前执行,顺序反了就会有一段时间老代码读新表结构,容易报错。手工执行时我会先盯着迁移跑完,再触发前端构建和重启服务。
6.2 Docker 镜像与服务器部署
T3 Stack 应用是标准的 Next.js 应用,可以直接用官方镜像构建。我写了多阶段构建,避免把源码和依赖一起塞进镜像:
FROM node:20-alpine AS base FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN pnpm prisma generate && pnpm build FROM base AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./ EXPOSE 3000 CMD ["pnpm", "start"]有两件事必须在构建阶段做,漏一个线上就跑不起来:一是pnpm prisma generate,不生成 Prisma Client,应用启动时找不到PrismaClient实例;二是next build必须拿到完整的.env,包括数据库连接串、NextAuth 密钥,否则构建期间访问数据库的静态生成页面直接报错。
6.3 环境变量与密钥管理
环境变量管理是部署环节最容易翻车的地方。t3code 的环境变量分三类:
| 分类 | 变量 | 处理方式 |
|---|---|---|
| 公开变量 | NEXT_PUBLIC_APP_URL | 构建时注入 |
| 私有变量 | DATABASE_URL、NEXTAUTH_SECRET | 运行时注入服务器 |
| 其他 | NEXTAUTH_URL | 按环境切换 |
最关键的NEXTAUTH_SECRET,是用openssl rand -base64 32生成的,存放到服务器上的.env文件里。这个密钥一旦丢失,所有登录用户的 Session 都会失效,所以我在服务器上做了一个副本到带权限控制的目录,并在密码管理工具里留了备份。
还有一个小细节:NEXTAUTH_URL必须和生产域名完全一致,包括协议头。如果配成http而实际是https,NextAuth 生成的回调地址就会导致登录后跳转错误。这种问题排查起来非常隐蔽,我上线第一天就撞上了,日志里只有一条 URL mismatch,愣是花了我半小时才定位。
6.4 上线初期的性能与稳定性复盘
上线后观察了两周,最核心的指标是接口耗时和错误率。tRPC 接口平均响应时间在 80ms 左右,主要是数据库查询开销。最慢的是任务列表接口,因为include加载了关联的 assignee 数据。第一次在 5000 条任务上测试时,响应时间涨到 300ms 以上。
优化方案是加索引。在 Prisma Schema 里要细致设计索引,比如把@@index([projectId, status])加到 Task 模型上,让按项目过滤、按状态过滤的查询都走索引。改完 Schema 后跑迁移,响应时间立刻回到 100ms 内。这给我们的教训是:关联查询不是越多越好,include需要用对场合。列表页需要的字段少,就只 select 那几列,没必要把整个 User 对象拉出来。这也是 tRPC 和 Prisma 配合时很容易忽略的性能问题。
7. 常见问题与排查技巧实录
7.1 tRPC 类型不匹配的排查
t3code 运行期间,前端偶尔出现调用报错,提示类似..."null" is not assignable to type ...。我排查后发现,问题出在 Prisma 的findUnique可能返回null,而前端推断出的是可空类型。如果业务上明确知道记录一定存在,就得在 handler 里做显式判空并抛出 NOT_FOUND 错误,而不是在前端用非空断言:
const task = await ctx.db.task.findUnique({ where: { id: input.id } }); if (!task) { throw new TRPCError({ code: "NOT_FOUND" }); }这样不但类型变干净了,运行时的错误也更规范。前端可以捕获错误提示用户"任务不存在",而不是收到一个 null 再判断。
这种问题的根源是"类型推断和运行时行为不一致",解决思路始终是:让运行时先断言,类型自然就收窄了。
7.2 Prisma 连接超时与连接池
上线第二天,服务器日志出现PrismaClientInitializationError,数据库连接超时。排查后发现是我们的 PostgreSQL 连接数打满了。原因有两条:一是服务器实例配置了过大的连接池,默认 100 条连接,对一个小型内部应用来说太多了;二是 Next.js 在开发模式下为每个热更新模块都创建一个 PrismaClient 实例,生产环境也有可能出现多个实例。
解决方案:
DATABASE_URL="postgresql://user:password@host:5432/t3code?connection_limit=10"另外在src/server/db.ts里加全局单例缓存,避免开发环境反复创建 PrismaClient。create-t3-app 生成的代码其实已经做了 global 缓存,但如果你自己改过 db 初始化位置,很容易把这段弄丢。
7.3 Next.js 构建内存溢出
部署到一台 2GB 内存的服务器上时,pnpm build跑到一半就 OOM,进程被杀。Next.js 的构建挺吃内存的,特别是页面里有大量服务端渲染数据时。解决办法有两个:一是加细粒度的编译内存上限:
NODE_OPTIONS="--max-old-space-size=2048" pnpm build二是服务器至少给 4GB 内存,构建后用next start常驻进程。我们最后选了第二种,因为 t3code 以后还要接 CI/CD,构建机器内存不够迟早是隐患。
7.4 常见问题速查表
| 现象 | 常见原因 | 快速解法 |
|---|---|---|
| 登录后跳转回登录页 | NEXTAUTH_URL 与域名不一致 | 检查环境变量协议和域名 |
| 页面样式缺失但本地正常 | 动态 class 拼接 | 改写成完整类名或映射表 |
| 接口能调但数据为 null | 忘了处理 Prisma 可空结果 | handler 内显式抛 NOT_FOUND |
| 登录用户看不到自己数据 | procedure 没加鉴权 | 加上 protectedProcedure 或业务鉴权 |
| 数据库提示连接数超限 | 连接池过大 | 调整 connection_limit |
| 刷新页面后暗黑模式闪烁 | 主题设置在客户端生效 | 换用 next-themes 或内联脚本 |
写在最后的项目感受
如果让我从头再做一次 t3code,我的第一选择依然是 T3 Stack,但启动顺序会调整。先花半小时把 Prisma Schema 设计完整,比直接写页面更重要,因为类型贯穿全链路,模型设计错一行,后面所有页面都得跟着改。我会更早把路由分组和权限中间件框架搭好,而不是等业务写了一半再拆。
还有一个建议送给所有接触 T3 Stack 的人:不要因为它带给你类型安全就忽视运行时校验。类型系统解决的是开发期的错误,zod、Prisma 的查询校验、NextAuth 的会话检查,这些守卫的是运行期的安全。t3code 项目运行至今,稳定性没有出过大问题,原因正在于每一层都有自己的职责:TypeScript 管编译,zod 管输入,Prisma 管数据,tRPC 管通信,NextAuth 管身份。它们各司其职,配合起来才是一个完整可靠的全栈应用。