ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code 实战:T3 Stack 全栈模板的端到端类型安全实践

t3code 实战:T3 Stack 全栈模板的端到端类型安全实践

最近在折腾 t3code 这套全栈项目模板,说直白点,它就是基于 T3 Stack 整理出来的代码骨架。T3 Stack 这四个字母在开发圈几乎已经成了 TypeScript 全栈的默认答案:Next.js 负责页面和接口,tRPC 负责打通前后端的类型,Prisma 负责数据库访问,Tailwind 负责样式,最后用 TypeScript 把整条链路焊死。t3code 的价值在于,它帮你把这些最佳实践固化成能直接改业务代码的模板,而不是让你每次起项目都把路由、鉴权、数据库连接、样式配置重新搭一遍。

我最早接触 T3 Stack 是因为受不了手写 REST API 类型定义的日子。前后端一分离,改一个字段要同步改三处地方:后端 DTO、前端的接口类型、还有文档。偶尔忘改,运行时直接白屏。t3code 最打动我的点就是端到端类型安全,数据库表字段改掉之后,前端调用处会立即标红,根本不需要我记着什么契约文档。这篇文章我就从项目设计、核心技术点、实操过程、排错经验四个维度拆一拆 t3code 这套东西,希望对想搞全栈、或者正在纠结技术选型的朋友有点参考价值。

1. 项目整体设计与思路拆解

1.1 它到底解决了什么问题

传统全栈项目最常见的痛点是类型断裂。后端用 Prisma 操作数据库,前端用 fetch 调接口,中间隔着一层 JSON。只要某天有人把后端字段从completed改成isDone,前端不编译报错,只有运行时才能看到。t3code 的整个设计就是围绕“类型信息安全地穿过网络边界”这一目标进行的。它把后端 Router 的类型直接暴露给前端,前端调用接口时,入参和出参的 TypeScript 类型全部由编译器推导,少写大量的 interface 定义,把错误拦截在编译期。

第二个痛点是样板代码太多。传统全栈项目一般要建 controllers、services、repositories、validators、types 五层目录,每个接口都要在多个目录里各写一遍。t3code 里这部分被压缩成了一个 router 文件,业务函数直接挂载,zod 校验写在 input 里,类型由 tRPC 自动推导。代码量大约只有传统方案的三分之一,但清晰度和可维护性反而更高。

第三个痛点是新项目初始化的决策疲劳。技术选型是个说起来复杂、做起来更要命的事情:要不要用 GraphQL?数据库用 SQLite 还是 Postgres?样式方案用 Tailwind 还是 CSS Modules?每个方案都有理由,也都有社区拥趸。t3code 的做法是直接帮你做决定,把有一致共识的方案固化成模板,让你把精力留给真正有价值的业务逻辑。

1.2 为什么选中这套技术组合

先说一下 Next.js。它提供了 SSR、SSG、ISR 三种渲染模式,同时从 13 版本开始力推 App Router,支持 Server Components,这让服务端逻辑和前端组件可以写在同一个文件里。对于 t3code 来说,页面组件里可以直接发起 tRPC 查询,而 tRPC 又会调用 Prisma 访问数据库,整条链路都在同一个进程里完成,不需要额外部署一个 API 服务。

再说 tRPC。它不生成协议文件,也不需要像 GraphQL 那样维护 schema,它只是把服务端函数“投影”到客户端。换成人话讲:你在服务端定义了todoRouter.create,前端就能直接api.todo.create.useMutation()去调用,而且参数类型、返回值类型全部自动匹配。对中小型项目来说,这比 REST 更省事,比 GraphQL 更稳。

数据库层选 Prisma 而非 TypeORM 或 Drizzle,主要看中它 schema 驱动的开发流程。写模型、生成 migration、同步到数据库、生成类型,四个步骤像流水线一样顺。虽然 Prisma 在复杂 SQL 场景下不如手写 SQL 灵活,但 CRUD 占到业务总量的八成以上,这一点取舍非常划算。

样式层 Tailwind 的逻辑是 utility-first,用原子类组合代替自定义 CSS。在 Server Components 场景下,Tailwind 的 class 会在编译期直接生成,不会在客户端注入任何运行时样式代码,性能表现很稳。它的生态里还有 shadcn/ui 这样的组件库,能把 Tailwind 的使用体验再提升一个档次。

1.3 选型背后的代价与权衡

没有完美的技术栈,只有适合场景的组合。t3code 把前后端放在同一个 Next.js 项目里,意味着这两部分没法独立扩缩容。如果将来业务量爆炸,需要把 API 独立拆出去,就需要额外引入 API Gateway 或者改用微服务架构。另外,tRPC 的设计目标是服务自己的前端,不适合直接对外暴露成公开 API。如果产品计划做开放平台,那就要考虑接一层 BFF(Backend for Frontend)或独立网关。

