☰
T3 Stack实战:从初始化到部署的类型安全全栈开发
2026/10/26 17:22:06 网站建设 项目流程

我最近把一个个人项目推倒重写,发现真正让我头大的不是业务逻辑,而是 TypeScript 类型没法从数据库一路焊接到页面。后端改了一个字段名,前端接口文档早忘了更新,跑起来全是运行时才暴露的错误。后来我干脆用 T3 Stack 的思路重做了这套东西,项目代号就叫 t3code。这次我把整个搭建和排坑过程完整记录下来,从技术选型到部署上线,所有代码都是我在 t3code 里实际跑过的。

t3code 说白了就是一个以 T3 Stack(TypeScript + Next.js + tRPC + Prisma + Tailwind)为底层的全栈实验项目。它能帮你用最小的成本,把前端、后端、数据库三者之间的类型关系统一起来,写代码的时候改一处,其他位置立刻跟着变,不需要手工维护接口定义。适合正在学全栈、或者被多服务联调折磨过的人参考。这篇文章不会只讲概念,我会把 t3code 从初始化到部署的每一步都拆开,照着做就能复现一套类型安全的全栈应用。

1. 为什么我会折腾 t3code:先搞清楚 T3 Stack 在救什么

1.1 前后端分离的“类型断层”痛点

很多项目的开发模式是后端写 REST 接口,前端用 axios 去请求,然后自己手写 interface。这在一两个接口的时候没问题,一旦业务到十几个接口、几十个字段,维护成本就开始失控。后端改了字段,前端声明的类型还是旧的,编译不报错,运行时数据结构和预期不一致,排查半天发现是类型不同步。

这种问题的本质是前后端之间有一条“类型断层”。REST 接口只是传输 JSON 文本,接口文档和 TypeScript 类型都是人工维护的,任何一边忘了更新,系统不会主动告诉你。我之前做过一个筛选功能,后端把status从字符串改成了联合类型,前端忘了同步,结果页面直接渲染出错误状态。这类问题在运行时才暴露,调试成本远高于编译期报错。

t3code 的核心思路就是把这条“类型断层”填平。不是靠文档,而是靠 TypeScript 的类型推断能力。前后端共享同一套类型定义,改一个字段,整条链路上的代码都会跟着变,编译期就能发现错误。这就像两个同事对接工作,与其靠口头传话,不如共享同一张任务表格,表格改了,双方看到的都是最新状态。

1.2 T3 Stack 的组合逻辑:为什么是 Next.js + tRPC + Prisma + Tailwind

很多人第一次听说 T3 Stack,会觉得它只是在堆砌热门框架。其实这四个东西各司其职,组合在一起恰好覆盖了一条完整的全栈链路。

Next.js负责应用框架,既是前端 React 的渲染层,又能通过 Route Handlers 提供后端能力。选择 App Router 是因为服务端组件可以直接调用后端逻辑,省略一层 HTTP 请求,响应更快。tRPC是用来替代 REST 的接口层。它的特点是无需定义 URL 路由,前端像调用本地函数一样调用后端方法,类型完全由 TypeScript 推断。Prisma负责数据库访问,用 schema 文件定义数据表,生成类型安全的查询客户端。Tailwind负责样式,原子化 CSS 让 UI 开发速度更快,最关键的是它和 Next.js 天然兼容,不会在打包时出幺蛾子。

这套组合最大的魅力在于类型安全是端到端的。从数据库的表结构开始,Prisma 生成类型,tRPC 继承类型,Next.js 组件消费类型,前端 UI 和数据库字段由同一套类型连接,任何一环修改都会被 TypeScript 检查到。这比传统的 “前端定义一套 interface + 后端定义一套 schema” 的方案爽太多。

2. 落地第一步:用 create-t3-app 初始化 t3code 项目

2.1 初始化命令与交互选项

搭建 t3code 最快的方式是用官方脚手架。以 create-t3-app 为例,一条命令就可以生成完整的项目骨架:

npx create-t3-app@latest t3code

执行后会有交互式选项,我实际勾选的项目是这样的:

  • Next.js:选择 App Router,tRPC 和 Prisma 的集成模板默认就是为 App Router 设计的,不需要额外配置 Server Actions。
  • TypeScript:必须启用 strict 模式,这是端到端类型安全的前提。
  • tRPC:勾选,脚手架会自动创建server/api目录和.ts文件。
  • Prisma:勾选,并选择 SQLite 作为起始数据库,本地开发零配置。
  • Tailwind CSS:勾选,样式方案没什么异议。
  • ESLint + Prettier:建议勾选,团队协作时格式化争议会少很多。

脚手架生成后,会有一个极简但完整的示例程序,包含一个示例post相关的 tRPC router。我第一次跑npm run dev时最直观的感受是:首页里展示的数据,确实是直接从数据库查出来的,但是代码里看不到 axios、fetch 这些调用,它是直接调用了一个函数。

2.2 目录结构逐层拆解:每个文件夹存在的理由

脚手架生成的目录不是随便放的,每个文件夹都在 T3 的约定里有明确职责。我以 t3code 的目录结构为例拆一下:

t3code/ ├── app/ # Next.js App Router 页面 │ ├── api/trpc/ # tRPC 的 HTTP 入口 │ └── page.tsx # 首页 ├── prisma/ │ └── schema.prisma # 数据库表定义 ├── server/ │ ├── api/ # tRPC 相关代码 │ │ ├── routers/ # 各业务 router │ │ ├── root.ts # root router 汇总 │ │ └── trpc.ts # tRPC 初始化文件 │ ├── auth.ts # 认证上下文(如果启用了) │ └── db.ts # Prisma Client 实例 ├── styles/ │ └── globals.css # Tailwind 入口 └── package.json

server/db.ts是整个应用的数据库入口,它创建并导出了一个 PrismaClient 单例。为什么需要单例?因为在开发模式下,Next.js 的热更新会重新加载模块,如果每次加载都创建一个新的 PrismaClient,会耗尽数据库连接。t3code 里做的处理是全局缓存:

import { PrismaClient } from "@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; }

这段代码解决了开发时数据库连接数暴涨的问题,也是很多人忽略的细节。server/api/trpc.ts则是 tRPC 的初始化文件,定义了上下文、错误格式化等基础能力。理解了这个文件,基本就理解了 tRPC 的扩展方式。

3. 首条业务链路:从 SQLite 表到页面渲染的类型安全闭环

3.1 Prisma Schema 设计:先定数据才能定类型

类型安全的源头在数据库。t3code 里我做了一个简单的文章列表功能,先定义Post模型:

model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

写完 schema 后执行:

npx prisma migrate dev --name init

这个命令会做两件事:创建迁移文件并应用到 SQLite 数据库,同时更新@prisma/client的 TypeScript 类型。也就是说,db.post.findMany()的返回类型会根据schema.prisma的字段定义自动生成。

我特别强调“先定数据才能定类型”,是因为很多前端出身的开发者习惯先写页面,再去补后端。T3 的方式是反过来,先把数据模型定清楚,后续代码才能获得正确的类型提示。如果表里没有title字段,写代码时 TypeScript 会在post.title上直接画红线,不用等到运行时才发现。

3.2 在 tRPC Router 里写数据库查询:publicProcedure 与 protectedProcedure 的区别

tRPC Router 是后端逻辑的容器。t3code 的/server/api/routers/post.ts里这样写的:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; import { db } from "@/server/db"; export const postRouter = createTRPCRouter({ getAll: publicProcedure.query(async () => { const posts = await db.post.findMany({ orderBy: { createdAt: "desc" }, }); return posts; }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), })) .mutation(async ({ input }) => { return db.post.create({ data: { title: input.title, content: input.content, }, }); }), });

