基于T3 Stack的全栈中后台模板:工程化实践与踩坑记录

📅 发布时间:2026/10/14 2:10:59
基于T3 Stack的全栈中后台模板:工程化实践与踩坑记录
1. t3code的定位一个可复用的全栈中后台底座1.1 从一次“重复造轮子”开始先讲讲t3code这个项目是怎么来的。大概一年多前我们团队接了一个内部运营后台的需求功能不复杂工单列表、人员管理、统计报表放在一般项目里可能一周就能写完。但真正动手时发现最耗时间的不是业务逻辑而是那些每个项目都得来一遍的“默认工作”——项目脚手架怎么搭、数据库模型怎么建、API路由怎么组织、登录鉴权怎么做、前端页面怎么连后端、部署到服务器时环境变量怎么配。这些工作在第一个项目里做完之后第二个、第三个项目又得重新做一遍哪怕用脚手架生成初始代码后续的权限控制、错误处理、通用组件仍然要重复搭建。t3code就是在这样的背景下成型的。它以T3 StackNext.js TypeScript tRPC Tailwind CSS Prisma为技术底座把内部后台类项目最常用的能力做成了一个模板仓库登录态与权限中间件、基于Prisma的数据模型示例、tRPC的Router组织方式、前端通用列表页与表单组件、Docker部署流程。说白了t3code不是一个从零发明的框架而是把T3 Stack这套技术栈“压扁”成了一份可以直接复制的工程骨架。1.2 t3code覆盖了什么、不覆盖什么我最初踩过的坑是把模板做得太重想替业务方把所有事情都决定好。后来调整了边界t3code只覆盖那些“每次都在重复且不容易改”的部分工程基础设施Next.js项目初始化、TypeScript配置、ESLint与Prettier、pnpm workspace数据访问层Prisma Schema基础模型、迁移脚本、种子数据API层tRPC Router拆分方式、鉴权中间件、错误格式化、zod输入校验UI基础组件基于Tailwind的表单控件、数据表格、分页、弹窗部署流水线Dockerfile、环境变量清单、GitHub Actions示例它刻意不覆盖的是具体业务表设计、页面交互细节、权限策略的完整实现。这些留给每个项目自己去填。这样做的原因很直接模板太薄省不了事模板太厚每个项目都在和模板打架。t3code要解决的是让每个新后台项目从“搭架子”变成“填业务”并且保证填业务的时候前后端类型安全不出错。如果你正在做的是小团队的内部工具、运营后台、MVP验证或者是第一次接触T3 Stack这套组合这个项目和这篇文章应该对你有点参考价值。如果你要做的是面向外部用户的高并发C端产品那更值得关注的是后面的性能优化和部署部分基础架构仍然适用但需要做更多加固。2. 起步阶段最容易后悔的三个选型包管理器、ORM与认证方案2.1 包管理器pnpm不是锦上添花而是约束T3 Stack默认推荐的包管理器是pnpm理由不是“快”这么简单。一个后台项目跑上几个月后node_modules体积膨胀、依赖版本冲突、多项目之间共享代码这些问题都真实会碰到。pnpm的硬链接机制让同一份依赖在磁盘上只存一份多个项目共享同一个store安装速度确实快但更关键的是它默认不扁平化的node_modules结构逼着你在package.json里显式声明每一个直接依赖间接依赖没有“蹭”到不会因为某个深层依赖升级导致本地跑得好好的、线上却报模块找不到。如果你之前用的是npm或者yarn转pnpm时有个细节容易忽略pnpm默认使用symlink方式链接依赖某些工具链比如node-gyp相关的原生模块、Prisma的二进制文件需要在postinstall脚本里单独处理。t3code的package.json里专门加了prisma postinstall生成client的步骤否则新成员clone仓库后很容易卡在“prisma命令找不到”这一步。scripts: { postinstall: prisma generate, dev: next dev, build: prisma generate next build, start: next start, db:push: prisma db push, db:migrate: prisma migrate dev }2.2 ORMPrisma和Drizzle的取舍t3code用的是Prisma不过做这个决定之前也认真对比过Drizzle。简单说下我的判断Prisma的优势在于Schema即文档、迁移工具成熟、类型生成稳定团队里哪怕不熟悉SQL的人也敢碰数据层Drizzle的优势在于更贴近SQL、性能开销更小、产物更轻。对内部后台来说绝大多数操作的瓶颈根本不在ORM这一层而在业务逻辑和前端渲染。Prisma生成的类型直接喂给tRPC从数据库到前端组件全程TypeScript贯穿这个收益远大于那一点性能差异。Drizzle的schema写起来更像SQL如果你团队的SQL功底很强或者对查询性能非常敏感Drizzle完全值得考虑但代价是迁移和schema管理没有Prisma那么省心。对比项PrismaDrizzleSchema定义独立DSL可读性强TypeScript代码定义查询API高度抽象链式调用更接近SQL语义迁移工具migrate dev/deploy成熟drizzle-kit功能在追赶类型生成自动生成完整Client类型类型推断为主适合场景后台管理系统、CRUD密集型高查询性能要求的业务2.3 认证选型NextAuth、Credentials还是自己写认证这事儿模板里最容易翻车。t3code一开始直接装了NextAuth并启用Credentials Provider结果发现内部后台要做的密码登录、会话持久化、用户角色管理绕来绕去还是在和Session策略、JWT回调较劲。后来我把方案改成了一条更朴素的路线登录接口由tRPC处理校验密码后签发httpOnly的session cookie服务端中间件统一解析cookie里的用户信息。这么做的好处是逻辑简单、好调试——登录成功就是成功失败就是返回明确的错误码没有NextAuth那套provider回调的隐式流程。代价是失去了NextAuth的社交登录、邮箱验证这些现成能力。对内部后台来说那些能力通常用不上。如果你要做面向公众的产品或者需要对接企业微信、钉钉、Google SSO那还是优先考虑NextAuth或Auth.jst3code预设的Credentials流程可以作为一个轻量起点照着中间件逻辑改起来也方便。这里特别提醒无论选哪种方案密码哈希一定要用bcrypt或argon2不要自己拼字符串再md5。这个错误犯过一次就够心痛了。3. 数据模型怎么写以工单系统为例的Prisma落地3.1 需求拆解工单系统要管哪些状态为了让t3code的数据库模板有参考价值我选了一个很典型的内部业务场景——工单系统来示范Schema设计。它没有特别复杂的算法但状态流转、多对多标签、用户与内容关联这些关系都有正好覆盖CRUD后台的大多数情况。工单的核心需求拆下来是几件事工单本身要有编号、标题、状态、优先级、创建人与创建时间工单下面要有评论评论属于某个用户工单可以打标签标签可以复用在多个工单上每个用户有角色至少区分管理员和普通成员。就这么简单的模型设计时仍然有一些容易忽略的点下面展开说。3.2 Schema落地的关键细节generator client { provider prisma-client-js } datasource db { provider mysql url env(DATABASE_URL) } enum Role { ADMIN MEMBER } enum TicketStatus { OPEN IN_PROGRESS DONE CLOSED } enum Priority { LOW MEDIUM HIGH URGENT } model User { id String id default(cuid()) name String email String unique passwordHash String role Role default(MEMBER) tickets Ticket[] comments Comment[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Ticket { id String id default(cuid()) code String unique title String status TicketStatus default(OPEN) priority Priority default(MEDIUM) ownerId String owner User relation(fields: [ownerId], references: [id], onDelete: Restrict) comments Comment[] tags Tag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Comment { id String id default(cuid()) content String db.Text ticketId String ticket Ticket relation(fields: [ticketId], references: [id], onDelete: Cascade) authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) } model Tag { id String id default(cuid()) name String unique tickets Ticket[] }三个我后来觉得特别值得说明的地方。第一主键没有用自增id而是cuid。原因很实际tRPC的mutation创建数据后要立刻拿到新记录id去更新前端状态自增id在分布式或数据搬迁场景下容易出问题cuid在数据库端生成不用等待数据库返回序列值。第二每个表都加了createdAt和updatedAt听起来基础但很多项目一开始省略后需要排序或追踪改动人时就只能回炉补字段。第三外键的onDelete策略要刻意设计工单的owner删了工单应该保留所以用Restrict评论跟着工单走工单删了评论没有存在意义所以Cascade。默认的NoAction在某些数据库上行为和Restrict不同不如直接写清楚意图。3.3 种子数据与迁移脚本模板仓库里我放了一个种子脚本用Prisma自带的方式写。种子数据的价值不只是开发期方便联调它还能当测试用例的基线数据甚至新人熟悉项目时可以直接查着看。import { PrismaClient, Role, TicketStatus } from prisma/client; const prisma new PrismaClient(); async function main() { const admin await prisma.user.upsert({ where: { email: adminexample.com }, update: {}, create: { email: adminexample.com, name: Admin, passwordHash: $2b$10$..., role: Role.ADMIN, }, }); const tagBug await prisma.tag.upsert({ where: { name: bug }, update: {}, create: { name: bug }, }); await prisma.ticket.createMany({ data: [ { code: TCK-001, title: 登录页在移动端样式错位, status: TicketStatus.OPEN, ownerId: admin.id }, { code: TCK-002, title: 报表导出超时, status: TicketStatus.IN_PROGRESS, ownerId: admin.id }, ], }); } main() .catch((e) console.error(e)) .finally(() prisma.$disconnect());种子密码这里直接写了一个预生成的bcrypt串不要写明文密码。生成方式很简单本地随便跑一段node脚本用bcrypt.hash(你想要的密码, 10)生成然后粘进去。迁移流程上开发期用prisma migrate dev创建并同步迁移文件生产环境用prisma migrate deploy执行已生成的迁移。这两条命令的区别最好让全团队都明白——前者可能自动重置数据库谁也不希望生产环境被一条dev命令搞挂。4. tRPC服务端的边界感Router组织、鉴权中间件与异常设计4.1 Router的组织方式按业务切还是按流程切刚开始写tRPC时很容易把router写成一个巨大的root.ts所有query和mutation都往里塞。t3code里我改成按业务域切分的结构一个文件对应一个业务模块比如userRouter、ticketRouter、tagRouter最后在rootRouter里聚合。这样做的直接好处是新成员看代码时不需要在几千行文件里找“某个接口到底在哪”改工单相关逻辑就只打开ticket.ts改用户逻辑就只打开user.ts责任边界从文件结构上就锁死了。// server/routers/root.ts import { router } from ../trpc; import { userRouter } from ./user; import { ticketRouter } from ./ticket; import { tagRouter } from ./tag; export const appRouter router({ user: userRouter, ticket: ticketRouter, tag: tagRouter, }); export type AppRouter typeof appRouter;关于文件组织还有一个易错点每个router里不要再重复创建trpc实例所有实例统一从server/trpc.ts导出。如果每个router文件都自己initTRPC一次会导致类型上下文不一致前端调用时类型推断时好时坏排查起来非常痛苦。4.2 鉴权中间件和错误格式化tRPC的中间件机制是这套体系里体验最好的部分之一。t3code里定义了两个基础procedure公开的和需要登录的。所有需要用户身份的接口直接用protectedProcedure声明不需要在每个query里手工判断session是否存在。// server/trpc.ts import { initTRPC, TRPCError } from trpc/server; import { ZodError } from zod; import superjson from superjson; export const createTRPCContext async ({ headers }) { const session await getSessionFromCookie(headers.get(cookie)); return { session }; }; const t initTRPC.contextReturnTypetypeof createTRPCContext().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); const isAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED, message: 请先登录 }); } return next({ ctx: { ...ctx, user: ctx.session.user }, }); }); export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(isAuthed); export const router t.router;鉴权中间件的意义在于把“这个接口是否允许访问”的判断收敛到一处。如果整个系统里有的接口走了中间件、有的接口自己在handler里判断很快就有人会在新接口里漏掉判断等到被捅了才想起来。我在模板里强制执行了一个约定除了登录、注册、健康检查所有Router一律用protectedProcedure起步能少做一个决定就少做一个决定。错误格式化这里也做了一点设计。服务端校验失败时如果直接返回默认错误前端拿到的是“UNAUTHORIZED”或“BAD_REQUEST”这种宽泛信息表单上根本没法告诉用户到底是哪个字段出了问题。我把zod的flatten结果塞进error.data.zodError前端拿到后可以直接把fieldErrors渲染到对应表单项下面。这个细节对用户体验影响非常大。4.3 zod校验前端后端共用同一套schemazod在t3code里不只是一个校验库它是前后端之间的契约文档。输入schema定义在独立文件里服务端用它校验前端表单用它做客户端校验类型用z.infer推导出来。这样业务上“新增工单需要什么字段、最多多少字、哪些必填”就只在一处维护不用后端写一遍、前端再写一遍两遍还经常不一致。// shared/schemas/ticket.ts import { z } from zod; export const ticketCreateSchema z.object({ title: z.string().min(1, 标题不能为空).max(120, 标题最多120字), priority: z.enum([LOW, MEDIUM, HIGH, URGENT]).default(MEDIUM), tags: z.array(z.string().cuid()).max(10).optional(), }); export type TicketCreateInput z.infertypeof ticketCreateSchema;一个实际项目里的隐含收益是改了schema服务端和前端会同时出现类型报错编译阶段就能发现哪里没有同步。这在十来个接口的项目里可能感觉不明显但等到几十个接口时这种“改一处全局编译告诉你哪里要跟着改”的安全感是纯手写any接口的团队根本体会不到的。5. 前端怎么消费APIServer Component、客户端Query与Mutation三条路径5.1 Server Component里直接调用tRPCNext.js App Router普及之后tRPC在前端的使用方式有了一个明显变化页面组件里可以直接通过createCaller在服务端调用tRPC不需要经过HTTP请求。t3code里列表页这类首屏数据优先用这种方式拿体验上的直接收益就是首屏加载更快没有客户端请求瀑布。// app/tickets/page.tsx import { headers } from next/headers; import { createCaller } from ~/server/api/root; import { TicketList } from ./ticket-list; export default async function TicketsPage() { const caller createCaller({ headers: await headers() }); const { items, total } await caller.ticket.list({ page: 1 }); return TicketList initialItems{items} total{total} /; }这里有一个必须注意的细节createCaller时要把headers传进去否则后续中间件里想解析cookie就完全没有上下文。我第一次用的时候漏了这一步结果所有service端调用都报UNAUTHORIZED排查了半个小时才发现是上下文丢了。Server Component适合的场景是首屏数据、详情页、不依赖交互状态的初始数据。它不适合的是搜索、筛选、分页这类高频交互场景——这些还是交给客户端Query处理。5.2 客户端列表的Query消费方式交互密集型的列表页t3code用tRPC的react-query封装也就是api.ticket.list.useQuery()这样的调用方式。t3code的初始化代码直接采用了trpc/next-lite的封装把api对象挂到组件全局页面里不需要关心query key、缓存策略、状态管理这些基础设施useQuery天然带缓存、带请求去重、带后台刷新。use client; import { api } from ~/utils/api; import { useMemo, useState } from react; import { TicketTable } from ~/components/ticket-table; import { Pagination } from ~/components/ui/pagination; export function TicketList({ initialItems, initialTotal }: Props) { const [page, setPage] useState(1); const [keyword, setKeyword] useState(); const { data, refetch, isFetching } api.ticket.list.useQuery( { page, keyword }, { initialData: { items: initialItems, total: initialTotal }, placeholderData: (prev) prev } ); return ( div classNamespace-y-4 SearchBar value{keyword} onChange{setKeyword} / TicketTable items{data.items} loading{isFetching} / Pagination page{page} total{data.total} onPageChange{setPage} / /div ); }placeholderData传给上一次的数据这个配置强烈建议加上。分页或搜索时用户看到的表格不会闪空白会停留在旧数据上直到新数据回来再平滑切换。没有这个配置时每次筛选都像刷新整个页面体验差非常明显。5.3 Mutation和表单的交互闭环表单提交在t3code里对应useMutation。以新增工单表单为例流程大概是提交时触发create mutation成功后refetch列表数据同时清空表单状态。这里有一个容易忽略的细节mutation的onSuccess里不只是refetch还要考虑“新数据要不要直接进缓存”的问题。如果列表当前在第3页新增了一条数据直接refetch会把用户踢回第1页这不一定是我们想要的效果。const createTicket api.ticket.create.useMutation({ onSuccess: () { void refetch(); setOpen(false); }, onError: (error) { const fieldErrors error.data?.zodError?.fieldErrors; if (fieldErrors) { // 把服务端校验结果映射到表单字段 } }, });还有一个经验表单组件里不要自己手写一套验证逻辑直接用前端调用zod schema这样和服务端校验完全一致。表面上省不了多少代码但避免了“前端提示通过、提交后被后端拦下来”的割裂感。对于内部后台这种低频使用的系统表单交互最重要的是反馈清晰——用户知道为什么失败、哪个字段有问题而不是看到一个“网络错误”就没了下文。6. 页面与交互样式体系、通用组件和表单体验的折中方案6.1 Tailwind shadcn/ui的取舍样式方案t3code选择了Tailwind CSS组件库则没有盲目选一个重型UI框架而是采用了shadcn/ui风格的“代码复制进项目”模式。原因很直接内部后台真的需要的是表格、表单、弹窗、下拉、标签这些基础组件不需要DatePicker的几十种主题、不需要企业级表格的虚拟滚动。shadcn/ui把组件源码直接放进项目里样式可以随时改不会因为组件库版本升级导致全局UI突然变样。这一点在长期维护项目里价值很大。Tailwind本身没什么可多说的用得越久越能体会到“不用起类名”的快乐。但注意给项目配置一套颜色变量和生产环境的Purge规则尤其是不小心把代码写进if (false)里永远不会执行构建时Tailwind也会扫描到并保留这部分样式老项目体积的膨胀很多时候就是这么来的。6.2 通用列表页组件的抽象后台系统里70%的页面长得很像——顶部一个搜索框中间一个表格底部一个分页。t3code里我把这套骨架抽成了PageContainer、FilterBar、DataTable和Pagination这几个通用组件。组件抽象的原则是“只解决80%的通用场景剩下20%的业务差异留到具体页面里覆盖”。比如DataTable组件它接收columns定义和数据源负责渲染表格骨架、空状态、加载状态。但排序列的点击排序、单元格自定义渲染这些能力不强行做进通用组件里而是通过render函数让页面自己控制。过度抽象的通用表格往往比不用还麻烦——API永远不够用最后一个简单的列表页要为不存在的复杂需求支付复杂度。t3code里一个更实用的抽象是“异步按钮”组件接收mutation对象和确认文案自动处理pending状态和二次确认。后台系统里“删除”“关闭”“重新打开”这类危险操作几乎每个页面都要写一遍防误触逻辑有这样一个组件能省掉很多重复劳动。7. 部署与上线的最后一公里容器化、迁移脚本与CI7.1 Docker多阶段构建与standalone输出t3code的部署一开始就是按Docker容器设计的。Next.js项目直接用next start跑生产模式没问题但镜像体积会很大因为整个node_modules和源码都要带进去。标准做法是在next.config.ts里开启output: standalone这会让构建产物里生成一个独立的server.js只包含运行时需要的文件。// next.config.ts const nextConfig { output: standalone, }; export default nextConfig;配合多阶段Dockerfile最终镜像体积可以压到200MB左右对内部系统来说完全可接受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 . . 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]一个容易踩的坑是standalone模式下Prisma的query engine二进制文件可能不会被自动拷贝进独立产物目录。需要手动确认node_modules/.prisma/client和node_modules/prisma/client是否在构建阶段被复制进去否则容器启动时任何数据库查询都会报“PrismaClientInitializationError”。t3code的Dockerfile里有一个显式的COPY步骤专门处理这个建议你也检查一下自己的构建脚本。7.2 迁移策略上线时的migrate deploy数据库迁移是部署流程里最不能跳过的一步。t3code的发布顺序是先执行迁移再启动新版本容器。这个顺序如果反了新代码访问不存在的字段会直接5xx。实际部署时我推荐的发布脚本是pnpm prisma migrate deploy数据库变更构建并推送Docker镜像滚动更新容器迁移命令放到CI里执行时DATABASE_URL必须单独配置不要从代码仓库读取更不要放在Docker镜像的环境变量里。我在t3code的GitHub Actions里把DATABASE_URL放在secrets中部署job运行时注入这样生产库地址不会出现在任何人能看到的镜像配置或日志里。7.3 CI/CD和发布检查单t3code的CI流程分两段合并前跑代码检查合并后自动部署。前者包括lint、typecheck、build保证类型在编译期是通的后者是部署job包含上面说的迁移和镜像发布。name: ci on: pull_request: push: branches: [main] jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: pnpm/action-setupv4 with: version: 9 - uses: actions/setup-nodev4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm typecheck - run: pnpm build上线前的检查单虽然很基础但真正能拦住大部分事故的就是这些朴素检查代码有没有通过CI、迁移有没有先执行、依赖的第三方服务比如对象存储、邮件服务的环境变量有没有填全、备份有没有做。t3code的README里放了一份环境变量清单让部署的人照着填比靠记忆靠谱得多。8. 一年后回看踩过的坑、改动最多的部分和下一步打算8.1 我踩过的三个印象最深的坑第一个坑是前面提到的Prisma binary在standalone模式下丢失。这个问题只在容器部署时出现本地跑完全正常第一次上生产环境时数据库相关的接口全部报错排查了很久才想到是构建产物里缺少query engine。解决方案就是Dockerfile里显式复制Prisma相关目录并且把prisma generate放到build阶段执行。第二个坑是tRPC中间件里取session的时机。早期版本的context里session解析依赖cookie但Server Component调用caller时如果没有传入headers中间件里拿到的永远是null所有接口都会401。这个问题修复方式就是给createCaller统一传headers封装好业务代码里不要再手动组装context。第三个坑关于数据量。工单列表到几万条之后不带任何索引的模糊搜索慢得肉眼可见。Prisma里如果只是用contains做查询MySQL里对应的是LIKE %keyword%索引基本失效。后来给工单表加了全文索引并且把搜索场景限定在标题字段列表查询才恢复到毫秒级。内部系统数据量再小这个优化也应该提前做。8.2 这个架子后续最想补的部分如果接下来继续迭代t3code我最想补的是两件事。一是更细粒度的权限控制现在只有ADMIN和MEMBER两个角色但实际业务中经常出现“工单A只有创建人和被分派人可见”这样的数据级权限这需要把权限判断从中间件下沉到具体查询条件里目前t3code的示例没有覆盖这个层次。二是给通用组件补一套表单生成器基于zod schema直接渲染表单、处理校验和错误展示进一步压缩“写页面”的时间。不过也要提醒自己别一次补太多模板类项目最怕变成“什么都有什么都不顺手”的大杂烩。t3code的核心价值一直是“开箱即用、少做决定”而不是追求功能无限膨胀。这个项目跑到今天我最大的体会是脚手架的价值不在于技术选型有多新、组件库有多全而在于团队在新项目启动时能不能少五分钟脑力劳动、多一点尽快进入业务正题的从容。如果你也在维护类似的内部模板希望这篇里提到的决策过程和踩坑记录能帮你绕开几个我走过的弯路。