☰
T3 Stack实战:从t3code看端到端类型安全的全栈开发
2026/10/28 15:36:50 网站建设 项目流程

最近我把一个内部工具项目跑完整条链路,代号起得特别随意,就叫 t3code。一开始我打算用老一套的 Next.js + REST API + MongoDB 直接堆,但正好那段时间被 T3 Stack 刷屏,于是决定拿真实项目试一次水。做完之后我得说:端到端类型安全带来的开发体验,确实有种回不去的感觉。这篇文章我就用 t3code 这个实战项目,把 T3 Stack 从选型、初始化、认证链路、数据流到部署优化的完整过程拆开讲,想到哪说到哪,顺便把我踩过的坑也一并交代了。

t3code 其实是一个面向团队的代码片段协作工具,用来解决“代码散落在聊天记录、个人笔记和文档里,很难检索和复用”的痛点。这类项目非常适合全栈 TypeScript 技术栈:业务边界清晰、表单和 CRUD 多、权限模型不复杂,但要求前后端类型同步。如果你也想在真实项目里验证 T3 Stack 是否靠谱,或者已经上手但总在认证和数据库环节卡住,那这篇内容大概率对你有用。

1. 先交代背景:t3code 是个什么项目,为什么选用 T3 Stack

1.1 需求画像:一个代码片段协作工具能做多大

团队内部的知识沉淀工具,听起来轻量,但真正做起来需求一点都不少。t3code 的核心目标有三个:能按语言、标签、作者快速检索代码片段;能对片段做版本记录,方便回溯谁改了什么;能按团队成员角色控制“谁能看、谁能编辑、谁能删除”。

我最初列了一个 MVP 功能清单:

  • 用户注册和登录,至少支持 GitHub OAuth
  • 代码片段的新增、编辑、删除,支持 Markdown 描述
  • 标签系统,一个片段可以关联多个标签
  • 全文检索(先用数据库模糊查询,后续再上搜索引擎)
  • 管理员能管理成员角色和权限

这个需求画像一出来,技术选型的边界就清楚了:需要用户系统、数据库 ORM、API 层和前端页面,而且最好所有类型定义能共享。这个时候把一个全栈 TypeScript 框架组合摆到桌面上,几乎是顺理成章的事。

1.2 T3 Stack 的底层逻辑:不是框架,是一套组合拳

所谓 T3 Stack,指的是由 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth 这套组合构建的全栈开发模式。它不是一个像 Next.js 那样的独立框架,而是一套被社区验证过的“黄金搭档”。核心思想是:所有代码从浏览器到数据库,穿过网络边界时都能保持类型安全。

打个比方,传统开发像是在两个城市之间修一条公路,你提前约定了路牌格式,但每次开车过去仍然要靠对讲机确认位置。T3 Stack 则像是直接修了一条高铁轨道,前后端能共享同一套类型蓝图,从函数调用到数据库查询,编译器帮你在发车之前就检查好路线。tRPC 在这里的作用最明显,它让你像调用本地函数一样调用后端逻辑,不再需要手写 OpenAPI 文档,也不需要在前端定义一堆接口请求函数。

Tailwind CSS 提供原子化样式,Prisma 负责数据库建模和迁移,NextAuth 搞定认证会话。这套组合天然适合中小型全栈应用,尤其是 t3code 这种需要快速迭代的内部项目。

1.3 选型时的横向对比:为什么不是 Next.js + NestJS

在定方案之前,我认真对比过 Next.js + NestJS + REST 的路线。NestJS 在大型企业级后端里确实很成熟,但它和 Next.js 并行,意味着你要维护两套 tsconfig、两套依赖,联调时生成的一套请求客户端偶尔还会和实际后端不一致。对 t3code 这种以 CRUD 为主的内部系统来说,这套复杂度是浪费的。

