那段时间我特别烦一件事:手上的短链接需求明明逻辑很简单,却要在 Next.js 里自己搭接口、写类型定义,前端再手搓一套对应的请求函数。改一个字段,前后端类型对不上,跑起来才报错。后来我重新开了个项目,代号就叫 t3code——用 T3 Stack 把整条链路从数据库到页面全部串起来,类型安全从头贯彻到尾。
这篇文章就把 t3code 从零到上线的完整过程拆开讲:怎么选型、怎么初始化、Prisma 表结构怎么设计、tRPC 路由怎么组织、前端怎么调、部署时踩了哪些坑。如果你正打算用 T3 Stack 做点实际项目,或者只是好奇 tRPC 和 Prisma 到底怎么配合,这篇应该能帮你省掉不少试错时间。
1. t3code是什么:我的T3 Stack项目选型复盘
1.1 一个短链接需求,为什么值得搭全栈
t3code 这个项目要做的事很朴素:用户填一个长网址和一个自定义短码,系统生成短链,访问短链时重定向到原始网址,同时记录访问次数。听起来像是个几天就能写完的小工具,但真正动手时发现没那么简单——短链接虽然业务逻辑简单,它却横跨了数据存储、API 设计、页面渲染、部署运维好几个层面。
如果按老思路做,大概是 Next.js 写几个 API Route,数据库用 Prisma 或直接 SQLite 操作,前端页面通过 fetch 去请求接口。问题是:request body里字段名写错了、返回结构改了但前端没同步、slug 校验逻辑在 API 里写了一遍前端又写一遍……这些坑在项目小的时候不致命,但足以消耗掉你本应花在业务上的时间。
所以我决定试一次 T3 Stack。它的核心组合是 Next.js + TypeScript + Tailwind CSS + Prisma + tRPC(可选 NextAuth),官方脚手架叫 create-t3-app。这套技术栈最大的特点不是某个库多厉害,而是把"类型安全"这件事从头到尾焊死了:数据库表结构能推导出 Prisma Client 的类型,Prisma 的返回值直接就是 tRPC 路由的返回类型,tRPC 的返回类型又自动供前端 React Query 使用。改一处 schema,整个链路都会在编译期告诉你哪里不匹配。
1.2 T3 Stack 到底包含什么,名字从哪来
T3 这个名字有很多种解释,有人说是 TypeScript、Tailwind、tRPC 三个 T 开头库的合称,也有人说是 TS/TRPC/Tailwind 加上创始人 Theo 的缩写。不管哪种说法,真正贯穿整套方案的是 T3 最底层的理念:Typesafe、Simplicity、Modularity——类型安全优先、保持简单、按需组合。
具体到技术选型,create-t3-app 通常给你这些选项:
- Next.js:React 全栈框架,既做页面又做 API 入口,App Router 支持 Server Components。
- TypeScript:全项目类型检查,这是整个类型安全链条的起点。
- Tailwind CSS:原子化 CSS,写样式基本不用离开 JSX。
- Prisma:ORM,负责数据库映射、迁移、类型生成。
- tRPC:前后端接口的定义与调用协议,所有路由的类型自动共享。
- NextAuth.js:认证方案,可选,需要登录场景时勾上。
这里我最看重的是 tRPC。传统 REST API 用 OpenAPI 或 GraphQL 也能做到类型共享,但都需要额外的 schema 定义文件和代码生成步骤。tRPC 的思路更直接——你写一个 TypeScript 函数(procedure),它天然就带类型,前端直接按函数签名调用,中间不需要任何胶水层。短链接这种增删改查密集、没有复杂聚合查询的场景,用 tRPC 几乎是零成本接入。
1.3 和传统方案的对比,我为什么没选 REST 或 GraphQL
选型阶段我还认真对比过三条路线:纯 Next.js + REST API、GraphQL(Apollo + codegen)、以及 T3 Stack 全家桶。下面这张表是我当时做决策时的核心考量:
| 对比维度 | Next.js + REST | Apollo + GraphQL | T3 Stack(tRPC) |
|---|---|---|---|
| 类型共享 | 需手写或另用 openapi 生成器 | 需 schema + codegen,配置较重 | 类型自动推导,零配置 |
| 学习成本 | 低 | 中高,需理解 GraphQL 语义 | 低,就是调用函数 |
| 缓存策略 | 自己设计或接 React Query | Apollo 自带缓存,但概念多 | React Query 集成顺滑 |
| 适合场景 | 对外公共 API | 复杂聚合查询、跨端消费 | 前后端同仓的全栈应用 |
说白了,短链接服务没有公共 API 需求,消费方只有我自己做的前端页面。GraphQL 那套 query 嵌套和缓存策略在这里纯属杀鸡用牛刀。REST 虽然简单,但类型定义和请求封装都得靠自觉,团队一忙起来就没人维护了。T3 Stack 让"规范"变成了"不这样写就编译不过",这才是它对个人项目最大的价值。
2. 初始化t3code:从空目录到第一屏能跑
2.1 环境准备与脚手架命令
动手前先确认本机环境:Node.js 版本不要低于 18,推荐直接用 20 LTS;包管理器我选了 pnpm,原因是 create-t3-app 的依赖树比较大,pnpm 的硬链接机制安装速度快,磁盘占用也少。npm 也能用,但后面跑 Prisma 脚本时 pnpm 的dlx体验更好。
初始化命令很简单:
npx create-t3-app@latest t3code如果你习惯用 pnpm 的脚手架方式也可以:
pnpm create t3-app@latest t3code执行后它会询问项目使用什么技术栈,以当前版本为例大致是这些选项:是否用 TypeScript(默认是)、是否用 Tailwind CSS、是否用 Prisma、是否用 tRPC、是否用 NextAuth、是否用 App Router、ESLint 和 Prettier 这些基础配置。第一次跑时我建议全选 yes(NextAuth 看情况),先把标准全家桶跑起来,再逐个理解每个模块的作用,不要一开始就做减法。
2.2 生成了哪些目录,每个文件是干什么的
初始化完成后,t3code 的目录结构大致长这样:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── routers/ │ │ │ │ └── post.ts │ │ │ └── trpc.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── react.ts │ │ ├── server.ts │ │ └── shared.ts │ ├── env.js │ └── styles/ │ └── globals.css ├── .env ├── .env.example ├── next.config.js ├── package.json └── tsconfig.json我只挑几个关键位置说。src/server/db.ts负责 Prisma Client 的实例化和全局复用,避免开发模式下连接数爆掉。src/server/api/trpc.ts是 tRPC 的服务端初始化文件,所有 router 都要从这里拿createTRPCRouter和publicProcedure、protectedProcedure这些基础构件。src/trpc/react.ts是前端封装的 tRPC React 客户端,页面里 import 的api对象就是从这里来的。src/trpc/server.ts则是给 Server Components 用的服务端调用封装。
第一次pnpm dev时大概率会遇到两个小问题:一是DATABASE_URL没设置,因为.env不会自动生成,需要手动复制.env.example为.env并填上数据库连接串;二是端口 3000 被占用,改一下 dev 脚本或者直接 kill 占用进程就好。
2.3 从模板项目到 t3code 的第一次改造
模板自带的页面是一个示例博客,我需要把它改成短链接服务的雏形。改造前建议先把模板跑通一遍,确认 dev server 正常、/api/trpc能回 ping。我的做法是先把 root router 里的示例路由删掉,加一个自己的linkRouter,同时把首页改成短链接输入框。
这里有一个很重要的心态:T3 Stack 的模板不是让你照搬业务的,它的价值在于展示"每个模块应该放在哪个位置"。比如示例的 post router 演示了 query 和 mutation 怎么写、输入校验怎么加,我照着这个结构改业务逻辑,比自己凭感觉创建目录要稳得多。不要急着删掉示例代码,先看两遍再动手,能少踩很多坑。
3. 数据库层设计:Prisma Schema 是t3code的地基
3.1 短链接的数据模型设计
我先把短链接的核心字段列出来:短码(slug)、原始 URL、创建时间、更新时间、访问次数。第一次设计时差点忘了访问次数,结果后面做统计功能时又回去加字段,得不偿失。Prisma 的 schema 长这样:
generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model ShortLink { id String @id @default(cuid()) slug String @unique url String visits Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }几个字段的设计理由说一下。id用cuid()而不是自增数字,一是短链接的 slug 本身已经承担了主键的业务语义,内部主键和对外 slug 分开更干净;二是 cuid 在分布式环境下生成不冲突,将来要扩展也不用担心。slug加@unique,这是短链接的核心约束:同一个短码不能出现两次。visits用Int加@default(0),每次重定向时用increment更新,比先查再写更安全高效。
3.2 创建迁移文件与种子数据
设计好 schema 之后,执行迁移命令:
pnpm prisma migrate dev --name init这个命令会把 schema 和数据库结构做 diff,生成迁移 SQL,并同步生成新的 Prisma Client。很多人会忽略prisma generate,其实 migrate dev 已经把 Client 生成好了,不需要手动执行。如果你改了 schema 但没有跑迁移直接启动项目,通常会报 "The generated client is out of date" 之类的错误,重启没用,老老实实跑迁移就好。
种子数据的目的是开发时有些假数据能用。我在prisma/seed.ts里写了两条短链接,方便前端页面调接口时不用空手看:
import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { await prisma.shortLink.createMany({ data: [ { slug: "github", url: "https://github.com" }, { slug: "docs", url: "https://nextjs.org/docs" }, ], }); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect());然后在package.json里加上 seed 配置:
{ "prisma": { "seed": "tsx prisma/seed.ts" } }每次重置数据库时,pnpm prisma migrate reset会自动跑一遍 seed,非常省心。
3.3 本地与生产的数据库策略:先用SQLite还是直接上Postgres
这是 t3code 踩过的第一个比较隐蔽的坑。create-t3-app 默认在.env里写的DATABASE_URL是 SQLite 文件路径,比如file:./db.sqlite,本地开发时启动最快、不需要额外服务。但如果线上要用 Vercel 部署,SQLite 是跑不了的——Serverless 环境的文件系统是临时且只读的,没法持久化数据库文件。
我的建议分两种场景。如果你只是本地体验 T3 Stack,SQLite 完全够用。如果你确定要部署上线,我从第一天就建议直接上 PostgreSQL 托管服务,比如 Neon 或 Supabase 的免费额度,把连接串填进.env,schema 里把 provider 改成postgresql:
datasource db { provider = "postgresql" url = env("DATABASE_URL") }然后重新跑prisma migrate dev。这一条经验帮我省掉了后面中期的成本:我先用 SQLite 开发了两天,部署前把 provider 切成 postgresql 重新迁移,结果发现表结构没问题,但本地数据和线上数据对不上,排查了半天。还不如一开始就统一。
4. tRPC 路由:t3code的类型安全API长什么样
4.1 从router到procedure:API的最小单元
tRPC 的思路可以理解成:你定义的每个 API 就是一个 TypeScript 函数,只不过这个函数被包装成 procedure,并且挂载到某个 router 下面。前端调用时,不需要 URL 拼接、不需要 request body 的手工构造,直接调对应函数名就可以了。
我在 t3code 里创建了src/server/api/routers/link.ts,核心路由如下:
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; const createLinkSchema = z.object({ slug: z.string().min(3).max(32).regex(/^[a-z0-9-_]+$/i), url: z.string().url(), }); export const linkRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.shortLink.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input(createLinkSchema) .mutation(async ({ ctx, input }) => { const created = await ctx.db.shortLink.create({ data: { slug: input.slug, url: input.url, }, }); return created; }), });getAll是 query 类型,适合读操作;create是 mutation 类型,适合写操作。这里的ctx.db是 Prisma Client 实例,t3code 在创建上下文时已经把它注入了,所以我可以在任意 procedure 里直接访问数据库。publicProcedure表示不需要登录就能调用,需要鉴权的接口换成protectedProcedure即可。
4.2 用zod把输入校验做在类型层
createLinkSchema里的z.object来自 zod 库。它的强大之处在于:这个 schema 既是运行时校验规则,又能在编译期推导出 TypeScript 类型。也就是说,我在前端写api.link.create.useMutation()时,编辑器会自动提示input应该是{ slug: string, url: string },slug 不合法直接类型报错。
校验逻辑上我做了几个约束:slug 最短 3 位、最长 32 位,只允许字母数字和-_,防止用户生成包含奇怪字符、甚至带路径分隔符的短码;url 必须是合法网址,避免有人存进javascript:alert(1)之类的危险值。这些约束看起来简单,但要是不在入口拦下,后面重定向和渲染时就会出各种幺蛾子。
这里有一个实际遇到过的问题:zod 校验失败时,tRPC 默认返回的错误信息是英文数组,前端拿到后很难直接展示。我给 create 接口包了一层错误转换中间件,把 zod 的 issues 映射成中文提示,这样表单才能直接展示"短码格式不正确"而不是一串英文 json。这个小改动我建议你一开始就做,后面会省很多事。
4.3 上下文(context)与鉴权是怎么串起来的
tRPC 的 context 是在每次请求时构建的一个对象,procedure 可以从ctx里获取它。在 create-t3-app 生成的trpc.ts里,默认会读取 session 并注入db。以 t3code 为例,如果短链接需要支持"登录后才能创建",我可以给 create 接口换用protectedProcedure:
create: protectedProcedure .input(createLinkSchema) .mutation(async ({ ctx, input }) => { // ctx.session.user.id 可以拿到当前登录用户 return ctx.db.shortLink.create({ data: { slug: input.slug, url: input.url, userId: ctx.session.user.id, }, }); }),protectedProcedure内部其实是通过 NextAuth 的 session 做了校验,未登录请求会直接抛 UNAUTHORIZED 错误。如果你以后要加"每个用户的短链接列表",只需要在数据模型里加一个 userId 外键即可,不需要重新设计 API。
4.4 路由的根节点配置与类型导出
写完 linkRouter 后,需要挂载到根路由上。打开src/server/api/root.ts:
import { linkRouter } from "./routers/link"; import { createTRPCRouter } from "~/server/api/trpc"; export const appRouter = createTRPCRouter({ link: linkRouter, }); export type AppRouter = typeof appRouter;导出AppRouter类型是为了给前端用的。React 端 tRPC Client 的类型参数就是AppRouter,这是实现"前端自动拥有后端类型"的关键。只要这个类型对,前端api.link.create的入参和出参就和后端完全对齐,改 schema 后编译报错的地方会精确到具体调用点。
5. 前端接入:React Query 在t3code里的正确打开方式
5.1 用 useQuery 读取短链接列表
前端调用 tRPC 时,它不是直接发请求,而是通过 React Query 做了一层数据请求、缓存和状态管理。在 t3code 首页组件里,读取短链接列表的代码非常简洁:
"use client"; import { api } from "~/trpc/react"; export function LinkList() { const { data, isLoading, error } = api.link.getAll.useQuery(); if (isLoading) return <div>加载中...</div>; if (error) return <div>加载失败:{error.message}</div>; return ( <ul> {data?.map((link) => ( <li key={link.id}> <a href={link.url}>{link.slug}</a> <span>访问次数:{link.visits}</span> </li> ))} </ul> ); }这里的api.link.getAll.useQuery()会自动带上 loading、error、data 三个状态,不需要再单独写 useEffect 和 fetch。因为 tRPC 的返回类型推导,link.url、link.slug、link.visits在编辑器里都有补全,写错字段会直接标红。
5.2 用 useMutation 创建短链接
创建操作与读取不同,它要触发写请求并处理副作用。我在表单组件里写了这样的逻辑:
"use client"; import { api } from "~/trpc/react"; export function LinkForm() { const utils = api.useUtils(); const createMutation = api.link.create.useMutation({ onSuccess: () => { // 创建成功后,让列表缓存失效并重新请求 utils.link.getAll.invalidate(); }, }); const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createMutation.mutate({ slug: String(formData.get("slug")), url: String(formData.get("url")), }); }; return ( <form onSubmit={handleSubmit}> <input name="slug" placeholder="短码" /> <input name="url" placeholder="长网址" /> <button disabled={createMutation.isPending}>创建</button> </form> ); }这里最核心的是utils.link.getAll.invalidate()。因为我创建成功之后,列表数据已经过时了,需要让 React Query 重新拉取。不调用这个,列表会一直显示旧数据,必须刷新页面才看得到新内容,非常容易踩。
5.3 注意useUtils和useContext的版本差异
tRPC v10 时代,缓存操作是通过api.useContext()拿到的;从 tRPC v11 开始,改名成了api.useUtils()。create-t3-app 的新版本脚手架默认生成的是useUtils。如果你在网上搜到很多教程用的还是useContext,别急着照抄,先看下你项目的 tRPC 版本号。这种命名变化是 tRPC 升级过程中最容易造成困惑的地方,我就见过同事把 v10 代码直接贴到 v11 项目里,结果 API 不存在,排查了半天。
5.4 Server Components里怎么调tRPC
Next.js App Router 里,页面默认是 Server Component,不能在服务端组件里直接用useQuery——那是 Client Component 的 API。那服务端怎么取数据?create-t3-app 生成的src/trpc/server.ts里封装了serverClient,我可以在服务端组件里直接这样调:
import { serverClient } from "~/trpc/server"; export default async function HomePage() { const links = await serverClient.link.getAll(); return <LinkList initialData={links} />; }这样首屏数据是在服务端直接查库渲染的,不经过 HTTP 请求,速度和 SEO 都更好。同时LinkList组件可以通过HydrateClient把服务端拿到的数据传给客户端 React Query 作为初始缓存。
这个模式值得重点理解:tRPC 不是只能在浏览器里发请求,它在服务端可以直接调用 procedure,因为一个 procedure 本质就是函数。这让 Next.js 的 Server Components 和 tRPC 做到了无缝衔接,前后端之间少了一次网络往返。
6. 部署到生产:t3code上线前必须过的几道关
6.1 数据库从SQLite切换到Postgres
前面我提过的策略问题,上线时终于要正面解决了。我最后选了 Neon 的 Postgres 免费实例,原因很简单:与 Vercel 同属 Serverless 生态,连接串可以直接通过环境变量注入,免费额度足够 t3code 这种小项目跑。
切换步骤很简单,但没有想象中那么简单。你要做的不只是把.env里的DATABASE_URL换成 postgres 连接串,还要把prisma/schema.prisma里的 provider 从sqlite改成postgresql,然后重新生成迁移。如果你本地还有旧 SQLite 数据,需要先考虑要不要导出导入。我这次因为还在开发阶段,直接重置了数据库,省去了数据迁移的麻烦。
切换之后重新执行:
pnpm prisma migrate reset --force pnpm prisma db push这两个命令的区别要知道:migrate reset会清空数据库并重新执行迁移历史,适合开发环境;db push是直接把 schema 推给数据库,不生成迁移历史,适合快速改结构。但生产环境不要用db push,要养成用迁移文件的习惯。
6.2 生产环境执行迁移的正确姿势
在 Vercel 上部署时,构建阶段会自动执行prisma generate,但不会自动执行migrate deploy。如果线上数据库还没有表结构,你的接口会全部报 500 P2021 之类的错误。正确做法是在项目部署设置里加一个构建命令,或者在根目录的package.json里配置:
prisma migrate deploy && pnpm build注意这里用的是prisma migrate deploy,不是migrate dev。migrate deploy只执行尚未应用的迁移文件,不会和线上数据库交互做 diff,更安全。我第一次部署时忘了加这行,结果页面能访问但所有接口都在报错,排查了半天才发现是表不存在。吃一堑长一智,之后我把这条命令固化到了部署脚本里,再没出过问题。
6.3 环境变量清单与常见部署坑
上线前我把 t3code 用到的环境变量全部整理了一遍,并且把NEXTAUTH_SECRET也一并配好了,即使现在还没启用 NextAuth,后面加登录功能时不用重新配置:
| 变量名 | 用途 | 示例 |
|---|---|---|
| DATABASE_URL | Postgres 连接串 | postgresql://user:pass@host/db |
| NEXTAUTH_SECRET | NextAuth 加密密钥 | openssl rand -base64 32生成 |
| NEXTAUTH_URL | NextAuth 回调地址 | https://t3code.vercel.app |
这里有一个很坑的细节:本地开发时 NextAuth 的 session 是正常的,但部署到 Vercel 后如果NEXTAUTH_SECRET没设置,服务可能在构建时直接报错,或在运行时读不到 session。如果只是用 tRPC 的 publicProcedure 不做登录,暂时没有 NextAuth 也不用慌,但环境变量建议先备好。
另外,如果前端在浏览器里直接访问 tRPC 的 HTTP 接口,可能会遇到 CORS 报错。t3code 的前后端是同源部署(/api/trpc在同一域名下),所以理论上不会触发跨域。如果你把 tRPC 单独部署到别的域名,就需要在服务端配置cors()中间件,允许指定来源。
6.4 上线后的验证清单与性能检查
部署完成并不代表结束。我给 t3code 列了一个验收清单,照着走一遍基本能确保核心链路没问题:
- 访问首页能看到短链接列表,且渲染的是数据库真实数据。
- 提交一个新的短链接,列表不刷新也能自动更新(缓存失效逻辑生效)。
- 访问
https://t3code.vercel.app/s/xxx能正确重定向到目标 URL。 - 重定向后,数据库里对应该记录的
visits字段加一。 - 输入不合法的 slug 时,前端能显示校验错误,接口没有被请求。
- 在 Vercel 的日志面板里确认没有 Prisma 报错和 tRPC 的运行时异常。
性能上,短链接服务没有大查询,唯一要注意的是重定向接口的响应速度。我用 Prisma 的findUnique按 slug 查询,命中唯一索引,Postgres 走索引扫描,路径上传入/s/[slug]这种动态路由,页面本身不渲染任何重量级内容,线上实测重定向响应时间在 100ms 以内,足够了。
最后再分享一个小技巧:每次改 schema 后,建议先把本地迁移跑通、seed 重置一遍、前端页面验证一遍,再提交到仓库。因为 Prisma Client 的生成和迁移脚本和路由代码强相关,一旦顺序错了,很容易出现"本地好好的,线上全红"的情况。我后来养成了一个习惯,所有 schema 相关的 commit 都会顺手把prisma migrate dev --name xxx生成的迁移文件、prisma/seed.ts的更新一起提交,团队协作时别人拉下来直接migrate deploy就能跑,省掉了无数"环境问题"的沟通成本。