资讯动态

T3 Stack全栈实践:从create-t3-app到类型安全的任务管理应用

发布时间:2026/10/27 16:03:42 来源:尧图企业网站定制
“t3code”这个代号是我给最近一个全栈项目起的内部名字。它没有多玄乎——就是基于 T3 StackTypeScript、Tailwind CSS、tRPC 三个同样以 T 开头的工具整体跑在 Next.js 上搭的一套完整应用。如果你最近看过不少前端模板或者开源项目多半会撞见 create-t3-app 这个脚手架t3code 就是在它之上长出来的产物一个从数据库到界面全程类型安全的小型业务系统。我选的这个方向是一个团队内部用的轻量任务与知识库工具包含登录、任务创建、列表查询、标签筛选和基础权限。听起来不复杂但它把所有现代全栈开发的要害都碰了一遍服务端渲染、API 层抽象、ORM 建模、认证集成、前端数据缓存。所以它很适合两类人看一类是前端转全栈的同学想知道 Next.js 加 tRPC 到底怎么把前后端串起来另一类是已经在写 REST API、但对类型安全越来越不满的开发者想看看另一种做法长什么样。1. 内容整体设计与思路拆解1.1 先搞清楚“T3”到底是什么意思写 T3 Stack 的文章不少但很多人其实是先听说过脚手架才回头理解它的名字。T3 不是一个框架而是一套组合建议TypeScript 提供全链路类型系统Tailwind CSS 负责样式tRPC 负责远程调用而 Next.js 是承载它们的宿主框架。除此之外这套组合还会带上 Prisma 和 NextAuth分别处理数据库操作和用户认证。这些工具看起来是“各干各的”实际上它们有一个共同的基因——类型安全。Next.js 本身没有强制前后端共享类型但当你把 tRPC 放进去之后从数据库查询出来的数据到服务端路由函数的返回值再到前端组件的 props类型是一路连贯的。用生活化的方式理解以前做前后端分离好比两家公司靠传真对接一方的表格改了版式另一方还在按旧版式填数据经常对不上账。而 T3 这套玩法是让两边坐进同一个办公室、用同一套表格后端改一个字段名前端立刻在编译期收到提醒。t3code 之所以能把开发效率提上来靠的正是这种“同表同步”。这套组合还有一个隐藏优势它默认是“约定大于配置”的。create-t3-app 初始化出来的项目已经把 tRPC 和 Next.js 的 API 路由打通把 Prisma 和数据库连接配好把 NextAuth 的 session 类型处理器挂上。你不需要像早年搭 SSR 项目那样四处找胶水代码把精力省下来全花在业务逻辑上。1.2 我为什么放弃传统 REST改用 tRPC在 t3code 之前我做过的几个全栈项目都是标准的“Next.js REST API”。前端写 fetch后端写路由中间再写一层 API 文档或者靠 Postman 手动维护。这套模式不是不能用但有一个很现实的问题接口一旦多起来类型维护成本会指数级上升。改一个响应字段后端改完还不够前端 type 定义要改接口文档要改联调时还要反复确认“这次部署上去了没有”。更难受的是这类错误大多在运行时才暴露用户打开页面看到 undefined你才知道哪个字段漏了。tRPC 完全换了思路。它的核心做法是把后端路由封装成一个个 procedure过程前端直接 import 这些 procedure 来调用。你不需要拼接 URL、不需要手写 fetch 封装、不需要定义重复的 interfaceTypeScript 会从后端函数的返回类型里自动推断出前端拿到的数据类型。也就是说tRPC 不是帮你把 API 文档写更好而是直接消灭了“文档与代码不一致”这种问题。我把两种方式做了一张对比表方便你看清取舍对比项REST 手写类型tRPC调用方式HTTP 动词 URL直接调用后端函数类型一致性手动维护容易漂移编译器自动推断接口文档需要额外工具或手写类型即文档缓存与状态管理自己封装 SWR/React Query内置 React Query 集成调试体验看 Network 面板直接断点进后端函数适用规模对外公开 API、跨团队复用前后端同仓库的应用需要说明的是tRPC 并不是要取代 REST。如果你的项目需要开放给第三方开发者REST 或 GraphQL 仍然更合适。但像 t3code 这种前后端同仓库、由同一个团队维护的产品tRPC 的收益非常直观少写一层胶水多一份安心。1.3 t3code 的功能规划t3code 的定位不是技术 demo而是一个能真实跑起来的内部工具。我给它规划了五个核心模块账号登录通过 NextAuth 接入 GitHub OAuth识别用户身份。任务管理创建任务、修改状态、编辑标题和描述。标签系统给任务打标签按标签筛选列表。个人视图只显示当前用户创建或负责的任务。基础统计在首页看到任务总数、待办数量、完成率。之所以选这个场景是因为它麻雀虽小、五脏俱全。你既能看到 tRPC 怎么做简单的 CRUD又能看到它怎么做跨表查询、怎么做权限校验、怎么结合 React Query 做数据缓存。每一步踩的坑都是实际业务里一定会遇到的。2. 核心细节解析与实操要点2.1 create-t3-app 生成的项目到底长什么样t3code 是直接用 create-t3-app 初始化的命令是 pnpm create t3-applatest。初始化时它会问你选哪些模块我的选择是全部都要Next.js、tRPC、Prisma、NextAuth、TailwindESLint 保留Playwright 不装。原因是这套脚手架的价值就在于“集成好的样板”去掉任何一个模块都要自己手工接回来性价比不高。初始化后项目结构大致是这样的prisma/ schema.prisma # 数据库模型定义 src/ pages/ api/ trpc/ [trpc].ts # tRPC 的 HTTP 入口 _app.tsx # 全局 App 组件 index.tsx # 首页 server/ api/ routers/ post.ts # 示例 router后改成 task.ts root.ts # 根 router聚合所有子 router trpc.ts # tRPC 实例、上下文、中间件定义 auth.ts # NextAuth 配置 db.ts # Prisma Client 实例 styles/ globals.css # Tailwind 入口 utils/ api.ts # 前端调用 tRPC 的封装第一次看到这个结构不少人会疑惑为什么 API 路由只有一个 [trpc].ts这就是 tRPC 的设计哲学——它把所有接口都收敛到一个统一入口通过 procedure 名称来区分具体路由。你不需要为每个接口新建一个文件后端逻辑按业务模块拆成一个个 router挂载到根 router 上即可。2.2 数据模型设计从任务表开始类型安全的根基在数据库模型。我用 Prisma 定义了三张表User、Task、Tag以及一张多对多关联表。之所以不用简单的字符串数组存标签是因为标签需要被筛选和统计用关联表查询效率更高语义也更清晰。model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? tasks Task[] relation(CreatorTasks) assignedTasks Task[] relation(AssigneeTasks) accounts Account[] sessions Session[] } model Task { id String id default(cuid()) title String description String? status String default(TODO) createdAt DateTime default(now()) updatedAt DateTime updatedAt creatorId String creator User relation(CreatorTasks, fields: [creatorId], references: [id]) assigneeId String? assignee User? relation(AssigneeTasks, fields: [assigneeId], references: [id]) tags TaskTag[] } model Tag { id String id default(cuid()) name String unique tasks TaskTag[] } model TaskTag { taskId String tagId String task Task relation(fields: [taskId], references: [id]) tag Tag relation(fields: [tagId], references: [id]) id([taskId, tagId]) }几个字段选择的理由我说一下id 用 cuid() 而不是自增数字是为了避免把业务规模暴露给外部。自增 id 一眼就能看出“你平台上有多少条数据”cuid 没有这个问题且生成不需要等待数据库自增分布式的场景也友好。updatedAt 让 Prisma 自动维护每次更新记录时不用手动 set这对审计场景很有用。status 我暂时用 String 而不是枚举是因为任务状态可能在产品迭代中变多待办、进行中、已完成、已归档用字符串过渡比较灵活。如果你确定状态数量固定用 Prisma 的 enum 更好。提示设计 schema 之前一定要先想清楚查询模式。比如“按标签筛选任务”如果标签只是字符串数组数据库层面就很难索引。我一开始图省事用了字符串数组后来加筛选功能时发现查起来很别扭才拆出了关联表。2.3 类型安全是怎么“流”到前端的t3code 最让人舒服的一点是类型不需要手动“搬运”。它经历了四层Prisma 根据 schema.prisma 生成完整的数据库类型包括查询结果、输入参数、关联关系。tRPC 的 procedure 在服务端定义好输入校验和返回结构。如果输入需要校验我用 zod 定义一个 schematRPC 会在运行时自动解析。前端 utils/api.ts 导出的 api 对象会根据服务端根 router 的类型自动生成客户端调用方法。这个过程是在 Next.js 编译时完成的。组件里 useQuery 的返回数据自动带上后端返回类型的结构IDE 里能看到所有字段不存在的字段会直接报错。也就是说你在 Prisma 里改一个字段名接下来 tRPC router 的返回类型会变前端 useQuery 的类型会变组件里用到的旧字段会被 TypeScript 标红。所有错误在开发阶段就暴露了而不是等到线上用户反馈才发现。这段链路有个细节值得注意tRPC 利用的是 TypeScript 的类型推断而不是运行时反射。所以你不需要启动什么代码生成服务只要确保 dev server 在运行、类型能正常编译前端拿到的类型就一定和后端一致。这也是为什么 t3code 里我几乎没有写过任何重复的 interface。3. 实操过程与核心环节实现3.1 环境准备与项目初始化在开始之前我建议把环境统一一下Node.js 版本不低于 18包管理器用 pnpm。pnpm 对 monorepo 和依赖隔离做得更好实测在 Next.js 项目里安装速度比 npm 快不少占用的磁盘也小。初始化命令很简单pnpm create t3-applatest t3code cd t3code pnpm install交互式选项里我建议全选除非你明确知道自己不需要某些模块。初始化完成后第一件事是把 Prisma 的数据库地址配好。我本地用的是 PostgreSQL直接在 .env 里修改 DATABASE_URLDATABASE_URLpostgresql://user:passwordlocalhost:5432/t3code?schemapublic然后执行数据库迁移pnpm prisma migrate dev --name init这一步会生成 migration 文件并把表结构同步到数据库。有些人会跳过 migrate 直接 db push如果项目还没上线db push 更省事但一旦有多个环境我强烈建议用 migrate 管理版本。3.2 写第一个 tRPC 接口并接上前端t3code 的第一个接口是“创建任务”。在 src/server/api/routers 下新建 task.tsimport { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const taskRouter createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), description: z.string().optional(), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { title: input.title, description: input.description, creatorId: ctx.session.user.id, tags: input.tags ? { create: input.tags.map((name) ({ tag: { connectOrCreate: { where: { name }, create: { name }, }, }, })), } : undefined, }, include: { tags: { include: { tag: true } } }, }); }), });注意这里用了 protectedProcedure 而不是 publicProcedure说明这个接口必须登录才能调用。ctx.session.user.id 是从 NextAuth 会话里取出来的当前用户 ID不需要前端传当前用户也就避免了“伪造他人身份创建任务”的问题。把 taskRouter 挂到根 router// src/server/api/routers/root.ts import { taskRouter } from ./task; export const appRouter createTRPCRouter({ task: taskRouter, });然后到前端页面里调用。首页加一个表单提交时用 tRPC 的 useMutationimport { api } from ~/utils/api; const createTask api.task.create.useMutation({ onSuccess: () { // 创建成功后刷新任务列表 utils.task.getAll.invalidate(); }, }); function handleSubmit(data: { title: string; description?: string }) { createTask.mutate(data); }这个调用方式看起来就像调用本地函数但它实际执行的是网络请求。React Query 会自动处理 loading、error、success 状态useMutation 返回的 mutate 方法在调用时会触发对应状态更新你不需要自己写请求状态管理。3.3 加上认证和权限t3code 的登录用的是 NextAuth接入 GitHub OAuth。配置写在 src/server/auth.tsimport NextAuth from next-auth; import GitHub from next-auth/providers/github; import { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ~/server/db; export const authOptions { adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], callbacks: { session({ session, user }) { if (session.user) { session.user.id user.id; } return session; }, }, }; export default NextAuth(authOptions);这里有一个关键配置session 回调里把 user.id 塞进 session.user。因为 PrismaAdapter 默认不把数据库 id 暴露给前端 session如果不加这个 callback你在 tRPC 的 protectedProcedure 里拿不到当前用户 ID。权限控制的核心在 tRPC 的中间件。create-t3-app 默认生成了一段 protectedProcedure 的定义本质是一个中间件如果 session 里没有用户直接抛 UNAUTHORIZED 错误有用户就把用户信息注入 ctx。// src/server/api/trpc.ts export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这样每个需要登录的接口只要用 protectedProcedure就自动完成了身份验证。相比 REST 里每个控制器手动去查 session这种声明式写法省事很多。3.4 部署到 Vercelt3code 的部署目标我选的是 Vercel因为 Next.js 是它亲儿子环境变量配置和自动部署都顺畅。在 Vercel 项目设置里需要填三个变量DATABASE_URL线上数据库的连接串。NEXTAUTH_SECRET用于加密 session 的随机字符串生产环境必须设置否则登录模块会提示错误。NEXTAUTH_URL线上域名比如 https://t3code.vercel.app。GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRETOAuth 应用凭证。部署前在本地执行 pnpm prisma migrate deploy 把线上数据库的迁移补上。注意不要跑到 Vercel 构建时自动跑 migrate因为构建环境不一定能访问到生产数据库的网络。最省心的做法是部署前手动跑一次迁移或者写一个部署前脚本显式执行。Vercel 部署完成后再把 GitHub OAuth 的回调地址改成线上域名否则登录会跳转失败。这套流程我第一次部署时踩了坑后面章节会细说。4. 常见问题与排查技巧实录4.1 前端类型总是报错但代码看起来没问题这是 t3code 项目里最容易被新手当成“灵异事件”的问题。现象是后端 router 明明加了一个新方法后端代码也没报错但前端调用 api.task.xxx 时TypeScript 报“属性不存在”。原因通常是 dev server 没有重新生成类型。tRPC 的前端类型由服务端 router 类型推导而来但这个推导结果在 Next.js 的编译缓存里是有时效的。改了 router 文件后如果 dev server 的热更新没有触发完整重编译前端会用旧的类型缓存。我的排查方法固定三步先保存所有文件确认后端代码没有编译错误。执行 tsc --noEmit看是否真的存在类型错误。如果这里过了说明代码本身没问题。重启 dev server让 Next.js 重新编译整个项目。注意VSCode 里的 TypeScript 语言服务和 Next.js 的编译是两个体系。有时候 VSCode 还显示红色波浪线但终端里 tsc 已经通过这种时候优先信终端结果或者重启 TS Server命令面板里输入 Restart TypeScript Server。4.2 部署后登录失败或者跳回首页Vercel 部署之后点登录按钮跳转到 GitHub授权完又跳回首页但页面没有显示登录状态。这个问题九成出在环境变量上。最容易被忽略的是 NEXTAUTH_SECRET。本地开发时不配这个变量 NextAuth 会警告但还能跑生产环境直接拒绝工作。生成一个强随机字符串openssl rand -base64 32然后填到 Vercel 的环境变量里重新部署。第二个常见原因是没有把 GitHub OAuth 的 callback 地址改成线上域名。GitHub 的 OAuth App 设置里Homepage URL 填 Vercel 分配的域名Authorization callback URL 填 https://你的域名/api/auth/callback/github。如果这一项不对GitHub 会拒绝回调。第三NEXTAUTH_URL 必须和生产域名一致不要留 localhost。本地开发时它会自动推断但线上就不一定了显式配置最稳妥。4.3 列表查询慢N1 查询问题t3code 的任务列表里每条任务要显示创建人名字、标签列表。我一开始在 tRPC router 里写了两次查询先查 Task再循环查每个 Task 的关联 Tag结果页面一打开数据库要执行几十条查询接口响应肉眼可见地慢。Prisma 的老手一眼就知道这是 N1 问题。解决办法是用 include 一次性把关联数据取出来getAll: protectedProcedure.query(({ ctx }) { return ctx.db.task.findMany({ where: { creatorId: ctx.session.user.id }, include: { creator: { select: { name: true, image: true } }, tags: { include: { tag: true } }, }, orderBy: { createdAt: desc }, }); });include 在 Prisma 里会生成 JOIN 查询而不是多次往返数据库。这里的取舍是如果关联层级太深比如多级嵌套查询语句会变得复杂甚至出现笛卡尔积。我一般只 include 两层再多就拆接口或在应用层做聚合。另外React Query 的缓存也是优化重点。t3code 的列表数据其实不需要每次进入页面都重新请求我给 useQuery 配了 staleTimeconst { data } api.task.getAll.useQuery(undefined, { staleTime: 30_000, });这样 30 秒内的重复访问直接走缓存不会打数据库。如果任务更新频繁可以在 mutation 成功后调用 invalidate 强制刷新。4.4 tRPC 输入校验失败报 400 错误t3code 里所有 mutation 的输入都用了 zod 校验。如果前端传的字段格式不对tRPC 会直接返回 400错误信息是英文的 zod 错误。新手容易在这卡壳明明填了标题为什么报错一个常见原因是 zod 的字符串校验默认是严格类型。如果你从前端传的 title 是 undefined但 zod 要求是必填就会校验失败。我在初始化表单时用空字符串而不是 undefined 默认值就避开了这个坑。还有一些情况是字段名对不上。比如 zod schema 里写的是 tags前端字段名写成了 tagListtRPC 会忽略多余字段只按 schema 解析。这种问题用 TypeScript 很难完全拦住因为 addTag 和 tagList 都是 undefined类型上不会报错。我自己的习惯是每个 mutation 的输入 schema 写完后去前端调用处确认字段名一致或者直接右键跳转到类型定义看一眼。5. 一些经验分享t3code 从初始化到第一版可用我前后花了一个周末。现在回头看最值的不是掌握了某个工具而是把“类型安全”从口号变成了工作习惯。有几个具体的心得我想单独拿出来说。第一不要一上来就铺太大。create-t3-app 默认模板里自带了一个 post 示例很多人上手会先急着改成自己的业务模型然后东加一个字段西加一个接口最后被各种类型报错劝退。我的做法是先把示例 post 完整跑通理解 router、procedure、客户端调用、invalidates 这一整条链路再去改自己的 schema。先学会走路再跑起来。第二遇到类型问题优先怀疑“缓存”和“重启”。我不是说代码一定没错而是在 tRPC Next.js 这种强类型项目里大量报错其实源自类型没刷新。先重启 dev server再 CtrlShiftP 重启 TS Server最后再查业务代码。按这个顺序排查大部分问题 5 分钟内解决。第三写 procedure 时保持“一个函数只做一件事”。t3code 早期我写过一个 getAllAndStats 的接口既返回列表又返回统计数字前端用起来是方便但后端每次查询都多算一遍聚合性能不划算。后来拆成两个接口统计用单独的 useQuerystaleTime 拉长到 60 秒列表保持高频刷新两边互不拖累。第四NextAuth 的 session 类型要自己补充。模板里默认的 session.user 只有 name、email、image没有 id。如果你在 procedure 里用 ctx.session.user.idTS 会报错。按我前面写的在 auth.ts 的 callbacks 里把 id 塞进 session这样类型和运行时都安全。如果你打算复刻 t3code我的建议是从任务管理这个最典型的 CRUD 场景入手它足够简单但又有认证、关联表、权限、缓存这些真实业务的骨干。把这套链路跑通之后再换成任何业务模型都只是替换 Prisma schema 和 router 里的逻辑而已。最后补一个小技巧数据库模型里凡是涉及多对多的关系命名时尽量用“实体 实体”Tag、TaskTag少用含义模糊的中间表名。t3code 的 TaskTag 虽然看着冗余但在 tRPC 的 include 里语义非常明确task.tags 下面挂的是 tag不会产生歧义。等你想在这个关联表上扩展业务字段比如“打标签的人是谁、什么时候打的”直接往 TaskTag 上加列就行不需要改已有代码。这种命名上的小选择会在项目变大后省下大量改查询的时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