GraphQL 也考虑过,但 Apollo Server + TypeScript 的 schema-codegen 流程,在多人协作时对代码生成器的依赖很强,尤其是字段变更频繁的早期阶段,代码再生成和缓存失效让人头疼。tRPC 不需要 schema 文件,它会直接从 Router 定义推断出类型,改一处全链路生效,这才是它最爽的地方。

当然,如果项目未来必须对第三方开放复杂 API、需要面向多语言客户端,或者有海量查询聚合场景,那 tRPC 的边界就不那么合适了。但在 t3code 这个场景里,T3 Stack 的取舍完全是合理的:足够快、足够类型安全、足够简单。

2. 初始化项目:create-t3-app 的完整配置过程

2.1 安装与选项取舍:哪些选项该勾,哪些该取消

初始化 T3 项目最简单的方式就是 create-t3-app。我当时的命令是:

npx create-t3-app@latest t3code

交互式命令行会问你要不要启用 Tailwind CSS、tRPC、Prisma、NextAuth,有的版本还会让选 App Router 还是 Pages Router,以及 ESLint 和 Prettier 之类的工程化工具。我的建议是:如果你不是纯为了学习,而是真的要做业务项目,App Router 直接选上,Tailwind、tRPC、Prisma、NextAuth 全部启用,ESLint 和 Prettier 也保持默认勾选,省得后补。

对于工具类选项,比如 Drizzle,我首先排除了它,不是因为 Drizzle 不好,而是团队里对 Prisma 的 schema 模式更熟悉,迁移工具也更顺手。选型有一条基本准则:在关键路径上,选你团队最熟悉、社区资料最多的库,而不是最新的库。初始化完成后,create-t3-app 会自动装好依赖并生成基础结构。

2.2 目录结构读解:create-t3-app 替你铺好了哪些路

项目生成之后,你会看到下面这个常见的结构:

src/ ├── app/ # Next.js 页面和布局 │ ├── api/ │ │ └── auth/ # NextAuth 的 API 路由 │ ├── snippets/ # 业务页面 │ ├── layout.tsx │ └── page.tsx ├── server/ │ ├── api/ │ │ ├── routers/ # tRPC 子路由 │ │ └── root.ts # tRPC 路由根节点 │ ├── auth.ts # NextAuth 配置 │ └── db.ts # Prisma 客户端 └── trpc/ ├── server.ts # tRPC 服务端初始化 └── react.tsx # tRPC React Hooks

这个结构有个很聪明的设计:server 目录下的代码只会运行在服务端,trpc 目录里是对 React 暴露的 hooks 封装。Page 组件可以直接 importserver/auth.ts里的getServerSession,也可以在客户端组件里使用 tRPC 的 hooks。目录边界帮你强制分离了客户端和服务端逻辑,不会出现“服务端代码被浏览器加载”的尴尬。

t3code 我拿到这个结构后,几乎没有改动目录布局,只是往里面填充业务代码。

2.3 数据库建模:从业务需求到 Prisma Schema

t3code 的核心数据模型是用户、代码片段、标签和片段版本。我用 Prisma schema 定义成这样(简化过):

model User { id String @id @default(cuid()) name String? email String? @unique role Role @default(USER) snippets CodeSnippet[] accounts Account[] sessions Session[] } model CodeSnippet { id String @id @default(cuid()) title String content String @db.Text language String description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id]) tags Tag[] versions SnippetVersion[] } model Tag { id String @id @default(cuid()) name String @unique snippets CodeSnippet[] } model SnippetVersion { id String @id @default(cuid()) content String @db.Text message String createdAt DateTime @default(now()) snippetId String snippet CodeSnippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) } enum Role { USER ADMIN }

数据库我直接选用了 PostgreSQL,而不是 create-t3-app 默认的 SQLite。虽然开发阶段 SQLite 很方便,但 t3code 后面要支持多用户并发写和事务操作,PostgreSQL 在并发场景下更稳。写完 schema 后运行迁移:

npx prisma migrate dev --name init