publicProcedure表示不需要登录就能调用。如果做需要登录的功能,就要用protectedProcedure,它会先校验会话,再把用户信息注入到 ctx 里。同一个 router 里可以混合使用不同权限的 procedure,开发起来非常灵活。

这里的zod输入校验值得多说一句。用z.string().min(1)定义了输入格式,TypeScript 会自动把这个 schema 推导成input的类型,后端不需要再手写interface CreatePostInput。前端调用时,传错类型会在编辑器里直接报错,交互双方拿到的输入类型完全一致。

写完 router 之后,需要把它注册到根 router 里。t3code 的/server/api/root.ts是这样的:

import { createTRPCRouter } from "@/server/api/trpc"; import { postRouter } from "@/server/api/routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;

这个AppRouter类型是整个链路的关键。它会作为泛型传给前端 tRPC 客户端,让前端拥有和后端完全一致的类型信息。忘掉这一步的话,前端永远无法知道后端有哪些方法。

3.3 在 Next.js 端渲染数据:Server Component 与 Client Component 的取舍

t3code 的首页采用两种方式来消费 tRPC API。如果数据主要用于初始渲染,而且不依赖用户交互状态,我选择在服务端组件里直接调用 tRPC 的 caller:

import { api } from "@/trpc/server"; export const dynamic = "force-dynamic"; export default async function HomePage() { const posts = await api.post.getAll(); return ( <main> {posts.map((post) => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </article> ))} </main> ); }

这里用到的是 tRPC 的 Server 端直接调用能力,不发送 HTTP 请求,等于在后端函数内部执行查询。设置export const dynamic = "force-dynamic"是让页面在每次请求时重新执行,避免 Next.js 默认的缓存行为让数据不新鲜。

需要交互的部分,比如创建文章的按钮,我用客户端组件配合 React Query hooks:

import { api } from "@/trpc/react"; function CreatePostForm() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: async () => { await utils.post.getAll.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createPost.mutate({ title: String(formData.get("title")), content: String(formData.get("content")), }); }} > <input name="title" placeholder="标题" /> <textarea name="content" placeholder="内容" /> <button type="submit">发布</button> </form> ); }

utils.post.getAll.invalidate()是 tRPC v11 里用于刷新数据的标准方式。它会把 React Query 里对应 key 的缓存标记为失效,后台自动重新请求。整个流程里,前端没有出现任何接口地址字符串,没有手写类型,也没有手工处理错误码,前后端出了问题 TypeScript 会第一时间告诉我。

4. t3code 上线记录:部署规范与高频踩坑

4.1 生产环境数据库迁移:从 SQLite 换成 PostgreSQL

本地开发用 SQLite 很顺手,因为它就是一个文件,不需要安装额外服务。但部署到生产环境时,问题就来了:很多托管平台的文件系统是只读的,或者部署实例重启后文件丢失,SQLite 数据根本留不住。t3code 在部署前我做了一个关键迁移:把数据库从 SQLite 换成 PostgreSQL。

首先在schema.prisma里修改 datasource:

datasource db { provider = "postgresql" url = env("DATABASE_URL") }

然后把本地开发时的数据迁移过去。我的做法是写一个脚本,从 SQLite 读取记录,再写入 PostgreSQL。如果你还没有复杂数据,可以直接跑一条命令:

npx prisma migrate reset --force npx prisma migrate dev --name init

这里有个容易踩的坑:如果之前本地已经执行过prisma migrate dev,数据库里会有迁移记录。换成 PostgreSQL 后,旧的迁移文件虽然还在,但新数据库没有执行过,直接部署时 Prisma 会提示数据库与迁移状态不一致。正确做法是在生产环境执行npx prisma migrate deploy,它会按迁移文件顺序执行,保证 schema 与本地一致。

另一个坑是DATABASE_URL的环境变量。Prisma 的env("DATABASE_URL")要求环境变量名完全一致。我在 Vercel 上部署时,第一次忘了把数据库连接串配到环境变量里,结果构建报错。记住:生产环境要单独配置,不要用本地的默认值。

