ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

T3 Stack实战:构建前后端类型贯通的全栈内部协作工具

T3 Stack实战:构建前后端类型贯通的全栈内部协作工具

t3code是我最近小半年一直压在手上的一套全栈项目代号。名字没起什么花活,就是“T3 Stack”直接顺过来的,但整套流程跑下来,我对“前后端类型贯通”这件事的理解,确实比之前写REST接口时深了一个台阶。如果你也在纠结要不要上T3这套玩法,或者正准备用Next.js搭一个需要登录、需要写数据、需要团队协作的内部工具,这篇文章应该能给你一份还算完整的参考。

t3code做的是个很常见的场景:一支小团队内部的需求与任务协作工具。需求池、任务看板、成员管理,外加一个自动化同步的接口清单。底层用的就是T3 Stack的标配组合——Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。它要解决的核心问题也很直白:需求描述不再靠聊天记录里的口语化表达,任务状态不再靠表格人工维护,前后端类型不用再各写一份然后等着对不齐。

1. 项目定位与整体设计思路

1.1 这个项目到底在解决什么问题

先说背景。我之前在团队里最常遇到的情况是这样:产品在IM群里丢一段语音或者几行文字,说“这里加个字段,那里加个状态”,开发听完凭感觉建表,前端凭后端接口文档猜类型,等接口真的出来,文档已经过时了。后端改了字段名,前端没跟上,一运行就是满屏的TypeError。这种问题不算难,但特别消磨人。

t3code就是冲着这件事去的。它把需求、任务、接口三者放到同一个系统里:需求有结构化字段,比如优先级、影响范围、期望上线时间;任务挂在需求下面,状态流转有明确规则;接口清单不再是手写的Markdown,而是直接从代码里生成出来的快照,后端定义一次,前端引用同一份类型。换句话说,它不是一个给人“演示”的项目,而是一个真正能拿来当团队内部工具用的项目。

当时的定位很低调:不追求做得像Jira那么大,也不打算做成开源产品,只要内部五到十个人用着顺手、不出乱子就够。这一定位直接影响了很多技术决策。比如我不需要极致的性能,但需要开发效率和可维护性;不需要支持多租户,但需要干净的权限控制;不需要对外开放API,所以可以放心大胆用tRPC。

1.2 为什么选T3 Stack而不是传统“前后端分离”

可能有人会说,这种内部工具用老套路就行:前端Vue,后端Spring Boot或者Express,搞个Swagger文档,再配个接口管理平台。不是说这条路走不通,而是维护成本真的高。

我先看一眼T3能带来什么:

  • TypeScript:全链路类型检查,从数据库到前端组件,类型不再断裂。
  • Next.js:服务端渲染和API能力合在一个项目里,少维护一套服务。
  • Tailwind CSS:写页面速度快,不来回切CSS文件。
  • tRPC:前端调用后端像调用本地函数,不需要手写HTTP客户端的URL和参数。
  • Prisma:类型安全的ORM,Schema一改,类型马上跟着变。
  • NextAuth:登录会话和T3的上下文能直接绑定,权限判断方便。

传统REST方案的问题在于“中间层全靠自觉”。Swagger文档写得好不好,完全看团队纪律;后端改了类型,前端只能靠联调去踩雷;参数校验每个接口写一套,写多了就开始偷懒。GraphQL能解决一部分类型和文档问题,但带来的复杂度不低——Schema定义、resolver组织、缓存策略、查询优化,都要花时间学。

T3的思路更直接:既然前端后端都是TypeScript,那为什么还要让网络边界成为类型断裂的起点?tRPC做的事就是把这个边界“缝合”起来。服务端定义一个procedure,客户端直接拿调用结果,类型从服务端推到前端,编译期就能发现字段没对上。

我自己的体会是,这套组合特别适合两类场景:一是内部管理系统,二是不需要对外开放API的BFF层。如果项目有大量第三方API需要开放,或者前端不止Web还有多个小程序、App,那REST/GraphQL仍然有它的位置。但单就t3code这种内部工具来说,T3几乎是当前开发效率最高的方案之一。