这会生成迁移文件并同步到数据库。这里有个小提醒:在写关联字段时,一定要想好删除策略。比如删除一个片段时,是保留版本做审计,还是级联删除?我在SnippetVersion上加onDelete: Cascade,同时保留SnippetVersion的独立表,方便以后做版本对比时间轴。

3. 打通认证与数据流:NextAuth + tRPC + Prisma 的协同

3.1 先理清三条链路:认证、请求、数据库

在 t3code 里,用户发起一个查询请求,实际会经过三条链路:NextAuth 负责确认“你是谁”,tRPC 负责把请求路由到对应函数,Prisma 负责操作数据库。这三者必须拧在一起,否则就会出现“接口能调通但不知道登录人是谁”的怪问题。

tRPC 的处理方式是通过 context 对象,在每个请求创建时注入会话信息。这样所有开发者在写 resolver 时,直接访问ctx.session就能拿到当前用户,而不用每个函数重新调一遍getSession。NextAuth 的getServerSession会和 tRPC 的 createContext 配合,形成一个标准的身份注入链路。

3.2 用 createContext 把 session 注入每个请求

create-t3-app 生成的src/trpc/server.ts里会有类似这样的逻辑:

import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; import { prisma } from "~/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { session, prisma, ...opts, }; };

注意,这里getServerSession必须从next-auth调用,而且要在服务端执行,不能把它放在一个共享的工具函数里让客户端也引用。因为在 App Router 下,它依赖 Next.js 的请求上下文,一旦被客户端 bundle 引用,运行时大概率报错。

3.3 用 protectedProcedure 做权限控制

tRPC 的初始化模板里提供了publicProcedure和protectedProcedure两个入口。protectedProcedure 的定义通常长这样:

export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });

这个中间件就是权限闸门。在 t3code 里,删除片段的 Router 会这样用:

export const snippetRouter = createTRPCRouter({ delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const snippet = await ctx.prisma.codeSnippet.findUnique({ where: { id: input.id }, }); if (!snippet) { throw new TRPCError({ code: "NOT_FOUND" }); } if (snippet.authorId !== ctx.session.user.id && ctx.session.user.role !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } await ctx.prisma.codeSnippet.delete({ where: { id: input.id } }); return { success: true }; }), });

这里有两个容易被忽略的细节。第一,先查存在性再做权限判断,避免直接 delete 返回不了明确错误。第二,管理员权限用 role 判断,但 role 要放在 session 对象里,默认 NextAuth 的 session 不包含数据库的 role 字段,需要在auth.ts里通过callbacks.session把用户角色塞进去,我后面会再提。

3.4 在 Mutation 中安全落库:事务与 Zod 校验

t3code 新增一个代码片段时,前端提交的字段有标题、内容、语言、描述和标签数组。标签是独立表,所以创建片段时要同时创建多个标签关联。这里直接串行两个 Prisma 操作会有一致性问题,我选择用事务:

create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default("plaintext"), description: z.string().max(500).optional(), tags: z.array(z.string()).max(10).default([]), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.$transaction(async (tx) => { const snippet = await tx.codeSnippet.create({ data: { title: input.title, content: input.content, language: input.language, description: input.description, authorId: ctx.session.user.id, }, }); for (const tagName of input.tags) { const normalized = tagName.trim().toLowerCase(); if (!normalized) continue; const tag = await tx.tag.upsert({ where: { name: normalized }, update: {}, create: { name: normalized }, include: { snippets: false }, }); await tx.codeSnippet.update({ where: { id: snippet.id }, data: { tags: { connect: { id: tag.id } } }, }); } return snippet; }); }),

Zod 负责在进入数据库前就把脏数据拦下来。比如title至少一个字符、tags最多 10 个,这些校验一旦出错,tRPC 会通过统一错误格式返回给前端,react hook 会直接抛出类型化错误。

4. 前端集成:Tailwind 样式体系和函数式组件配合

4.1 设计系统落地:避免 Tailwind 类名爆炸

t3code 页面多、表单多,直接在 JSX 里堆 Tailwind 类名很容易失控。我采用的办法是“原子类只在组件内部出现,业务组件不直接写超长类名”。

