ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

t3code 实战:基于 T3 Stack 的全栈类型安全工程模板

t3code 实战:基于 T3 Stack 的全栈类型安全工程模板

前几个月,我在梳理自己的技术选型清单时,发现社区里讨论最多、热度最稳的,除了各种 AI 工具的迭代,就是 T3 Stack。我们这次聊的 t3code,就是一个围绕 T3 Stack 做的代码实践项目。简单说,t3code 不是某个框架的名字,而是一整套“怎么把 TypeScript、Next.js、Tailwind CSS、tRPC 这几个东西组合起来干活”的工程模板和落地笔记。它解决的痛点是:全栈应用里,前后端类型不同步、API 文档靠人肉同步、状态管理越写越乱、一上线就出现各种环境差异。我把这些坑都踩了一遍之后,整理出了 t3code 这套可复用的方案。这篇文章适合两类人看:一类是已经会用 React 和 Node,但想找一个更顺手的全栈技术组合的开发者;另一类是团队想统一前后端技术栈,但不确定从哪入手的技术负责人。

我会从设计思路、核心模块拆解、实操流程、问题排查这几个维度,把 t3code 里的关键决策和踩坑经验完整讲清楚。

1. 技术选型与整体设计思路

1.1 为什么是 T3 Stack,而不是其他组合

我早期做全栈项目,最常见的工作流是“React 一套、Node 一套、API 文档一套”,代码仓库里躺着三份明明描述同一个数据模型、却永远对不上的类型定义。后来用了几年 GraphQL,Schema 统一的问题确实解决了,但运维成本和团队学习曲线又把中小项目压得喘不过气。T3 Stack 的思路比较“实用主义”:我不承诺给你一个包治百病的方案,我只保证在你前后端都用 TypeScript 的前提下,类型可以自动贯通。

t3code 沿用了 T3 Stack 里的核心组合,但我在实践过程中做了几个改动。默认的 T3 Stack 模板使用 Next.js 做全栈应用载体,tRPC 负责 API 调用,Prisma 管数据库,Tailwind CSS 管样式,NextAuth 管鉴权。这几个组件单独拿出来都不算新东西,但合在一起,最大的红利是“全程类型安全”。我在 t3code 里增加了一些对项目工程化更友好的配置,比如目录按模块拆分、环境变量集中校验、API 层和业务层分离,这些改动让模板可以直接套用到真实业务项目里,而不是只停留在教程 demo 的程度。

1.2 t3code 定位:从脚手架到可落地的工程模板

如果你只是想要一个能跑起来的 demo,官方 create-t3-app 已经够快了。但 t3code 想做的是“拿来即用、改改就能上线”的工程底子。我在里面预置了用户认证、数据库模型示例、受保护的 API 路由、错误处理中间件、表单校验这些刚需功能。团队拿到 t3code 之后,不需要再从零研究布局框架、鉴权流程、类型怎么导出,而是直接往自己的业务模块里填代码。

这一点在实际协作里特别重要。后端同学最烦的“前端又来问我这个字段是什么意思”,前端同学最烦的“后端接口文档又过期了”,在 t3code 这个结构下会自然减少。因为类型定义是从数据库模型和 API 路由自动推导出来的,前端写代码的时候,编辑器里提示的字段就是后端真实返回的字段,没有任何中间翻译环节。我实测下来,新成员上手项目的速度至少快一两天,因为“看不懂的数据流”这个问题基本不存在了。

1.3 关键技术决策背后的取舍逻辑

  • 选 Next.js 而不是 Vite + Express:Next.js 的目录约定天然支持前后端同仓,API 路由和页面组件放在同一个代码仓库里,开发体验少了一层跨域、跨服务的沟通成本。
  • 选 tRPC 而不是 REST 或 GraphQL:tRPC 不需要定义 URL、不需要写接口文档、不需要手动维护类型,它走的是“函数调用”的模型,前端像调用本地函数一样调用后端方法。它不适合外部开放 API 的场景,但做内部业务系统、后台管理系统、工具类应用非常顺手。
  • 选 Prisma 而不是直接写 SQL:Prisma 把数据库表结构变成了 TypeScript 对象,迁移和类型生成是一条命令搞定,对全栈类型闭环来说是关键一环。

2. 核心细节解析与实操要点

2.1 类型安全如何贯穿全链路

t3code 里“类型安全”不是一句口号,它是通过三层协议闭环实现的。第一层是数据库层,Prisma 的 model 定义就是权威数据来源,数据库里有什么表、什么字段、什么关系,全都由 Prisma Schema 一种文件管理。第二层是 API 层,tRPC 的 router 里每个 query 和 mutation,输入参数用 zod 做运行时校验,输出类型由 Prisma 查询结果自动推导。第三层是前端层,React 组件里调用 tRPC 的 hooks,返回的数据类型不用自己写一行 interface。

