在社区里搜全栈脚手架,大概率会碰见create-t3-app和 T3 Stack 这组关键词。t3code这个说法,其实就是围绕这套技术栈的一整套编码实践:Next.js 管前端展示、tRPC 管 API 通讯、Tailwind CSS 管样式、Prisma 管数据模型。它最核心的观点是,TypeScript 类型应该从数据库一路穿透到按钮上的onClick回调。这套组合不是某个新框架,而是一份被大量项目验证过、开箱即用的“全栈基线工程”。
我自己用t3code命名过不止一个项目仓库:内部后台、个人作品集、甚至给客户交付的小型管理系统。初始化一条命令搞定,之后所有时间几乎都花在写业务代码上,不用再纠结 API 层怎么连、类型怎么同步、样式要不要上设计系统,这些 T3 都给了一个还不错的默认答案。这篇东西准备把从初始化到部署的全过程拆开讲清楚,适合两类人:一类是用 Next.js 开发但还没试过 tRPC 的开发者,另一类是已经跑起来但总在类型、数据库、认证配置上反复踩坑的人。
1. 什么是 T3 Stack,为什么全栈开发绕不开它
T3 Stack 不是某个公司出的框架,也不是一门新语言,它是一套由社区推动的“最佳组合方案”。名字里的 T3 来自 The T3 Stack,最早由create-t3-app这个脚手架项目确立起来。所谓 T3,其实就是 Typescript、Tailwind、tRPC 三个都以 T 开头的核心成员,再加上 Next.js 和 Prisma,凑成了前后端一体的完整闭环。
1.1 T3 Stack 由哪些库组成,各自的定位
先看一张总览表,后面每个环节还会继续展开。
| 组件 | 定位 | 主要解决的问题 |
|---|---|---|
| Next.js | 应用框架 | 路由、SSR/SSG、API 路由、部署适配 |
| TypeScript | 语言层 | 全链路静态类型检查 |
| tRPC | API 层 | 前后端零成本共享类型,替代 REST 手写 DTO |
| Tailwind CSS | 样式层 | 原子化 CSS,加速 UI 搭建,统一主题变量 |
| Prisma | 数据层 | 类型安全的 ORM,模型迁移、查询、seed |
| NextAuth / Auth.js | 认证层 | Session、OAuth、Credentials 集成 |
| zod | 校验层 | 输入校验,配合 tRPC 实现参数安全 |
这七个玩意单独拿出来都是成熟工具,但真正让它们产生化学反应的是“组合方式”。以前做前端,接口返回的数据类型是“二次声明”的:后端写一份 Java/Kotlin/Python 的类型,前端再手工抄一份 TypeScript interface,字段一变,两边经常对不上。T3 Stack 试图消除这个手工同步的过程。数据库表结构用 Prisma 定义,Prisma 生成 TypeScript 类型,tRPC 直接把这个类型透传给前端,前端调用api.post.list()的时候,编辑器里自动补全返回字段。类型从数据库里长出来,而不是人在两边维护出来的。
1.2 为什么会选这套组合,它是怎么避免选择困难的
每个项目一开始都要做一堆技术决策:用 Next.js 还是纯 Vite?接口走 REST 还是 GraphQL?要不要单独起一个 Node 服务?样式用 CSS Modules 还是 styled-components?数据库直连还是封一层 Service?这些决定对资深工程师来说可能半小时就有答案,但对新手或者想快速铺业务的人来说,决策本身就是巨大的时间黑洞。
create-t3-app的思路和那些退到最小功能的脚手架完全不同,它直接把一套“最不容易后悔”的选型写死。创建项目时只问你几个模块的开关,而不是让你从零搭十层抽象。这个设计背后有一条很实在的经验:给业务开发团队一个完整、紧凑、类型安全的起点,远比给他们一堆灵活但需要自行拼装的积木更有价值。有些项目为了可扩展性引入一堆中间层,结果业务还没写起来,光是修类型和配置就花了两周。T3 的选择是“先跑起来,再按需拆解”。
在此基础上它还有一层考虑:单一依赖链。你能想象一个全栈项目同时用 Express + REST + GraphQL + JWT + Redux + Sass + TypeORM 有多痛苦吗?版本、类型、上下文,每一处都要自己接线。T3 把链路上的关键点都选成互相兼容、类型友好的库,tRPC 直接复用 Next.js 的 API 路由,Prisma 自带 migrate 命令,Tailwind 的插件生态和 Next.js 的编译链路无缝集成。这种“同一生态内彼此咬合”的组合,显著降低了日常开发的心智负担。
2. 用 create-t3-app 初始化一个 t3code 工程
create-t3-app提供了交互式命令行工具,跑一次就能拿到一个立刻能编译、立刻能连数据库、立刻有用户登录骨架的项目。这一节写的都是我自己跑过的完整流程,每一步都会告诉大家“为什么这样做”。
2.1 环境准备和初始化命令
首先确认本机 Node.js 版本在 18.18 以上,建议直接用 20.13 LTS 或更高。create-t3-app依赖较新的 Node 特性,版本太老会出现ERR_UNSUPPORTED_NODE_VERSION之类的问题。包管理器我用 pnpm,它的依赖隔离做得干净,安装速度快。如果你习惯 npm 也不影响,后面的命令我会按 pnpm 写,用 npm 的话等价替换即可。
初始化命令很简单:
npx create-t3-app@latest t3codenpx会临时下载最新模板,t3code是项目目录名。交互式对话会依次询问:
- 包管理器:
pnpm/npm/yarn - 是否启用 TypeScript(默认启用)
- 是否启用 ESLint / Prettier
- 需要哪些模块:NextAuth.js、Prisma、Tailwind CSS、tRPC
- 是否启用路径别名
~/*
我自己的推荐是:四个模块全选,路径别名也选上。后面如果要加东西,模板里已经帮你把文件夹、依赖、环境变量样例全部铺好了,省下的配置时间很可观。如果项目里压根不需要用户系统,再考虑把 NextAuth 关掉。
2.2 生成出来的项目结构,先认识再动手
初始化完成后,目录是这个样子的:
t3code/ ├── app/ # Next.js App Router 页面与布局 │ ├── _components/ # 私有组件(T3 默认下划线文件夹不会暴露成路由) │ ├── api/trpc/[trpc]/ # tRPC 服务端入口 │ └── page.tsx ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── server/ │ ├── api/ │ │ ├── root.ts # 根 router,汇总子路由 │ │ └── routers/ │ └── auth.ts # NextAuth 配置 ├── trpc/ │ ├── server.ts # tRPC 服务端上下文、procedure 定义 │ ├── client.ts # 前端 tRPC Client │ └── react.tsx # Provider 封装 ├── styles/globals.css ├── .env / .env.example └── package.json这套结构把服务端逻辑、客户端调用、数据库模型分开。server/放业务逻辑,trpc/管客户端和服务端的桥,prisma/管数据层。这样分的意义在于:当项目大到需要拆模块时,可以直接把对应 router 文件拆出去,不会把所有 API 逻辑堆在一个文件里。
2.3 首次启动前必须做的两步:环境变量和数据库迁移
看到.env.example后,第一件事是复制成.env:
cp .env.example .env模板默认使用 SQLite,所以DATABASE_URL="file:./db.sqlite"不需要额外装数据库。接着执行首次数据库迁移。注意不是直接用prisma migrate dev,而是先跑模板预置的脚本:
pnpm db:pushdb:push会把schema.prisma里的模型直接同步到数据库,不需要生成迁移文件。如果对迁移记录有洁癖,也可以用pnpm prisma migrate dev --name init,效果是创建正式的迁移历史。本地开发两者都能用,但建议尽早用migrate dev习惯迁移工作流,后面要上生产环境时会少很多迁移文件缺失的问题。
然后启动开发服务器:
pnpm dev打开http://localhost:3000就能看到模板首页。此时数据库里已经创建了示例的Post和User表。实际动手建议:把模板自带的示例 router 打开看一下,它包含了 query、mutation、auth、数据库查询各一个完整样本,是理解全链路的最好教材。
3. tRPC:把“类型安全”从口号变成业务标配
tRPC 是 T3 Stack 里最值得细讲的一部分。很多人第一次看见 tRPC 会觉得像魔法:前端直接调用一个函数,函数体跑在服务端,参数和返回值类型完全一致,还不需要任何代码生成工具。它之所以能做到,不是靠运行时魔法,而是靠 TypeScript 的类型推导和编译器。
3.1 tRPC 解决了 REST 和 GraphQL 没解决好的边界问题
REST 接口的传统工作流是:前端发fetch→ 后端返回 JSON → 前端as SomeType断言 → 如果接口字段改了,前端这里直到运行时才知道。GraphQL 稍微好一点,但要么引入 codegen,要么需要前后端共享 schema 文件,额外基建不少。
tRPC 的思路很直接:不再把接口设计成“URL + 动词”,而是把服务端的路由变成一个可被类型系统直接调用的对象。你在服务端定义postRouter.list,前端编辑器里输入api.post.list时,IDE 能自动补全到useQuery,返回数据也能自动推断出来。类型错误在编译期就暴露,而不是等接口跑了才发现字段对不上。
为了讲清楚这个原理,我画一个非常情绪化的对比:写 REST 接口时,你维护的是“URL、方法和 DTO 三件套”,写 tRPC 时,你维护的只有“一个 TypeScript 函数”。后者少了一层手工同步,并且因为函数就是类型,IDE 的跳转、重构、自动补全全都白送。
3.2 写一个完整的 tRPC router,从 query 到 mutation
拿一个常见场景“文章列表 + 发布文章”来拆解。先在server/api/routers/post.ts定义业务逻辑:
import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(50).default(10) })) .query(async ({ ctx }) => { const posts = await ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: ctx.input?.limit ?? 10, }); return posts; }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });注意几个细节:
publicProcedure是无需登录就能调的;protectedProcedure要求存在有效 Session,否则会直接抛UNAUTHORIZED。zod承担输入校验,非法参数根本不会进入业务代码,天然防了一波恶意请求。ctx是每个 procedure 的上下文,里面默认包含db和session,由服务端注入。
然后把它挂到根 routerserver/api/root.ts:
import { postRouter } from "./routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;注意AppRouter的类型是直接从代码推导出来的。前端 import 这个类型(只导入类型,不导入代码),就能获得完整智能提示。
3.3 React 端怎么调用,缓存和失效的思路
页面里调用:
"use client"; import { api } from "~/trpc/react"; export function PostList() { const { data, isLoading, refetch } = api.post.list.useQuery({ limit: 10 }); const createPost = api.post.create.useMutation({ onSuccess: () => refetch(), }); if (isLoading) return <div>加载中...</div>; return ( <div> {data?.map((post) => <p key={post.id}>{post.title}</p>)} <button onClick={() => createPost.mutate({ title: "hello", content: "world" })}> 发布 </button> </div> ); }这里api.post.list.useQuery背后是 TanStack Query。它自动帮你做请求缓存、失败重试、状态管理。useMutation的onSuccess里手动调用refetch(),保证数据一致。这套代码全程没有任何手工定义 request/response 类型,但编辑器里的提示是完整的。
3.4 中间件和上下文:鉴权逻辑是怎么被统一吃掉的
在trpc/server.ts里,模板给了两个基础 procedure:
export const createTRPCContext = ({ session, db }) => ({ session, db }); export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) throw new Error("UNAUTHORIZED"); return next({ ctx: { session: ctx.session } }); });这个中间件机制值得细品。所有需要登录的接口统一在protectedProcedure做校验,业务代码里不用再手写if (!req.user) throw 401。而且注意中间件返回的ctx被重新注入了更精确的session类型,后面的业务代码里ctx.session.user.id是被 TypeScript 确认过的字段,不会有空指针隐患。
我见过一些项目为了给每个接口写@AuthRequired装饰器,花了很多功夫。在 tRPC 里,这个约束天然就挂在 procedure 的“命名类型”上,可读性和透明度都更高。
4. Prisma 数据层:模型、迁移与 seed 一稿成型
Prisma 在 T3 Stack 里承担数据访问层。它提供的不是简单 ORM,而是一套完整的数据库工作流:模型定义 → 数据库迁移 → 类型生成 → 查询客户端。在 T3 项目里,Prisma 是从初始化就绑定的成员,因此直接看prisma/schema.prisma即可。
4.1 模型定义、迁移与 seed 的最佳顺序
schema.prisma的经典模板包括 User 和 Post,带关系外键:
model User { id String @id @default(cuid()) name String? email String? @unique posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }改完模型后执行迁移,让表结构和代码保持同步:
pnpm prisma migrate dev --name add_post_authormigrate dev在开发环境很有用:它会对比当前 schema 和数据库差异,生成迁移 SQL,然后再跑一次生成客户端。注意执行完这步后,node_modules/.prisma/client里的类型会更新,所有引用了新字段的代码立刻能检测到类型。
seed.ts文件做的是初始化数据:
import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const user = await prisma.user.upsert({ where: { email: "demo@example.com" }, update: {}, create: { name: "demo", email: "demo@example.com" }, }); await prisma.post.create({ data: { title: "第一篇文章", content: "来自 seed", authorId: user.id }, }); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect());upsert的用法是为了让 seed 可以重复执行,不会因为主键冲突中断。每次运行pnpm db:seed都能安全地把数据补到目标状态。
4.2 与 tRPC 组合时的查询最佳实践
tRPC 的 resolver 内部可以直接使用ctx.db,但不要在 query 里做太多“组装数据后手动拼接类型”的事情。Prisma 支持include和select来控制返回结构:
detail: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id }, include: { author: { select: { name: true } } }, }); }),使用select而不是先取全部字段再在 JS 里过滤,有两个好处:减少传给客户端的字段,避免把email这类隐私字段不知不觉发出去;另外 Prisma 能生成更精简的 SQL,提升查询效率。
一个常被忽视的坑:N + 1 查询。tRPC + Prisma 写起来太顺手,列表查询里如果不小心在循环里调prisma.user.findUnique,就会出现“1 条列表 + N 条 user 查询”。解决思路是在外层查询里直接用include,把关联数据一次性取回来,或者分两次查询然后内存合并。这也是为什么很多优化的第一步不是加 Redis,而是看这个查询被隐式放大了多少次。
4.3 部署时数据库怎么选:从 SQLite 切到 PostgreSQL
本地开发默认 SQLite,零配置很舒服,但上线时千万别带着 SQLite 上生产。并发写入、连接生命周期、磁盘持久化这几个场景,SQLite 都不适合。推荐直接切到 PostgreSQL,国内可用免费的线上数据库服务很多,自己起一个也简单。
切换只需改.env:
DATABASE_URL="postgresql://user:password@host:5432/dbname?schema=public"然后执行:
pnpm prisma migrate deploy pnpm prisma generatemigrate deploy是生产环境专用命令,它会静默执行已生成的迁移文件,不会像migrate dev那样交互式确认,这正是自动化部署想要的。数据库连接上之后,原来的所有 Prisma 查询代码不需要改动,这就是 ORM 层带来的迁移红利。
5. 样式方案与认证选型:Tailwind 与 NextAuth 的实战姿态
T3 项目里样式和认证是默认模块,但很多新人会用出问题:Tailwind 样式不生效、NextAuth 登录一直跳圈。这一节把关键配置和直觉讲清楚。
5.1 Tailwind CSS 为什么是 T3 的默认样式解
Tailwind 的核心价值不在于“快”,而在于把样式原子化以后,UI 迭代节奏极大加快。在t3code这个工程里,Tailwind 配置文件tailwind.config.ts已经初始化好了,styles/globals.css也被改造成包含@tailwind base; @tailwind components; @tailwind utilities;三个指令。直接在组件里写:
<div className="flex min-h-screen items-center justify-center bg-zinc-950 text-white">我自己的体会是:T3 项目里不太需要额外的 CSS-in-JS 库,Tailwind 的 utility class 已经能覆盖绝大多数场景。真出现需要复用的大块样式时,用@layer components封装,而不是又多引入 styled-components 增加运行时负担。
一个高频坑是“改了tailwind.config但不生效”。需要检查content数组是否包含了所有可能用到类名的目录,特别是新型的app/**/*.{ts,tsx}路径。如果自定义颜色或阴影没有效果,八成是content漏配了。
5.2 深浅色切换与主题变量
现代前端项目基本都要深浅色切换。T3 模板提供了next-themes,把带主题状态塞进 Context。最简用法:
"use client"; import { useTheme } from "next-themes"; export function ThemeToggle() { const { resolvedTheme, setTheme } = useTheme(); return ( <button onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}> {resolvedTheme === "dark" ? "亮色模式" : "暗色模式"} </button> ); }然后在tailwind.config.ts中开启darkMode: "class",之后任何地方用dark:bg-zinc-900,页面切到暗色时自动生效。注意不要用darkMode: "media",因为“跟随系统”会剥夺用户在系统设置和网站偏好之间的独立选择权。
5.3 NextAuth / Auth.js 怎么接 Session 并同步给 tRPC
T3 模板里的认证默认是 NextAuth v4(新模板已逐步迁移到 Auth.js),配置文件在server/auth.ts。里面定义了session.strategy = "jwt"时,Session 会以 JWT 形式存在 Cookie 里,DB Session 表也不需要建,适合 serverless 部署。
Providers 通常是 Google/GitHub OAuth,但企业内部项目常用 Credentials。给一个最小 Credentials 示例:
providers: [ Credentials({ name: "账号密码", credentials: { email: { label: "邮箱", type: "email" } }, async authorize(credentials) { const user = await db.user.findUnique({ where: { email: credentials?.email }, }); if (!user) return null; return { id: user.id, name: user.name, email: user.email }; }, }), ],注意如果只开 Credentials,项目里最好再保留一个 OAuth Provider,否则 NextAuth 的登录页会提示“未配置 provider”并拒绝渲染登录入口。这是很多新手第一次跑pnpm dev后点登录必踩的坑。
tRPC 和 NextAuth 的连接在createTRPCContext里完成:模板会把getServerAuthSession()拿到的 session 放入ctx。于是protectedProcedure里直接ctx.session就是登录态。从页面发起登录只需要:
import { signIn, signOut, useSession } from "next-auth/react"; const { data: session } = useSession(); if (session) { /* 已经登录 */ } else { signIn(); }整个认证链路没有“用户表格里额外存 token”这套逻辑,JWT 在 Cookie 里自动携带、自动过期,对前后端一体场景足够。
6. 部署到生产环境:Vercel 与 Docker 两条可行路径
开发完成后,部署才是真正考验工程水平的地方。T3 项目因为涉及数据库迁移、构建期类型检查、Prisma 客户端生成,部署比纯静态站点多几个细节步骤。
6.1 Vercel 部署步骤与环境变量清单
Vercel 对 Next.js 的适配是最好的。仓库推到 GitHub 后,在 Vercel New Project 里选择该仓库,框架会自动识别成 Next.js。填环境变量时,注意别把这些漏了:
DATABASE_URL:生产数据库连接串NEXTAUTH_SECRET:用于 JWT 签名,可以用openssl rand -base64 32生成NEXTAUTH_URL:部署后的域名- 其他 OAuth Provider 的
CLIENT_ID/CLIENT_SECRET
构建命令如果卡住,多半是postinstall里没有自动跑prisma generate。Vercel 会在安装依赖后执行构建脚本,T3 模板的package.json默认已经加了"postinstall": "prisma generate",所以正常不需要手动处理。但如果使用 pnpm 且版本较新,注意pnpm approve-builds对 Prisma 的postinstall脚本拦截,可能反过来导致 Prisma Client 没生成。经验是:直接在构建命令里显式加上:
npx prisma migrate deploy && npx prisma generate && next build这样能绕开依赖安装策略差异带来的问题,也让构建日志更透明。
6.2 Docker 多阶段构建的要点
如果项目要部署在自有的云服务器或容器平台,推荐多阶段构建。参考下面这个 Dockerfile 的骨架:
FROM node:20-alpine AS base RUN npm i -g pnpm 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 npx prisma generate RUN pnpm build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/node_modules/.pnpm/@prisma+client*/node_modules/.prisma ./node_modules/.prisma EXPOSE 3000 CMD ["node", "server.js"]注意next.config.ts里必须开启output: "standalone",否则.next/standalone目录不会生成。standalone 模式把 Next 运行时精简成一个server.js,内存占用比完整 node_modules 小很多。另外,生产镜像里仍然需要 Prisma 的 schema 文件和迁移文件,因为启动时理论上还可能执行 migrate,所以上面把prisma目录也复制进去。
6.3 生产环境必查的三件套
环境变量配齐之后,部署完先不要急着测功能,先做三件事:
第一,检查数据库连接。可以用一条简单的 query 验证,也可以在 Prisma Studio 或数据库控制台直接看 rows 是否正常。常见问题是数据库实例有白名单限制,Vercel/Docker 的出口 IP 不在允许列表里。
第二,检查登录态。JWT 策略下,如果部署后登录页面跳转正常但刷新后 session 消失,大概率是NEXTAUTH_URL和实际域名不一致,或者 Secure Cookie 在 HTTP 环境下没有发送。本地开发用http://localhost:3000,生产环境必须是https://域名,否则浏览器默认不发送 Secure Cookie。
第三,检查静态资源缓存。Next.js 默认在/public下的文件采用一年强缓存,如果你的接口结果被写成静态 JSON 放在那里,更新版本后发现前端读的还是旧数据,需要调整响应头或改用 API 路由。
7. 常见踩坑与排查速查表
这部分是无数个深夜调试攒出来的经验。如果有人问 T3 好不好用,我的答案通常是“好用,但坑都集中在少数几个固定角落里”。把这些问题按症状整理成表,方便直接对照。
7.1 按症状分类的典型故障与排查顺序
| 症状 | 可能原因 | 排查顺序 |
|---|---|---|
数据库连接报错Can't reach database server | 环境变量没配、数据库白名单、连接串格式错误 | 1. 检查.env;2. 用数据库客户端手动连接;3. 看错误信息里 host 和端口 |
| 前端调用 tRPC 返回 404 | app/api/trpc/[trpc]/route.ts被误删或路径错误 | 检查该文件是否存在于 app 目录,且createNextApiHandler绑定的 router 正确 |
tRPC 报UNAUTHORIZED | protectedProcedure 在未登录时被调用 | 查看请求是否携带有效 cookie;看ctx.session是否为空 |
| Prisma 类型报错,新字段不存在 | 修改 schema 后没有重新generate | 运行pnpm prisma generate,必要时重启 IDE |
| Tailwind 新加的类名不生效 | content路径没覆盖到文件 | 检查 tailwind.config 的 content 数组,包含所有组件目录 |
| 部署后样式加载但布局全乱 | 基础样式文件被覆盖 | 确认globals.css里还有@tailwind base; |
| 登录后刷新就掉线 | JWT 过期配置 / Secure Cookie / NEXTAUTH_URL 不一致 | 检查 Cookie 是否在每次响应中都正常 set;用 curl 观察 set-cookie |
| 生产构建时 TS 报错中断 | 代码有未使用变量或类型不完整 | 看 T3 的 tsconfig 严格模式,修正类型再构建 |
| seed 脚本执行报错主键冲突 | seed 没有用 upsert 或删除旧数据 | 把 create 换成 upsert,或先清空表再审慎写入 |
| 部署到 Docker 后容器一直重启 | .next/standalone未生成 / 环境变量没注入 | 检查是否开启output: standalone,容器启动日志是否有 module not found |
| 图片外链加载不出来 | next/image 默认限制远程域名 | 在next.config.ts的images.remotePatterns加上对应域名 |
7.2 几个值得单列的隐藏雷区
第一个雷区是NEXT_PUBLIC_开头的环境变量。Next.js 会在构建时把它们内联进浏览器 bundle,因此不要把数据库密码、OAuth Secret 这类机密写成NEXT_PUBLIC_。有人把DATABASE_URL误写成NEXT_PUBLIC_DATABASE_URL后直接打包发布,相当于把数据库连接串公开了出去,这种事故我见过不止一次。
第二个雷区是 tRPC 和 Next.js 的缓存机制混在一起。Next.js App Router 默认会对无动态函数的页面做静态渲染,如果你的页面里调用 tRPC 却没有把它标记为动态使用,构建后页面会把初始数据固化到 HTML 里。解决方法是给需要实时数据的页面加上:
export const dynamic = "force-dynamic";或者明确在该页面的某个组件里调用api.post.list.useQuery,让 Next.js 自动识别为客户端渲染。
第三个雷区是 Prisma 在 serverless 环境的连接数膨胀。Vercel 的每个 Serverless Function 实例都独立持有数据库连接,高峰期可能把 PostgreSQL 的连接池打满。解决思路是使用支持连接池的外部数据库服务,或者在 Prisma 配置里设置合理的connection_limit。本地开发还好,生产环境不处理的话,半夜流量一冲,数据库先趴窝,前端也跟着全挂。
7.3 排查方法论:从现象倒推链路
遇到问题时,我的排查顺序一般固定为“页面 → 前端调用 → tRPC handler → Prisma 查询 → 数据库”。先打开浏览器 DevTools 看 Network 里请求是否发出去、返回什么状态码;再在服务端加日志或直接看服务器 stdout;然后才是查数据库连接和表结构。不要一上来就怀疑框架,T3 这套链路里,大概率问题出在环境变量和配置,而不是框架本身的 bug。
如果某个 tRPC procedure 返回 500,最快的方式是在开发模式直接看终端错误栈。生产环境则必须依赖日志服务,否则错误信息很容易被框架吞掉。
最后说点个人的心得体会
我最初接触t3code时,是抱着“框架又多了一个”的心态去看的。真正用了一个周末把内部工具站从零写出来后,我发现它带来的真正好处不是某个特定 API 有多巧妙,而是“默认决定全部帮你做对了”:数据层类型安全、接口层免维护、样式层不纠结、认证层直接可用,这套组合让一个开发者的产出效率高到接近一个小型团队。这种体验在全栈框架里不多见。
如果未来某个项目规模膨胀到需要微服务,我可能会把一部分领域逻辑拆成独立的 Go/Rust 服务,但前端这层依然会保留 tRPC 作为内部入口,因为它的类型穿透能力在跨团队协作里太省心了。如果你还没试过,真的推荐拿一个周末,按这篇文章从初始化推到部署,跑通一次完整链路。你会意识到:所谓高效开发,很多时候不是“用的技术多新”,而是“不用维护的那一层多薄”。