第一次注意到 t3code 这个词,是我在某次刷推的时候看到别人发的项目截图,代码目录里全是src/server/api/routers、prisma/schema.prisma这样的结构。当时第一反应是:又是什么新脚手架?点进去仔细看了一圈才意识到,这其实是 T3(也就是 TypeScript 全栈技术栈)社区里常见的一套代码实践方式,融合了 Next.js、tRPC、Prisma、Tailwind 这些工具。你要是最近在了解全栈开发,大概率也刷到过这类项目。
t3code 解决的是我很久以前就烦透的问题:前后端联调时类型对不上、API 文档和实际接口不一致、一个字段改了要手动同步三四个地方。这套方案的思路很明确,把类型安全从数据库一路贯彻到 React 组件,该交代的类型系统自己交代,不该你操心的路由跳转也帮你省掉。这篇文章我就按照自己实际跑通一个项目的顺序,把 t3code 背后的选型逻辑、关键配置、踩坑记录和部署经验完整梳理一遍,适合想上手 Next.js + tRPC 全栈开发,又被各种概念绕晕的同学参考。
1. t3code到底是什么:先把它拆成看得懂的部分
很多人第一次听到 t3code 这个关键词,会和 T3 那款知名游戏混淆,或者是以为某种加密工具。其实在开发者社区里,t3code 更像一个代号,指代围绕 T3 Stack 展开的代码实践。T3 Stack 的核心是 TypeScript,而它的全家桶通常包含 Next.js、tRPC、Prisma、Tailwind,再加一个可选的 NextAuth。这几个名字单独拎出来你都认识,但组合在一起,整个项目的开发体验会发生质变。
1.1 我第一次接触t3code的场景
我接触 t3code 是在给团队做内部工具的时候。当时的情况是:管理后台需要快速迭代,前后端都要写,但公司现有项目用的是 Java 后端 + React 前端,一个简单的列表接口都要先写 Controller、Service、Mapper,再定义 TypeScript 类型,来回切上下文特别累。后来在 GitHub 上看到一个名为 create-t3-app 的模板,抱着试点的心态建了一个项目,结果一发不可收拾。
最直观的感受是,过去需要花大量时间维护"接口文档"这件事基本消失了。原因在于 tRPC 允许你在服务端定义一个函数,前端直接调用这个函数,而且 TypeScript 会自动把入参类型和返回类型推到组件里。我改动一个字段,如果前端某处用错了类型,编译期就能看出来,而不是等到运行时接口 500 才知道。这套体验在之前用 REST 的时候是想都不敢想的。
当时团队里有人问,这不就是 RPC 吗,和 GraphQL 有什么区别?其实区别挺大。GraphQL 需要定义 schema、写 resolver,前后端各有一套类型;tRPC 不需要额外的 schema,直接用 TypeScript 的类型推断完成全链路约束。对中小型项目来说,省掉的代码量非常可观,这也是 t3code 能在开源社区快速火起来的核心原因。
1.2 T3技术栈的核心组成与设计原则
T3Stack 的官方定位是"构建类型安全全栈应用的最简方式"。它不是一个新框架,而是优秀工具的整合方案。官方推荐的四件套各有分工:
Next.js 负责服务端渲染、路由和部署,同时兼顾前端页面的展示;tRPC 负责端到端类型安全的接口调用;Prisma 负责数据库映射,用 schema 文件定义数据模型后自动生成查询客户端;Tailwind 负责样式,在组件里直接使用原子类快速搭建 UI。这几者组合起来,覆盖了一个全栈应用从数据库到浏览器界面的完整链路。
我当时最受触动的是它的"渐进式"设计思想。create-t3-app 在初始化时允许你按需选择工具,不需要的东西可以直接不装。比如我的内部工具不需要用户登录,那就不选 NextAuth;需要用 SQLite 快速验证,那本地数据库就不接 Postgres。一切都是可选的,这种自由度对实际项目非常重要,毕竟不是每个业务都需要全套工具。
在选型背后还藏着一个原则:安全问题交给框架。T3 强调不要把 API 路由暴露得太多,tRPC 的 procedure 可以精确控制哪些接口需登录、哪些接口公开,比传统写路由守卫更细粒度。我后面编排业务时,把权限判断都放进了 tRPC 的中间件里,代码简洁,逻辑还清晰。
# create-t3-app 的最新版支持 pnpm,推荐优先使用 pnpm create t3-app@latest my-t3-project运行上面的命令后,会出现一组交互式选项,包括是否启用 Tailwind、tRPC、Prisma、NextAuth 等。整个过程大概一分钟,之后就是一个能直接启动的完整项目骨架。
2. 工具选型解析:为什么这套组合值得你抄作业
很多初次接触 t3code 的人都会疑惑:用 Express 写 API 不是更熟悉吗?用 Vite 搭前端不是更轻量吗?为什么偏偏是 Next.js + tRPC 这套组合?下面我逐个讲清楚选型背后的逻辑,以及我在对比其他方案时踩过的弯路。
2.1 Next.js与tRPC:框架和API层的匹配逻辑
先聊 Next.js。它的优势不仅仅是有服务端渲染,更关键的是它同时支持服务端组件和客户端组件,你可以在同一个项目里既写数据库查询,又写交互复杂的前端逻辑。相比传统的"Vite 前端 + Express 后端"双项目结构,用 Next.js 做全栈能省掉跨域、部署、环境变量同步这些杂事。
它也有劣势,以前我在只用 Next.js Pages Router 的时候,写 API Routes 仍要手动处理 HTTP 方法和参数解析。拿到req.body后还要做基本的数据校验,校验不过就返回 400,这一大堆模板代码让简单项目显得特别啰嗦。加入 tRPC 后,这些事被完全替代了。
tRPC 的核心模型是"远程程序调用",你可以把它理解为:前端 import 一个函数,然后在浏览器里直接调用它。我曾经拿同样的功能分别用 REST、GraphQL 和 tRPC 实现过,用一张表总结各自差异更直观:
| 对比维度 | REST API | GraphQL | tRPC |
|---|---|---|---|
| 类型安全 | 需要手动维护 DTO 类型 | schema 和 resolver 分开维护 | 由 TypeScript 自动推断 |
| 学习成本 | 低 | 中高,需要学 schema 语法 | 低,像写普通函数 |
| 调试成本 | 需要用 Swagger 等工具 | 需要 Playground 查询 | 直接看函数调用栈 |
| 适合场景 | 对接第三方系统 | 复杂聚合查询 | 全栈项目内部通信 |
从表格能看出,tRPC 不是来取代 REST 的。如果你的服务要开放给第三方系统用,REST 依然是最稳妥的标准。但如果接口只服务于自家前端,tRPC 的开发效率会明显高出一截。我在内部项目里就是这么处理的:对外暴露的接口保留 REST,内部页面调用全部走 tRPC,两边互不干扰。
2.2 Prisma与Tailwind:数据层与样式层的落地体验
数据层选 Prisma 而不选 TypeORM 或 Drizzle,是因为它的 schema 声明非常直观,几乎人人都能上手。model User { id String @id @default(cuid()) email String @unique }这样一行定义,配合prisma migrate dev自动生成建表语句,比手写 SQL 方便太多。它还自带一个可视化的 Studio 工具,运行npx prisma studio就能像看表格软件一样浏览数据库内容,排错时非常有用。
Tailwind 也用,但它并不是所有人都喜欢。团队中如果有成员习惯写传统 CSS 文件,可能会觉得 Tailwind 的类名堆在 HTML 里很难看。可一旦用熟了,你会发现它最大的价值是省去了"命名样式"这件事。不再纠结.sidebar-item-active该叫什么,直接在类名里写bg-gray-100、hover:bg-gray-200,完成。
T3 选择 Tailwind 的另一个原因是 SSG 和函数式组件适配好。Tailwind 的 CSS 输出可以按需打包,体积控制得不错,不像某些组件库会把整个样式包塞进首屏。配合 Next.js 的 App Router,构建出来的页面体积普遍很小,性能测试数据很好看。
2.3 create-t3-app的初始化选项逐个拆解
create-t3-app的交互选项很重要,很多新手在这里就纠结了很久。我的实践经验是把每个选项当做一个独立需求来判断,而不是盲目全选。
| 选项 | 是否推荐 | 我的判断依据 |
|---|---|---|
| TypeScript | 必须 | t3code 的灵魂就是类型安全 |
| Tailwind CSS | 推荐 | 省样式心智负担,后续也能移除 |
| tRPC | 推荐 | 这是 T3 的核心优势 |
| Prisma | 推荐 | 数据库层类型安全的基础 |
| NextAuth | 按需 | 需要登录体系再选 |
| Docker | 按需 | 只在要容器化部署时选 |
| CI | 按需 | 需要持续集成再选 |
我见过不少同学不管三七二十一把所有选项全开,结果项目体积大、概念杂,学了半天还是在处理脚手架里自带的各种示例代码。比如我做的第一个 t3code 测试项目选项全开,光调试 NextAuth 的 session 回调和 Prisma 的 adapter 表就花了一天。所以建议按最小可用集开始:TypeScript、Tailwind、tRPC、Prisma 四件套足够,把核心链路跑通再逐步添加其他功能。
采用"最小可用集"还有一个好处:排查问题时你能明确知道问题是出在 tRPC、Prisma 还是 Next.js,而不是被一堆集成模块的交互干扰。我后来给团队建的模板就一直保持这种精简风格,新增需求时再通过npx shadcn@latest add、npx prisma db pull等方式按需扩展。
3. 核心实操:从零到一跑通一个t3code全栈项目
这一节我会带你完整过一遍实操流程。为了让你能照着复现,我设计的示例是一个简单的"团队任务便签":数据库里存任务,页面上能展示列表、新增任务、标记完成。麻雀虽小,但端到端类型安全的整个链路都会涉到。
3.1 初始化项目并理解目录结构
先用 pnpm 初始化项目。我这里说的 pnpm 是 Node.js 生态的包管理器之一,相比 npm 的磁盘占用更小、安装更快,create-t3-app 对它支持也最好。命令执行完后进入项目目录,你会看到这样一套结构:
my-t3-project/ ├── prisma/ │ └── schema.prisma # 数据库模型定义 ├── src/ │ ├── app/ # Next.js 页面路由 │ │ ├── api/trpc/ # tRPC HTTP 服务入口 │ │ └── layout.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ # tRPC 路由文件 │ │ │ ├── root.ts # 根路由聚合 │ │ │ └── trpc.ts # tRPC 初始化与中间件 │ │ └── db.ts # Prisma 客户端实例 │ ├── trpc/ │ │ ├── react.tsx # 前端 Hooks 封装 │ │ └── server.ts # 服务端类型引用 │ └── env.js # 环境变量校验 ├── .env # 本地环境变量 └── package.json这里面的设计逻辑非常清晰:一切可被服务端引用的代码放在server目录下;一切仅用于客户端组件引用的 tRPC Hooks 放在trpc目录下。之所以要区分,是因为 Next.js 的打包模型里,服务端代码一旦被客户端组件引用,就可能把数据库连接等敏感逻辑带到浏览器端,造成安全隐患。所以你在 import 文件时要注意后缀,比如import { api } from "~/trpc/react"是给客户端用的,import { createTRPCRouter } from "~/server/api/trpc"只能写在服务端。
初始化完项目后,先做一件事:把脚手架自带的helloRouter简化一下,避免它还带着演示用的post路由和样例数据,干扰你的理解。保留一个根路由,哪怕是空的,也能让项目跑起来时明明白白。
3.2 用Prisma建表:三步完成数据库初始化
以任务便签为例,我们先定义任务模型。打开prisma/schema.prisma,加入下面这段:
model Task { id String @id @default(cuid()) title String done Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }写完后执行两条命令。第一条是npx prisma migrate dev --name init,它会生成 SQL 迁移文件并把表建到数据库里;第二条是npx prisma generate,它会根据 schema 生成类型安全的客户端代码。如果本地没有安装数据库,create-t3-app 的默认配置会使用 SQLite,数据库文件直接落在prisma/dev.db,对本地开发非常友好。等真要上生产,再切换成 Postgres 数据库也只需要修改一行DATABASE_URL。
有人觉得 Prisma 多此一举,直接写 SQL 不就好了?但实际开发中,表结构经常变动:增加一个字段、修改一个命名、调整索引,手写迁移脚本非常容易遗漏。Prisma 的 migrate 命令会把每次变动记录到prisma/migrations目录,团队成员拉取代码后执行prisma migrate deploy就能同步到最新结构。这个流程比手工维护 SQL 安全得多。
3.3 用tRPC写第一个带类型安全的后端接口
建完表,接着写 tRPC 的接口。所有的路由都放在src/server/api/routers下。我新建一个taskRouter.ts,代码如下:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const taskRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.task.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1, "任务标题不能为空"), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title }, }); }), toggle: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const task = await ctx.db.task.findUnique({ where: { id: input.id }, }); if (!task) { throw new Error("任务不存在"); } return ctx.db.task.update({ where: { id: input.id }, data: { done: !task.done }, }); }), });这段代码里的几个关键词必须说透。publicProcedure表示这个接口无需登录即可调用,如果你启用了 NextAuth,可以换成protectedProcedure,未登录请求会被自动拦截。input字段用 Zod 做运行时校验,小小的title校验就能把非法请求挡在业务逻辑之外。ctx.db是经过 tRPC 上下文注入的 Prisma 实例,在服务端可以安全访问数据库。
写完业务路由后,还要把它挂到根路由上,打开src/server/api/root.ts:
import { createTRPCRouter } from "~/server/api/trpc"; import { taskRouter } from "~/server/api/routers/task"; export const appRouter = createTRPCRouter({ task: taskRouter, }); export type AppRouter = typeof appRouter;这一步千万不要省,因为前端引用的所有类型都是从这个AppRouter推断出来的。你没挂路由,编辑器里就找不到对应的方法。
3.4 在前端组件里调用接口,完成端到端闭环
tRPC 最爽的部分来了。在页面组件中,你不需要手写 fetch,也不需要拼接 URL,直接通过自动生成的 hooks 调用即可。下面是一个简单的客户端组件,负责渲染任务列表并新增任务:
"use client"; import { useState } from "react"; import { api } from "~/trpc/react"; export function TaskBoard() { const [title, setTitle] = useState(""); const utils = api.useUtils(); const { data: tasks, isLoading } = api.task.list.useQuery(); const createTask = api.task.create.useMutation({ onSuccess: async () => { await utils.task.list.invalidate(); setTitle(""); }, }); const toggleTask = api.task.toggle.useMutation({ onSuccess: () => utils.task.list.invalidate(), }); if (isLoading) return <div>加载中...</div>; return ( <div className="p-6 max-w-md mx-auto"> <h1 className="text-2xl font-bold mb-4">团队任务</h1> <form onSubmit={(e) => { e.preventDefault(); createTask.mutate({ title }); }} className="flex gap-2 mb-4" > <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="输入任务标题" className="border rounded px-3 py-2 flex-1" /> <button type="submit" className="bg-blue-600 text-white rounded px-4 py-2" > 新增 </button> </form> <ul className="space-y-2"> {tasks?.map((task) => ( <li key={task.id} className="border rounded p-3 flex items-center gap-2"> <input type="checkbox" checked={task.done} onChange={() => toggleTask.mutate({ id: task.id })} /> <span className={task.done ? "line-through text-gray-400" : ""}> {task.title} </span> </li> ))} </ul> </div> ); }这里值得关注的是invalidate机制。执行完 mutation 之后,通过utils.task.list.invalidate()让 tRPC 自动重新拉取任务列表,前端 UI 立即刷新,完全不用手动管理状态。对于中小型应用来说,这个机制比引入全局状态管理器要省心得多。
如果你用的是 curl 测试 endpoints 的习惯,在 t3code 里可能要改一下了。tRPC 的接口入口统一在/api/trpc/*,但它只接受特定序列化的 POST 请求,手动 curl 主要是用来验证服务是否在线。真正的前端调用还是会走 tRPC 封装好的 hooks,这也是官方推荐的方式。
4. 部署与排查:t3code项目上线前必看的避坑清单
代码写的顺风顺水,不代表部署也能顺畅。我在这里整理了自己和周边朋友踩过的几个高频坑,按严重程度排序。每一个都有对应的排查思路和解决办法,建议收藏起来对照使用。
4.1 环境变量配置的细节,比想象中更敏感
t3code 项目里最坑的是环境变量。create-t3-app 会生成一个.env.example文件,你需要手动复制为.env并填入DATABASE_URL。很多人复制完就忽略了 Next.js 环境变量前缀的规则:只有以NEXT_PUBLIC_开头的变量才会暴露给浏览器端,其余变量只在服务端可用。比如在 tRPC 客户端组件里读取NEXT_PUBLIC_API_URL是可行的,但读取DATABASE_URL是读取不到的,这其实是安全设计而非 bug。
我在团队里见过一个真实事故:某位同事把生产数据库的连接字符串直接写在了前端代码里,结果部署后数据库被扫爆。后来我在项目里引入src/env.js,用 zod 对所有环境变量做启动时校验。缺少变量或格式错误,项目在next build阶段就会失败,而不是等你线上运行时才发现。
上生产时数据库连接也要特别处理。本地默认是 SQLite,部署到 Vercel 这类 serverless 平台时文件系统是只读的,必须切换到 Postgres,推荐使用 Neon 或 Supabase 这类兼容 serverless 的数据库服务。连接字符串尽量加上连接池参数,如?pgbouncer=true,否则并发连接过多容易触达数据库上限。我用 Vercel Postgres 部署过一个测试项目,冷启动时 Prisma 首次连接耗时从原来的 2 秒降到了 400 毫秒左右。
4.2 常见报错与解决方案速查表
下面是 t3code 项目开发中我遇到最多的几类报错。整理成速查表方便快速定位:
| 报错现象 | 可能原因 | 解决方案 |
|---|---|---|
PrismaClientInitializationError | DATABASE_URL 缺失或格式错误 | 检查.env,确认 use 了正确的连接字符串 |
Cannot find module '~/server/db' | 路径别名配置不一致 | 检查tsconfig.json的paths配置 |
You cannot use tRPC client outside of server | 把服务端代码 import 进了客户端组件 | 改用~/trpc/react导入 hooks |
Hydration failed | 服务端与客户端渲染结果不一致 | 避免在组件里直接用new Date()等随机值,确认加"use client" |
Schema file not found | 数据库连接存储位置异常 | 运行npx prisma generate重新生成客户端代码 |
next build时 ESLint 报错 | 变量未使用或类型不匹配 | 检查代码后重新运行 build,必要时调整 lint 规则 |
Task table does not exist | 数据库迁移未执行 | 运行npx prisma migrate deploy |
请求返回 401 | 接口未登录被拦截 | 确认 procedure 是publicProcedure还是protectedProcedure,检查 Session |
这些报错的共性是:八成出在配置和边界情况,而非框架本身。所以排查时先看环境变量、再看路径别名、最后看数据库状态。按这个顺序走,大部分问题都能快速解决。
4.3 部署到服务器的四个注意点
如果你打算部署到一台云服务器,而非直接用 PaaS 平台,有几个注意点。第一,别忘了执行数据库迁移。很多同学git push完代码就去启动服务,结果服务因为找不到表而报错。正确顺序是:拉代码、安装依赖、执行npx prisma migrate deploy、执行npx prisma generate、构建、启动服务。
第二,用生产构建方式启动。开发环境的next dev千万不能直接用来跑生产流量,要用next build加next start。如果你用 Docker 部署,记得在 Dockerfile 中把 prisma schema 复制进去,并在构建阶段执行prisma generate,否则容器内会找不到 Prisma 客户端。
第三,反向代理的配置。t3code 项目默认监听 3000 端口,通过 Nginx 转发时要把 WebSocket 相关的升级头配置好,否则部分功能可能异常。当然,如果只是普通页面交互,常规 HTTP 转发就够了。
第四,CDN 相关的资源缓存不要开太激进。Next.js 的静态资源路径通常带哈希值,适合长效缓存;但页面 HTML 需要动态生成,设置过长的Cache-Control会导致用户看到旧版本。我的习惯是 HTML 缓存 60 秒,静态资源缓存一年。
4.4 我踩过的三个真实教训
第一,在客户端组件里 import Prisma。当时为了图省事,直接在"use client"组件里查询数据库,Next.js 构建时报了一堆安全错误。后来才明白,客户端组件打包后运行在浏览器里,你 import 的 Prisma 客户端根本没有权限访问数据库。正确的姿势是通过 tRPC procedure 封装数据库访问,前端永远只调用 hooks。
第二,忽略了 TypeScript 严格模式下的空值检查。tasks?.map里的可选链是因为data可能是undefined,很多新手朋友第一次写 tRPC 查询时总是漏掉这个细节,结果渲染时直接报Cannot read properties of undefined。建议把所有查询结果都当成可能为空来对待,UI 层面统一做加载态和空态处理。
第三,用错 caches 导致invalidate失效。有些场景下我只调用了utils.task.list.invalidate(),但没有等它 promise 完成就跳转了页面,导致新数据还没回来页面就切走了,回来时仍是旧数据。后来统一在onSuccess里 async/await 处理,再加一层乐观更新,体验才稳定下来。
5. 最后的实践心得
做 t3code 项目学到最重要的一件事:不要被工具数量吓倒。很多人在初步接触时看到那么多依赖,以为学习曲线很陡,真正跑起来才发现它只是在帮你省去那些重复劳动。我后来给团队培训的时候,习惯用一个极端示例来演示:整个后端只写一个createTRPCRouter,前端一行api.task.list.useQuery(),再加上 Prisma 里的一行model,一个带数据库的全栈功能就完成了。这在传统开发模式下至少要写三到五个文件。
我个人现在会把 create-t3-app 当作全栈项目启动的默认底座,但不是所有项目都适合。如果业务逻辑极其复杂,需要大量异步任务队列和微服务拆分,那 T3 这种单体式设计反而不够灵活;如果只是做内部工具、MVP 验证、小团队协作应用,它的收益是最大的。判断标准很简单:你更想把精力花在业务逻辑上,还是花在前后端接口对接上?如果答案是前者,那 t3code 值得你花一个周末认真玩一遍。
如果你决定上手,建议先从替换脚手架自带示例代码开始,把 hello world 改成自己的一个小功能,然后运行next build验证类型和生产构建是否通过。这套流程走完,你就真正理解 t3code 这套组合是怎么把类型安全变成日常开发的一部分了。