这里容易踩的坑是:千万不要在前端单独定义interface User或type Post这类“镜像类型”。一旦你手动复制了一份,数据库结构一改,你就要全文搜索所有类型定义,改漏一处就是线上事故。t3code 的约定是:前端所有的业务实体类型,全部由 tRPC 返回值推导,自己写类型只允许出现在 props 提取、组件内局部状态、工具函数参数这些场景。我试过用export type RouterOutputs['post']['list']这种写法直接从路由输出里抽类型,配合编辑器的自动提示,基本能做到改完数据库字段,前端立刻报错,定位到需要修改的地方。

2.2 目录结构与模块划分经验

我见过很多 T3 Stack 项目,虽然代码能跑,但 src 目录下面乱成一锅粥。t3code 对目录划分做了强制约束,看起来规整,也更容易做代码 Review。

我推荐的结构是:

src/ server/ db.ts // Prisma 客户端实例 auth.ts // 鉴权配置 api/ root.ts // tRPC 根路由,合并所有子路由 routers/ post.ts user.ts trpc.ts // tRPC 上下文创建、鉴权中间件 pages/ api/trpc/ [trpc].ts // Next.js 接入 tRPC 的唯一入口 components/ ui/ // 通用UI组件,不放业务逻辑 feature/ // 业务功能组件,按模块分文件夹 lib/ config.ts // 环境变量校验和导出 utils.ts // 通用工具函数 styles/ globals.css

这里有个细节值得说一下:pages/api/trpc/[trpc].ts是整个 tRPC 的边缘入口,所有请求都通过它转发到 tRPC 的处理器。我之前想图省事,把数据库实例直接放进页面组件里调用,结果服务器端渲染和客户端渲染路径全混在一起,数据库连接池很快被打满,页面偶尔出现卡顿。后来全部改成统一的 tRPC 入口之后,请求收敛到一处,连接数可控,中间件逻辑也只在入口执行一次,性能稳定了很多。

2.3 zod 校验和中间件设计

tRPC 的天然搭档是 zod,这一点怎么强调都不过分。我用 zod 不是只做类型校验,而是把“输入校验”和“类型推断”当成一件事来做。在 router 里定义 input 的时候,一个 zod schema 同时承担运行时校验和 TypeScript 类型推断两个责任。比如一个创建文章的路由:

const createPostSchema = z.object({ title: z.string().min(1).max(100), content: z.string().min(10), published: z.boolean().default(false), }); export const postRouter = createTRPCRouter({ create: protectedProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), });

这段代码里,前端如果传少了title,tRPC 会直接返回 400,并且错误信息里会写明是哪个字段校验失败。更舒服的是,前端调用create方法时,编辑器的类型提示会自动带出title、content、published这些字段。这比我以前用 REST 接口时“前端传参靠猜、后端接收靠文档”的体验好太多了。

中间件设计方面,t3code 里最常用的是protectedProcedure。我写了一个统一判断登录态的过程:先在 tRPC 的上下文里取 session,如果 session 不存在,直接抛出UNAUTHORIZED错误。每个需要登录的 router 方法都用这个protectedProcedure包装,就不用在每个方法里手写一遍“if (!ctx.session) 报错”。这套设计也方便扩展权限,比如某个操作需要管理员权限,可以再加一个adminProcedure,内部二次校验用户角色。

3. 从零实操:t3code 核心模块搭建过程

3.1 初始化项目与基础配置

t3code 的落地方案,我是基于 create-t3-app 的生成结果再调整出来的。如果你手头还没有项目,可以直接执行:

npm create t3-app@latest my-t3code-app

创建过程中会提示选择要用哪些模块。我强烈建议全选:Next.js、Tailwind CSS、Prisma、tRPC、NextAuth,一个都不要省。这几个模块虽然看起来多,但是 create-t3-app 已经把它们之间的初始联动关系接好了,比如 Prisma 和 tRPC 的数据库连接、NextAuth 的会话类型注入,都帮你放好了位置。你后面少哪一个,都会破坏类型闭环的完整性。

初始化完成之后,先把环境变量文件检查一遍。t3code 的要求是:所有环境变量必须在src/lib/config.ts里集中校验,而不是散落在代码各处用process.env直接读。我在 config 里用 zod 定义了一个envSchema,启动时校验,缺一个变量就当场报错。这样做的唯一目的是避免“本地跑得好好的,上线之后某些字段是 undefined”这种尴尬问题。

export const envSchema = z.object({ DATABASE_URL: z.string().url(), NEXTAUTH_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), }); export const env = envSchema.parse(process.env);

