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-app@latest 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_URL="postgresql://user:password@localhost:5432/t3code" NEXTAUTH_SECRET="..." NEXTAUTH_URL="http://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的创建,我用了一个带鉴权的protectedProcedure:
import { 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开头都判断session:
if (!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.context<typeof 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.ts:
import 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 } }); }), });这里的输入校验用了Zod,tRPC的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 --from=deps /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 --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /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_URL="postgresql://user:password@host:5432/t3code?connection_limit=5&pool_timeout=10&socket_timeout=10"这里connection_limit=5是压测后得到的较稳值,既不让连接数爆掉,也不至于饿死请求。第二步,在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、部署跑通一遍,你对这套技术栈的理解会远超看十篇文章。