ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code 全栈脚手架详解:基于 T3 Stack 构建类型安全应用

t3code 全栈脚手架详解:基于 T3 Stack 构建类型安全应用

很多做过全栈项目的朋友应该都有过这种体验:新项目开工第一件事,不是写业务代码,而是折腾脚手架。今天用 Next.js,明天想接个 API 层,后天又要配 Tailwind,再加上数据库 ORM、请求校验、认证方案,一圈下来少说几个小时,多则一两天。我最初看到t3code这个项目时,第一反应是“又一个模板生成器”,但实际用完之后,我改变了自己的看法。它不是简单把几个仓库拼在一起,而是把一套经过验证的全栈架构理念做成了可复用的起点。

t3code这个名字和它背后的技术选型有直接关系,核心思路是围绕由 Next.js、TypeScript、tRPC、Tailwind CSS 组成的 T3 Stack 做整合,再附带 Prisma、Zod 等配套工具。它要解决的核心痛点很明确:全栈 TypeScript 项目从零搭建时,类型安全、数据校验、API 联通、样式系统这几件事能一次到位。无论你是刚接触全栈开发的新人,还是想快速验证产品原型的老手,t3code都能让你跳过配置地狱,直接进入业务逻辑开发。这篇文章我会从技术选型、搭建流程、端到端实现到常见问题排查,把整个使用过程拆开讲透。

1. t3code 是什么:一个全栈脚手架的定位与价值

1.1 它到底帮你省掉了哪些事

我第一次跑t3code初始化的感觉是“快”。它生成的不是一个静态页面项目,而是一个包含了完整数据链路的前后端一体工程。所谓“完整数据链路”,指的是从前端 UI 发起的请求,到服务端路由处理、数据库读写、类型推断与校验,全流程都有统一的技术方案衔接。传统做法里,前端写fetch或axios,后端开 REST 接口,再手动定义一套 TypeScript 类型供两端共享;而在t3code的项目里,这部分由框架直接接管了。

具体来说,t3code帮你省掉的工作包括:

  • 项目初始化与目录结构搭建,无需手动创建几十个文件夹。
  • Next.js 页面路由、API 路由、服务器组件的协同配置。
  • Tailwind CSS 的安装、配置以及和 Next.js 的集成。
  • tRPC 客户端的连接配置,包括httpBatchLink、superjson等细节。
  • Prisma 数据库模型初始化与客户端生成。
  • Zod 数据校验与 tRPC 输入解析的中间件打通。
  • 基于react-hook-form或zodResolver的表单校验预设。

这些条目如果自己手动做,每一环都可能因为版本不兼容而耗费时间。t3code的价值在于它把“每件事应该在哪个位置、以什么方式接线”都固化下来了,形成一套可维护的默认结构。

1.2 适合谁来用,哪些场景最合适

从我的实际体验来看,t3code不是银弹,它有非常明确的适用场景。如果你是做一个以数据交互为核心的 Web 应用,比如内部管理系统、SaaS 工具、内容发布后台,那它非常合适,因为这类应用对类型安全、接口规范、数据校验要求高,而且会持续迭代。

反过来,如果你的项目是纯静态展示站,不需要数据库和复杂 API 层,那么t3code的很多功能是冗余的,用 Vite 加静态生成工具更轻松。另外,如果团队里多数人只有前端经验,对 Node.js 服务端并不熟悉,t3code的 tRPC 抽象会降低后端开发门槛,但依然需要理解数据库等概念,所以完全零基础的新人最好先单独补一补 TypeScript 和 REST API 的常识。

我在团队内部推广t3code时常用一个类比:它就像是全栈项目里的“毛坯房精装方案”。每个组件和库单独拎出来都挺好,但把它们组装在一起并且保证水电线路通畅,才是最难的部分,t3code做的就是这件“装修”工作。你拿到的不是一个只能演示的 Demo,而是一套可以按自己业务改造的基础架构。

2. 技术栈拆解:每个选型背后到底为了什么

2.1 核心三件套:Next.js、TypeScript、Tailwind CSS