比如按钮,我先抽一个Button组件:

import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "~/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", { variants: { variant: { primary: "bg-blue-600 text-white hover:bg-blue-700", secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200", destructive: "bg-red-600 text-white hover:bg-red-700", }, size: { sm: "h-9 px-3", md: "h-10 px-4 py-2", }, }, defaultVariants: { variant: "primary", size: "md" }, } );

class-variance-authority 负责管理变体,cn函数合并条件类名。用这个模式,业务页面里最多出现<Button variant="destructive" size="sm">删除</Button>,类名不分散,也方便统一调整主题。

Tailwind 的配置文件里,我还根据设计规范定义了语义色,比如primary、surface、danger,这样就不会出现一个页面里三种蓝色互相打架的问题。

4.2 t3code 的表单处理:React Hook Form + Zod + tRPC

代码片段的创建页是一个典型的受控表单。我用 React Hook Form 管理字段状态,用zodResolver复用 tRPC 的输入校验规则。这样前端校验和后端校验是同一套 schema,不会出现“前端过了、后端报错”的逻辑分叉。

以新增片段为例,核心代码:

const snippetFormSchema = z.object({ title: z.string().min(1, "标题不能为空"), content: z.string().min(1, "内容不能为空"), language: z.string().default("plaintext"), description: z.string().max(500).optional(), tags: z.array(z.string()).max(10).default([]), }); type SnippetFormValues = z.infer<typeof snippetFormSchema>; function SnippetForm() { const utils = api.useUtils(); const createSnippet = api.snippet.create.useMutation({ onSuccess: () => { utils.snippet.getAll.invalidate(); reset(); }, }); const { register, handleSubmit, control, reset } = useForm<SnippetFormValues>({ resolver: zodResolver(snippetFormSchema), defaultValues: { title: "", content: "", language: "plaintext", tags: [] }, }); const onSubmit = handleSubmit((values) => { createSnippet.mutate(values); }); return <form onSubmit={onSubmit}>{/* 表单字段 */}</form>; }

这段代码里最有价值的一行是utils.snippet.getAll.invalidate(),提交成功后,tRPC 会让所有该查询的 React Query 缓存失效并重新拉取。如果没有这一步,新增的片段只有在刷新页面后才会出现,非常影响体验。

这里有个实操注意点:useForm 的defaultValues必须给全字段,尤其是数组类型,否则 React Hook Form 在校验 tags 时会把 undefined 当成错误。我一开始漏了tags: [],导致提交前控制台一直报错。

4.3 错误反馈和 loading 态设计:别只显示一个 spinner

tRPC 的 mutation 返回isLoading、isError、error这些状态。经验是,不要把isLoading当成唯一的提交反馈。在 t3code 里,我统一封装了一个useSubmitStatehook,把提交中、成功、失败三态映射成按钮禁用、Toast 提示和表单错误。

Toast 使用 sonner 组件,error.message里其实是 tRPC 返回的友好错误信息,因为后端 Zod 校验失败时会包含字段路径。为了让前端能直接展示“标题不能为空”这种信息,我建议在 tRPC 的 router 里通过TRPCError的 message 抛出可读文案,而不是默认的Input validation failed。

5. 生产部署与性能优化实录

5.1 Vercel 部署前的准备:环境变量和第三方配置

t3code 最终部署在 Vercel 上。部署前要确认三组环境变量:DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL。很多人只设置DATABASE_URL就点部署,结果登录接口一直报 500。原因是NEXTAUTH_SECRET没设,NextAuth 在非开发环境会拒绝使用随机生成的密钥。

如果你用了 GitHub OAuth,还要在 Vercel 项目域名里追加回调地址:

https://your-vercel-app.vercel.app/api/auth/callback/github

第三方 OAuth 回调是最容易漏的配置,但也是最容易排查的:登录跳转后 URL 里多半会带上error=access_denied之类的参数。

5.2 减少 Prisma 冷启动风险:连接数与 serverless 的冲突

Vercel 是 serverless 环境,函数实例随时会冷启动。默认情况下,Prisma 客户端第一次被导入时会创建一个连接实例,但如果代码里每次请求都 new PrismaClient,会迅速打爆 PostgreSQL 的连接数。

create-t3-app 生成的src/server/db.ts会处理这个问题,核心代码类似:

import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = prisma; }

注意这个模式:在开发模式下,把客户端实例挂到 globalThis 上,避免 Next.js 热重载时创建多个实例。生产模式下每个 serverless 实例会共享一个全局实例,因为 globalThis 在同一个隔离环境内不会重新创建。

如果你的数据库连接数依然紧张,可以考虑 Prisma Accelerate 或者把 PostgreSQL 移到支持连接池代理的托管服务上。t3code 目前的用户量还不需要这么重的方案,但提前在连接池上留好环境变量,能省很多事。

5.3 优化 API 响应体体积:别把不需要的字段扔给前端

Prisma 查询默认会返回 model 的全部标量字段,但某些字段前端根本用不到。t3code 的列表页,我只需要标题、语言、标签和作者名,不需要content正文全文。所以查询时要用select:

const list = await ctx.prisma.codeSnippet.findMany({ where: { authorId: userId }, select: { id: true, title: true, language: true, description: true, createdAt: true, author: { select: { name: true } }, tags: { select: { name: true } }, }, orderBy: { updatedAt: "desc" }, });

这样响应体体积能减小一个量级。尤其当content里存的是大段代码正文时,列表接口很可能会成为性能瓶颈。对应地,详情接口再去单独查content。

5.4 Next.js 缓存策略:动态渲染和静态化的取舍

t3code 的页面大部分是登录后可见的“私有数据”,不适合全局静态化。Next.js App Router 模式下,如果页面里使用了cookies()、headers()或getServerSession(),默认就是动态渲染。动态意味着每次请求都会打数据库,数据新,但响应时间稍微长一点。

对于“标签列表”这种几乎不变的数据,我用了 Next.js 的unstable_cache缓存 60 秒:

import { unstable_cache } from "next/cache"; const getCachedTags = unstable_cache( async () => prisma.tag.findMany({ orderBy: { name: "asc" } }), ["tags"], { revalidate: 60 } );

这个组合最大的价值是:在保持类型安全的情况下,你把“动态内容”和“静态内容”分开处理,不用为了一个标签列表把整页缓存掉。

6. 常见问题排查清单

6.1 Prisma 连接数爆涨:开发环境热重载反复触发

开发时你会看到控制台频繁弹出P1001或Can't reach database server,这通常是两个原因:一是数据库地址填成了localhost,但 Docker 容器里的 Postgres 端口没映射;二是开发环境热更新导致 PrismaClient 实例反复创建。

第二个原因是重灾区。如果你发现代码里有多处直接new PrismaClient(),请立刻收敛成单例模式。我一开始在server/db.ts之外又写了一个lib/prisma.ts,导致两个文件各创建一个实例,最后花了一晚上排查。

6.2 session 获取不到:context 里的 user 为什么是空

tRPC 的 context 里明明写了session,但页面调用protectedProcedure依然报UNAUTHORIZED。最常见的可能:NextAuth 的authorized回调配置错误,或者NEXTAUTH_SECRET未设置。另一个常见原因是你在 server 组件里手动调用getSession(),但 NextAuth 版本升级后应该使用getServerSession()。前者依赖 cookie 上下文,在无请求上下文的场景会返回 null。

另外,如果你自定义了 session 回调,比如往 session.user 里塞role和id,一定要在auth.ts的类型声明里补充类型定义:

declare module "next-auth" { interface Session { user: { id: string; role: string; } & DefaultSession["user"]; } }

否则 TypeScript 在ctx.session.user.id这行会报不存在 id。

6.3 类型报错“无法匹配”怎么定位:三板斧排查法

在 t3code 开发中,我遇到最多的错误是TS2322: Type ... is not assignable to ...,尤其是在给 tRPC 的 Router 增加入参或者修改 Prisma model 之后。

我的排查套路是三步走:

  1. 先看报错文件是否 import 了旧的类型定义。tRPC 的类型是从root.ts推断出来的,如果你在页面里手写了一个接口,就失去了类型同步的意义。
  2. 检查后端 Router 里该 procedure 的 input 是否有 Zod schema,且 schema 的字段名与前端的参数对象完全一致。三次常见错误是undefined和null不匹配。
  3. 在 IDE 里把鼠标悬停在 mutation 或 query 的返回值上,和前端期望的结构逐字段对比。

记住,类型不匹配时不要急着写as any,如果用了它,等于亲手拆了 T3 Stack 最值钱的那堵墙。

6.4 tRPC 查询不更新的奇怪场景:React Query 缓存和失效时机

页面做了一个新建片段操作,跳转回列表页,结果新片段没出现。按 F5 刷新后又正常。这是 React Query 的缓存特性导致的:同一 key 的查询结果 30 秒内不会重新请求。

解决方案就是我在表单部分提到的invalidate()。但在某些情况下,你调用了invalidate还是没更新,这时候要检查你操作的是不是同一个路由。比如列表页调用的是api.snippet.getAll.useQuery(),而新增成功后你调用了utils.snippet.getOne.invalidate(),那当然什么都不会发生。

更隐蔽的问题是input不相同。React Query 的 cache key 会包含 tRPC procedure 的输入参数,如果你的getAll查询传了{ filter: "my" },新增后却用utils.snippet.getAll.invalidate({ filter: "my" })去失效,那么只有完全一致的对象结构才会命中缓存。推荐的做法是:在 mutation 的 onSuccess 回调里调用不带参数的utils.snippet.getAll.invalidate(undefined),把该路由下所有查询都刷新一遍。

7. 最后一次复盘:我建议你从 t3code 里学什么

7.1 代码组织的三个好习惯

做完这个项目,我觉得有三个习惯比任何架构都值钱:第一,所有前后端共享的通用类型尽量用 Zod schema 派生,不要手写 interface;第二,prisma 的查询建议集中写在 router 里,不要散落在各个页面组件中;第三,给每个 mutation 都设定好 onSuccess 的失效路径,这是 tRPC 应用后劲最大的地方。

t3code 最初的代码里,mutation 成功后统一router.refresh()刷新页面,后来改成精细化的invalidate,交互体验明显提升,数据库压力也小了。

7.2 给后来者的配置建议

如果现在有人要复制 t3code 这个项目,我建议初始化时认真想清楚三件事:数据库直接选 PostgreSQL,别先用 SQLite 再中途迁移;NextAuth 的 Secret 从一开始就持久化保存;tRPC 的 Router 按业务模块拆分,而不是一个巨大的 router 文件撑到底。

尤其是最后一条,t3code 早期只有一个 snippetRouter,后来加了 userRouter、tagRouter 和 analyticsRouter,都是从那个大文件里 40 多分钟后用 git 的提取重构出来的。一开始就按领域拆分会轻松得多。

7.3 我的下一步:把 t3code 扩展到什么方向

这个项目后续我打算做三个小迭代:用全文检索替换现在的contains模糊查询;给代码片段增加“查看详情”时的语法高亮和复制按钮;再加一个基于角色的事件操作日志,方便管理员审计。每次迭代我大概率还是会沿用 T3 Stack——因为在这个项目里,它让我把时间花在业务上,而不是纠结类型定义和接口同步上。

如果你也正在准备用 T3 Stack 做一个类似的中小型全栈应用,我的建议是别犹豫太多,直接跟着 create-t3-app 跑一遍,把认证、数据库、API 这三条链路亲手打通。等你在生产环境遇到一两个真实的坑之后,你对这套技术栈的信心,会比看任何教程都来得实在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询