3.2 数据库模型设计与 Prisma 接入

数据库是类型闭环的起点,所以模型设计要尽可能贴近真实业务。t3code 自带一个示例模型:用户、文章、评论。每个模型都加了必要的字段和关系,方便你直接感受 Prisma 和 tRPC 的结合方式。如果你想快速体验完整链路,我建议先按这个示例跑通,再删掉换成自己的业务表。

以文章和评论为例:

model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? posts Post[] comments Comment[] } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Comment { id String @id @default(cuid()) content String post Post @relation(fields: [postId], references: [id]) postId String author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) }

改完模型之后,执行迁移命令:

npx prisma migrate dev --name init npx prisma generate

migrate dev会把模型同步到数据库,generate会生成 Prisma Client,前端代码里可以用来做类型推导的Prisma.PostGetPayload之类的高级类型,也是这一步生成出来的。这里务必记住:每次改完schema.prisma,都要重新执行generate,否则编辑器里的类型还是旧的,容易出现“明明改了字段却不报错”的假象。

3.3 tRPC 路由与业务逻辑实现

tRPC 的 router 是整套应用的心脏。我在 t3code 里把路由分成两个文件来管理:一个root.ts负责汇总所有子路由,一个routers/文件夹按业务模块放具体的操作逻辑。这样做的好处是:业务增长之后,每个模块的 API 方法都待在各自文件里,合并冲突和互相影响都小得多。

示例:文章列表查询,我在 postRouter 里加了一个带分页的list方法:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), cursor: z.string().nullish(), }) ) .query(async ({ ctx, input }) => { const posts = await ctx.db.post.findMany({ take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } }, }, }); let nextCursor: string | null = null; if (posts.length > input.limit) { const nextItem = posts.pop(); nextCursor = nextItem!.id; } return { posts, nextCursor }; }), });

这个 cursor 分页是从 GitHub API 的分页思路学来的,比传统的offset分页在数据量大时稳定很多,不会因为新插入的数据导致页码错位。前端调用的时候也很简单:

const { data, fetchNextPage } = api.post.list.useInfiniteQuery({ limit: 10, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, });

3.4 前端页面的数据接入方式

前端接入 tRPC 数据,关键一行代码事先生成好类型,后面就是“丝滑调用”。在pages/index.tsx里,我通常会先设置一个统一的 query 入口,然后按组件拆分数据消费点。比如首页展示文章列表并支持滚动加载:

import { api } from "~/utils/api"; export default function Home() { const postQuery = api.post.list.useInfiniteQuery( { limit: 6 }, { getNextPageParam: (lastPage) => lastPage.nextCursor } ); const posts = postQuery.data?.pages.flatMap((page) => page.posts) ?? []; return ( <div className="mx-auto max-w-3xl p-6"> <h1 className="text-2xl font-bold">t3code 示例:文章列表</h1> <div className="mt-6 space-y-4"> {posts.map((post) => ( <article key={post.id} className="rounded-md border p-4"> <h2 className="text-lg font-semibold">{post.title}</h2> <p className="text-sm text-gray-500">作者:{post.author.name}</p> <p className="mt-2 line-clamp-3 text-sm">{post.content}</p> </article> ))} </div> {postQuery.hasNextPage && ( <button className="mt-6 rounded-md bg-black px-4 py-2 text-white" onClick={() => postQuery.fetchNextPage()} > 加载更多 </button> )} </div> ); }

这里能看到一个典型好处:post.author.name这个字段在编辑器里会有自动补全和类型检查,因为 Prisma 的 include 查询已经把 author 的关联类型注入到返回值里了。如果数据库里没有name字段,前端这里会直接报红,不会等到运行时报 undefined。

3.5 样式系统与页面布局技巧

Tailwind CSS 在 t3code 里主要是用来快速搭页面框架的。我建议不要在全局 CSS 里写太多组件类,而是把常用的布局和交互拆成小组件,用 class 组合的方式复用。比如我封装了一个Card组件,文章列表、用户信息、评论区都复用它,风格统一又不重复写样式。

另外,Tailwind 的line-clamp-3工具类在处理文章摘要时很好用,不需要额外引入 JS 库,直接截断文本超出三行的部分。这套模板对移动端适配也做了基础预设,我默认使用max-w-3xl居中布局,既适合桌面阅读,也适配手机屏幕宽度。

4. 常见问题与排查技巧实录

4.1 tRPC 类型报错与 Prisma 不同步