t3code的基础层是 Next.js,这一点几乎是必然选择。Next.js 提供了文件约定式路由、服务端渲染、静态生成和 API 路由,可以在一个项目里同时承担前端应用和服务端逻辑。它还是 React 生态里支持服务器组件比较成熟的框架,对于需要服务端数据读取的场景,能减少客户端到服务器之间的往返。不过,选择了 Next.js 也意味着放弃了 SPA 式的纯客户端渲染模式,如果团队习惯了 Vite 加 React 的状态,需要适应页面加载模型的变化。

TypeScript 是整个 T3 体系的灵魂。t3code之所以能实现端到端类型安全,是因为前端组件、服务端路由、数据库模型全在一个代码库中,TypeScript 的静态类型可以在编译期发现类型不匹配。这一点和传统前后端分离项目有本质区别。传统方案中,即便后端用 OpenAPI 生成了类型定义的包,两端仍然存在同步延迟和手动维护成本;而在t3code里,类型是从实际函数实现中推断出来的,规范即实现。

Tailwind CSS 的加入则解决了样式系统的一致性问题。它采用原子类的方式组织样式,避免创建和维护大量全局 CSS 类名。我在实践里用 Tailwind 构建复杂页面时发现,它最大的收益不在“不用写 CSS”,而在于样式变更的影响范围被控制在具体类名上,不会被旧的全局样式干扰。搭配深色模式、响应式断点也能在配置文件里统一管理。

2.2 数据链路三兄弟:tRPC、Prisma、Zod

如果说前三个工具属于“基础设施”,那么tRPC、Prisma、Zod就是t3code里真正决定开发效率的中枢。tRPC 是一个基于 TypeScript 的远程过程调用框架,它不用写 API 路由定义,也不用手动生成客户端 SDK,只要在服务端定义好过程(procedure),前端就能像调用本地函数一样调用它。

Prisma负责数据库访问层。它通过schema.prisma文件定义数据模型,然后生成类型安全的查询客户端。t3code默认连接 SQLite 或用环境变量切换 PostgreSQL、MySQL,这种设计让项目可以从小型单机数据库平滑迁移到生产级数据库。实际操作中,我常建议先把 SQLite 跑通业务流程,再根据部署环境更换数据库,这样能降低起步复杂度。

Zod是数据校验库,它在 tRPC 里扮演输入保护的角色。每个 tRPC 路由的输入参数都可以声明为一个 Zod schema,请求进来时自动完成类型解析、必填校验、长度限制和自定义规则。非法数据在到达业务逻辑之前就会被拦截,这对后端安全性是重要保障。

2.3 选型逻辑与潜在取舍

这套组合并非无懈可击,我自己用下来也发现了值得注意的取舍。

第一,tRPC 绑定的是 TypeScript 生态。如果你的项目未来要开放 API 给非 TypeScript 的第三方客户端,tRPC 并不擅长,这时需要额外写出 OpenAPI 规范或另起 REST 接口。第二,Prisma 的查询能力和原生 SQL 相比,仍有复杂聚合、子查询表达上的限制,遇到深度报表需求,可能还是得写$queryRaw。第三,Next.js 的服务器组件渲染模型变化较快,版本升级时容易遇到破坏性变更,锁定版本并持续关注官方更新日志是必要动作。

但放在一个近两年新启动的全栈项目里,这套组合带来的开发效率提升是显然的。我的经验是,如果你追求最短路径地把想法变成可交互产品,t3code是值得优先尝试的方案。

3. 从零开始搭建 t3code 项目的实操流程

3.1 初始化项目与基本配置

搭建t3code的第一步其实很标准,直接在终端里运行初始化命令。这里以 npm 为例:

npm create t3-app@latest my-app

命令执行后,交互式命令行会询问数据库、认证方式、是否需要nextAuth、prisma、tailwind等选项。初次使用我建议全选默认推荐项,把整个结构生成完整,后面再按需裁剪。这一步之后,项目会生成一个可以直接运行的 Next.js 工程,同时包含 tRPC 路由、Prisma schema 和 Tailwind 配置文件。

接着安装依赖:

cd my-app npm install

