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类型安全的ORMSchema一改类型马上跟着变。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.contexttypeof 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-applatest t3code创建过程中会问你要用哪些模块。t3code选了Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。没选Drizzle因为我当时更想用Prisma的成熟生态迁移工具和社区资料都多遇到问题好查。初始化之后先把基础目录跑起来cd t3code npm install npm run dev默认端口3000页面能打开就算第一步完成。接着要做的是把Prisma的数据库连接改成自己的库。开发环境我用的是docker起一个Postgresdocker run --name t3code-pg -e POSTGRES_PASSWORDdevpassword -e POSTGRES_DBt3code -p 5432:5432 -d postgres:16-alpine然后修改.env里DATABASE_URL为DATABASE_URLpostgresql://postgres:devpasswordlocalhost:5432/t3code?schemapublic这里我建议直接用Postgres别在本地用SQLite、线上切Postgres。两种数据库在Prisma里的语法和约束有差异SQLite上没问题的SQLPostgres上可能因为类型严格而出错。早点统一环境后面省得踩雷。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做的多阶段构建既保证镜像体积可控又避免开发依赖带进生产环境。# syntaxdocker/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 --fromdeps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED1 RUN pnpm prisma generate RUN pnpm build FROM base AS runner ENV NODE_ENVproduction COPY --frombuild /app/.next/standalone ./ COPY --frombuild /app/.next/static ./.next/static COPY --frombuild /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里按CmdShiftP执行“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_URLpostgresql://user:passhost:5432/t3code?schemapublicconnection_limit5pool_timeout105.3 NextAuth登录回调地址问题开发环境一切正常部署后点登录却跳回错误地址或者报[auth][error][redirect_denied]。原因是NextAuth在反向代理后面拿不到正确的Host头。解决方式在NextAuth v5里是设置环境变量AUTH_TRUST_HOSTtrue同时确认AUTH_URL老版本叫NEXTAUTH_URL配置成了你对外访问的完整地址。如果前面再加一层Nginx还要保证转发头里带上了X-Forwarded-Proto和Host否则HTTPS会被误判成HTTPCookie也存错域。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查询、一个页面渲染。闭环通了再往里加需求、看板、通知这些功能你会发现每一步都走在可运行的代码上心里特别踏实。