这是新手最容易卡住的地方。现象是前端调用某个 query 或 mutation 时,编辑器提示返回值类型不对,或者某个字段不存在。我排查这类问题的第一件事永远是检查 Prisma 是否重新生成过客户端。因为 tRPC 的类型是依赖 Prisma Client 的,schema.prisma改了但忘记跑prisma generate,类型系统就会处于新旧混合的状态。

实操里我建议把prisma generate加到项目的 postinstall 脚本里。这样任何人在任何环境执行npm install之后,Prisma Client 都是最新版,不会出现“我换了个分支代码就报奇怪错误”的情况。

4.2 服务端渲染与客户端渲染的类型差异

一旦项目用上 Next.js 的 SSR,或者启用了静态生成,就容易出现“服务端能取到数据,但浏览器渲染时数据为 undefined”的情况。排查思路是把渲染路径拆开:tRPC 的 query 在服务端会走createServerSideHelpers预取,在客户端会走trpcProvider的缓存储备。两者如果不一致,前端拿到的初始数据可能不是最新的。

一台部署在 Node 上的应用,如果日志里出现“自签名的证书校验失败”这种报错,通常和数据库连接使用的 TLS 配置有关。t3code 的.env里连接本地和远程数据库的字符串需要仔细检查,本地可以不带sslmode,但生产环境强烈建议加上?sslmode=require。另外,NEXTAUTH_URL 一定要填对外可访问的域名,不要填 localhost,否则生产环境的鉴权回调会失败。

4.3 热更新失效与依赖版本冲突

开发模式下,Next.js 的热更新偶尔会失灵,改动代码之后页面不自动刷新。这种情况大部分是编译缓存和类型缓存冲突导致的。我有一套固定的处理流程:先停开发服务器,删除.next目录,再重新执行npm run dev。绝大多数情况下都能解决。如果问题依旧,再看依赖版本有没有异常,tRPC 的 beta 版本升级很频繁,跨小版本容易出现运行时方法不匹配的问题。

强烈建议把依赖固定到具体版本,不要用^自动升级。我用 t3code 做实际项目时,吃过一次 beta 版本自动升级的亏,某天所有 query 突然全部 404,排查半天发现是 tRPC 的 v10 到 v11 的迁移导致路由路径规则变了。从那以后,我的package.json里所有关键依赖都锁死版本号,只有明确要做升级测试时才手动改。

4.4 鉴权回调与 Session 失效排查

NextAuth 在本地开发时通常没问题,但部署到线上之后,登录成功后回调不到页面,或者刷新一下 session 就丢了。我总结下来,绝大多数情况是这三个原因之一:NEXTAUTH_URL 没写对、NEXTAUTH_SECRET 没有设置持久值、session 存储方式选错了。t3code 默认把 session 放在 cookie 里,但如果你的业务需要在多个子域共享登录态,就必须把 session 配置为 JWT 模式,并在 NextAuth 配置里指定session: { strategy: "jwt" }。

如果你的应用部署在反向代理后面,还要注意代理层有没有正确传递x-forwarded-proto请求头,否则 Next.js 会以为当前请求是 HTTP,导致 NextAuth 生成回调地址时变成http://,浏览器再跳转一下就全部乱套。排这一块问题时,我是直接在浏览器里看登录跳转的地址,凡是看到 protocol 变成 http 的,十有八九都是代理头没配好。

5. 写在最后:从 t3code 里沉淀出来的几个习惯

做 t3code 这套模板,对我个人最大的影响不是掌握了一个新框架,而是改变了我写全栈代码的顺序。以前我习惯先画页面、再写接口、最后补数据库,结果经常为了迁就接口把数据库结构改得面目全非。现在我的顺序反过来了:先定义数据库模型,再写 tRPC 路由,最后让类型自动流到页面上。这个“类型先行”的习惯,让我在写业务逻辑时更有底气,因为编译器的检查会帮我把大部分低级错误挡在运行之前。

另外一个很实在的建议是:不要忽视 zod 校验在前后端之间的“契约作用”。很多团队觉得校验只是“防止别人乱传参数”,其实它的价值更高的是让前后端在一个 schema 上达成共识。前端修改表单、后端调整接口,只要 schema 变了,双方代码中受影响的位置都会立刻被类型系统标记出来。从这个角度看,tRPC 加 zod 不只是提高开发效率,更是在维护团队沟通边界。

最后分享一个小技巧。如果你在 t3code 基础上做二次开发,每次写完一个完整的业务模块,记得回来看一眼 tRPC router 的代码量。如果一个文件超过三百行,说明业务逻辑可能混在一起了,这时候尽早拆分。tRPC 对模块拆分的友好程度远高于我预期,普通函数该抽象就抽象,不会出现“拆了半天反而被框架束缚”的情况。代码粒度控制了,这个模板才能一直保持“拿来即用”的手感。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进