代价还包括 Prisma 对数据库的抽象。ORM 的好处是类型安全、迁移方便,但复杂查询、窗口函数、子查询这些场景写起来就别扭。我的经验是:遇到复杂报表查询时不要硬用 Prisma,直接在 router 里暴露一个手写 SQL 的查询方法,反而干净。

这些权衡不是缺陷,而是明确的取舍。t3code 面向的是产品开发的核心价值:快速验证、快速上线、快速迭代。等到真正遇到性能和扩展瓶颈时,你通常已经赚到了足够的时间去重构,或者已经验证了业务方向不需要继续膨胀。

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

2.1 tRPC 的端到端类型安全到底是怎么回事

要理解 tRPC,得先忘掉“接口”这个概念。它没有 URL 路径、没有 HTTP method、没有 OpenAPI 文档,它就是 TypeScript 函数直接调用。服务端定义一个 router:

// server/api/routers/todo.ts export const todoRouter = createTRPCRouter({ getAll: publicProcedure.query(({ ctx }) => { return ctx.db.todo.findMany(); }), });

客户端拿到的类型是这样的:

// 前端某个组件里 const { data } = api.todo.getAll.useQuery();

data的类型不是any,也不是手写的 interface,而是 Prisma 的Todo[]类型。这里没有代码生成,没有运行时校验,纯粹是 TypeScript 的类型推断在起作用。原理说起来也不复杂:createTRPCReact<AppRouter>接收的是服务端 router 的类型签名,tRPC 的类型体操会把每个 procedure 的输入输出全部推导出来,客户端 proxy 对象只是这个类型签名的载体。

这里要补一句:别在服务端手动加类型断言。有些人觉得 Prisma 返回的类型还不够“精确”,在 router 里写return data as SomeDTO[],这一下就把类型推导链路打断了,前端拿到的类型就不再可信。正确的做法是直接在 Prisma schema 里用@select、@omit或者查询时指定select字段,让类型系统自己完成剩余工作。

2.2 zod 到底起了什么作用

tRPC 本身只负责调用,参数校验靠 zod。拿create方法举例:

create: publicProcedure .input( z.object({ title: z.string().min(1, "标题不能为空").max(100), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.todo.create({ data: input }); }),

zod 的 schema 同时承担了两个职责:运行时校验请求参数是否合法,以及编译期提供输入类型。前端传了一个空字符串,后端会直接返回可读的校验错误,而不是数据库报错。前端传了一个多余的字段,也会被 zod 默认过滤掉,这一点对安全性很重要。如果你用 REST 方案,这一步通常要在 controller 里手写一堆 if-else,非常容易漏。

我见过一些项目,为了“省事”把 tRPC 的 input 类型直接写成any,这等于把端到端类型安全这条护城河填平了,后面一定会看到数量膨胀的运行时 bug。所以 t3code 模板里给每一个有入参的 procedure 都配好了 zod schema,你在改业务时也应该保持这个习惯。

顺便说一下,zod 还能和表单库配合。比如 react-hook-form 的zodResolver,直接把表单校验、类型推导、后端校验三件事合并成一份 schema 定义,前后端永远不会有校验规则不一致的问题。

2.3 Prisma 模型设计与迁移工作流

schema.prisma 是数据层的唯一事实来源。我一般习惯先定模型,再想接口:

model Todo { id Int @id @default(autoincrement()) title String completed Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([completed]) }

写完模型后执行:

npx prisma migrate dev --name init

这条命令会做三件事:比对 schema 与数据库的差异、生成新的 migration 文件、同步到本地数据库。同时它会自动重新生成 Prisma Client,让 TypeScript 类型立即跟上模型变化。生产环境就不要用 dev 了,要用npx prisma migrate deploy,它只执行 migration 文件,不会尝试生成新的差异,安全很多。

实操中有个很容易踩的坑:修改 schema 之后没有重启 TypeScript 语言服务,编辑器里 Prisma Client 的类型还是旧的,你会看到一堆莫名其妙的类型报错。解决办法很简单,VSCode 里执行 TypeScript: Restart TS Server,或者直接重启编辑器。

另外一个细节:SQLite 适合本地和原型,但 t3code 最终上生产建议切换到 PostgreSQL。切换逻辑非常简单,只需要改DATABASE_URL,然后重新生成一次 migration。t3code 的 schema 里也不需要写方言相关的代码,因为 Prisma 会在背后处理,这块做得相当稳。

2.4 Tailwind 的配置与组件化套路

Tailwind 在 t3code 里的地位是“把样式写得不难受”。它本质上是按类名生成 CSS,所以配置的关键在于 content 路径要覆盖到你所有的源码文件:

export default { content: ["./src/**/*.{js,ts,jsx,tsx}"], darkMode: "class", theme: { extend: { colors: { brand: "rgb(var(--brand) / <alpha-value>)", }, }, }, plugins: [], } satisfies Config;

我推荐团队里统一设计 token:主色、边框、圆角、间距都通过 CSS 变量定义,Tailwind 里用rgb(var(--brand))引用。这样暗黑模式切换只需要换个>npm create t3-app@latest my-t3code

交互阶段有几个选项需要确认:

  • TypeScript:必须开
  • Tailwind CSS:开
  • tRPC:开
  • Prisma:开
  • NextAuth:如果是需要登录态的项目就开,不然后续手动加也很麻烦
  • ESLint / Prettier:开

初始化完成后,先把.env配置好。模板里.env.example已经写好了变量名,你需要复制一份.env并填写:

DATABASE_URL="file:./db.sqlite" NEXTAUTH_SECRET="$(openssl rand -base64 32)" NEXTAUTH_URL="http://localhost:3000"

NEXTAUTH_SECRET 注意不要用带NEXT_PUBLIC_前缀的变量名,因为服务器端密钥一旦打进浏览器 bundle 就等于泄露了。

然后启动开发服务器:

npm run dev

第一次启动可能会遇到PrismaClient is not defined,这是因为还没有执行任何 migration。跑一句npx prisma migrate dev或者npx prisma db push就能解决。我自己更推荐先建好模型再 migrate,一步到位。

目录结构长这样:

src/ ├── app/ │ ├── api/trpc/[trpc]/route.ts │ ├── layout.tsx │ └── page.tsx ├── components/ ├── server/ │ ├── api/ │ │ ├── root.ts │ │ ├── routers/ │ │ └── trpc.ts │ └── db.ts └── utils/ └── api.ts

server/api/trpc.ts里定义了 base procedure,server/api/root.ts是挂载所有子 router 的入口,utils/api.ts是前端获取类型化 proxy 的地方。这三个文件是 t3code 的核心,新手第一次看容易绕晕,但弄懂之后,新增一个业务模块基本只需要改两个文件。

3.2 实现一个带数据库的 Todo 模块

从模型开始。在prisma/schema.prisma里加模型:

model Todo { id Int @id @default(autoincrement()) title String completed Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

然后生成迁移:

npx prisma migrate dev --name init

接着在src/server/api/routers/todo.ts里写业务逻辑:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; export const todoRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return await ctx.db.todo.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) => { return await ctx.db.todo.create({ data: input }); }), toggle: publicProcedure .input(z.object({ id: z.number() })) .mutation(async ({ ctx, input }) => { return await ctx.db.todo.update({ where: { id: input.id }, data: { completed: true }, }); }), delete: publicProcedure .input(z.object({ id: z.number() })) .mutation(async ({ ctx, input }) => { return await ctx.db.todo.delete({ where: { id: input.id } }); }), });

再到root.ts里挂载:

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

前端页面组件我放在src/app/todos/page.tsx:

"use client"; import { useState } from "react"; import { api } from "@/utils/api"; export default function TodosPage() { const utils = api.useUtils(); const { data: todos, isLoading } = api.todo.getAll.useQuery(); const createTodo = api.todo.create.useMutation({ onSuccess: () => utils.todo.getAll.invalidate(), }); const [title, setTitle] = useState(""); if (isLoading) return <p>正在加载...</p>; const handleAdd = () => { if (!title.trim()) return; createTodo.mutate({ title }); setTitle(""); }; return ( <div className="p-6"> <h1 className="text-2xl font-bold">Todo List</h1> <div className="mt-4 flex gap-2"> <input className="border rounded px-3 py-2" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="输入待办事项" /> <button onClick={handleAdd} className="bg-brand text-white px-4 py-2 rounded"> 添加 </button> </div> <ul className="mt-6 space-y-2"> {todos?.map((todo) => ( <li key={todo.id} className="flex items-center gap-3"> <span>{todo.title}</span> </li> ))} </ul> </div> ); }

几个关键点:

  • "use client"必须有,因为这里用了 useState 和 useMutation。
  • api.useUtils()是 React Query 的 hook,里面封装了各种 invalidation 方法。
  • createTodo.mutate发送 mutation,成功后utils.todo.getAll.invalidate()会自动重新拉取列表,页面不需要手动刷新。这就是 t3code 里最常用的数据流模式:mutation 成功后让相关 query 失效,React Query 重新请求。

3.3 部署到 Vercel 的完整流程

部署前先检查数据库。本地用的是 SQLite,Vercel 的 serverless 环境不方便持久写文件,所以生产环境一定要换 Postgres。我习惯接 Vercel Postgres 或者 Supabase,因为两者都能直接拿到连接串,填进DATABASE_URL即可。