安装过程中如果遇到 peer dependency 冲突,先不要急着加--force。更好的做法是把package.json里几个关键库的版本调到一致,再重新安装。我在实际操作中遇到最多的问题就出在 Next.js 和 React 的版本对应关系上,这类问题靠强制安装解决之后,运行时大概率会出现警告或诡异报错。

初始化完成后建议先跑一次:

npm run dev

如果能正常打开首页,说明项目的基础骨架没问题。此时先别急着改代码,熟悉目录结构更重要。

3.2 目录结构与核心文件解读

t3code生成的目录结构是我见过比较清晰的一类模板,它把客户端、服务端、公共代码的边界划分得很明确。核心结构大致如下:

. ├── prisma/ │ └── schema.prisma ├── src/ │ ├── pages/ # Next.js 页面路由 │ ├── server/ │ │ ├── api/ # tRPC 路由与 root │ │ ├── db.ts # Prisma 客户端实例 │ │ └── auth.ts # 认证配置封装 │ ├── styles/ │ └── utils/ │ ├── api.ts # tRPC 客户端与 Provider │ └── auth.ts # 认证工具函数

prisma/schema.prisma是数据模型的核心。src/server/db.ts里导出了一个全局单例的 PrismaClient,避免开发模式下热重载创建大量连接。src/server/api/root.ts是所有 tRPC 路由的聚合入口,src/server/api/routers/按业务模块拆分路由文件。

src/utils/api.ts是很关键的一个文件,它创建了 tRPC 客户端,并挂载到 Next.js 的AppProps中。前端组件通过这里导出的api对象调用后端方法,同时保持 React Query 的状态管理能力。

3.3 配置环境变量与数据库

数据库连接信息在.env文件中配置。t3code初始化时默认生成一个本地 SQLite 数据库,因此环境变量类似:

DATABASE_URL="file:./db.sqlite"

更换数据库时,只需要把DATABASE_URL改为 PostgreSQL 或 MySQL 的连接串,同时调整schema.prisma里的provider字段即可。改完之后运行:

npx prisma migrate dev --name init

该命令会根据schema.prisma生成迁移文件并同步数据库结构,同时生成新的 Prisma Client,这一步是数据模型变更后必须执行的动作。

3.4 创建第一个 tRPC 接口

在t3code里写一个后端接口,体验和写一个普通函数非常接近。比如在src/server/api/routers/下新建post.ts:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany(); }), create: publicProcedure .input( z.object({ title: z.string().min(1), content: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, }, }); }), });

这里的publicProcedure表示无需认证即可访问,query是查询操作,mutation是写入操作。input字段声明了参数格式,如果前端传入了缺失字段或类型错误,请求会在进入数据库之前被拦截。

接着把postRouter注册到root.ts:

import { postRouter } from "./routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, });

服务端完成之后,前端组件里就可以这样调用:

import { api } from "~/utils/api"; export default function PostsPage() { const { data: posts, refetch } = api.post.getAll.useQuery(); const createPost = api.post.create.useMutation(); const handleSubmit = async () => { await createPost.mutateAsync({ title: "Hello" }); refetch(); }; return <div>{/* 渲染 posts 列表,触发 handleSubmit */}</div>; }

不需要手动写 URL,不需要设置请求头,不需要维护接口文档,类型直接跟着函数签名走。这是t3code最让人上瘾的一点。

4. 完整实现:从一个待办事项模块看端到端流程

4.1 数据库模型设计

光看基础模板还不够,我拿一个具体的“待办事项”业务模块走一遍完整链路,这样更能看清t3code的整个工作方式。

先定义数据模型。在prisma/schema.prisma中增加 Todo 模型:

