做全栈项目最头疼的就是前后端各写一套类型定义,接口文档对不齐,联调时才发现字段名拼错了。我做完t3code这个项目后,最大的感受就是:只要技术栈选对,很多传统的协作成本可以被直接消灭。这是一个基于 T3 Stack(Next.js + TypeScript + tRPC + Prisma + Tailwind)搭建的全栈应用,解决了从数据库到 UI 全程类型安全、接口免文档、前端免手写请求层这一系列问题。如果你正在做中小型全栈项目、内部工具,或者想找一套“一个人也能高效维护”的完整方案,这篇文章里的内容应该能帮你省掉不少弯路上的时间。
1. 先聊清楚 t3code 到底在做什么
1.1 这个项目解决的是哪一类问题
t3code 搭建的是典型的业务管理系统:有用户、有内容、有权限差异,需要登录后才能操作数据。这类应用最常见的痛点倒不是功能有多难,而是前后端如何保持一致。
传统开发模式下,后端写好 REST API,提供一份 Swagger 文档;前端照着文档手写 request 函数、类型声明、状态管理。一旦需求变更,要么前端等后端改完再联调,要么一人身兼前后端但两份代码各自维护。t3code 尝试用一套代码同时覆盖服务端逻辑和客户端调用,让类型从数据库字段一路“流淌”到页面组件里。
1.2 为什么偏偏是 T3 Stack 这套组合
T3 Stack 由 Next.js、TypeScript、tRPC、Prisma、Tailwind CSS 组成,可能有人会问:Next.js 可以上 React Server Components,为什么还需要 tRPC?Prisma 接 MySQL 或 PostgreSQL,为什么不用 REST?
我选择这套组合的关键原因有三个:
- 单一来源的类型系统:数据模型定义在 Prisma Schema 里,tRPC 的 router 输入输出自动推断出类型,前端调用时不需要任何手写的 interface。
- 开发效率高:tRPC 不需要定义 URL、方法、参数位置,写业务接口就像调用本地函数,新增一个查询只改服务端一处,前端立刻能用。
- 约束清晰:T3 Stack 的默认目录结构把所有代码按功能组织好,不依赖“约定优于配置”的魔法,出问题时能顺藤摸瓜。
1.3 项目总体架构怎么搭
t3code 的目录分为三层。最外层是 Next.js App Router 的标准结构,页面组件放在src/app;业务逻辑集中在src/server,里面跑 Prisma Client 和 tRPC Router;视图层的数据获取通过src/trpc里的 React Hooks 完成。
实际目录结构长这样:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── dashboard/ │ ├── server/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ ├── router.ts │ │ │ └── routers/ │ │ ├── db.ts │ │ └── auth.ts │ ├── trpc/ │ │ ├── client.ts │ │ ├── server.ts │ │ └── react.tsx │ └── styles/ │ └── globals.css ├── .env ├── next.config.mjs └── tailwind.config.ts这个结构最大的好处是:页面不需要关心数据从哪来,只需要调用api.post.all.useQuery()这样的 Hook;后端不需要暴露一堆端点,只需要维护一个汇总所有子路由的appRouter。
2. 核心技术点拆解:从数据库到 API 全程类型安全
2.1 Prisma Schema 设计:把业务模型一次写对
t3code 的数据模型围绕用户、项目和任务设计。用户是认证主体,项目归属用户,任务挂在项目下。写 Prisma Schema 时要注意关系字段和处理级联删除。
model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? image String? projects Project[] tasks Task[] accounts Account[] sessions Session[] } model Project { id String @id @default(cuid()) name String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt ownerId String owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) tasks Task[] } model Task { id String @id @default(cuid()) title String description String? status String @default("todo") projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) }这里有几个设计细节值得展开说。
第一,所有 ID 都用cuid()而不是自增整数。原因在于:cuid 由客户端 SDK 或数据库层生成,不依赖数据库自增;在分布式环境或导入数据时,避免 ID 冲突;同时对外暴露时不会被人通过 ID 枚举猜到数据量。
第二,外键关系必须显式写onDelete: Cascade。如果不写,删除用户时如果其项目下还有任务,Prisma 会直接报外键约束错误。项目里用户删除功能是最容易触发这类问题的环节。
第三,我把status先用字符串存储而不是建枚举。对于后加字段的业务场景,字符串足够灵活,后续需要约束时再在应用层做白名单校验即可。枚举在 Prisma 里迁移成本略高,能少用就少用。
注意,Prisma Schema 里的模型名用了单数大写,迁移时 Prisma 会帮我们把表名映射好。运行npx prisma migrate dev --name init后,本地数据库的表结构就会和 Schema 完全一致。
2.2 tRPC 路由:不用写 REST Controller 的全栈接口
tRPC 的核心概念是 Router 和 Procedure。Procedure 分为query(查询)和mutation(写操作),在服务端定义输入校验和业务逻辑,在客户端自动获得对应的 React Hook。
t3code 里的任务路由长这样:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const taskRouter = createTRPCRouter({ listByProject: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.task.findMany({ where: { projectId: input.projectId }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ projectId: z.string(), title: z.string().min(1, "任务标题不能为空"), description: z.string().optional(), })) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, description: input.description, projectId: input.projectId, authorId: ctx.session.user.id, }, }); }), });注意这里没有写GET /api/tasks、POST /api/tasks这类东西,也没有自己手工解析 query string。客户端调用时,所有参数类型和返回类型都会自动推断出来,如果传错字段或者类型不匹配,编译期立刻红条。
我在实际项目中坚持一个原则:所有输入都在 tRPC Procedure 里用 zod 做白名单校验。REST 时代常见的“前端传错了后端也不知道”的问题,到 tRPC 这基本绝迹了。输入校验层还能同时扮演文档的角色,新同事来看代码,直接读.input()就知道接口要求什么。
2.3 鉴权与中间件:session 信息怎么在前后端流转
T3 Stack 默认集成 NextAuth.js,t3code 里我配置了 GitHub OAuth 和 Credentials 登录两种方式。关键点是:session 数据如何传给 tRPC context。
在src/server/api/trpc.ts中,通过createTRPCContext把 session 注入到每个 Procedure 的 ctx:
import { getServerSession } from "next-auth"; import { authOptions } from "../auth"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db: prisma, session, headers: opts.headers, }; };protectedProcedure是一个加了中间件的 Procedure,在进入业务逻辑之前先检查 session:
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 }, }, }); });这里有个细节很多人容易忽略:在中间件里如果没有把session传递到next,后续 Procedure 的 ctx 里拿不到完整的 session 对象。TS 会直接报错,所以 T3 项目里比较少出现“session 忘传”的运行时问题,这是类型安全框架的天然优势。
前端如果需要判断登录状态,直接用useSession()Hook,不需要额外请求。后端需要用户身份的接口全部用protectedProcedure,确保匿名请求在进入数据库查询前就被拦截,避免无谓的数据库查询压力。
2.4 Next.js 页面的数据获取方式:服务端直接调 tRPC
t3code 的页面分为三种情况。
第一种是静态或者弱交互页面,直接在 Server Component 里调用 tRPC Server 端的 caller。比如首页展示公共项目列表:
import { api } from "~/trpc/server"; export default async function HomePage() { const projects = await api.project.listPublic(); return <ProjectList projects={projects} />; }第二种是强交互页面,比如任务看板,这种页面在 Client Component 里使用useQuery来获取数据,配合utils.revalidate()在增删改后刷新缓存。
第三种是混合场景,像 dashboard 这种需要登录后立即展示用户数据、同时又需要操作按钮的页面,我会把初始数据通过 Server Component 传入,再在客户端用useQuery接管后续更新,避免首屏白屏。
这里最容易踩的坑是:在 Client Component 里首次调用useQuery时,如果传入的初始数据没有 key 对齐,页面会闪一下旧数据再刷新。所以useQuery的 key 必须和 Server Component 里调用时的输入参数完全一致,必要时用__variable方式把变量名写清楚。
3. 实操过程:从初始化到功能落地
3.1 初始化 T3 项目与依赖安装
t3code 的初始化我用的是官方脚手架:
pnpm dlx create-t3-app@latest t3code交互式选项里我选择了 Next.js App Router、TypeScript、Tailwind、tRPC、Prisma、NextAuth,数据库选 PostgreSQL。这里推荐用pnpm,安装速度快,node_modules 结构也更干净。如果团队统一用 npm 也没问题,但不要混用包管理器,锁文件不一致很容易在部署时出幺蛾子。
初始化完成后,先确认.env文件里DATABASE_URL和NEXTAUTH_SECRET都配置好了。NEXTAUTH_SECRET可以用openssl rand -base64 32生成,一定不要用默认值。
3.2 配置数据库连接与 Prisma 迁移
本地开发我用 Docker 起一个 PostgreSQL:
docker run --name t3code-db -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=t3code -p 5432:5432 -d postgres:16对应的.env配置:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/t3code?schema=public"写完 Schema 后执行迁移:
npx prisma migrate dev --name init这个命令会做三件事:把 Schema 和数据库当前状态对比、生成 SQL 迁移文件、重新生成 Prisma Client。日常修改 Schema 后,我用npx prisma db push做快速同步,但正式的多人协作项目还是应该走migrate dev生成迁移记录,否则团队成员各自动手容易丢失变更历史。
迁移完成后打开 Prisma Studio 检查数据:
npx prisma studio在可视化界面里手工验证关系字段和级联删除,比自己拼 SQL 查库直观得多。这个习惯帮我发现过一个建表顺序的问题:外键约束引用的表还没建好时就执行了迁移,Prisma 迁移文件里顺序错乱会报relation does not exist。
3.3 写第一个 tRPC 查询和变更接口
我习惯从最核心的 model 开始写 router。以 t3code 的项目模块为例,先定义projectRouter:
export const projectRouter = createTRPCRouter({ getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { id: input.id }, include: { tasks: { orderBy: { createdAt: "asc" } } }, }); if (!project || project.ownerId !== ctx.session.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "项目不存在" }); } return project; }), create: protectedProcedure .input(z.object({ name: z.string().min(1).max(50) })) .mutation(async ({ ctx, input }) => { return ctx.db.project.create({ data: { name: input.name, ownerId: ctx.session.user.id }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.db.project.deleteMany({ where: { id: input.id, ownerId: ctx.session.user.id }, }); return { success: true }; }), });这里最值得注意的地方是getById里的权限校验。如果查询条件里直接用id而没带ownerId,就会出现任意登录用户通过改 URL 参数看到别人项目数据的情况。我在 delete 里用了deleteMany配合ownerId,可以同时完成“权限校验 + 删除操作”两个动作,比先查后删更省一次数据库往返。
3.4 前端表单调用接口并处理错误
页面组件里调用接口,以新建任务为例:
"use client"; import { useState } from "react"; import { api } from "~/trpc/react"; export function NewTaskForm({ projectId }: { projectId: string }) { const utils = api.useUtils(); const [title, setTitle] = useState(""); const createTask = api.task.create.useMutation({ onSuccess: async () => { setTitle(""); await utils.task.listByProject.invalidate({ projectId }); }, onError: (error) => { alert(error.message); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); createTask.mutate({ projectId, title }); }} className="flex gap-2" > <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="输入任务标题" className="rounded border px-3 py-2" /> <button type="submit" disabled={createTask.isPending} className="rounded bg-blue-600 px-4 py-2 text-white" > 添加任务 </button> </form> ); }这个组件的核心逻辑在invalidate。tRPC 的缓存策略是“按 key 缓存”,当我创建一个新任务后,调用utils.task.listByProject.invalidate({ projectId }),tRPC 会把列表数据标记为失效,在后台自动重新请求;不需要自己维护一个全局 state,也不需要手动刷新页面。
3.5 部署到 Vercel 或 Docker 的注意事项
t3code 部署方案我试过两个:Vercel 和 Docker。
Vercel 部署最简单,但有几个坑:第一,DATABASE_URL如果是本地 Docker 地址,构建时连不上;部署前要把数据库换成云数据库,加上?sslmode=require。第二,Prisma 在 Vercel 构建阶段需要跑prisma generate,如果迁移还没应用,线上首次请求会报表不存在。我建议在 CI 流程里加上prisma migrate deploy这一步,而不是依赖本地手动迁移。第三,Serverless 环境下数据库连接数很容易被打满,这个问题我放在第 4 节详细说。
Docker 部署则适合内部系统,我提供一个精简版 Dockerfile:
FROM node:20-alpine AS base RUN npm i -g pnpm FROM base AS builder WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm prisma generate RUN pnpm build FROM base AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/prisma ./prisma EXPOSE 3000 CMD ["pnpm", "start"]注意一个细节:prisma generate必须在build之前执行,因为@prisma/client的生成代码是 tRPC router 编译时的依赖。如果跳过这一步,类型检查往往不会报错,但运行时拿到的是旧版 Client,会出现字段不存在的问题。
4. 常见问题与排查技巧实录
4.1 Prisma 在 Serverless 环境下的连接数问题
这是我在 t3code 部署后遇到的第一个稳定性问题。上线初期,Vercel 的 Serverless 函数会为每个请求实例化新的 Prisma Client,导致数据库连接数迅速飙高,数据库直接拒绝连接。
解决方法是让 Prisma Client 在 Serverless 环境中被全局复用。实现方式是在src/server/db.ts里做全局缓存:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"], }); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = db; }原理其实很简单:globalThis在整个 Node.js 进程生命周期内是共享的,虽然 Serverless 平台会起多个进程,但每个进程内部可以复用同一个 Client。同时我给 Prisma 配置了连接字符串上的连接池参数,比如?connection_limit=5,避免单个数据库实例被吃满。
4.2 tRPC 类型报错的排查思路
tRPC 的类型错误信息很抽象。我遇到过Type '{}' is missing the following properties from type '{ ... }'这类问题,主要是因为.input()的 schema 和调用端传入的数据结构不一致。
排查思路我总结成一个顺序清单:
- 先看
.input(z.object(...))里定义的字段名是否和mutate()传入的 key 完全一致,大小写、下划线不能出差错。 - 再看 Procedure 返回值,
query或mutation的返回类型会作为前端 hook 的泛型返回类型,如果返回了null但前端预期是{ id: string },类型推导会直接报错。 - 如果用了
include查询关联数据,检查 Prisma 的返回类型是否带上了嵌套字段,有时需要手写 zod schema 对返回结果做二次校验。
4.3 客户端水合告警
T3 项目都是 SSR 首屏,如果服务端渲染的 HTML 和客户端第一次渲染的 React 树不一致,浏览器会报 hydrate 告警,常见表现是页面内容闪现后又变化。
最容易触发这个问题的场景是:直接从 tRPC 的useQuery里拿到的数据中含有Date对象,序列化后两边格式不同。我的处理方法是给相关字段用toISOString()转成字符串再传给组件,或者在createTRPCClient的 transformer 里统一配置 superjson。
T3 脚手架默认已经配好 superjson,但如果你手动改过 trpc client 的配置,很容易丢掉这个核心设置。建议检查src/trpc/react.tsx里的 transformer 配置是否和服务端一致,两端不一致会导致序列化失败,接口返回数据变成乱码对象。
4.4 用 t3code 复盘时的性能与维护性思考
项目跑通之后,我回看整个代码库,总结了几个值得注意的性能点和维护性考量。
第一个是 N+1 查询问题。Prisma 的findMany默认不会关联查询,如果列表页面里需要展示每个项目的任务数,直接在循环里逐条count()会非常慢。解决方案是用include的_count:
ctx.db.project.findMany({ where: { ownerId: ctx.session.user.id }, include: { _count: { select: { tasks: true } } }, });这条查询最后会生成一个带LEFT JOIN和GROUP BY的 SQL,一次拿到所有项目及其任务数量,效率远超循环查询。
第二个是路由拆分粒度。t3code 早期把所有接口都塞进一个appRouter里,后期维护时发现单个文件越来越长。后来我按业务模块拆成projectRouter、taskRouter、userRouter,再在顶层 router 里合并:
export const appRouter = createTRPCRouter({ project: projectRouter, task: taskRouter, user: userRouter, });这样前端调用也能保持语义清晰:api.project.getById、api.task.create,一眼看出模块归属。
第三个是中间件和鉴权逻辑的复用。tRPC 的中间件机制其实就是函数组合,你可以把“日志记录”“统计埋点”都做成中间件挂在某个 router 上,而不用每个 Procedure 里都写一遍。我在 t3code 里做了一个logProcedure,记录每次调用的耗时和输入参数,排查线上问题时非常有用。
第四个是依赖更新。T3 Stack 的各个包升级频率不低,尤其是 Next.js、NextAuth、Prisma 这三个。我的经验是每两周做一次pnpm up --latest,升级后重点回归登录、数据查询、迁移三个链路。Prisma 的升级要特别注意 Client 生成版本是否匹配,不然本地跑得好好的,部署时直接报错。
最后说一个我个人的习惯:t3code 里凡是涉及权限判断的地方,我都尽量在数据库查询层面加上归属条件,而不是只靠前端按钮的disabled状态去控制。像deleteMany({ where: { id: input.id, ownerId: ctx.session.user.id } })这种写法,即使前端被绕过、请求被伪造,后端也收不到别的用户的数据。这是我在实际运营中被人反馈“可以通过改请求参数访问他人数据”后才补上的漏洞,踩过这次坑之后,我形成了条件反射:所有按 ID 操作数据的接口,必须同时校验归属字段。
如果你打算用 T3 Stack 搭自己的项目,我建议别急着复制别人的模板,先把 Prisma Schema 里的模型关系想清楚,再把 tRPC 的 router 拆分方式定下来。这两件事决定了项目后期是好维护还是越来越乱。t3code 这个项目能在一周内从零跑到部署,主要就是靠了这套技术栈的隐性约束。写到这里,我把项目过程中最值得保存的几条心得放在最后:TypeScript 的类型推导能帮你挡住大部分低级错误,但真正的安全边界还得靠在数据库查询层手动加条件;Serverless 环境下的数据库连接必须全局复用,否则总会有一天莫名其妙地连接断开;tRPC 的缓存失效机制用熟了之后,你会发现前端状态管理这块基本可以退居二线了。这套架构不算新,但它把正确的事情变得简单了,这已经是我现在做全栈项目时最看重的特质。