t3code 这个名字,最初是同事丢给我的一套项目代号,折腾两周之后,我把它当成了自己新项目的默认起点。它不是什么惊天动地的框架,本质上是把 Next.js、TypeScript、Tailwind 这套组合按照“类型安全优先”的思路拧成了一个完整的工程模板。今天这篇就是想把我拆解 t3code 的过程、踩过的坑、以及最后落地成一套可复现方案的经验完整写下来,给正在纠结全栈技术选型、或者想从零起步做现代 Web 应用的朋友一个参考。这套东西到底适不适合你,看完你自己就能判断。
1. 先说清楚 t3code 是套什么思路的项目模板
1.1 T3 这个前缀到底指什么
T3 最早是 “The T3 Stack” 的简称,核心三件套是Next.js + TypeScript + Tailwind CSS。但大家真正把它当宝贝,不是因为这三个工具本身多稀奇,而是因为它们凑在一起能形成一条“类型安全从数据库一直贯穿到浏览器”的链路。后来社区把 tRPC、Prisma、NextAuth 这些也加了进去,变成一套更完整的全栈样板。
t3code 在我这里的定位,就是一套基于这套技术栈的工程化代码骨架。它不是库、不是插件,而是一个你可以直接复制、改名、写业务逻辑的起点。换句话说,脚手架帮你解决“项目怎么搭”的问题,t3code 要解决的是“搭完之后代码怎么组织才不别扭”的问题。
我见过太多人用 Next.js 开发,前端写一套类型定义、后端又写一套接口文档,两边经常对不上。t3code 的思路偏偏要改变这个局面:数据库的表结构、后端 API 参数、前端调用代码全部共享同一套类型推断。改了一个字段,编译的时候直接报错,不用等到联调才被对方喊“你接口写错了”。
1.2 它到底解决了哪些实际痛点
第一,前后端类型割裂。传统 REST API 模式下,后端定义一个User接口,前端再复制一份User类型,中间隔着一层网络协议,两边总有一个会过期。t3code 用 tRPC 后,前端直接从后端 router 里推导出类型,手写类型文件和 request 封装基本可以下岗。
第二,样板代码重复。每开一个新页面都要建文件夹、写路由、配权限、处理加载状态。t3code 把鉴权、数据库客户端、API 路由、UI 样式底子都预置好了,我拿到手就是一棵长好的树,只需要在自己的业务树枝上添叶子。
第三,新手不知道什么配置是必要的。以前看别人项目,里面堆了一堆不知道干嘛的依赖。t3code 的交互式初始化会问你:要不要 tRPC?要不要 Prisma?要不要 NextAuth?选完之后只生成你需要的部分,没有多余的一行配置。这一点对减少“看不懂项目”带来的挫败感非常关键。
第四,多人协作时的规范问题。团队里面每个人都有自己的代码习惯,有人用 CSS Modules,有人用 styled-components,有人直接用内联 style。模板把 Tailwind 定为原子类方案,再加 ESLint 和 Prettier 的默认规则,至少能让新代码风格统一到一个基准线上。
所以如果你是一个独立开发者、一个小团队,或者一个想快速验证产品原型的人,这套思路能让你把精力全放在业务上,而不是每周花半天调 webpack 配置。
2. 核心设计拆解:类型安全链路是怎么一步步闭环的
2.1 从 Prisma Schema 到数据库:类型的第一站
在 t3code 里,数据模型定义不是写在 SQL 文件里,而是写在schema.prisma。这个文件既是数据库迁移的依据,也是 TypeScript 类型的源头。定义一张表之后运行prisma generate,它就会根据 schema 生成完整的类型代码,包括查询条件、关联关系、返回类型、创建参数。
举个最简单的例子。假如我们定义一张文章表:
model Article { id String @id @default(cuid()) title String content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }运行时,Prisma Client 就能给出一整套严格的类型。传参少了一个字段、类型写错、把字符串传给 id 字段,编辑器直接标红。相比手写 SQL、用无类型的 ORM,这种“数据库即类型源头”的方式把错误拦截在写代码阶段,而不是运行时。
你可能会问:为什么不用 Mongoose 那种定义在代码里的方式?Prisma 的好处是数据库 migration 也由 schema 统一管理,生产环境的表结构和开发环境完全一致,不会出现“本地能跑,线上缺字段”的尴尬。
2.2 tRPC 层:让 API 参数和返回值也变成类型
常规 REST 接口需要手写 OpenAPI 文档、维护接口网关,前端拿到的永远是字符串 JSON,类型全靠人肉定义。tRPC 的做法反过来了:你写一个函数,告诉它输入什么、输出什么,前端直接从函数签名里拿到类型。
比如后端的文章查询:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const articleRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.article.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1), content: z.string(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.article.create({ data: input }); }), });你没有写任何额外的类型定义,但前端的api.article.create.useMutation()已经知道title是 string、content是 string,也知道返回对象里面有哪些字段。Zod 在这里承担的是运行时校验与 TypeScript 类型推导的双重职责,输入校验和类型定义合二为一。
我最早看 t3code 的代码时,对这种“凭空推断类型”的感觉很玄学,还专门去翻了 tRPC 源码。它本质上是在编译期通过 TypeScript 的高级类型推导把 router 的所有 procedure 类型提取出来,再通过泛型传给前端 hooks。你不需要理解深层原理,只要记住:后端写完,前端类型自动出现,不用去维护接口文档。
2.3 前端调用链路:数据从查询到渲染的全过程
一个标准的 t3code 请求流程大概是这样的:
- 前端组件调用
useQueryhooks,比如api.article.list.useQuery()。 - 请求通过 Next.js 的 API Route 转发到 tRPC 服务端。
- 服务端执行 procedure,调用 Prisma 查询数据库。
- 数据返回后,tRPC 序列化内容,前端对应 hooks 拿到带类型的数据。
- React Query 负责缓存、重新验证、错误重试。
这里有个容易被忽略的点:tRPC 和 React Query 是绑定的。t3code 初始化的时候会同时在服务端配置 tRPC 的响应处理,在客户端设置 QueryClient 和 TRPCProvider。如果你只装了 tRPC 却忘了配置 React Query 的缓存机制,每次切换页面都会重新拉数据,体验会非常差。
数据量小的时候,直接用useQuery就好。但当你发现某组数据在很多页面复用,就要考虑给 query 加staleTime或者用 Server Components 了。t3code 默认生成的trpc/react.tsx里面已经做了一层封装,你可以全局配置缓存时间,不用每个页面单独写。
2.4 Tailwind 和 UI 层:原子类如何降低样式维护成本
样式方案选了 Tailwind,理由是它和组件化的开发方式配合起来最省心。t3code 里你不会看到一堆.css文件,所有样式都写在 className 里。一开始可能会觉得乱,但用久了会发现:改样式不用跳到另一个文件,组件删了样式也跟着走了,不会留下死代码。
为了处理类名覆盖的问题,建议搭配clsx和tailwind-merge。t3code 模板里通常会在cn工具函数中整合这两个库:
import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }这个cn函数在写组件库的时候几乎是必需品。父组件传一个className进来,你既要保留组件的默认样式,又要允许外部覆盖,safeMerge就是干这个的。没有它,类名冲突会让你最后只会加!important,然后整个项目越改越脏。
3. 实操:用 t3code 从零搭一个可跑通的完整应用
3.1 环境准备:Node、包管理器和初始化命令
动手之前先把环境理清楚。Node 版本建议 18.17 以上或者直接 20 LTS,npm 7 以上也行,但我个人强烈推荐用 pnpm。原因是锁文件严格、安装速度快、依赖隔离做得干净。t3code 这类模板对 pnpm 支持最顺滑,用 npm 有时会遇到peerDependencies冲突,虽然能修,但没必要。
初始化模板,执行:
pnpm dlx create-t3-app@latest my-t3code-app命令跑起来以后,终端会问你要装哪些模块。我建议这样选:
- Next.js:App Router
- TypeScript:是
- Tailwind CSS:是
- tRPC:是
- Prisma:是
- NextAuth.js:如果你的应用需要登录,就选;纯内容展示可以先不选
- 偏好 ESLint / Prettier / 非 interactive 编译:默认即可
至于包管理器,模板会自动识别你执行命令时的工具并生成对应的锁文件。这一步看着简单,但有个坑:中途切换包管理器会导致 lock 文件混乱,比如比对了pnpm-lock.yaml和package-lock.json里面的版本号不一致。所以初始化之前先想好,团队统一用什么,选定了就别换。
3.2 配置数据库:SQLite 起步,迁移到 Postgres
我实际开发时喜欢先用 SQLite 跑通联调,再切生产数据库,t3code 默认的示例配置也是 SQLite。你只需要把.env里DATABASE_URL设为:
DATABASE_URL="file:./db.sqlite"然后执行:
npx prisma migrate dev --name init这个命令会完成三件事:创建db.sqlite、生成 Prisma Client、同步 TypeScript 类型。因为 t3code 的schema.prisma里已经预置了一个示例模型(通常是Post),你可以先跑一遍,确认页面能读到数据,再删掉示例模型换成自己的表。
等到要上生产了,SQLite 不适合并发场景,必须切换到 PostgreSQL 之类的服务型数据库。切的时候只需要把DATABASE_URL换成连接串,然后改一下 provider:
datasource db { provider = "postgresql" url = env("DATABASE_URL") }再执行一次prisma migrate deploy。这里要提醒一下:开发用migrate dev,它会自动重置数据;生产环境必须用migrate deploy,它只应用迁移文件,不会清数据库。这俩命令搞混了,损失的是真数据。
3.3 定义你自己的数据模型和第一个接口
我们做一个简单点的话题记录应用。先在schema.prisma里加上一个模型:
model Topic { id String @id @default(cuid()) title String note String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }注意我把note设成可选字段,这里意味着前端创建接口里可以不传它,但传了必须是字符串。Prisma 的可选性等同于 TypeScript 的?,它同样会影响 tRPC 的input类型。
同步迁移:
npx prisma migrate dev --name add_topic然后在src/server/api/routers下新建topic.ts:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const topicRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.topic.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1).max(100), note: z.string().max(500).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.topic.create({ data: { title: input.title, note: input.note, }, }); }), });再把topicRouter注册到src/server/api/root.ts里:
import { topicRouter } from "~/server/api/routers/topic"; export const appRouter = createTRPCRouter({ topic: topicRouter, });做完这两步,后端接口就定义完了。类型已经自动生成,前端不需要再看接口文档,直接往下走。
3.4 前端页面:一个表单加一个列表
打开src/app/page.tsx,把调用代码写进去。这里要留意,如果你用的是 Next.js App Router,页面本身可能是 Server Component,但 useQuery 是客户端 hooks,所以列表组件必须标记"use client"。我习惯把业务组件拆到src/components,页面只负责组合。
新建一个TopicList组件:
"use client"; import { useState } from "react"; import { api } from "~/trpc/react"; export function TopicList() { const [title, setTitle] = useState(""); const [note, setNote] = useState(""); const { data: topics, refetch } = api.topic.list.useQuery(); const createTopic = api.topic.create.useMutation({ onSuccess: () => { setTitle(""); setNote(""); refetch(); }, }); function handleSubmit(e: React.FormEvent) { e.preventDefault(); createTopic.mutate({ title, note }); } return ( <div className="mx-auto max-w-xl p-6"> <form onSubmit={handleSubmit} className="mb-8 space-y-3"> <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="标题" className="w-full rounded border px-3 py-2" /> <textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="备注(可选)" className="w-full rounded border px-3 py-2" /> <button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white" > 创建 </button> </form> <ul className="space-y-2"> {topics?.map((topic) => ( <li key={topic.id} className="rounded border p-4"> <h2 className="font-semibold">{topic.title}</h2> {topic.note && <p className="mt-1 text-sm text-gray-600">{topic.note}</p>} </li> ))} </ul> </div> ); }这里有个细节:useMutation成功后我调用的是refetch(),而不是直接操作topics数组。因为在 tRPC + React Query 的模式下,数据缓存是按 query key 管理的,最安全的做法是让 React Query 重新请求。你要是手动浅拷贝修改数据,很容易和缓存里的数据打架,造成页面刷新后状态不一致。试过一次就长记性了。
最后把TopicList放在page.tsx的默认导出里面,pnpm dev打开浏览器,一个完整的前后端联调应用就跑起来了。
4. 部署与项目结构:从开发机挪到生产环境的完整路线
4.1 理解 src/server、src/trpc 的目录边界
t3code 把服务端代码和客户端代码严格分开,这是它一个容易被忽视但很重要的设计。拿src/server目录来看,里面只有数据库实例、鉴权配置和 API 路由相关代码。这些代码绝对不会被打包到浏览器里面,所以你在里面写了数据库凭据、内部服务地址也不会漏给客户端。
src/trpc里面有server.ts和react.tsx两个文件。前者是服务端创建 tRPC 上下文的地方,后者是把 tRPC 客户端 hooks 绑到 React Query 上的入口。你可能觉得两个文件各自只写了几行,但拆开是有意的:服务端 imports 不要来到客户端 bundle,否则很容易把 Prisma 的二进制依赖带进前端,导致部署后客户端报错。
我刚接触时犯过一个错误:为了省事,直接在组件里import { db } from "~/server/db",结果 Next.js 报了一堆 “server-only” 的警告。后来养成一个习惯,组件里只用api对象,所有数据操作都走 tRPC,数据库只存在于 procedure 内部。
4.2 部署到 Vercel 的完整步骤
部署到 Vercel 是 t3code 最常见的路径,毕竟 Next.js 本身就是 Vercel 家的。配置流程如下:
- 推送代码到 Git 仓库,我习惯用 GitHub。
- 在 Vercel 里导入项目,框架它会自动识别成 Next.js。
- 环境变量必须把
.env里的DATABASE_URL、NEXTAUTH_URL、NEXTAUTH_SECRET等都填进 Vercel 的 Environment Variables。 - 构建命令保持默认,如果是 pnpm 项目,Vercel 会自动检测用 pnpm 安装。
有一点要特别注意:千万不要把本地的 SQLite 文件db.sqlite提交到 Git,更不要指望它在线上能用。Vercel 的文件系统是无状态的,每次部署都是全新环境,数据库必须用外部服务。我推荐直接上 PostgreSQL,可选的服务有 Neon、Supabase 或者 Vercel Postgres。选好后把新的连接串填到环境变量里,再执行一次prisma migrate deploy把表结构推过去。
我自己的习惯是生产部署前先在本地执行pnpm build,把所有类型错误过一遍再推仓库。Vercel 构建过程也会执行类型检查,但本地报错能定位得更快,省去推送触发构建再等一两分钟的时间。
4.3 环境变量管理与团队协作规范
t3code 默认给你生成了一个.env.example,这个文件必须保持更新,里面只放变量名不放真实值。真实.env要加进.gitignore。团队成员拿到项目后复制.env.example成.env,填上自己本地用的数据库连接串就能跑,不必知道生产的密钥。
对于多人协作,还有几个习惯值得从一开始就定下来:
- 提交前必须跑
pnpm lint和pnpm typecheck,别让警告堆积到发布前一晚。 - Prisma schema 改动必须附上一条 migration 记录,不要手动改数据库。
- 共享组件的
className统一走cn函数,避免样式覆盖问题。 - 包依赖升级走 PR 流程,重点留意 Next.js 和 tRPC 的 breaking change。
这些听起来像流程管理,但实际体验下来,它们比任何代码技巧都更能减少返工。
5. 常见问题与排查技巧实录
5.1 类型对不上:Property “xxx” does not exist
这大概是所有人用 t3code 最先遇到的报错。可能原因有三个:一是 Prisma Client 没更新,schema 改了但没跑prisma generate;二是 tRPC router 新增了 procedure 但前端类型还没重新生成;三是服务端和前端版本的 TypeScript 配置不一致。
排查顺序很简单:先跑npx prisma generate,再重启pnpm dev,九成问题能解决。剩下那一成,去看是哪个包的版本冲突,用pnpm why <包名>查依赖树,把重复的包去重。
5.2 tRPC 请求直接 404
页面能正常打开,但一调用 useQuery 就收到 404。这种多半是 Next.js 的 API Route 没配对。t3code 还原正常,但如果你自己手动调整过src/app/api/trpc/[trpc]/route.ts,就要确认文件名是不是[trpc]方括号形式,以及里面是否调用了fetchRequestHandler。
在 Next.js 15 里,context.params已经改成 Promise 形式,如果你把旧的同步写法粘到新项目里,也会导致请求处理异常。代码看起来没报错,但路由根本不匹配。遇到这种情况,直接对照官方示例重写 route.ts 最快。
5.3 Prisma migrate 失败:表已存在或字段冲突
开发阶段改 schema 时,如果之前跑过migrate dev,再改模型重新迁移可能提示冲突。这时候别急着删数据库,先看看有没有未应用的 migration:
npx prisma migrate status如果有 “not yet applied” 的记录,先执行migrate deploy再migrate dev。如果只是本地开发库,你也可以直接重置:
npx prisma migrate reset --force这个命令会清空本地数据库并重新应用所有迁移,适合开发环境。但生产环境千万别跑,线上数据丢了我可负责不起。生产环境出问题,正确做法是备份数据库、检查迁移记录、手动修复冲突。
5.4 Tailwind 类名不生效或样式闪一下消失
页面加载时能看到样式,但交互后某些类失效,大概率是类名拼接出问题。如果你的组件在条件渲染中动态拼接 className,比如active ? "bg-blue-500" : "bg-gray-200",Tailwind 扫描源码时会正确识别这些字符串。但如果你把类名放在变量里拼出来,像`bg-${color}-500`,Tailwind 的 content 扫描就找不到完整类名,生成出来是空样式。
解决办法是不要拼类名,写成完整的类名字符串,或者用配置好的静态映射表。另外检查tailwind.config.ts里的content是否包含了src/app、src/components这些目录。新加的页面路径没有被扫描到时,类名自然不生效。
5.5 数据反复请求,页面一直 loading
React Query 默认staleTime是 0,意味着每次组件挂载都会重新请求数据。这在本地跑还行,生产环境切换页面就会感觉一直在转圈。合理的做法是在trpc/react.tsx里给 QueryClient 设一个合理的默认值:
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30 * 1000, refetchOnWindowFocus: false, }, }, });设置 30 秒以上的staleTime,同一页面重复进入时就能直接读缓存,体感会快很多。如果你的数据对实时性要求高,可以在具体的 useQuery 里单独覆盖这个配置。
5.6 服务端组件拿不到 tRPC 的 hooks 数据
App Router 模式下,Server Component 不能直接调用api.post.list.useQuery()这类客户端 hooks。要么把组件标记为"use client",要么在服务端直接使用 tRPC 的 server caller 来查询数据,然后作为 props 传给子组件。t3code 模板里这两种方式都有现成用法,视你要不要交互而定。纯展示页用 server caller 更省请求,有交互状态就用 client hooks 配 React Query 缓存。
我在实际项目中经常混用:页面骨架用 server component 提前渲染,交互区域单独拆成 client component。一开始会花点时间想清楚哪些组件需要客户端状态,但架构会清爽很多,性能也更好。
6. 借 t3code 谈一点我的工程心得
如果你问我 t3code 最值得学的是什么,我的答案不是 Prisma 也不是 tRPC,而是它背后那种“让类型替你守住边界”的理念。整套模板的设计目标,就是让开发者在编译阶段发现大多数低级错误,而不是等到浏览器控制台里跳出莫名其妙的报错。
我自己现在写新项目,一定会先做三件事:定好数据模型、画清页面组件边界、约定变更流程。把这三件是想清楚,再用 t3code 把架子搭起来,后续写业务代码就成了一件很流水线的事。很多新手总想着一口气把技术栈全部学会,但更高效的做法是先跑通一个完整闭环,看到数据从数据库到页面的全链路,再去深挖其中某一个点的原理。
最后分享一个我踩过几次坑后的习惯:本地开发数据库一律用 SQLite,每改一次 schema 马上跑迁移,业务逻辑调通后再切到生产数据库。这个习惯让我避免了很多次把测试数据插进生产库的尴尬。以 t3code 为起点,你可以少走很多弯路,但前提是你得自己动手把流程跑几遍,踩几个坑,才能真正把这些工具变成你自己的东西。