model Todo { id String @id @default(cuid()) title String done Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

这里把id设置为cuid()而不是自增整数,是为了避免公开接口中暴露数据总量,同时便于分布式环境下生成唯一标识。改完模型后执行迁移,并启动开发服务器:

npx prisma migrate dev --name add_todo npm run dev

4.2 服务端路由实现

在routers/todo.ts中实现增删改查。这里刻意包含输入校验和错误处理,方便观察 tRPC 在异常场景下的行为:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const todoRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ filter: z.enum(["all", "active", "completed"]).default("all"), }) ) .query(async ({ ctx, input }) => { const where = input.filter === "all" ? {} : input.filter === "completed" ? { done: true } : { done: false }; return ctx.db.todo.findMany({ where, orderBy: { createdAt: "desc" }, }); }), add: publicProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.create({ data: { title: input.title } }); }), toggle: publicProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const todo = await ctx.db.todo.findUnique({ where: { id: input.id } }); if (!todo) throw new Error("Todo not found"); return ctx.db.todo.update({ where: { id: input.id }, data: { done: !todo.done }, }); }), remove: publicProcedure .input(z.object({ id: z.string().cuid().or(z.array(z.string().cuid())) })) .mutation(async ({ ctx, input }) => { if (typeof input.id === "string") { await ctx.db.todo.delete({ where: { id: input.id } }); } else { await ctx.db.todo.deleteMany({ where: { id: { in: input.id } } }); } return { success: true }; }), });

注意toggle路由里手动查询再更新的写法,比直接updateMany多了一次查询,但能更准确地在代码里处理“待办不存在”的情况。这个设计在需要权限校验的业务代码里更加安全,因为可以先获取目标记录,再做访问控制判断。

4.3 前端页面与交互

前端的核心逻辑是把 tRPC 查询结果和本地状态结合起来。我写了一个简单的待办列表页面,使用 React Query 的缓存机制来管理数据状态:

import { useState } from "react"; import { api } from "~/utils/api"; export default function TodoPage() { const [title, setTitle] = useState(""); const utils = api.useUtils(); const { data: todos } = api.todo.list.useQuery({ filter: "all" }); const addMutation = api.todo.add.useMutation({ onSuccess: () => utils.todo.list.invalidate(), }); const toggleMutation = api.todo.toggle.useMutation({ onSuccess: () => utils.todo.list.invalidate(), }); const removeMutation = api.todo.remove.useMutation({ onSuccess: () => utils.todo.list.invalidate(), }); const handleAdd = async () => { if (!title.trim()) return; await addMutation.mutateAsync({ title }); setTitle(""); }; return ( <div className="max-w-xl mx-auto p-6"> <div className="flex gap-2 mb-4"> <input className="border rounded px-3 py-2 flex-1" value={title} onChange={(e) => setTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleAdd()} placeholder="输入待办事项" /> <button className="bg-blue-600 text-white rounded px-4 py-2" onClick={handleAdd}> 添加 </button> </div> <ul className="space-y-2"> {todos?.map((todo) => ( <li key={todo.id} className="flex items-center gap-2 border rounded px-3 py-2"> <input type="checkbox" checked={todo.done} onChange={() => toggleMutation.mutate({ id: todo.id })} /> <span className={todo.done ? "line-through text-gray-400" : ""}> {todo.title} </span> <button className="ml-auto text-red-500 text-sm" onClick={() => removeMutation.mutate({ id: todo.id })} > 删除 </button> </li> ))} </ul> </div> ); }

api.useUtils()是从 tRPC v11 开始提供的工具方法,它允许你在一个 mutation 成功之后,手动让相关查询失效并自动重新拉取数据。相比直接调用refetch,invalidate在多个组件依赖同一数据源时更加高效,它会同时唤醒所有使用该查询键的组件。

4.4 校验与错误处理的细节

这里的输入校验在两层同时发生:Zod 在服务端拦截非法格式,而前端如果使用了react-hook-form加zodResolver,也能在提交前提供即时反馈。即使只靠当前代码,max(100)的长度限制和后端非空校验也已经能挡住绝大多数异常请求。

remove路由里把输入类型设计成了“单个 id 或 id 数组”,这是一个有意的取舍。它的好处是前端删除单个条目和批量删除都能复用同一个接口;坏处是类型分支让代码稍显复杂。实际项目中,如果批量删除出现的频率很低,我更建议拆成两个独立 mutation,保持类型单一是更简单的做法。

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

5.1 tRPC 调用挂起但页面不报错

这个现象我排查了很久,最后发现是服务端抛出的异常被superjson序列化时吞掉了。tRPC 默认使用superjson作为数据序列化器,它能处理Date、Map等特殊类型,但如果你在服务端抛出了一个自定义类的实例,序列化时可能丢失信息,导致前端只看到一个很模糊的通用错误。