2. 架构拆解与核心模块规划

2.1 目录结构与数据流转

T3工程由create-t3-app自动生成后,目录组织得很规整。我稍微调整过,但总体保持官方推荐:

t3code/ ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── public/ ├── src/ │ ├── components/ │ ├── pages/ │ │ ├── api/ │ │ │ └── trpc/[trpc].ts │ │ ├── dashboard/ │ │ ├── projects/ │ │ └── index.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ └── routers/ │ │ │ ├── project.ts │ │ │ ├── task.ts │ │ │ ├── requirement.ts │ │ │ └── user.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ └── utils/ │ └── trpc.ts └── package.json

数据流典型长这样:

  • 页面组件里调用api.project.list.useQuery()。
  • 前端通过/api/trpc/[trpc].ts这个单一入口发出请求。
  • 请求到达服务端,由root.ts里的 appRouter 分发到具体路由。
  • 路由里的 procedure 执行校验和鉴权,调用 Prisma Client 访问数据库。
  • 数据原路返回,因为类型在构建期已经推导好,前端拿到的对象天然带类型。

这中间最值得注意的地方是,tRPC把“接口定义”从URL规则变成了“类型声明”。对前端来说没有专职的API层文件,没有URL拼接,也没有any。删字段、改类型这类破坏性变更,在编译期就会被TS揪出来,而不是上线后接口突然报错。

2.2 认证与会话管理

内部工具必须有登录,否则数据没法隔离。我直接用了NextAuth的credentials provider,账号密码存在数据库里。

配置上最关键的一环,是把NextAuth的session对象注入到tRPC的context里:

// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, headers: opts.headers, }; }; const t = initTRPC.context<typeof createTRPCContext>().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); 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 }, }, }); });

这里有我踩过的一个小坑:最开始我把getServerSession写在每个路由里,后来发现太啰嗦,而且容易漏。统一在context创建时拿一次session,后面所有procedure都共享,干净很多。

密码存储用的bcrypt哈希,注册时hash,登录时compare。NextAuth的authorize回调里这一步绕不开:

import { compare } from "bcryptjs"; import { db } from "~/server/db"; export const authOptions: NextAuthOptions = { session: { strategy: "jwt" }, pages: { signIn: "/login" }, providers: [ CredentialsProvider({ name: "credentials", credentials: { email: { label: "邮箱", type: "email" }, password: { label: "密码", type: "password" }, }, async authorize(credentials) { const user = await db.user.findUnique({ where: { email: credentials?.email }, }); if (!user || !user.passwordHash) return null; const ok = await compare(credentials!.password, user.passwordHash); if (!ok) return null; return { id: user.id, name: user.name, email: user.email }; }, }), ], callbacks: { jwt({ token, user }) { if (user) token.uid = user.id; return token; }, session({ session, token }) { if (token.uid) session.user.id = token.uid as string; return session; }, }, };

2.3 数据模型设计要点

Prisma的Schema设计决定了整个应用的骨架。t3code里有四个核心实体:用户、项目、需求、任务。它们的关系不复杂,但有几个细节我觉得很重要。

第一是ID选型。我用的cuid()而不是自增整数。自增ID容易泄露业务规模,而且合并数据时容易冲突。cuid在分布式场景下更安全,索引性能也够用。

第二是软删除和级联删除的关系。任务删除我用了级联,需求删除也级联,但项目删除做了保护——项目下还有任务时不能直接删,需要先归档。这种业务规则写在代码里,而不是靠数据库裸删。

简化后的Schema大概是这样的:

