资讯动态

T3 Stack全栈实践:用Next.js+Prisma+tRPC构建端到端类型安全应用

发布时间:2026/10/28 5:43:58 来源:尧图企业网站定制
最近把一个基于 T3 Stack 的完整项目从头到尾跑通了项目代号就叫 t3code。这名字没什么高深含义T3 三个字母加上 code直接说明了它是 T3 全家桶的代码实践。在这之前我对 Next.js、Prisma、tRPC 这些名词都算熟悉文档也刷过多遍但真正完整搭一个有用户认证、数据库、增删改查的落地应用时问题还是远比预想的多。这篇文章就把 t3code 从脚手架初始化、数据建模、接口封装、前端联调到部署上线的完整流程拆开讲重点说清楚每一步为什么要这么做以及哪些地方容易踩坑。适合用 Next.js 做全栈项目、又不太想写一堆手写类型和 REST 接口定义的人也想给正在了解 T3 Stack 的初学者一条从零到一、可直接参照的路径。1. 项目定位与 T3 Stack 选型逻辑1.1 t3code 到底在解决什么问题我最初想得很简单做一个轻量的团队任务看板。小团队内部用放在公网或者内网都行核心是创建看板、添加任务、拖动或点击变更状态、按负责人过滤再配上登录。功能不复杂但这类应用的痛点很典型——认证、数据模型、接口层、前端状态管理四块东西要拼在一起传统做法里接口定义、类型定义、数据库表结构几乎是三套独立代码改一个字段要同步改三处漏改一处就运行时报错。t3code 要解决的问题也就在这能不能用一套 TypeScript 类型把数据库、服务端接口、前端调用整个串起来让数据从 Postgre 走到 UI 的过程中类型检查器一路盯到底。这听起来像是“理想型”工程设想但 T3 Stack 确实把它做成了开箱即用的一组工具链组合。对个人开发者和小团队来说最大的收益不是少写几行代码而是改数据模型时不需要再脑补接口文档、不需要维护一份可能过期的类型定义编译器会在所有需要同步修改的地方直接报错。任务看板这个选题是我特意选的因为它麻雀虽小五脏俱全有用户、有父子关系数据、有枚举状态、有带归属权限的写操作、有列表查询几乎能覆盖日常业务项目里八成以上的数据操作模式。用这个项目跑通 T3之后换到电商、CMS、工具类项目套路完全是相通的。1.2 为什么固执地选择 T3 Stack 全家桶T3 Stack 不是某个单一框架而是由Next.js TypeScript Tailwind CSS tRPC Prisma NextAuth.js组成的一组“受控的固执己见”技术栈。这个组合的每个成员解决一个问题工具职责我为什么认可它Next.js应用框架、路由、服务端渲染同时承担页面和 API 层减少部署复杂度TypeScript静态类型系统类型安全是这个技术栈的灵魂Tailwind CSS原子化样式写 UI 快不额外引入 CSS 架构负担tRPC端到端类型安全接口不需要接口文档server 端类型自动流向 clientPrisma数据库 ORM 与迁移schema 即模型声明迁移文件可审查NextAuth.js认证方案支持多种 Provider和 Prisma 适配器配合完整为什么我对这个选型比较坚持最核心的一点是省掉了一整层“接口类型手工维护”的工作。以前用 REST React Query 做项目后端需要 OpenAPI 或 Swagger前端要装 codegen 工具或者手写AxiosResponseFoo。tRPC 的做法是server 端定义一个 router类型能被 client 在编译期直接推导不需要额外生成代码也不需要“接口名 参数 返回类型”的文档卡片。这不是说 T3 比其他方案更好到碾压——REST 依然适合大量第三方开放 API 的场景GraphQL 在复杂聚合查询上也有天然优势。但在一个前后端同仓、单人/小团队维护的全栈项目里T3 的“类型单源”优势会被放到最大。很多团队抱怨类型安全麻烦本质上是工具链太割裂tRPC 把这条链路补得足够顺滑。我也留意到T3 组合里没有 Redux、Zustand 这类状态管理库这是刻意取舍。Next.js App Router 下的 Server Component 能直接读取数据库和调用 service 层客户端只需要处理局部 UI 状态和 mutation 的 loading 态再用 tRPC 的 React Query 缓存覆盖服务端状态。真把状态管理库引进来反而会模糊数据流的边界。2. 初始化与前置配置全流程2.1 create-t3-app 脚手架下的关键取舍初始化 t3code 时我用了官方脚手架命令npm create t3-applatest t3code交互式提示会让你选择要不要 App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth.js、ESLint 这些模块。我全部选上了。一个真实项目的初始化阶段与其后面再手动补模块不如一开始就走完整组合脚手架生成的文件结构经过了大量项目验证比手动组装稳定得多。这里有一个容易忽略的选项环境变量 schema。create-t3-app 会在src/env.js里用 Zod 做环境变量校验启动时如果缺少DATABASE_URL这类必备变量会直接报错而不是等运行时才炸。很多人在这一步觉得麻烦喜欢把所有变量设成 optional我建议不要这样做尤其是NEXTAUTH_SECRET这种变量缺失时的行为非常隐蔽早暴露远好于晚暴露。项目生成后第一件事就是打开.env# 数据库连接我本地用 Docker 跑了一个 PostgreSQL DATABASE_URLpostgresql://postgres:postgreslocalhost:5432/t3code?schemapublic # NextAuth 相关 NEXTAUTH_SECRET用 openssl rand -base64 32 生成一串 NEXTAUTH_URLhttp://localhost:3000 GITHUB_CLIENT_ID你的GitHub OAuth App ID GITHUB_CLIENT_SECRET你的GitHub OAuth App SecretGitHub OAuth 需要在 GitHub 上创建一个 OAuth AppAuthorization callback URL 填http://localhost:3000/api/auth/callback/github。这一步新手很容易卡住——回调地址填错登录后就会跳到 404。调试时可以直接看api/auth/session的返回能快速判断是 provider 配置问题还是 callback 地址问题。启动开发服务前还要先同步数据库npx prisma migrate dev --name init这时如果DATABASE_URL格式或数据库权限不对脚手架立刻就报错反过来也验证了 env 校验的价值。2.2 目录结构与三个容易忽略的配置文件初始化完成后t3code 的目录结构如下t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/auth/[...nextauth]/route.ts │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── ... │ ├── pages/ # 旧版 Pages Router 会生成App Router 模式没有 │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ ├── utils/ │ │ └── api.ts │ └── env.js ├── next.config.js └── tailwind.config.ts三个容易被忽略但很重要的文件第一个是src/server/api/root.ts。它是所有子路由器的聚合入口。每次新建一个业务 router都要在这里挂载一次否则客户端调接口时找不到对应 procedure// src/server/api/root.ts import { boardRouter } from ./routers/board; import { taskRouter } from ./routers/task; import { createTRPCRouter } from ~/server/api/trpc; export const appRouter createTRPCRouter({ board: boardRouter, task: taskRouter, }); export type AppRouter typeof appRouter;第二个是src/server/api/trpc.ts。它定义了 tRPC 的上下文与初始化逻辑。上下文是整个请求生命周期里共享的对象里面通常会放session和db。我在这个文件里看到了 create-t3-app 的默认做法用 inner 函数构造不依赖请求的数据再按需引入 headers。实际业务中tRPC 的 procedure 能拿到 session 用户 ID全靠这个上下文所以这里的类型不能随便标注成 any。第三个是src/server/auth.ts。它导出了 NextAuth 配置包括 adapter、providers、session 策略、callbacks。常见问题是很多人直接在app/api/auth/[...nextauth]/route.ts里写完整配置但 t3code 把配置单独拆出来是为了其他 Server Component 和服务端逻辑可以复用同一份 auth 配置。我也留意到src/utils/api.ts是 client 侧 tRPC 实例它引用了AppRouter类型从而获得完整的类型推导能力。这一层是“类型单源”的最后一公里service 端 router 的类型从这里流向 React Query 的useQuery/useMutation。3. 核心模块实操从数据模型到页面渲染3.1 设计 Prisma 模型任务看板的数据底层任务看板的核心数据模型我设计成四个实体用户、看板、列、任务。关系如下User 拥有多个 Board也能创建 TaskBoard 属于某个 User包含多个 Column 和 TaskColumn 属于某个 Board任务可以挂在 Column 下Task 属于某个 Board 和 Column且有状态枚举对应的prisma/schema.prisma核心部分generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } enum TaskStatus { TODO IN_PROGRESS DONE } model User { id String id default(cuid()) name String? email String? unique boards Board[] tasks Task[] } model Board { id String id default(cuid()) title String owner User relation(fields: [ownerId], references: [id], onDelete: Cascade) ownerId String columns Column[] tasks Task[] createdAt DateTime default(now()) } model Column { id String id default(cuid()) name String order Int default(0) board Board relation(fields: [boardId], references: [id], onDelete: Cascade) boardId String tasks Task[] } model Task { id String id default(cuid()) title String description String? status TaskStatus default(TODO) order Int default(0) board Board relation(fields: [boardId], references: [id], onDelete: Cascade) boardId String column Column? relation(fields: [columnId], references: [id], onDelete: SetNull) columnId String? createdBy User relation(fields: [createdById], references: [id]) createdById String createdAt DateTime default(now()) updatedAt DateTime updatedAt }主键用cuid()而不是自增数字主要考虑是多个环境数据合并或迁移时不容易撞 IDAPI 层也不会把内部自增主键暴露出去。onDelete: Cascade保证删除看板时关联的列和任务也跟着清掉否则删除操作会被外键约束挡下来留下孤儿数据。Column和Task的关系用onDelete: SetNull这是有意的设计删除列时任务不应该被一起删掉而是变成未分列状态。写完 schema执行迁移命令npx prisma migrate dev --name init这个命令会做三件事比对 schema 生成迁移 SQL、将迁移应用到本地数据库、重新生成 Prisma Client。执行完成后prisma/migrations/目录里会有一个带时间戳的文件夹里面的 SQL 文件应该被提交到代码仓库。迁移文件是团队的宝贵资产不要加到 .gitignore。之后团队成员拉代码后执行npx prisma migrate deploy就能把库结构调整到一致状态。3.2 用 tRPC 打通前后端类型安全通道数据模型确定后最核心的一步是把数据库操作暴露成 tRPC 的 procedure。我按照领域拆分 router任务相关操作放在src/server/api/routers/task.ts// src/server/api/routers/task.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const taskRouter createTRPCRouter({ // 查询某个看板下的任务列表 getByBoard: protectedProcedure .input(z.object({ boardId: z.string().min(1) })) .query(async ({ ctx, input }) { return ctx.db.task.findMany({ where: { boardId: input.boardId }, orderBy: [{ order: asc }, { createdAt: asc }], include: { createdBy: { select: { name: true } }, column: { select: { id: true, name: true } }, }, }); }), // 创建任务 create: protectedProcedure .input( z.object({ boardId: z.string().min(1), columnId: z.string().optional(), title: z.string().min(1, 任务标题不能为空).max(100), description: z.string().max(500).optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { title: input.title, description: input.description, boardId: input.boardId, columnId: input.columnId, createdById: ctx.session.user.id, }, }); }), // 更新任务状态 updateStatus: protectedProcedure .input( z.object({ taskId: z.string().min(1), status: z.enum([TODO, IN_PROGRESS, DONE]), }) ) .mutation(async ({ ctx, input }) { return ctx.db.task.update({ where: { id: input.taskId }, data: { status: input.status }, }); }), });这里有几个新手很容易忽略的细节为什么用protectedProcedure它来自createTRPCRouter文件里预定义好的“受保护版 procedure”内部会判断 session 是否存在。未登录用户直接调用时tRPC 会抛出 UNAUTHORIZED 错误根本轮不到业务代码执行。这是认证的第一道闸门。为什么输入用 zod 校验客户端传进来的参数被认为是不可信的哪怕前端已经做了表单校验。z.string().min(1).max(100)在后端再校验一次能挡住明显异常的数据。至于和 Prisma 类型之间的配合zod 输入推导的类型和ctx.db.task.create的 data 类型基本一致编译器会在不匹配时直接提醒你比如某个字段是undefined但数据库层又不接受。query 和 mutation 的分工tRPC 沿用了 React Query 的命名习惯query 负责读mutation 负责写。这个区分也决定了缓存失效的粒度后面讲页面时再展开。客户端调用时不需要写任何接口路径也不用生成类型定义use client; import { api } from ~/utils/api; function CreateTaskForm({ boardId }: { boardId: string }) { const utils api.useUtils(); const createTask api.task.create.useMutation({ onSuccess: () { utils.task.getByBoard.invalidate({ boardId }); }, }); function handleSubmit(formData: FormData) { const title String(formData.get(title) ?? ); createTask.mutate( { boardId, title }, { onError: (error) { console.error(error.message); }, } ); } return ( form action{handleSubmit} input nametitle placeholder任务标题 required maxLength{100} / button typesubmit disabled{createTask.isPending} {createTask.isPending ? 创建中... : 添加任务} /button /form ); }utils.task.getByBoard.invalidate({ boardId })是 React Query 的按参数精确失效创建成功后只刷新当前看板的任务列表缓存其他看板的缓存不受影响。不用手写事件总线或全量刷新这个细节在实际业务里很提升体验。3.3 接入 NextAuth 并处理会话与权限t3code 用 GitHub OAuth 实现登录。NextAuth 的配置集中在src/server/auth.ts// src/server/auth.ts import { getServerSession, type NextAuthOptions } from next-auth; import GitHubProvider from next-auth/providers/github; import { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ~/server/db; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(db), session: { strategy: jwt }, providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], callbacks: { jwt({ token, user }) { if (user) token.id user.id; return token; }, session({ session, token }) { if (session.user) session.user.id token.id as string; return session; }, }, }; export function getServerAuthSession() { return getServerSession(authOptions); }这里有个非常重要的细节使用了PrismaAdapter(db)只有这样才能把 GitHub 账号和本地 User 表关联起来。否则即使 GitHub 登录成功数据库里也不会有对应的 User 记录tRPC 上下文里的session.user.id就是一个不存在的 ID导致创建任务时外键约束报错。session 策略我选了jwt没有用默认的 database session。对 t3code 这种规模的项目JWT 策略少了一次每次请求都查 session 表的开销并且 token 里可以直接携带用户 ID。代价是用户被禁用后已有 token 在过期前仍然有效但个人项目完全可接受。tRPC 的上下文里是这样拿到 session 的// src/server/api/trpc.ts关键部分 import { getServerAuthSession } from ~/server/auth; import { db } from ~/server/db; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db, session, headers: opts.headers }; };权限校验不能只靠 UI 隐藏按钮必须在服务端 procedure 里做归属校验。比如更新任务就看任务是否属于当前用户删除看板就看board.ownerId session.user.id。我见过不少项目只在页面上判断了“是不是管理员”接口层却放任不管结果被人拿脚本直接调接口改数据。tRPC 的参数校验、session 校验、业务归属校验三层都做齐才算合格的接口。3.4 页面与 API 的协作一个可复现的全流程最后把整条链路拼起来。以“看板详情页”为例第一步Server Component 读取初始数据。Next.js App Router 下页面默认是 Server Component可以直接在服务端获取 session 并查询数据库。t3code 的做法是在src/server/api/root.ts里导出createCaller然后这样用// src/app/boards/[boardId]/page.tsx import { createCaller } from ~/server/api/root; import { getServerAuthSession } from ~/server/auth; import { redirect } from next/navigation; import BoardView from ./board-view; export default async function BoardPage({ params }: { params: { boardId: string } }) { const session await getServerAuthSession(); if (!session) redirect(/api/auth/signin); const caller createCaller({ headers: new Headers() }); const board await caller.board.getById({ boardId: params.boardId }); const tasks await caller.task.getByBoard({ boardId: params.boardId }); return BoardView initialTasks{tasks} board{board} /; }createCaller可以在服务端直接调用 tRPC procedure不需要走网络请求。它的类型和 tRPC client 完全同源所以caller.board.getById的入参、返回值类型都是受编译器检查的。第二步Client Component 接管交互。初始数据通过initialData传给 React Query之后新增、更新状态都走 tRPC mutation然后通过缓存失效刷新列表use client; import { api } from ~/utils/api; export default function TaskList({ boardId, initialTasks }: { boardId: string; initialTasks: TaskPayload[]; }) { const utils api.useUtils(); const { data: tasks } api.task.getByBoard.useQuery( { boardId }, { initialData: initialTasks } ); const updateStatus api.task.updateStatus.useMutation({ onSuccess: () { utils.task.getByBoard.invalidate({ boardId }); }, }); return ( div classNameflex gap-4 {tasks.map((task) ( div key{task.id} classNamerounded border p-4 h3{task.title}/h3 p{task.description}/p select value{task.status} onChange{(e) updateStatus.mutate({ taskId: task.id, status: e.target.value as TODO | IN_PROGRESS | DONE, }) } option valueTODO待办/option option valueIN_PROGRESS进行中/option option valueDONE完成/option /select /div ))} /div ); }第三步数据流闭环。这时的完整链路是用户操作 → Client Component 里api.task.updateStatus.useMutation→ tRPC POST 请求 → Server 端updateStatusprocedure 里的 zod 校验 鉴权 Prisma 更新数据库 → 返回数据 → React Query 更新缓存 →onSuccess里调用invalidate让列表重新获取最新数据。这整条链路从客户端调用到数据库写入所有类型都能在编写过程中被 TypeScript 检查器约束住。改数据库模型后Prisma Client 类型会立刻变化然后 tRPC 的返回值跟着变再然后useQuery拿到的类型也变编译错误会指向所有需要改动的地方。这就是端到端类型安全最有价值的体现。4. 踩坑实录与排查技巧4.1 Prisma 迁移与 schema 变更中的四个典型问题t3code 在开发过程中迭代了不少次 schema最容易出问题的就是 Prisma 迁移。我把实际踩过的问题整理成一张速查表问题现象常见原因解决思路migrate dev报 shadow database 权限不足数据库账号没有 CREATE DATABASE 权限无法建临时影子库给账号分配权限或者本地直接用超级用户共享库环境改用migrate deploy 手工 SQL修改 enum 值时报错Prisma 生成的一条 ALTER 语句不兼容当前 Postgres 版本/已有数据拆成多步迁移先新增枚举值再更新已有数据最后删旧值带外键关系的表删数据卡死存在关联记录没有设置onDelete策略在设计 schema 时为每条 relation 显式设置 Cascade / SetNull / Restrictmigrate reset后种子数据没自动插入Prisma 不会自动运行 seed除非配置了prisma: { seed: ... }在package.json配置 seed 命令重置后用npx prisma db seed印象最深的是shadow database的问题。本地开发时migrate dev会临时创建一个影子数据库来对比 schema 差异如果数据库账号只有业务库的权限而没有创建库的权限就一直报Permission denied to create database。我当时是在一台多人共用的 Postgres 实例上调试后来直接给本地专用账号加上了CREATEDB权限问题就消失了。上生产环境时千万不要在部署机跑migrate reset那会把数据全清掉应该用migrate deploy只应用未执行的迁移文件。4.2 tRPC 类型报错的常见成因与排查方法tRPC 圈子里最常见的一句话是“类型报错了怎么办”。我统计了自己在 t3code 里遇到的问题绝大多数不是 tRPC 本身的问题而是用错了类型上下文第一个高频问题在 Client Component 里引用了服务端类型。比如在use client组件里 import 了AppRouter或某个 server-only 的类型Next.js 会直接抛错。正确做法是 client 组件只依赖从~/utils/api导出的api对象所有客户端需要的类型都应该从 api 调用的返回结果推导而不是手动 import server 类型。第二个高频问题session 里的 user 类型没有扩展到{ id: string }。NextAuth 默认的Session[user]类型只包含name、email、image等字段没有id。我在 tRPC procedure 里写ctx.session.user.id时TypeScript 会提示属性不存在。解决办法是在项目里声明模块扩展// src/types/next-auth.d.ts import { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; } DefaultSession[user]; } }第三个高频问题zod 输入的字段和 Prisma 需要的字段不一致。举个例子前端传入的时间通常是 ISO 字符串而 Prisma 的DateTime字段是Date对象直接在data里赋值会报类型错误。解决方式是在 mutation 内部做转换而不是简单地把 zod 的 input 类型直接当成 Prisma 的 data 类型。这类错误多写几个 router 之后会形成肌肉记忆前端传什么、后端校验什么、数据库要什么三个层面要分开看。第四个问题是开发时的类型缓存。tRPC 在 dev 模式下偶尔会出现改了 router 但 client 侧类型没更新的情况。遇到这种玄学问题先别怀疑逻辑重启一遍 dev server大多数情况下是 Next.js 的编译缓存没刷新。实在不行就删掉.next目录重新跑npm run dev。4.3 部署阶段容易忽略的环境变量陷阱t3code 我最终部署到了 Vercel这一步踩的坑数量不亚于开发阶段。先把容易出错的地方列出来环境变量必须全部在平台侧配置。我在本地.env里写了DATABASE_URL、NEXTAUTH_SECRET等部署到 Vercel 时如果不在项目设置里添加这些环境变量线上应用能启动但功能处处异常登录跳转 404、数据库连接超时、NEXTAUTH_SECRET缺失导致 session 签名失败。Vercel 的 Environment Variables 面板里NEXTAUTH_URL尤其容易错——它必须指向线上的域名比如https://t3code.vercel.app如果还是http://localhost:3000回调地址就会全部失效。数据库连接串的网络安全组限制。我用的是云数据库默认只允许白名单 IP 连接。Vercel 的 Serverless Function 出口 IP 不固定如果数据库侧只开了本地机器的 IP线上就会间歇性连接失败。解决方法是把数据库连接方式改成公网加密连接并在防火墙层放通对应端口或者使用支持连接池的云数据库方案。别在构建时跑prisma migrate dev。我在早期 CI 配置里用过migrate dev --name deploy结果构建环境没有数据库写权限整个部署直接失败。推荐做法是本地或 CI 中执行prisma migrate deploy来应用迁移文件构建过程只执行prisma generate生成客户端。这种顺序能保证数据库结构调整只发生一次且可以被审查和回滚。NextAuth 的 secret 一定不要提交到代码仓库。即使是私有仓库也建议把 secret 放在平台环境变量里。泄漏 secret 的后果不仅是会话可以被伪造还会让所有在线用户会话面临风险。生成高强度 secret 最简单的方式是openssl rand -base64 32把输出的一整串复制到NEXTAUTH_SECRET即可。4.4 T3 的边界什么时候不该用它t3code 做完之后我也更清楚 T3 Stack 的适用范围了。它不是万能模板以下情况我建议三思而后行项目需要开放 API 给第三方客户端。tRPC 本身是为“同一个应用的前后端”设计的它没有为外部消费者准备跨语言 SDK 或完善的接口文档体系。如果业务还包含开放平台、移动端原生调用、第三方 webhook 等场景REST 或 GraphQL 会更合适。团队前端经验偏弱或纯后端团队维护前端。T3 的类型安全红利高度依赖 TypeScript 的参与如果团队不熟悉类型思维反而会把这套体系当成负担。数据查询模式特别复杂。需要大量跨实体统计、多层嵌套筛选时tRPC 其实也可以做但维护成本会上升这时候 GraphQL 的查询能力优势更明显。至于“什么时候果断用 T3”快速验证产品想法、内部工具、个人全栈项目、前端想顺手掌握数据库操作这些都是它的主场。5. 最后分享几个实际操作中的体会t3code 跑完之后我很明显感觉到端到端类型安全最爽的时刻不是“写代码时”而是“重构后”。我中途改过一次任务和看板的关系把原来任务直接挂在看板下的设计改成了通过 Column 挂载。如果是在传统 REST 项目里我可能要同时改数据库表、后端接口、前端 fetch 的路径、TypeScript 接口还要担心哪里漏了。但在 t3code 里我改完 Prisma schema 和生产数据的迁移然后跟着编译错误一个个修整个改动过程几乎没有“运行时才知道坏了”的情况。这种体验靠的不是某个单独工具而是 T3 整套组合把类型推到了数据链路的终点。还有一个值得养成的习惯给 tRPC mutation 的onSuccess里写精确的缓存失效逻辑而不是为了省事直接router.refresh()或全量 invalidate 所有 query。一开始我图快创建任务后直接把所有任务列表缓存清掉数据量小的时候没感觉数据一多用户就会明显感到卡顿。改成按boardId参数精确失效后体验提升很明显这也是 React Query 缓存机制的正确用法。最后说一个小技巧如果打算长期维护这类项目建议写一个简单的 README把本地启动步骤、prisma 迁移命令、环境变量清单都列出来。两个星期后再回来看代码你会发现这些“不起眼”的文档比任何架构图都管用。t3code 这个项目本身不算复杂但它把 T3 Stack 的核心优势完整地发挥出来了——对一个想用全栈 TypeScript 快速交付的开发者来说这是目前我觉得最顺手的套路没有之一。

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

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

免费获取报价 →
↑