最近把一个基于 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 端类型自动流向 client |
| Prisma | 数据库 ORM 与迁移 | schema 即模型声明,迁移文件可审查 |
| NextAuth.js | 认证方案 | 支持多种 Provider,和 Prisma 适配器配合完整 |
为什么我对这个选型比较坚持?最核心的一点是省掉了一整层“接口类型手工维护”的工作。以前用 REST + React Query 做项目,后端需要 OpenAPI 或 Swagger,前端要装 codegen 工具,或者手写AxiosResponse<Foo>。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-app@latest 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_URL="postgresql://postgres:postgres@localhost:5432/t3code?schema=public" # NextAuth 相关 NEXTAUTH_SECRET="用 openssl rand -base64 32 生成一串" NEXTAUTH_URL="http://localhost:3000" GITHUB_CLIENT_ID="你的GitHub OAuth App ID" GITHUB_CLIENT_SECRET="你的GitHub OAuth App Secret"GitHub OAuth 需要在 GitHub 上创建一个 OAuth App,Authorization 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,也能创建 Task
- Board 属于某个 User,包含多个 Column 和 Task
- Column 属于某个 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()而不是自增数字,主要考虑是多个环境数据合并或迁移时不容易撞 ID,API 层也不会把内部自增主键暴露出去。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 name="title" placeholder="任务标题" required maxLength={100} /> <button type="submit" 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 className="flex gap-4"> {tasks.map((task) => ( <div key={task.id} className="rounded 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 value="TODO">待办</option> <option value="IN_PROGRESS">进行中</option> <option value="DONE">完成</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 / Restrict |
migrate 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 快速交付的开发者来说,这是目前我觉得最顺手的套路,没有之一。