接着在项目根目录跑:

npx prisma migrate deploy

然后推送代码到 GitHub,在 Vercel 里创建 new project,选择仓库,环境变量里填好DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL,点击 Deploy 就完成了。

这里有个高频报错:部署后页面能打开,但一提交数据就报 500。十有八九是没执行 migrate deploy,数据库里根本没有表。另一个容易忽略的是 Vercel 的 Node.js runtime 版本,Prisma 需要 nodejs runtime,如果你在自己的 API Route 或 page 上面手动加了export const runtime = "edge",就会看到 PrismaClient 初始化失败。t3code 默认不主动用 edge runtime,所以只要你没乱改,一般不会碰这个问题。

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

4.1 高频报错速查表

我把这段时间实际踩过、身边朋友也问过的问题整理成一张表,方便你直接对照:

现象可能原因处理方式
PrismaClient is not defined没执行 prisma generate运行npx prisma generate
运行时报Input validation failedzod 校验不通过检查 input schema 和前端传参是否一致
useQuery 能拿到数据但类型是 any手动断言或类型引用链断了删除多余类型断言,检查 AppRouter 导出方式
部署后数据都是空的migration 未部署npx prisma migrate deploy
组件不显示样式tailwind content 路径没覆盖确认 content 包含 src 下所有模板
NextAuth 回调跳转失败NEXTAUTH_SECRET 缺失或不稳定设置固定随机 secret
修改模型后编辑器类型不更新TS Server 缓存重启 TypeScript 服务

4.2 我踩过的几个坑

第一个坑是"use client"忘记加。在 App Router 里,如果页面里用了useState、useMutation这些 hooks,但忘记声明"use client",编译会直接报错。这不是技术难点,但很容易在复制模板时漏掉这一行,报错信息又长又吓人,最后定位到只是少了一行注释,非常搞心态。

第二个坑是 tRPC procedure 里 catch 了错误但不抛出。我一开始做删除接口时,担心 Prisma 报错导致页面崩溃,就在 mutation 里 try-catch 然后返回undefined。结果前端拿到 undefined,直接渲染出一个空对象,页面就静默崩溃了。排查好久才明白:异常还是应该抛出,让 tRPC 把错误信息结构化返回,前端才能在统一的地方处理。

第三个坑是日期序列化。Prisma 返回的createdAt是 Date 对象,如果直接放在页面组件里,React 报“不能序列化 Date”。create-t3-app 默认集成了 superjson,tRPC 序列化会自动处理这类问题。但如果你自己手动搭项目忘了配 superjson,就会踩到。所以除非很有把握,否则不要轻易从模板里去调 superjson。

4.3 性能和安全优化建议

先谈性能。tRPC 默认走 React Query,重复的 useQuery 会用同一个 key 做缓存,所以同一个接口在多个组件里调用不会重复请求。如果你有一些数据变化不频繁的查询,比如系统配置,可以给 useQuery 设置staleTime: 60_000,一分钟内即使组件重新挂载也直接用缓存,省掉大量请求。

再谈安全。t3code 模板里的 procedure 分为 publicProcedure 和 protectedProcedure,后者依赖 NextAuth 的 session 做鉴权。如果某个 mutation 需要登录用户才能调用,记得用 protectedProcedure,不要图省事全写 public。另一个隐性问题是 Prisma 查询返回的字段,默认会把 schema 里的所有字段都返回。用户模型里如果有 passwordHash,就一定要在 select 里排除它,否则就算 router 写得很漂亮,隐私还是漏出去了。t3code 虽然安全基线远比手写 REST 高,但该做的数据最小化工作一项都省不了。

最后说一个实用技巧:如果页面里需要服务端渲染,直接让 tRPC 查询在 Server Component 中使用api.todo.getAll.query()调用一次,再把结果作为 prop 传给客户端组件。这样既拿到了 SSR 的效果,客户端又不需要重复请求。这个模式在 t3code 里是完全支持的,很多新手不知道,往往白白多加载一次数据。

坦白说,我从 REST 迁到 tRPC 最深的感受就是:类型安全不该是奢求,而应该成为全栈开发的基线。t3code 这个模板的价值不在于它用了多么新奇的技术,而在于它把端到端类型安全、数据库迁移、样式构建这些原本分散的知识浓缩成了一个可复用的起点。我团队里现在新开的内部工具、原型验证项目全都跑在这个模板上,几乎不用花时间在基建上。

最后再分享一个小技巧:当你给 useMutation 加 onSuccess 回调时,不要只写utils.todo.getAll.invalidate(),可以顺手await utils.todo.getAll.refetch(),这样针对关闭了自动 refetch 的场景也能稳妥刷新列表。这个细节很多人不会写在文档里,但实测下来能省不少排查时间。

RELATED READING

延伸阅读

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