首页 / 资讯中心 / 文章详情

T3 Stack实战:从零构建类型安全的短链接全栈应用

T3 Stack实战:从零构建类型安全的短链接全栈应用 ★ FEATURED ARTICLE
那段时间我特别烦一件事手上的短链接需求明明逻辑很简单却要在 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.jsReact 全栈框架既做页面又做 API 入口App Router 支持 Server Components。TypeScript全项目类型检查这是整个类型安全链条的起点。Tailwind CSS原子化 CSS写样式基本不用离开 JSX。PrismaORM负责数据库映射、迁移、类型生成。tRPC前后端接口的定义与调用协议所有路由的类型自动共享。NextAuth.js认证方案可选需要登录场景时勾上。这里我最看重的是 tRPC。传统 REST API 用 OpenAPI 或 GraphQL 也能做到类型共享但都需要额外的 schema 定义文件和代码生成步骤。tRPC 的思路更直接——你写一个 TypeScript 函数procedure它天然就带类型前端直接按函数签名调用中间不需要任何胶水层。短链接这种增删改查密集、没有复杂聚合查询的场景用 tRPC 几乎是零成本接入。1.3 和传统方案的对比我为什么没选 REST 或 GraphQL选型阶段我还认真对比过三条路线纯 Next.js REST API、GraphQLApollo codegen、以及 T3 Stack 全家桶。下面这张表是我当时做决策时的核心考量对比维度Next.js RESTApollo GraphQLT3 StacktRPC类型共享需手写或另用 openapi 生成器需 schema codegen配置较重类型自动推导零配置学习成本低中高需理解 GraphQL 语义低就是调用函数缓存策略自己设计或接 React QueryApollo 自带缓存但概念多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-applatest t3code如果你习惯用 pnpm 的脚手架方式也可以pnpm create t3-applatest t3code执行后它会询问项目使用什么技术栈以当前版本为例大致是这些选项是否用 TypeScript默认是、是否用 Tailwind CSS、是否用 Prisma、是否用 tRPC、是否用 NextAuth、是否用 App Router、ESLint 和 Prettier 这些基础配置。第一次跑时我建议全选 yesNextAuth 看情况先把标准全家桶跑起来再逐个理解每个模块的作用不要一开始就做减法。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 StackSQLite 完全够用。如果你确定要部署上线我从第一天就建议直接上 PostgreSQL 托管服务比如 Neon 或 Supabase 的免费额度把连接串填进.envschema 里把 provider 改成postgresqldatasource db { provider postgresql url env(DATABASE_URL) }然后重新跑prisma migrate dev。这一条经验帮我省掉了后面中期的成本我先用 SQLite 开发了两天部署前把 provider 切成 postgresql 重新迁移结果发现表结构没问题但本地数据和线上数据对不上排查了半天。还不如一开始就统一。4. tRPC 路由t3code的类型安全API长什么样4.1 从router到procedureAPI的最小单元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 接口换用protectedProcedurecreate: 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.tsimport { 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.FormEventHTMLFormElement) { 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 nameslug placeholder短码 / input nameurl 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里怎么调tRPCNext.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_URLPostgres 连接串postgresql://user:passhost/dbNEXTAUTH_SECRETNextAuth 加密密钥openssl rand -base64 32生成NEXTAUTH_URLNextAuth 回调地址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就能跑省掉了无数环境问题的沟通成本。
阅读完成 · 觉得有帮助?
咨询建站