t3code 是我这半个月一直在折腾的一个全栈小项目代号。一开始看到 T3 这个词,大多数人可能会以为是什么新出的框架或者某个神秘组织的名字,实际上它就是 T3 Stack 的延伸:TypeScript、Tailwind、tRPC 三件套打底,再加上 Next.js 做应用框架、Prisma 管数据库,最终拼出了一个从数据库到浏览器全程类型安全的应用脚手架。我给它取名叫 t3code,纯粹是因为它结构上有点像“代码的三层骨架”。
这个东西能干什么,说白了就是解决一个很实际的痛点:以前做一个全栈应用,后端写接口、前端调接口,两边各有一套类型定义,接口字段一改,前端报错又得对着文档改半天,别提多难受。t3code 的做法是让“类型”从数据库表一路贯穿到 React 组件里,你改了 Prisma Schema,运行 generate 之后,前端调用处马上就能收到类型提示,对不上就编译不过。它适合两类人:一类是像我这样想用一套代码快速搭出带登录、数据库、增删改查完整业务的小团队或个人开发者;另一类是刚学完 React 和 Node.js、想进入全栈但又不想维护两套工程的初学者。这篇就当成一次踩坑记录来写,把我在 t3code 里从零搭到上线的整个过程、关键决策和一些翻车经验一并整理出来。
1. 先把思路理顺:t3code 到底在解决什么问题
1.1 名字与原始动机:T3 的三层含义
t3code 这个名字,追根溯源来自 T3 Stack。第一次听说“T3”的时候,我以为是某种缩写考核,后来查了才知道它背后有三层意思:Tech(技术选型)、Tool(配套工具)、Team(团队协作)。从个人体会来说,这套理念最核心的就是一条——凡是要跨边界传递数据的地方,都要让类型“锁死”。
为什么会萌生做 t3code 的念头?因为我之前做过一个内部管理系统,前端 React 里定义了一套User接口,后端 TypeScript 里又写了一份几乎一样的User类型,两边看似对应,实际已经悄悄分叉了:后端多了一个role字段,前端忘了补,结果线上按钮显示逻辑出了 bug,查了半天才发现是类型定义对不上。这种问题在传统前后端分离项目里太常见了,本质是“数据契约”得靠人肉同步,人一多就会漂移。t3code 的解法就是把契约交给工具:tRPC 的 procedure 定义在哪,调用方的类型就从哪自动推出来,不存在第二份需要维护的副本。
第三层“Team”也很好理解。团队协作里最浪费时间的不是写代码,而是来回对齐接口文档。t3code 这种端到端类型安全的项目,接口签名直接暴露给调用方,前端同事打开编辑器就能看到输入输出类型,少了无数“这个字段到底是 string 还是 nullable”的低效沟通。
1.2 技术选型背后的取舍:tRPC 比 REST 好在哪里
t3code 整个技术栈,我选的是 Next.js 14(App Router)、TypeScript(strict 模式)、Tailwind CSS、tRPC、Prisma、NextAuth。这套组合在社区里有个叫法叫“T3 Stack”,但它不是官方框架,更像一组经过验证的默认配置。
当时我纠结过一个问题:要不要用 REST 或者 GraphQL。REST 的好处是通用、生态大,任何客户端都能调,但它要求你把“资源”和“方法”提前设计好,而且类型定义往往要自己另写。GraphQL 的好处是客户端按需取字段,坏处是 schema 和 resolver 有点复杂,小项目切进去容易杀鸡用牛刀。tRPC 走的完全是另一条路:本质上它把后端函数当作可远程调用的对象,客户端直接import { api }然后调用api.link.create,类型顺着函数签名走。
举个例子,我在 t3code 里写了一个查询当前登录用户的 procedure:
getMe: protectedProcedure.query(async ({ ctx }) => { if (!ctx.session?.user) { throw new Error("请先登录"); } return ctx.db.user.findUnique({ where: { id: ctx.session.user.id }, }); });前端组件里调用的时候,TypeScript 会立刻告诉我返回类型里有什么字段,写错了 IDE 直接红线。这在 REST 模式下几乎不可能这么优雅。当然代价也明显:tRPC 适合前后端都是 TypeScript 的场景,如果你将来要开放第三方 API,那还是得另做一层 REST 网关。t3code 定位是内部项目,所以这个取舍完全划算。
关于替代方案,我做了一张表方便对比:
| 技术点 | t3code 的选择 | 典型替代方案 | 为什么这么选 |
|---|---|---|---|
| 应用框架 | Next.js | Vite + Express | 服务端渲染、路由、API 层一体化 |
| 语言 | TypeScript strict | JavaScript | 类型安全贯穿全链路 |
| 数据查询 | tRPC | REST、GraphQL | 无需维护接口文档和类型副本 |
| ORM | Prisma | TypeORM、Drizzle | Schema 直观,迁移工具好用 |
| 样式 | Tailwind CSS | CSS Modules、Styled Components | 开发效率高,生产包体积可控 |
| 认证 | NextAuth | 自建 Session / JWT | 开箱即用,支持多 provider |
1.3 目录结构与数据流设计
我习惯把 t3code 的目录拆成“内外两层”。内层是src/server,专门放数据库访问、tRPC router、认证相关逻辑,这些都是服务端代码,绝不能出现在客户端 bundle 里。外层是src/app和src/components,负责页面和 UI。这层物理隔离非常重要,不然很容易手滑把数据库密码打包进前端。
核心目录大概长这样:
src/ app/ api/auth/[...nextauth]/route.ts links/ page.tsx layout.tsx components/ LinkCard.tsx LinkForm.tsx server/ api/ routers/ link.ts user.ts root.ts trpc.ts db/ schema.prisma client.ts auth.ts trpc/ server.ts client.ts react.tsx数据流是单向的:React 组件发起调用,tRPC 路由收到请求后先做鉴权和输入校验,再交给 Prisma 查数据库,返回的数据又带着类型回到组件。整个过程没有手工拼接 URL,也没有 JSON 序列化的心智负担,调试时只要盯住 tRPC 的 middleware 和 procedure 返回值就行。
2. 动手前的核心准备:初始化与数据模型
2.1 用脚手架快速起步,别从零搭轮子
搭建 t3code 的第一步,我直接用了create-t3-app。为什么不从 Next.js 空项目开始?因为 T3 栈里牵扯到十几个包的版本兼容(NextAuth 的app目录适配、Prisma 的 client 生成、tRPC 的 React Query 封装),手动搭一遍至少耽误半天,而且很容易配错。
初始化命令很简单:
pnpm create t3-app@latest交互式勾选的时候,我建议把 Prisma、NextAuth、Tailwind、tRPC 全选上。后面会有两个小问题:一个是包管理器,我选的 pnpm,另一个是src目录,我选了“是”。pnpm 的优势在于依赖安装快、磁盘占用小,还能强制避免幽灵依赖,这对多包项目来说太重要了。
完成后项目会自带一套最小可跑的示例代码:首页、受保护页面、数据库示例(Example 模型)。我第一时间不是去删示例,而是先跑一遍pnpm db:push把数据库结构推上去,再pnpm dev确认整个链路通顺,然后再开始魔改。
2.2 数据模型怎么设计才不返工
t3code 的业务我定为“链接收藏夹”:用户可以登录,收藏链接,添加标签,后续还能编辑描述。之所以选这个业务,是因为它覆盖了全栈开发的常见场景:多对多关系、鉴权操作、列表查询、输入校验。
Prisma Schema 里我最关心三张表:User、Link、Tag。核心关系是用户和链接是一对多(一个用户有很多链接),链接和标签是多对多(一个链接多个标签,一个标签可以被多个链接使用)。多对多如果不用隐式关系表,直接写中间表更灵活,但这里没额外字段,所以我用了 Prisma 的隐式多对多写法:
model User { id String @id @default(cuid()) name String? email String? @unique links Link[] accounts Account[] sessions Session[] } model Link { id String @id @default(cuid()) url String title String description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id]) tags Tag[] } model Tag { id String @id @default(cuid()) name String links Link[] }设计时有几个点一定要想清楚。首先是索引:列表页必然按createdAt排序,所以我在Link表加了@@index([userId, createdAt]),这样按用户查最新链接时能走联合索引,数据量大之后不会全表扫。其次是url字段长度:普通 URL 可能超过 255 个字符,默认的String对应数据库的TEXT,就没必要再限制长度。第三是软删除:收藏夹里我暂时不做,就用deletedAt字段预留,后续如果要恢复记录,不至于改表。
2.3 环境变量与基础配置的那些坑
初始化完成后,第一件事就是把.env里的变量填好。create-t3-app会生成一个.env.example,我照着抄到.env,然后把本地的 PostgreSQL 连接串填进去:
DATABASE_URL="postgresql://postgres:password@localhost:5432/t3code" NEXTAUTH_SECRET="这里填足够长的随机字符串" NEXTAUTH_URL="http://localhost:3000"NEXTAUTH_SECRET这个变量容易踩坑。它必须是一个足够随机的长字符串,不能顺手填个secret。因为 NextAuth 要用它给会话 cookie 签名字段,如果太短或者太规律,线上可能出现频繁掉登录的情况。生成随机串最简单的方式是:
openssl rand -base64 32另外要注意:DATABASE_URL在 Prisma 的migrate命令下需要直接可连,但部署后如果数据库有连接池地址,可能会是另一个变量(比如DIRECT_URL)。尤其在 Vercel 上跑 Serverless 函数时,并发高会导致数据库连接过多,正确做法是DATABASE_URL指向连接池地址,DIRECT_URL指向直连地址,并在schema.prisma里给datasource加directUrl = env("DIRECT_URL")。这个配置我放到了部署那一章再说。
3. 从数据库到界面的全链路实现
3.1 第一个 tRPC Procedure:创建一条收藏链接
t3code 核心功能是“收藏链接”。我首先在src/server/api/routers/link.ts里定义了一个createprocedure。tRPC 支持输入校验用 zod,一个很妙的地方是:输入和输出的类型完全由代码推导,你不用为了 API 文档额外写类型定义。
代码看起来是这样:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const linkRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ url: z.string().url(), title: z.string().min(1).max(200), description: z.string().max(500).optional(), tagNames: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; const link = await ctx.db.link.create({ data: { url: input.url, title: input.title, description: input.description, userId, tags: { connectOrCreate: input.tagNames.map((name) => ({ where: { name }, create: { name }, })), }, }, }); return link; }), });这段里最核心的是protectedProcedure和zod的配合。protectedProcedure是 tRPC 的中间件封装,会先判断ctx.session是否存在,不存在就直接抛未授权错误,这样业务函数里不用重复写登录判断。zod的校验发生在 procedure 真正执行之前,URL 格式不对、标题为空、标签数量超限,都会被拦在外层,不会打到数据库。
这里要注意tagNames的处理。我用了connectOrCreate,意思是标签名如果不存在就创建,存在就连接,避免重复标签。但connectOrCreate要求Tag表里的name字段必须有唯一约束,否则 Prisma 会报错。所以在 Schema 里我给Tag.name加了@unique。
3.2 前端接入:React Query 的自动类型与缓存
页面端调用 tRPC 的办法,在 T3 栈里是配合 TanStack Query(React Query)使用的。create-t3-app生成的src/trpc/react.tsx里已经封装好了一个带类型上下文的api对象。
新增链表的表单组件,核心逻辑是这样的:
"use client"; import { api } from "~/trpc/react"; export function LinkForm({ userId }: { userId: string }) { const utils = api.useUtils(); const createLink = api.link.create.useMutation({ onSuccess: () => { utils.link.list.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createLink.mutate({ url: formData.get("url") as string, title: formData.get("title") as string, description: (formData.get("description") as string) || undefined, tagNames: [], }); }} > <input name="url" type="url" placeholder="https://..." required /> <input name="title" type="text" placeholder="标题" required /> <button type="submit">收藏</button> </form> ); }第一次看到api.link.create.useMutation()这种写法的人可能会觉得有点“魔法”,其实原理很简单:tRPC 在构建时把linkRouter里的 procedure 名称映射成了 React Hook,同时把输入输出类型注入进去。你写错字段名或类型,IDE 会立刻提示。onSuccess里调utils.link.list.invalidate()是为了刷新列表缓存,这样创建完新链接后列表页自动变新,不用手动刷新页面。
这里有个必须留心的细节:这个组件用了"use client"指令。tRPC 调用涉及 React Query 的 restful 缓存机制和状态管理,天然是客户端行为,不能在服务端组件里直接用useMutation,否则会报hooks can only be called inside the body of a function component的错。至于服务端取数,需要用另外的方式,下面小节会说。
3.3 服务端组件里取数:别被 API 请求拖慢首屏
t3code 的列表页,我没有走“客户端组件 + useEffect 请求接口”的老路,而是用了 Next.js App Router 的服务端组件能力。这意味着列表数据在服务端就读取完成,直接渲染成 HTML 返回,首屏特别快,还能避免闪烁。
在app/links/page.tsx里,我直接使用 tRPC 的 caller 在服务端调用 router:
import { api } from "~/trpc/server"; import { LinkCard } from "~/components/LinkCard"; export default async function LinksPage() { const links = await api.link.list.query({ limit: 20 }); return ( <main> <h1>我的收藏</h1> <div className="grid gap-4"> {links.map((link) => ( <LinkCard key={link.id} link={link} /> ))} </div> </main> ); }这里api.link.list.query是在服务端环境中直接执行的函数调用,而不是 HTTP 请求。它的好处有两个:第一,少一次网络往返,页面响应时间可以缩短几十到几百毫秒;第二,服务端直接访问数据库,不需要额外的接口鉴权逻辑,因为protectedProcedure本身已经在服务端检查了 session。
但要注意:服务端组件里调用 tRPC 时,别忘了src/trpc/server.ts里的缓存设置。默认 tRPC 在 RSC 场景下不缓存,每次渲染页面都会查一次数据库。对于列表页来说这可以接受,但如果是数据完全静态的页面,可以包装一层 React 的cache或者直接改成静态渲染,避免不必要的数据库开销。
4. 部署上线与性能调优
4.1 数据库连接:本地开发与生产环境的分水岭
t3code 开发时连本地 PostgreSQL 很开心,一到部署就发现数据库连接成了第一道坎。Vercel 的 Serverless 函数每来一个请求就启动一个实例,如果用直连地址,几十个并发一过来,数据库的连接数瞬间被打爆。
解决办法是在生产环境的DATABASE_URL上使用连接池。常见做法是用 Neon 或 Supabase 这类 Postgres 托管服务,它们都提供连接池地址。配好之后,schema.prisma里的 datasource 要改一下:
datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") }这样 Prisma 在跑迁移命令时用DIRECT_URL直连数据库,应用运行时用DATABASE_URL走连接池,两边互不干扰。这个坑我一开始没注意到,结果部署成功后的第二天,数据库服务商就给我发告警邮件说连接数超标。
4.2 部署到 Vercel 的注意事项
t3code 的部署目标我选了 Vercel,因为 Next.js 本身就是它家的,天然契合。部署流程很简单:代码推到 GitHub,导入项目,配置环境变量,点击部署。真正需要小心的是环境变量列表,我整理了一个清单:
| 变量 | 本地值 | 生产值 | 说明 |
|---|---|---|---|
| DATABASE_URL | 本地 Postgres | 连接池地址 | 生产时用池化连接 |
| DIRECT_URL | 本地 Postgres | 直连地址 | 迁移时使用 |
| NEXTAUTH_SECRET | 随机串 | 另一个随机串 | 生产环境绝不能用本地同一串 |
| NEXTAUTH_URL | http://localhost:3000 | https://你的域名 | 影响 OAuth 回调地址 |
还有很重要的一步:部署完要跑一次数据库迁移。Vercel 本身不提供数据库迁移任务,我是在本地执行pnpm prisma migrate deploy指向生产库,或者在 Vercel 的构建命令里加上prisma migrate deploy && prisma generate。不加的话,数据库表结构还是本地那个,线上会直接 500 错误。
4.3 优化细节:图片、字体与构建产物
T3 栈默认的 Tailwind CSS 在生产构建时会自动清除没有用到的样式类,所以不用担心样式表膨胀。真正要注意的是 Next.js 的图片组件和字体加载。
t3code 的收藏链接里经常带 og:image,我用了next/image来托管这些图片,但next/image默认会向 Vercel 的图片优化服务发请求。如果图片来自外部域名,必须在next.config.js里配置remotePatterns,否则会出现本地能显示、线上全是破图的情况。
字体方面,我建议用next/font加载字体,它会自动做子集化和 preload,能有效减少 CLS(布局偏移)。next/font的使用很简单:
import { Inter } from "next/font/google"; const inter = Inter({ subsets: ["latin"] });然后把inter.className加在html标签上即可。这个优化几乎无成本,但对 Lighthouse 分数的提升很明显。
5. 问题排查与踩坑记录
5.1 常见问题速查表
再顺的项目也会遇到问题,t3code 在开发和部署过程中,我遇到了不少典型的坑,整理成下表:
| 问题 | 常见原因 | 解决方法 |
|---|---|---|
| tRPC 调用返回 404 | router 没挂载到根 router | 在root.ts里加link: linkRouter |
Prisma 报Type 'undefined' is not assignable | DATABASE_URL没加载 | 确认.env存在,重启 dev server |
| NextAuth 登录后回调地址不对 | NEXTAUTH_URL没设生产值 | 在 Vercel 设置NEXTAUTH_URL=https://域名 |
| 样式全部丢失 | Tailwind 没扫描到文件 | 检查tailwind.config.ts的 content 路径 |
服务端组件里不能用useMutation | 依赖 React hooks,属于客户端代码 | 加"use client"或改用 caller 查询 |
connectOrCreate报唯一约束错误 | 表里没有唯一索引 | Schema 给字段加@unique,重新 migrate |
| 构建成功但首页 500 | 迁移没执行 | 构建前执行prisma migrate deploy |
next/image外链图片全部加载失败 | 没配置remotePatterns | 在 next.config.js 加白名单 |
这张表看着数量多,但绝大多数问题都有一个共同根源:类型系统或环境配置不一致。先检查环境变量,再检查类型报错,基本能解决七成问题。
5.2 一次线上问题的排查实录
印象最深的是一次线上故障:t3code 部署后的第二天,用户反馈“收藏链接后页面一直不刷新,反而报错了”。我在本地反复试都复现不了,后来打开浏览器控制台才发现是接口抛了一个 zod 校验错误——前端传过来的tagNames参数是null,而 schema 定义的是z.array(z.string()).default([]),照理说null应该被 zod 拒之门外才对。
问题出在哪?我在前端表单里对FormData做了处理,但tagNames字段没有被初始化为[],实际序列化时变成了null。zod 的default只对undefined生效,对null会直接报错。修复方式是前端传参时显式处理空值,或者把校验器改成z.preprocess把null转成undefined:
input: z.object({ tagNames: z.preprocess((v) => v ?? [], z.array(z.string()).default([])), })这类坑属于典型的“类型安全也不能完全防住运行时传入脏数据”,除了靠 zod 校验,更重要的还是把边界值考虑全。
5.3 两个能省半天时间的调试技巧
关于 t3code 这类项目的调试,我有两个比较实用的个人技巧。
第一个是善用 tRPC 的procedure日志。tRPC 自带一个logger中间件,可以在开发模式下把每个 procedure 的输入输出和耗时打印出来,配置方式很简单:
export const t = initTRPC.context<typeof createTRPCContext>().create({ logger: process.env.NODE_ENV === "development" ? { log: (args) => console.log("tRPC:", args.type, args.path, "in", args.durationMs, "ms"), } : undefined, });请求出问题的时候,先看日志里被拦在哪一层:是输入校验不过,还是中间件鉴权失败,还是数据库查询报错。这一眼就能定位,不用翻前端 DevTools 和后端日志来回猜。
第二个技巧是修改 Prisma Schema 后不要总想着重启。只要改字段,我就跑pnpm prisma generate重新生成 client,然后继续写代码。如果发现编辑器里 Prisma client 类型不对,基本就是忘了 generate。养成“改 schema 必 generate”的习惯,能避开很多奇怪的编译报错。
6. 一点题外话:全链路类型安全带来的变化
t3code 这个项目做到后期,我对类型安全这件事的感受已经从“挺好的”变成了“回不去了”。以前写项目,接口定义是开发流程里最容易糊弄过去的一环,甚至有人为了省事直接给前端一个any。但 t3code 的体验是,后端改数据库字段,前端保存文件的瞬间就报错,问题根本留不到联调阶段。这种“错误发生在编译时而不是运行时”的爽感,是传统 REST 项目里体会不到的。
如果你也想试,我建议不要急着为自己的业务做太多定制,先把 create-t3-app 生成的默认结构跑通,然后只改一个模型、一个页面,走完整条链路再开始扩充。工程量不大,但对理解“类型、校验、鉴权、缓存”这四者怎么协作非常有帮助。t3code 目前在我这已经从“收藏夹”扩展到了“带标签的笔记”方向,下一步我还打算加一个“分享卡片”功能,让每一条收藏都能生成一个可公开访问的卡片页。这事放到以前,设计和联调成本会让我犹豫很久,但现在有 tRPC 的类型安全托底,起码前端和数据库两处不用再担心接口对不上了。