model User { id String @id @default(cuid()) email String @unique name String? passwordHash String? projects Project[] tasks Task[] @relation("AssigneeTasks") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Project { id String @id @default(cuid()) name String slug String @unique ownerId String owner User @relation(fields: [ownerId], references: [id]) members ProjectMember[] tasks Task[] createdAt DateTime @default(now()) } model Task { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) title String status TaskStatus @default(BACKLOG) assigneeId String? assignee User? @relation("AssigneeTasks", fields: [assigneeId], references: [id]) requirementId String? requirement Requirement? @relation(fields: [requirementId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([projectId, status]) } enum TaskStatus { BACKLOG TODO IN_PROGRESS DONE CANCELED }

设计时我特别给Task加了@@index([projectId, status])。因为看板页面最常见的查询就是“某个项目下某个状态的一批任务”,没有这个联合索引,数据量大了之后查询会慢,加了之后扫描范围小很多。

3. 核心流程的实操实现

3.1 从零搭建工程

用官方脚手架创建项目,命令很简单:

npm create t3-app@latest t3code

创建过程中会问你要用哪些模块。t3code选了Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。没选Drizzle,因为我当时更想用Prisma的成熟生态,迁移工具和社区资料都多,遇到问题好查。

初始化之后,先把基础目录跑起来:

cd t3code npm install npm run dev

默认端口3000,页面能打开就算第一步完成。接着要做的是把Prisma的数据库连接改成自己的库。开发环境我用的是docker起一个Postgres:

docker run --name t3code-pg -e POSTGRES_PASSWORD=devpassword -e POSTGRES_DB=t3code -p 5432:5432 -d postgres:16-alpine

然后修改.env里DATABASE_URL为:

DATABASE_URL="postgresql://postgres:devpassword@localhost:5432/t3code?schema=public"

这里我建议直接用Postgres,别在本地用SQLite、线上切Postgres。两种数据库在Prisma里的语法和约束有差异,SQLite上没问题的SQL,Postgres上可能因为类型严格而出错。早点统一环境,后面省得踩雷。

3.2 数据库迁移与种子数据

Schema写完后,跑迁移:

npx prisma migrate dev --name init

这个命令会创建迁移文件,并把表建到数据库里。开发阶段我会频繁改Schema,所以migrate dev几乎是日常命令。但要注意,一旦有同事基于旧迁移文件做了开发,就不要再随便改已经提交的迁移文件,应该新增一次迁移。

种子数据对开发体验提升很大。没有数据,看板页面空荡荡,联调效率低。我在prisma/seed.ts里写了一套生成脚本,造了三个用户、两个项目、若干需求和任务。

package.json里打开种子配置:

"prisma": { "seed": "tsx prisma/seed.ts" }

执行:

npx prisma db seed

种子脚本里我会故意造一些“边界数据”:没有负责人的任务、状态为DONE但更新时间很早的任务、一个需求下挂两三个任务等。这样前端组件能尽早看到各种渲染状态,不用等到真实数据才暴露问题。

3.3 tRPC路由声明式写法

tRPC的核心概念是router和procedure。业务路由我按“实体”拆成多个文件,然后在root里合并。

需求路由示例:

// src/server/api/routers/requirement.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; const requirementCreateSchema = z.object({ title: z.string().min(2, "标题太短"), description: z.string().optional(), priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"), projectSlug: z.string(), }); export const requirementRouter = createTRPCRouter({ listByProject: protectedProcedure .input(z.object({ projectSlug: z.string() })) .query(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { slug: input.projectSlug }, }); if (!project) { throw new TRPCError({ code: "NOT_FOUND" }); } return ctx.db.requirement.findMany({ where: { projectId: project.id }, orderBy: { createdAt: "desc" }, include: { tasks: { select: { id: true, title: true, status: true } } }, }); }), create: protectedProcedure .input(requirementCreateSchema) .mutation(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { slug: input.projectSlug }, }); if (!project) { throw new TRPCError({ code: "NOT_FOUND" }); } return ctx.db.requirement.create({ data: { title: input.title, description: input.description, priority: input.priority, projectId: project.id, creatorId: ctx.session.user.id, }, }); }), });

注意几点:

  • 输入校验用Zod。tRPC天然把Zod和procedure绑定在一起,校验失败会自动返回结构化的zodError。
  • 所有需要登录的操作都用protectedProcedure,防止有人绕过前端直接调用。
  • 返回值不需要手写类型,Prisma查出来的对象自动推导。以后改include字段,前端拿到的类型立刻跟着变。

3.4 前端调用与类型推导

服务端定义好路由后,前端调用非常直接。以任务看板为例:

// src/pages/dashboard/[slug].tsx import { api } from "~/utils/api"; import type { NextPage } from "next"; const ProjectDashboard: NextPage<{ slug: string }> = ({ slug }) => { const { data: requirements, refetch } = api.requirement.listByProject.useQuery({ projectSlug: slug, }); const createTask = api.task.create.useMutation({ onSuccess: () => { refetch(); }, }); return ( <div> {requirements?.map((req) => ( <RequirementCard key={req.id} requirement={req} onCreateTask={(title) => createTask.mutate({ title, requirementId: req.id })} /> ))} </div> ); };

这里不要手动封装fetch、不要写axios调用、不要定义接口类型。api.requirement.listByProject的入参和出参都被推导出来了,字段名拼错直接飘红,传错类型直接编译报错。

表单校验我也把Zod用起来了。useForm配合Zod resolver,前端校验规则和服务端入参校验用的是同一份Schema定义——至少对核心字段保持一致。这样用户填表单时能立刻看到错误提示,后端也不怕收到脏数据。

4. 部署、性能与稳定性实践

4.1 多阶段Docker构建

内部工具最终要部署到一台云服务器上。我用Docker做的多阶段构建,既保证镜像体积可控,又避免开发依赖带进生产环境。

# syntax=docker/dockerfile:1 FROM node:20-alpine AS base RUN corepack enable WORKDIR /app FROM base AS deps COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile FROM base AS build COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm prisma generate RUN pnpm build FROM base AS runner ENV NODE_ENV=production COPY --from=build /app/.next/standalone ./ COPY --from=build /app/.next/static ./.next/static COPY --from=build /app/public ./public EXPOSE 3000 CMD ["node", "server.js"]

注意几个细节:

  • Next.js默认输出不是standalone,需要在next.config.js里加output: "standalone",这样产物才包含一个精简的Node服务入口。
  • prisma generate必须在构建阶段执行,否则容器里没有Prisma Client。
  • 不需要把node_modules整个拷进去,standalone产物已经包含了运行时依赖。

服务器上用docker compose管理,环境变量通过.env文件注入,主要三个:DATABASE_URL、NEXTAUTH_SECRET、AUTH_TRUST_HOST。

4.2 性能优化与缓存策略

t3code是内部工具,但我还是做了几项基础优化,成本低收益高。

第一个是tRPC的请求合并。tRPC支持HTTP批量传输,同一个页面里多个useQuery可以合并成一次请求。开发测试时打开Network面板能看到一个TRPC-Response: 1的响应,说明合并生效了。这样首屏加载的请求数量从七八个降到两三个。

第二个是数据库查询瘦身。看板页面初期我图省事,直接include了需求的全部任务,一次查出几百条记录,渲染没问题但数据量大后有冗余。后来改成按状态拉取第一屏,再配合“加载更多”按钮,体验反而更清爽。

第三个是静态页面策略。项目首页和个人信息页没有实时数据,我用了getStaticProps配合revalidate做ISR,让页面能在后台定时刷新,又不用每次请求都查数据库。

4.3 错误上报与日志

生产环境里点击按钮没反应是最难排查的。tRPC的errorFormatter已经帮我把Zod错误结构化了,但业务错误还需要记录。

我做了两层。第一层是在tRPC的errorFormatter里把错误信息写到日志;第二层是接入Sentry,把异常堆栈和用户id关联起来。登录后的用户操作出错,Sentry上报里能看到是哪个用户、哪个页面、哪个procedure。

日志方面,Next.js自带console够用,但为了结构化,我用了pino,把requestId、procedure名和耗时打印出来。排查慢请求时特别有用。

5. 常见问题与排查记录

5.1 tRPC类型不同步问题

这是用T3最常遇到的坑。表现是服务端改了返回字段,前端却仍然提示旧类型,甚至直接报“模块没有导出成员”。

常见原因有三个:

  • 修改Schema或路由后,没有重新生成Prisma Client。运行npx prisma generate可解决。
  • TS Server缓存没刷新。在VSCode里按Cmd+Shift+P,执行“TypeScript: Restart TS Server”。
  • tRPC类型链太长,IDE卡在旧快照。升级@trpc/*版本到最新稳定版,并把依赖锁文件重新生成。

我自己的习惯是,每次git pull后先跑npm install,再npx prisma generate,最后重启TS Server,基本能避开这类问题。

5.2 Prisma连接池与数据库连接数

上线第一天就遇到了“数据库连接数超过最大限制”。原因是每个Node进程开了多个Prisma Client实例,连接池膨胀,Postgres默认连接数100,几下就满了。

排查时我先用SELECT * FROM pg_stat_activity;查看连接来源,发现全是同一个应用的。解决方式是确保全局只实例化一个Prisma Client,官方推荐的较新写法是用prisma6的driverAdapters或直接导出单例:

// src/server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

另外,连接串里可以加连接数限制:

DATABASE_URL="postgresql://user:pass@host:5432/t3code?schema=public&connection_limit=5&pool_timeout=10"

5.3 NextAuth登录回调地址问题

开发环境一切正常,部署后点登录却跳回错误地址或者报[auth][error][redirect_denied]。原因是NextAuth在反向代理后面拿不到正确的Host头。

解决方式在NextAuth v5里是设置环境变量AUTH_TRUST_HOST=true,同时确认AUTH_URL(老版本叫NEXTAUTH_URL)配置成了你对外访问的完整地址。如果前面再加一层Nginx,还要保证转发头里带上了X-Forwarded-Proto和Host,否则HTTPS会被误判成HTTP,Cookie也存错域。

5.4 独家调试技巧

最后分享几个调试技巧,常规文档里写得不多,但真实开发里帮了我大忙。

一是React 18的严格模式在开发环境会重复执行effect,如果某个useQuery里触发mutation,可能看到请求发了两次。这不是bug,别慌。

二是tRPC返回的日期对象默认会被JSON序列化成字符串,前端拿不到Date类型。我开了superjson作为transformer,所有日期对象才能原样还原。如果没开superjson,建议立刻打开,只改一行配置。

三是想快速测试procedure而不通过UI,可以直接访问/api/trpc/<router>.<procedure>,比如/api/trpc/requirement.listByProject?input={"json":"{\"projectSlug\":\"demo\"}"},在浏览器里就能看到JSON响应。联调时不需要开完整页面,节省时间。

四是在本地跑通整个流程后,再用Docker在干净环境走一遍启动、迁移、登录。有些问题只会在“没有本地开发依赖”时暴露,比如漏装的sharp、缺失的libc。早一点做因为打包会少受折磨。

结尾

跑完t3code这个项目,我对T3这套体系最深的感触是:类型安全不是少数人的洁癖,而是切切实实减少沟通成本的工程杠杆。过去前后端接口对不齐,浪费的是双方的时间;现在改一个字段,编译器立刻告诉我哪里要同步改。这种反馈速度,用传统REST方案很难体验到。

如果你也在筹划一个中小型的全栈项目,尤其是内部工具或者BFF层,我真的建议试一把Next.js加tRPC的组合。前期花一两天熟悉目录结构和tRPC的写法,后面开发速度会有肉眼可见的提升。踩坑时也不要慌,无非就是Prisma类型没刷新、NextAuth代理没配好、连接池不够用这几类问题,今天这篇文章里基本覆盖了。

最后再分享一个小建议:起步阶段不要贪多,先把一个最小的闭环跑通——登录、建表、一条tRPC查询、一个页面渲染。闭环通了,再往里加需求、看板、通知这些功能,你会发现每一步都走在可运行的代码上,心里特别踏实。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进