T3 Stack实战:t3code全栈项目从零到上线的经验总结 t3code这个仓库名最开始只是我随手起的当时手头正好在做一个想长期维护的全栈项目技术选型翻来覆去定了好几轮。最终整套工程命名为“t3code”本质上就是在用T3 Stack那套全家桶Next.js TypeScript Tailwind CSS tRPC Prisma。t3既是说T3 Stack也包含我自己看重的三个关键词类型安全、自动化、可维护性。这篇内容不是教程更像是我从零到上线一路拆过的轮子、踩过的坑的整理给正在考虑用类似技术栈做全栈应用的人一个真实参考。1. t3code的定位一个把“类型安全”当卖点的全栈基座1.1 为什么不是REST也不是GraphQL接触t3code这个项目之前我做过不少前后端分离的活儿。传统REST方案里后端定义接口、写文档前端封装请求、定义类型中间一旦字段改名通常要等运行时才炸出来。GraphQL虽然解决了字段按需查询但引入Schema、Resolver、Codegen一套链路维护成本并不低。T3 Stack给了一个很务实的思路前后端用同一套TypeScript类型调用过程不做手工翻译。tRPC不是框架层面的魔法它把服务端函数直接暴露成客户端可调用的方法类型从Router自动推导到前端hooks。所以t3code里几乎没有“写接口文档”这个动作函数签名本身就是契约。我见过不少人第一次用tRPC时觉得它“不够正规”没有URL、没有HTTP动词前端直接调函数好像回到了过去的RPC时代。但放在Next.js这种全栈框架里这种模式反而极大缩短了开发链路。t3code实际运行半年后的体验是类型断裂导致的bug数量几乎可以忽略重构字段时IDE会直接把所有引用点标红。1.2 T3 Stack里各个角色的职责边界很多朋友对T3 Stack的理解是“一个现成框架”直接create一个项目就能跑。实际它更像一套组合约定Next.js负责路由、SSR/SSG、API承载是整个应用的运行容器TypeScript负责所有静态类型约束贯穿前后端tRPC负责API调用层的类型安全替代手写REST协议Prisma负责数据访问层用Schema定义数据库结构生成类型安全的查询客户端Tailwind负责样式省掉CSS命名和样式隔离的心智负担。我的习惯是代码里每一个数据从数据库到页面渲染的路径都尽量保持在类型推断链路里。比如Prisma查出User对象后tRPC的输出类型、前端组件接收的props类型都能自动关联。任何一步断开TypeScript编译器会直接提醒。提示T3 Stack之所以把TypeScript当成“真正的编程语言”来用而不是单纯给JS加注解核心就在于让数据流的所有边界都变成编译器可检查的对象。2. 初始化阶段create-t3-app选配后留下的工程骨架2.1 脚手架选择与目录结构说明t3code起步用的官方脚手架create-t3-app。执行命令pnpm create t3-applatest t3code交互式选项里我勾选了NextAuth、Prisma、Tailwind、tRPC额外勾了ESLint没有勾Docker。这里的取舍也解释一下早期阶段不需要容器化依赖Vercel部署最快Docker是后面加出来的这个在后面章节会专门讲。初始目录长这样t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── pages/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc].ts │ │ └── index.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ └── utils/ └── package.json这个目录最有价值的地方是把服务端代码和客户端代码物理区分开。src/server里放数据库、鉴权、Router定义pages/api/trpc/[trpc].ts是唯一的API入口所有请求都走这里。刚开始会觉得“怎么所有接口都挤在一个入口”但实际Router内部已经按业务域拆分外部统一的目的是让认证、日志、上下文处理在中间件层集中完成。2.2 Prisma Schema与第一次数据库迁移t3code的数据模型一开始很简单就是用户、项目和任务记录。Prisma Schema如下generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] projects Project[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Project { id String id default(cuid()) name String userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) tasks Task[] createdAt DateTime default(now()) updatedAt DateTime updatedAt index([userId]) } model Task { id String id default(cuid()) title String done Boolean default(false) projectId String project Project relation(fields: [projectId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) updatedAt DateTime updatedAt index([projectId]) }这里我踩了第一个自己埋的坑直接用cuid()做主键。当时觉得自增id不够“分布式友好”但cuid有个实际问题排序时不直观日志里看id是一串乱码。后来又补了createdAt排序才解决。如果重来一次我大概率会用UUID v7或者保持id独立、把业务标识单独建字段。第一次迁移命令pnpm prisma migrate dev --name init生成迁移SQL后还要执行pnpm prisma generate生成客户端。这个步骤忘记跑的话前端类型里不会出现Prisma相关类型编辑器会报一堆找不到模块的错误。2.3 环境变量清单和最容易漏配的一处t3code早期环境变量包括DATABASE_URLpostgresql://user:passwordlocalhost:5432/t3code NEXTAUTH_SECRET... NEXTAUTH_URLhttp://localhost:3000最容易漏的是NEXTAUTH_SECRET。本地开发用脚手架自动生成的随机值没问题但部署到线上后如果忘了换每次重启服务会话都会失效用户会被频繁踢下线。还有一点虽然NextAuth v5以后对NEXTAUTH_URL的要求变了但老版本里这个值如果和后端实际域名不一致OAuth回调会一直报redirect_uri_mismatch。这个字段要养成上线前对照环境变量的习惯。3. tRPC与Prisma联调全链路类型推导的编写手感3.1 Router的拆分与procedure设计逻辑t3code的Router不是单一文件堆出来的我按业务域拆成三个userRouter、projectRouter、taskRouter在root.ts里合并。import { projectRouter } from ./routers/project; import { taskRouter } from ./routers/task; import { userRouter } from ./routers/user; import { createTRPCRouter } from ~/server/api/trpc; export const appRouter createTRPCRouter({ user: userRouter, project: projectRouter, task: taskRouter, }); export type AppRouter typeof appRouter;拆分的原因很简单项目会持续膨胀单一文件几百行以后改一个逻辑要翻很久。每个Router内部按功能继续拆procedure一个procedure对应一个前端可调用的方法。tRPC里“procedure”这个词第一次接触有点绕其实可以理解成“一个有输入校验、有上下文、有处理逻辑的完整函数”。比如Task的创建我用了一个带鉴权的protectedProcedureimport { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const taskRouter createTRPCRouter({ create: protectedProcedure .input(z.object({ projectId: z.string(), title: z.string().min(1), })) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { title: input.title, projectId: input.projectId, userId: ctx.session.user.id, }, }); }), });3.2 从服务端数据到前端自动推导的完整链路前端调用指定方法tRPC会自动生成带类型的hooks。比如这里列出某项目的所有任务import { api } from ~/utils/api; function TaskList({ projectId }: { projectId: string }) { const { data, isLoading } api.task.listByProject.useQuery({ projectId }); if (isLoading) return div加载中.../div; return ( ul {data?.map((task) ( li key{task.id} span{task.title}/span span{task.done ? 已完成 : 进行中}/span /li ))} /ul ); }这个useQuery返回的data类型就是服务端procedure函数的返回值类型。服务端返回的Task[]到前端显示中间没有任何手写的interface。我改过多次数据库模型比如给Task加了一个priority字段只要Prisma客户端重新生成编译期就能看到哪些地方缺了这个字段。这种体验和传统前后端联调完全不同。以前改接口字段要同时打开后端代码、接口文档、前端类型定义三处在t3code里最小改动其实就是改Schema和Router返回值其余交给编译器兜底。3.3 middleware做鉴权而不是在业务代码里到处if早期我写过一些“不够优雅”的代码每个procedure开头都判断sessionif (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); }接口少的时候还行一旦Router多了重复代码成倍增长。后来把鉴权逻辑统一收敛到procedure builder这一层import { initTRPC, TRPCError } from trpc/server; import { type CreateNextContextOptions } from trpc/server/adapters/next; export const createTRPCContext async (opts: CreateNextContextOptions) { const { session } await getServerAuthSession(); return { db, session }; }; const t initTRPC.contexttypeof createTRPCContext().create(); export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user } } }); });之后业务Router只需要引用protectedProcedure就天然保证登录态。这个设计看似简单实际对后续权限扩展很有好处后来我要给Project加成员角色owner/viewer只需再包一层角色校验中间件而不必逐个Procedure改动。4. 业务功能落地t3code里的认证、CRUD与权限隔离4.1 NextAuth会话接入细节t3code认证用的是NextAuth并与Prisma适配器做了集成。主要的坑在于适配器版本与NextAuth主版本要严格对应。早期我把next-auth和next-auth/prisma-adapter版本装得很随意结果登录回调一直报AdapterError查了半天才发现是版本错配。正确的接入方式是按脚手架生成的src/server/auth.tsimport NextAuth from next-auth; import DiscordProvider from next-auth/providers/discord; import { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ~/server/db; export const { handlers, auth, signIn, signOut } NextAuth({ adapter: PrismaAdapter(db), providers: [DiscordProvider], callbacks: { session: ({ session, user }) ({ ...session, user: { ...session.user, id: user.id, }, }), }, });session回调里把user.id手动塞进去很关键。默认会话对象可能不带数据库主键而t3code里的权限判断又需要拿到当前用户id所以这一步早晚得加。忘了加的话后面所有按用户过滤的查询都会失效。4.2 一个典型CRUD模块的写法项目创建逻辑大概是import { z } from zod; import { protectedProcedure } from ~/server/api/trpc; export const projectRouter createTRPCRouter({ create: protectedProcedure .input(z.object({ name: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.project.create({ data: { name: input.name, userId: ctx.session.user.id, }, }); }), update: protectedProcedure .input(z.object({ id: z.string(), name: z.string().optional() })) .mutation(async ({ ctx, input }) { return ctx.db.project.update({ where: { id: input.id }, data: { name: input.name }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.project.delete({ where: { id: input.id } }); }), });这里的输入校验用了ZodtRPC的input方法直接接受Zod Schema。好处是类型既能用于校验又能推导为前端入参类型前端调用时如果不小心传了错误类型的字段编辑器直接标红。4.3 多用户数据隔离的几个常见坑单用户阶段怎么查都行。一旦上多用户最容易翻车的地方是只校验“数据存在”没校验“数据属于当前用户”。比如上面delete方法如果只传入projectId直奔delete那用户A删掉用户B的项目就毫无阻碍。这也是很多全栈新手在tRPC项目里会踩的安全漏洞。正确做法是先把数据查出来验证归属const project await ctx.db.project.findFirst({ where: { id: input.id, userId: ctx.session.user.id }, }); if (!project) { throw new TRPCError({ code: NOT_FOUND }); }我一开始图省事只按主键查后来做权限测试时才发现问题。为了避免这类问题扩散我在封装更新和删除逻辑时都统一走“先查归属再改数据”的模式。写码时看似多一步但安全等级完全不一样。5. 部署演进从Vercel托管到自建Docker容器的取舍5.1 为什么初期选Vercel后来又要自建t3code前期直接在Vercel上部署体验确实顺滑连Git仓库后自动构建环境变量在后台配一次就行。但用了两个月后我开始遇到几个让我下决心迁移的场景Prisma连接在Serverless环境下的连接数压力明显数据库偶尔报too many connections页面需要长时间轮询或跑定时任务Serverless单次执行时长限制让人难受某些业务场景需要更稳定的网络出口我不想被平台绑定。所以我把部署目标改成了Docker容器跑在自己的云主机上。这里不讨论具体服务商只说技术路径。5.2 Docker多阶段构建的镜像瘦身记录t3code的Dockerfile经过三轮优化最终长这样FROM node:20-alpine AS deps RUN apk add --no-cache libc6-compat WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile FROM node:20-alpine AS builder WORKDIR /app COPY --fromdeps /app/node_modules ./node_modules COPY . . RUN pnpm prisma generate RUN pnpm build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV production COPY --frombuilder /app/public ./public COPY --frombuilder /app/.next/standalone ./ COPY --frombuilder /app/.next/static ./.next/static USER node EXPOSE 3000 CMD [node, server.js]第一版我把整个node_modules和源码都塞进镜像体积将近1.2GB。后来发现Next.js默认支持standalone输出只需在next.config.js里加一句output: standalone运行时镜像可以大大瘦身。优化后镜像体积降到了150MB左右构建时间也从四分钟缩到一分钟内。5.3 上线后的稳定性实测容器跑起来后我专门记录了一段时间的并发表现。普通业务接口P95响应在80ms到150ms之间静态资源由Nginx缓存接管。对比Vercel冷启动问题基本消失Serverless模式下某些低频API第一次访问往往要额外等1秒以上自部署后这个现象没了。但自部署也要买单日志采集、进程守护、HTTPS证书续期、定时重启这些都得自己搞。对个人项目而言这套成本是可控的如果团队小、人手不足我仍然会建议前期用平台托管把业务跑通再迁移。6. 一次上线事故数据库连接池配置引发的连锁故障6.1 事故现象首页偶发转圈API超时概率上涨上线一个月后的某天监控开始频繁报警。现象很规律流量稍微上来一点首页请求开始转圈API超时概率从0.1%涨到3%以上。一开始我以为是Docker资源不够看了CPU和内存都非常空闲顺手重启容器问题短暂缓解十几分钟后又复发。这种“重启能好一会儿过阵子又犯”的问题在数据库层面最常见。我随即去数据库侧看活动连接数结果吓了一跳连接数一直顶着上限大量连接处于idle状态。6.2 排查链路从网关日志追到Prisma连接池第一步排除了网络层。容器到数据库的网络延迟正常DNS也没问题。第二步检查Nginx访问日志超时的请求集中在含数据库读写的API上静态资源无一异常。第三步看数据库慢查询日志SQL本身执行都在几十毫秒内不是索引问题。最后打开Prisma客户端的日志发现每次请求都在创建新的连接几乎没有复用。问题定位到了连接池配置上。查看当时的连接代码import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: [warn, error], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;问题不在这个文件而在于Prisma默认连接池大小是num_cpus * 2 1我的容器分配了4核这就意味着最多可以开出9条连接。理论上够用但Next.js在并发请求多的时候每个Serverless或Node请求上下文都会尝试从连接池拿连接如果拿不到就等待等待多了就表现为超时。加上连接池内的连接没有设置合理的复用策略高并发下压力全堆到数据库那一侧。6.3 修复方案与事后验证修复方案分两步。第一步显式配置连接池大小export const db new PrismaClient({ log: [warn, error], datasources: { db: { url: process.env.DATABASE_URL, }, }, });在DATABASE_URL连接串里加上连接池参数DATABASE_URLpostgresql://user:passwordhost:5432/t3code?connection_limit5pool_timeout10socket_timeout10这里connection_limit5是压测后得到的较稳值既不让连接数爆掉也不至于饿死请求。第二步在Nginx层加了upstream keepalive让容器间的TCP连接得以复用。改完以后连续观察一周数据库连接数稳定在5到7之间API超时率降到0。这个事故让我意识到Prisma是个好工具但默认配置不等于生产配置凡是上线的服务连接池参数必须根据自己的并发模型重新调过。7. 沉淀下来的编码习惯与t3code的下一步7.1 维护类型边界少用any别绕开推导t3code开发到后期我给自己定了一条纪律任何从数据库到UI的数据流都要留在类型推导链路上。遇到某些第三方库类型不完整我优先补声明文件而不是直接any。因为一旦开了any的口子tRPC提供的类型安全闭环就断掉了修一个any可能要花掉未来三个晚上。还有一点不要在Prisma返回结果上手动加字段再丢给前端。以前我想给Task加一个冗余的计算属性直接改返回对象结果前端拿到的是“带类型但不完全匹配”的数据写起来十分别扭。后来改成在Router里做一个显式映射类型就清晰了。7.2 把公共逻辑前移到中间件层t3code的鉴权、日志、参数校验、异常响应格式化全都尽量收敛在tRPC中间件里。业务代码只干一件事拿到输入处理数据返回结果。这样每个procedure读起来都很短。好处是新人接手时不需要钻进每个接口里找权限判断审计代码时也快很多。7.3 后续迭代方向t3code下一步我打算做几件事一是把Prisma的部分频繁查询加上缓存层用Redis做数据热点缓存减少数据库压力二是把单容器部署改成docker-compose管理把数据库、Redis和应用拆开三是给文件上传做一个独立服务避免大文件请求堵住主应用的API通道。还有个小技巧值得分享每次升级Next.js或tRPC版本前我会先在一个分支上跑一遍全量类型检查和构建确认没有类型报错再合入主分支。一次版本跳跃不要跨太多像Next.js从13直接跳到15这种大跨度中间无数破坏性改动会让你改到崩溃。t3code对我而言是一个长期实验场所有关于全栈架构的想法都能在里面快速验证。如果你也在纠结要不要用T3 Stack我的建议是直接上手做个小项目不用非得是企业级哪怕只是个待办事项应用把认证、CRUD、部署跑通一遍你对这套技术栈的理解会远超看十篇文章。