4.2 tRPC 类型报错与 Router 合并高频问题

t3code 开发过程中,我遇到的 tRPC 类型报错主要有三类。

第一类是忘记把子 router 注册到 root router。写完了postRouter,但root.ts里没有post: postRouter。前端调用api.post.getAll时 TypeScript 报错说post不存在。这个错误很直观,但越是靠类型推断的开发方式,这类“少挂载”的问题越容易被忽略,因为编译要到最后才知道发现。

第二类是输入 schema 与前端调用不一致。比如后端create用了z.object({ title: z.string().min(1), content: z.string() }),前端传了content: undefined,tRPC 会在编译期通过类型提示给出警告,在运行时会抛出TRPCClientError。处理方式是把 zod schema 放在一个共享文件中,前端和后端都用同一个 schema,避免两端各自维护一份拷贝。

第三类是ctx 为空导致 Cannot read properties of undefined。如果启用了protectedProcedure,但没有正确注入会话,代码里取ctx.session.user就会报错。解决方法是先查ctx.session是否为空,或者直接用脚手架提供的getServerAuthSession函数初始化上下文。

做一个速查表,方便以后快速排错:

错误场景典型报错检查思路
router 未合并Property 'post' does not exist on type检查root.ts的appRouter是否包含该 router
输入类型不匹配Argument of type 'X' is not assignable检查 zod schema 与前端传参是否一致
ctx 注入失败Cannot read properties of undefined检查trpc.ts的createContext是否返回了会话
Prisma 模型未更新Type 'Post' is not a valid value执行npx prisma generate重新生成类型
缓存数据不刷新页面数据一直是旧的检查是否设置了force-dynamic或使用invalidate()

4.3 页面数据不新鲜的排查:Next.js 缓存与 tRPC 的交互

部署 t3code 之后,我发现一个很隐蔽的问题:新增文章后,页面没有立即显示最新数据。一开始我怀疑是 tRPC 的问题,其实根源在 Next.js 的缓存策略。

Next.js App Router 默认会对动态数据做缓存,如果页面没有显式声明动态行为,它可能被渲染成静态页面。解决方式是加一行:

export const dynamic = "force-dynamic";

或者使用revalidatePath在数据变更后主动刷新:

import { revalidatePath } from "next/cache"; export async function createPostAction(input: { title: string; content: string }) { await db.post.create({ data: input }); revalidatePath("/"); }

使用revalidatePath的方式更适合有 Server Actions 的业务。但如果选择在客户端组件里通过 tRPC mutation 写数据,就依靠 React Query 的invalidate()来刷新缓存。两者不要混用,否则会出现“服务端数据已经更新、客户端缓存还有旧数据”的错觉。

t3code 最终方案是:纯展示页面用服务端直调 tRPC,并显式声明force-dynamic;所有写操作放在客户端组件,mutation 成功后调用invalidate。这样两条链路各管各的,不会互相干扰。

最后的实操体会

我做完 t3code 之后最大的感受是:T3 Stack 不是某种魔法,它只是把 TypeScript 的能力在时钟链条上用到了极致,让编译期就能挡住大量低级错误。可能有读者会觉得 tRPC 不如 REST 通用,确实如此,如果要做开放 API 给第三方,REST 或 GraphQL 才是更好的选择。但只要前后端都是 TypeScript 项目,tRPC 的开发效率和安全性远高于传统接口联调。

最后再分享一个我在实际中用得很顺手的小技巧:尽量把每个 tRPC procedure 的输入校验写成 zod schema,并且放在后端文件里直接导出。这样不只在调用层能复用同一个类型,写单元测试时也能直接引用,省掉很多重复声明的体力活。做完这一套,你再回头看原来那种“接口文档靠嘴、类型靠猜”的开发方式,应该很难再回去了。

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

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

立即咨询