1. 项目起底:t3code到底是什么
先说结论:t3code 是我最近折腾的一个TypeScript 全栈项目代号,核心栈是 Next.js + tRPC + Tailwind CSS + Prisma,也就是社区里常说的T3 Stack的实战形态。项目的主要目的,不只是做个能跑的 demo,而是把一个从零开始的现代 Web 应用完整地落到生产级别,把类型安全这件事从数据库一路贯穿到前端 UI。
很多人第一次听到 T3 Stack 会以为是个"花架子"——一堆技术名词堆在一起。但真正用它做完一个完整功能模块后,我的感受完全不同:这套技术选型的核心思路,是尽可能减少"类型边界"。
什么意思?传统全栈项目里,你在后端定义一个 User 模型,到前端 API 请求时又重新声明一个 interface User。数据在传递过程中,类型信息会"丢失"一次,每次丢失都是一个潜在 bug 的来源。T3 Stack 的路线是让同一个类型定义贯穿前后端,一端改了,另一端立刻编译报错,把运行时才能暴露的问题提前到开发时解决。
在用 t3code 沉淀这套实践之前,我在几个传统项目里反复踩过同一类坑:后端接口文档更新不及时导致前端联调翻车、数据库字段重命名后一堆地方忘了改、请求参数拼错只在线上时报错。这些问题的根源,都是"类型不同步"。t3code 这个项目就是针对这些问题的一次系统解法,实战验证下来,团队协作效率和代码可维护性确实有明显提升。
我把整套实现过程、架构决策、踩坑记录都整理在这篇文章里。如果你是正在做 TypeScript 全栈开发、想了解 T3 Stack 落地细节的工程师,这篇文章很适合你。不是理论科普,而是一份可以直接照着操作的项目实录。
2. 整体架构设计与技术选型逻辑
2.1 为什么是 T3 Stack,而不是传统前后端分离
在正式拆解 t3code 之前,我想先把技术选型的底层逻辑理清楚。因为如果只是"听说某种技术很火就想用",项目做到一半必然会遇到阻力。
传统的前后端分离架构下,通常是一个 React/Vue 前端项目加一个 Node/Java/Go 后端服务。前后端通过 REST 或 GraphQL 通信,为了定义接口格式,要么手写文档,要么引入 OpenAPI/Swagger 等工具自动生成。这套方案成熟稳定,但也带来了三个很实际的问题:
- 类型重复定义:数据库模型、后端 DTO、前端接口类型,往往是三份各自维护的代码。改一处、漏一处,是家常便饭。
- 联调成本高:即使有接口文档,前后端也是两个团队、两个代码库、两套开发节奏,接口一变动就容易陷入等待和返工。
- 环境割裂:前端起一个 node 服务做 mock,后端起一个微服务,数据流转路径长,排查问题需要在多个日志和工具间横跳。
T3 Stack 的取舍,本质上是把前后端放进同一个项目里,用"单一代码库 + 端到端类型安全"的方式规避上述问题。Next.js 既做 SSR 也做 BFF(Backend For Frontend),tRPC 提供类型安全的函数调用层,Prisma 用 schema 生成完整的数据库类型,Tailwind 则把样式层的问题简单化。整套方案的核心主张是:既然团队规模不大、业务复杂度尚可,那就没必要人为制造那么多边界。
2.2 技术选型细节:tRPC 与 Prisma 的配合逻辑
这里重点说一下 tRPC 和 Prisma 这两个容易让人"半懂不懂"的环节。
tRPC的核心概念就像"远程函数调用"。你不需要在 API 层定义 URL 路径、请求方法、参数校验方式,只需要在后端导出一个个函数(procedure),在前端直接像调用本地函数一样去调用它。
// 后端定义(router/user.ts) import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const userRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.user.findUnique({ where: { id: input.id }, }); }), });// 前端调用(components/UserProfile.tsx) import { api } from "~/utils/api"; export function UserProfile({ userId }: { userId: string }) { const { data, isLoading } = api.user.getById.useQuery({ id: userId }); if (isLoading) return <div>Loading...</div>; return <div>{data?.name}</div>; }看到区别了吗?前端不需要知道后端路由挂在哪个路径、是 GET 还是 POST、请求的键名是什么。tRPC 通过 TypeScript 类型推断,把这一切信息打包成一份"类型契约"。getById这个函数的输入输出,在编辑器里就被严格推导出来了。
Prisma这边,是通过 schema 文件把数据库结构先写清楚,再运行prisma generate自动生成类型安全的数据访问代码。
model User { id String @id @default(cuid()) email String @unique name String? posts Post[] createdAt DateTime @default(now()) } model Post { id String @id @default(cuid()) title String content String? author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) }Schema 文件一改,跑一遍 migration,再去写业务代码时,编辑器能自动提示所有合法字段。从数据库到 Prisma Client,再到 tRPC Router 的输入输出,最后到前端组件状态,一条链路上没有一个环节是"手写类型定义"的。
2.3 项目目录结构规划
t3code 的目录结构基本遵循 T3 Stack 的官方最佳实践,但我在实际使用中做了一些调整,这里分享最终版本的布局逻辑:
t3code/ ├── prisma/ │ ├── schema.prisma # 数据库模型定义 │ └── migrations/ # 数据库迁移记录 ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ # 各业务模块的 tRPC router │ │ │ ├── root.ts # router 聚合 │ │ │ └── trpc.ts # tRPC 实例初始化 │ │ └── auth.ts # 认证逻辑封装 │ ├── pages/ │ │ ├── api/ # Next.js API 路由入口 │ │ │ └── trpc/ # tRPC HTTP 处理 │ │ ├── _app.tsx # 全局组件包装 │ │ └── index.tsx # 首页 │ ├── components/ # 前端 UI 组件 │ ├── styles/ # 全局样式 │ └── utils/ │ └── api.ts # tRPC client 封装 ├── .env # 环境变量 ├── package.json └── tsconfig.json一个值得注意的设计细节:所有业务逻辑尽可能收敛在 server 层,前端组件尽量做成"无状态展示 + 调用 tRPC hooks"的模式。这样做的好处是,业务逻辑可以通过 tRPC 中间件统一做权限校验,前端只管渲染,逻辑复用和测试都会更容易。
3. 核心功能实现与实操要点
3.1 认证体系的完整落地
任何真实项目的第一个硬骨头都是认证。t3code 用的是 NextAuth.js 集成方案,配套 Prisma 做会话存储。
认证这块有两条路可以选择:JWT 方案和数据库会话方案。我最终选了数据库会话方案,原因是它的退出登录和会话撤销机制更可控。JWT 虽然无状态、实现简单,但遇到"用户修改密码后强制下线"这类需求时,处理起来非常别扭。数据库会话把 session 存在表里,注销时删掉一行记录即可,逻辑直观可靠。
配置上,核心是创建src/server/auth.ts:
import { getServerSession, type NextAuthOptions } from "next-auth"; import DiscordProvider from "next-auth/providers/discord"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { prisma } from "~/server/db"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(prisma), providers: [ DiscordProvider({ clientId: process.env.DISCORD_CLIENT_ID!, clientSecret: process.env.DISCORD_CLIENT_SECRET!, }), ], session: { strategy: "database" }, callbacks: { session: ({ session, user }) => ({ ...session, user: { ...session.user, id: user.id }, }), }, };然后在 tRPC 这边做一个受保护的 procedure,任何需要登录才能访问的接口都用它来定义:
// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; export const createContext = async (opts: CreateNextContextOptions) => { const session = await getServerSession(opts.req, opts.res, authOptions); return { prisma, session }; }; const t = initTRPC.context<typeof createContext>().create(); export const protectedProcedure = t.procedure.use(({ next, ctx }) => { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: ctx.session } }); });这里有个实操中的关键点:不要为了省事把所有 procedure 都设成 public。t3code 里我是默认全部 protected,只有明确不需要登录的接口才显式标成 publicProcedure。这个习惯帮我堵住了好几个安全漏洞,强烈推荐你参考。
3.2 典型业务模块:一个完整的 CRUD 流程
以"文章管理"这个功能为例,看 t3code 里一个业务模块从数据库到 UI 的完整链路。
首先在 Prisma schema 中定义模型(前面已展示 Post 模型),运行迁移:
npx prisma migrate dev --name "create-post-model"然后在 tRPC router 中写接口逻辑:
// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional(), })) .mutation(async ({ ctx, input }) => { return ctx.prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), listMine: protectedProcedure .query(async ({ ctx }) => { return ctx.prisma.post.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), update: protectedProcedure .input(z.object({ id: z.string(), title: z.string().optional(), content: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const post = await ctx.prisma.post.findUnique({ where: { id: input.id }, }); if (!post || post.authorId !== ctx.session.user.id) { throw new Error("无权修改该文章"); } return ctx.prisma.post.update({ where: { id: input.id }, data: { title: input.title, content: input.content }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const post = await ctx.prisma.post.findUnique({ where: { id: input.id }, }); if (!post || post.authorId !== ctx.session.user.id) { throw new Error("无权删除该文章"); } return ctx.prisma.post.delete({ where: { id: input.id } }); }), });前端调用就变得很轻量:
// src/pages/dashboard.tsx import { api } from "~/utils/api"; export default function Dashboard() { const utils = api.useUtils(); const { data: posts, isLoading } = api.post.listMine.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.listMine.invalidate(); }, }); // ... }注意invalidate这个细节。tRPC 前端 hooks 自带缓存管理,mutation 成功后调用invalidate()可以让对应的 query 自动重新拉取,不用手动刷新页面。这个机制刚开始容易忽略,用熟了之后你会发现根本离不开它。
3.3 Zod 校验:把错误拦截在入口处
tRPC 和 Zod 是深度集成的。每个 procedure 的 input 都可以定义一个 Zod schema,tRPC 会在调用时自动做校验。
我强烈建议把 Zod schema 提取成独立文件,而不是直接写在 router 里:
// src/server/schemas/post.ts import { z } from "zod"; export const createPostSchema = z.object({ title: z.string().min(1, "标题不能为空").max(100, "标题最长100字"), content: z.string().max(5000, "内容最长5000字").optional(), }); export const updatePostSchema = z.object({ id: z.string().min(1), title: z.string().max(100).optional(), content: z.string().max(5000).optional(), });这样做的好处是,schema 可以被多个 router 复用,也可以导出到前端作为表单校验的同一套规则。前后端共用一份校验规则,就不会出现在前端校验通过、后端又抛出不同错误提示的尴尬情况。
实际使用中我踩过一个坑:z.string().optional()和z.string().optional().nullable()是不同的,前者的类型是string | undefined,后者是string | null | undefined。如果你在 Prisma update 的 data 里直接展开 input 里的字段,undefined表示"不更新该字段",null表示"把该字段设为 NULL"。语义差别极大,写业务时一定要搞清楚。
4. 部署上线与常见问题排查实录
4.1 生产环境部署方案
t3code 的部署我最终选定在 Vercel 上跑前端,数据库单独托管在托管 Postgres 服务上。Vercel 平台对 Next.js 的原生支持非常顺滑,只需要在控制台连接 Git 仓库、配置环境变量,每次 push 到 main 分支就会自动触发构建发布。
环境变量这块是个容易翻车的点。我维护了三套环境变量:
| 环境变量 | 本地开发 | 预览环境 | 生产环境 |
|---|---|---|---|
| DATABASE_URL | 本地 Postgres / Docker 容器 | 云数据库测试实例 | 云数据库生产实例 |
| NEXTAUTH_SECRET | 随机字符串 | 独立生成 | 独立生成 |
| NEXTAUTH_URL | http://localhost:3000 | 预览域名 | 正式域名 |
| DISCORD_CLIENT_ID | 开发应用 | 测试应用 | 正式应用 |
三个环境的变量必须完全隔离。我第一次部署时偷懒,直接复制了本地 .env 到预览环境,结果登录回调全指向 localhost,排查了半小时才定位到问题。
Docker 部署也是可行方案。如果你更倾向于容器化,用多阶段构建把 Next.js 应用打包成镜像,配合 Nginx 反向代理也能跑得很稳。Vercel 的优势是省心,Docker 的优势是灵活,两者没有绝对的好坏,取决于团队的运维能力。
4.2 阶段性遭遇的典型问题与解法
这一路开发下来,积累了不少印象深刻的 bug 和排查过程,整理成速查表分享给你。
tRPC 报错 "UNAUTHORIZED" 但确定已登录
最常见原因是 session 没有正确传到 tRPC context。在 Next.js App Router 和 Pages Router 中获取 session 的方式不同,如果你刚迁移过路由模式,一定要检查createContext里的getServerSession写法。另一个坑是 NextAuth 的NEXTAUTH_SECRET没有设置,导致开发环境下 session 每次重启后失效。
Prisma 连接数爆满 / 请求超时
Serverless 环境下每个请求实例可能都会创建 Prisma Client,如果不做连接池复用,数据库很快被打爆。解决办法是在全局声明一个单例:
// src/server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"], }); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = prisma; }Next.js 龙卷风式的大量 hydration 警告
这个通常不是 Next.js 本身的问题,而是服务端渲染的 HTML 和客户端渲染的结果不一致。常见原因:直接用new Date()渲染时间戳、组件内用了Math.random()、或者依赖了 window 对象的逻辑没有做动态导入。解决思路是把这类动态内容放到useEffect里更新,或者组件用dynamic(..., { ssr: false })禁用服务端渲染。
数据库字段变更后,前端类型没有同步
这个问题在新手阶段特别容易出现——改了 Prisma schema,忘了跑prisma generate,前端所有相关类型还是旧的。习惯养成:每次改了 schema,先prisma migrate dev再prisma generate,最后重启 dev server。三条命令一个不能少。
4.3 性能优化与工程化经验
t3code 跑了一段时间后,我开始做性能层面的优化。三个最有成效的动作分享出来。
第一个是 React Query 的缓存策略调整。tRPC 底层依赖 React Query,默认情况下数据缓存 0 秒、重新拉取策略是"页面聚焦时刷新"。这对实时性要求高的场景没错,但对文章列表这类低频变化的数据来说,每次页面加载就重新请求是种浪费。
我按数据特点做了差异化配置:用户信息缓存 5 分钟,文章列表缓存 1 分钟,只有站内信这种真正实时的数据才保持默认策略。调整后,首页加载时的请求数从十几个降到了几个,体验提升非常明显。
第二个是 Next.js 的next.config.js配置优化。
/** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, images: { remotePatterns: [ { protocol: "https", hostname: "cdn.example.com", }, ], }, compiler: { removeConsole: process.env.NODE_ENV === "production", }, }; module.exports = nextConfig;removeConsole这个配置能移除生产环境的所有 console 输出,减少一部分打包体积。images.remotePatterns则避免在next/image中加载外部图片时报错或带宽消耗异常。
第三个是头部资源优化。用 Next.js 的headers()给静态资源配置合理的缓存策略,给页面配置安全相关响应头。这一块虽然不起眼,但在 Lighthouse 评分和实际访问体验上的提升都能感觉到。
5. 项目复盘:这套方案到底值不值得用
5.1 T3 Stack 的真实优劣势
走到项目尾声,我想用更诚实的态度聊聊 T3 Stack 的优劣势,不能让读者产生"这是一套完美方案"的错觉。
优势层面,最大的价值确实体现在"类型安全"和"开发体验"上。在创业项目和中小型项目中,团队规模不大,业务迭代速度快,T3 Stack 这套方案能显著减少前后端联调时间,甚至可以说直接消灭了"接口文档"这个环节。所有 proxy 类型自动推导,基本不会有"参数拼错""返回值结构搞错"这类低级 bug。
劣势层面也不容忽视。最大的问题是tRPC 的"前后端耦合"。你和后端团队并行开发时,意味着前后端必须在同一个代码库里协作。如果组织架构上前后端分属不同团队,T3 Stack 的协作模式会带来不小的摩擦。另外,tRPC 的协议不是标准 HTTP REST,如果未来需要把某些 API 开放给第三方开发,tRPC 并不能直接暴露,需要额外包一层 REST/gRPC 适配层。这一点在做平台型项目时尤其要注意。
5.2 二次开发与扩展空间
基于 t3code 当前沉淀下来的架构,后续的扩展方向其实很清晰。
如果你想加入新的数据模型,只需三步:修改 prisma schema、执行 migrate、写对应的 tRPC router。整个链路是被工具链牵着走的,很少会出现"改了数据库、前端忘了该类型"的问题。
如果你想接入定时任务,可以在 Next.js API Route 里挂 cron 任务,配合 server 层的 tRPC procedure 复用业务逻辑。
如果你想换数据库,Prisma 的抽象层让迁移成本变得极低。开发环境用 SQLite,生产环境切到 Postgres,基本上只改 schema 的开头和 DATABASE_URL。
如果你处于技术选型的探索期,我的建议是不要盲从"最新最热",而是根据你的团队构成和业务形态来判断。单团队、快迭代、前端为主的项目非常适合 T3 Stack;大组织、强边界、平台化的业务还是走标准前后端分离更稳。
5.3 迭代过程中的几点习惯沉淀
最后整理几个我在这套项目里沉淀下的工程习惯,它们不是写在任何文档里的标准规范,但每一条都是从多次返工中换来的教训。
第一,tRPC 的 procedure 命名要有前缀规范。比如统一用listMine、getById、createOne、updateOne、deleteOne这套动词开头来命名。团队协作时,看名字就知道调用的接口大概是什么行为,避免同一个人名不同约定造成认知混乱。
第二,Zod schema 永远和数据库字段对齐。每改一次 Prisma schema,就检查一遍相关的 Zod schema 是否同步。Zod 和 Prisma 共享语义,但字段的min、max限制往往需要根据业务补充,这些限制写在 schema 文件里比写在业务代码里更便于维护。
第三,错误信息要具体,不只是错误码。tRPC 默认抛出TRPCError,但如果你不填message,前端拿到的错误提示会非常难理解。尽量把所有服务端 catch 到的情况翻译成人话:
try { // 业务逻辑 } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "文章保存失败,请稍后重试", }); }这套细节积累起来之后,项目的可维护性、团队的上手速度、线上的稳定程度都会有一个肉眼可见的提升。t3code 这个项目本身已经跑通了从开发到部署的完整链路,后续我会持续完善测试覆盖和性能监控。做技术选型和项目搭建,真正重要的不是跟风追新,而是找到一套适合自己团队协作模式的方案,然后用工程手段把它打磨到顺手。我的实践证明,T3 Stack 这条路线在 t3code 里是走通的,也希望这份拆解能帮你的项目少几次返工。