解决思路是在trpc.ts里自定义onError回调,开发环境打印完整错误栈,生产环境输出可控错误码。另外,保持自定义异常都基于Error派生,不要手写对象作为 throw 值,能减少很多奇怪的问题。

5.2 Prisma 客户端版本不一致

t3code生成的模板里,Prisma 的版本通常固定在一个范围。当你使用npx prisma migrate命令时,如果全局安装的 Prisma CLI 和项目里的@prisma/client版本不一致,会出现“Query engine version mismatch”的错误。

我记得有一次运行迁移成功后,页面访问数据库接口直接崩溃,原因就是全局 CLI 是 5.x,项目里是 6.x。最稳妥的做法始终是使用项目本地依赖:

npx prisma migrate dev

不要依赖全局 Prisma,也不要在这类项目里使用npm install -g prisma,版本漂移大概率会在某个时刻咬你一口。

5.3 新增路由后前端没有对应方法

t3code的 tRPC 类型是基于 TypeScript 编译的,理论上后端新增路由后,前端导入的api对象会自动获得新方法。但实际使用时,如果你开启了 Next.js 的持久化缓存或者没重启开发服务器,偶尔会遇到前端类型没有更新的情况。

这时候关掉 dev server 重新启动,通常能解决。如果还不行,检查src/server/api/root.ts是否导出了新路由,以及是否忘了在根路由中注册。还有一个小技巧,进入node_modules/.prisma删除生成缓存后重新npx prisma generate,可以解决很大一部分“怎么改都没反应”的问题。

5.4 部署到服务端后样式丢失或 API 404

本地开发正常,部署后 CSS 和 API 一起出错,大多是环境变量配置问题。t3code里NODE_ENV为production时,部分 URL 逻辑和开发模式不同。检查.env.production是否完整,DATABASE_URL是否指向线上数据库,NEXTAUTH_URL是否正确。如果用了 Docker 部署,还需要注意环境变量注入的时机,不要在镜像构建阶段就固化连接串。

生产环境下另一个常见问题是数据库迁移未执行。开发时用了migrate dev,线上则需要跑migrate deploy,否则表结构不存在,所有 Prisma 查询都会失败。

6. 我的踩坑心得与下一步建议

6.1 用下来最值得注意的几个习惯

这套技术组合给了开发者很多便利,但也要求你改变一些旧习惯。第一是不需要再手动维护“接口文档”和“mock 数据”的同步,你可以把文档精力放在 Zod 的 schema 注释上,用类型本身作为规范。第二是尽量在服务端多写逻辑,不要什么事都在客户端用useEffect里拉数据,t3 架构里 server component 和 tRPC 的组合足以处理大多数数据获取场景。

另一个我特别想强调的是升级节奏。t3code核心依赖更新频繁,每次升级大版本前,先对比官方模板的新项目结构,看哪些配置变更了。直接升级依赖却忽略文件结构调整,很容易出现“看起来能跑但实际行为不对”的问题。我一般会给项目建一个小的“升级测试分支”,升级后跑一遍核心业务用例再合入主分支。

6.2 后续可以怎么扩展

如果t3code项目已经稳定运行,我会建议从几个方向扩展:给所有需要登录的procedure换上受保护的protectedProcedure,让认证逻辑在路由层统一生效;用zod定义全局通用的分页参数,做成可复用的输入 schema;把数据库读写中频繁出现的复杂查询提取到repository层,避免路由文件越来越臃肿。

就我个人经验而言,t3code最大的收获并不仅仅是省了配置时间,而是让我重新梳理了一套全栈项目的组织方式。之前我习惯把前端、后端当成两个独立项目分开维护,类型、校验、API 各写一套,心智负担很重。而在这个全栈一体化的架构里,代码即接口、类型即文档,整个开发流程变得顺畅很多。如果你正打算启动一个新的全栈应用,不妨先用t3code跑通一个带数据库的小需求,亲身体验一下“不写 API 层的开发节奏”,大概率你也会喜欢上这种模式。

RELATED READING

延伸阅读

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