☰
T3 Stack全栈实战:端到端类型安全与工程化部署全解析
2026/10/14 6:23:52 网站建设 项目流程

做全栈项目做到第三年,我最大的感受是:写代码本身从来不是瓶颈,瓶颈全在"怎么把前后端之间那层看不见的类型契约给守住"。t3code 这个名字,对应的是我长期使用的一整套全栈代码实践——T3 Stack,也就是 Next.js + TypeScript + Tailwind CSS + tRPC + Prisma 这套组合。t3code 不算什么开源大项目,它是我自己在这套技术栈下沉淀出来的工程模板和代码组织方式,取这个名字就是"T3 技术栈下的代码实践"的意思。

我经常跟朋友说,T3 Stack 最值钱的地方不是某个单独的库,而是它把所有环节的类型安全串成了一条线:数据库表的字段类型,一路推导到页面组件里data.post.title的补全提示,中间不需要你写一行接口类型定义。对于做全栈项目的人来说,这种体验一旦用过就很难回去。

这篇文章会把 t3code 从初始化到上生产的完整过程拆开讲,包括选型逻辑、目录结构、核心实现、我踩过的坑,以及性能调优和 Docker 部署的实测结论。适合两类人看:一类是准备从纯前端转全栈、想找一套少折腾方案的开发者;另一类是已经在用 T3 Stack,但还想把工程细节打磨得更稳的人。

1. 我为什么把项目命名为t3code:全栈技术选型里的"少即是多"

1.1 技术选型焦虑的真实来源

做全栈项目最痛苦的阶段其实是开场。前端框架要选、后端框架要选、数据库 ORM 要选、鉴权方案要选、样式方案要选、校验库要选,每层都有三到五个候选,层与层之间还得考虑能不能好好协作。很多项目死在选型讨论上,不是因为某个技术不好,而是组合起来之后,类型定义、请求协议、错误处理各玩各的。

t3code 这个项目就是在这种背景下诞生的。我当时的诉求很简单:团队只有两三个人,没有人专职维护后端接口文档,也没人有耐心在前端联调的时候对着 Postman 找字段。我们需要一个"默认就正确"的组合,最好从数据库到页面都是同一套类型体系。T3 Stack 正好是这个思路的集大成者。

1.2 T3这套组合解决的是"契约问题"而非"技术堆叠"

很多人以为 T3 Stack 就是把五个热门库塞在一起,其实不是。它真正解决的是开发里的"契约问题":

  • Prisma 管数据库模型,表结构写在schema.prisma里,迁移和类型都由它生成;
  • tRPC 管服务端接口,不再需要 REST 路由、不需要手写 OpenAPI、不需要前端维护接口地址;
  • zod 管输入校验,和 tRPC 无缝结合,请求参数的校验规则就是类型来源;
  • Next.js 管渲染与路由,App Router 下服务端组件可以直接调用 tRPC 的 server 端函数;
  • NextAuth 管鉴权,session 类型可以注入到 tRPC 的 context 里。

这五层不是简单的并列关系,而是一条类型推导链。我在 t3code 里定义了一个Post模型,Prisma 会根据zod校验后的输入自动推断出可查询的字段;tRPC Router 返回这个对象后,前端的useQuery拿到的类型和数据库字段完全一致。整个链路中没有 JSON 序列化、没有手写 DTO、没有接口文档。这种体验的本质是:你不需要在"类型定义"和"运行时数据"之间做翻译,把翻译工作交给框架。

1.3 什么样的团队和项目适合抄这套作业

说句实话,T3 Stack 不是为了所有场景设计的。t3code 这套方案最适合的是:

  • 中小型全栈应用,尤其是内部工具、内容管理、SaaS 原型、独立开发者产品;
  • 团队以 TypeScript 为主,前后端不分家,一个人能包一条业务线;
  • 项目对实时性和高并发没有极端要求,tRPC 基于 HTTP 协议,比 WebSocket 方案轻量得多。

如果你的项目是重计算服务、需要大量异步任务调度,或者前端有独立的 BFF 团队,那 T3 可能不是最优解。技术选型没有银弹,知道自己要省什么才是关键。

2. 用create-t3-app搭起t3code:初始化细节与工程目录拆解

2.1 交互式初始化时该怎么勾选

创建 t3code 的第一步很简单,一条命令:

npm create t3-app@latest t3code

但交互式选项值得认真对待。create-t3-app 会问你用哪个包管理器、要不要 TypeScript、Tailwind、Prisma、tRPC、NextAuth、ESLint、Prettier。我的经验是这几个选项全选,因为后续你自己加任何一个都会比一开始就生成要麻烦。

需要注意 Node.js 版本,我用的 Node 20 LTS。如果你还在 Node 16 上,很多依赖会直接报 engine 不匹配,尤其是 Next.js 15 和 Prisma 6 对 Node 版本要求比较高。初始化完成后第一时间跑一下:

npm run dev

如果首页起来了,说明一切正常。如果有报错,八成是.env里的DATABASE_URL没配对,或者没有生成.env文件。

2.2 生成出来的目录结构每一层是干什么的

初始化完成后的 src 目录,看起来不大,但每一层都有明确分工:

src/ ├── app/ # Next.js App Router 页面路由 ├── server/ │ ├── api/routers/ # tRPC 的业务 Router │ ├── auth/ # NextAuth 配置 │ ├── db/ # Prisma Client 实例 │ └── trpc.ts # tRPC router 构建与 context ├── trpc/ │ ├── server.ts # 服务端调用的 tRPC 客户端 │ └── react.tsx # 前端 React 版 tRPC hooks ├── env.js # 环境变量校验 └── styles/

我一开始最不理解的其实是src/trpc/这层。为什么要分成 server 和 react 两个文件?后来才明白:服务端组件里要用 tRPC,不能直接引前端 hooks 文件,因为里面用了useQuery这类 React hooks,引进来会造成客户端污染。server.ts是给 RSC 用的无 hooks 版本,react.tsx是给客户端组件用的。这个分离是 t3code 能同时兼顾"服务端直连"和"客户端查询"的关键。

2.3 三个值得动手修改的默认配置

create-t3-app 默认配置已经很合理,但有几个地方我建议按项目情况改:

第一个是next.config.mjs。默认生成的 ESLint 和 TypeScript 检查在构建时会跑,一点小 warning 就会导致next build失败。按 t3code 的实际节奏,我倾向于先开启ignoreDuringBuilds保住构建,但前提是你本地和 CI 里的 lint 命令没有被跳过,别把构建时的检查当唯一防线:

const nextConfig = { eslint: { ignoreDuringBuilds: true }, typescript: { ignoreBuildErrors: true }, };

第二个是tsconfig.json的路径别名。默认有@/指向src/,我一般会再加一层@server指向src/server,让业务代码里引用数据库和鉴权时路径更短。改完 tsconfig 后记得重启 TS server,否则提示还是老的。

第三个是tailwind.config.ts里的内容安全名单。如果你做的是 CMS 类项目,用户提交的富文本内容里会有class="text-red-500"这种字符串,Tailwind 扫描不到就不会生成对应样式。把content数组里加上你渲染 HTML 的路径,能避免上线后样式莫名丢失。

3. 贯穿全链路:从Prisma表结构到tRPC路由,再到前端调用

3.1 Schema先行:定义模型与迁移命令

我在 t3code 里最早做的事不是写页面,而是把所有业务表结构先定下来。Prisma 的schema.prisma是整条类型链的源头,改表结构一定先改这里,然后跑迁移:

model User { id String @id @default(cuid()) name String? email String @unique posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

字段名和类型一定要想清楚,因为迁移一旦跑完,改字段名意味着数据库列也要重命名。t3code 里我吃过一次亏:早期把content叫body,后面想统一术语,结果npx prisma migrate dev --name rename_body生成了一条很复杂的迁移文件。现在我的经验是,建模阶段多花半小时讨论字段名,后面省三天。

迁移命令我建议直接记住这一套组合:

npx prisma migrate dev --name init # 开发环境建表并生成迁移文件 npx prisma generate # 重新生成类型(迁移时一般会自动跑)

3.2 tRPC Router的设计边界

在src/server/api/routers/post.ts里,t3code 定义了一个标准的 CRUD Router。核心代码不长,但边界很清晰:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { prisma } from "~/server/db"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ publishedOnly: z.boolean().default(true) })) .query(({ input, ctx }) => { return prisma.post.findMany({ where: { published: input.publishedOnly ? true : undefined }, include: { author: { select: { name: true } } }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional() })) .mutation(({ input, ctx }) => { return prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id }, }); }), });

这里面有两个细节值得展开。

第一是protectedProcedure。它是在trpc.ts里基于publicProcedure扩展出来的中间件,里面先检查ctx.session.user是否存在,不存在就抛UNAUTHORIZED错误。post 的 create 操作必须登录,list 可以匿名看,这就是接口的权限边界。

第二是ctx的类型注入。create-t3-app 生成的trpc.ts会自动把 NextAuth 的 session 和 Prisma client 放进 context,所以 Router 里能直接ctx.db或ctx.session。这个上下文设计是 tRPC 的精华,注册在createContext里的东西,所有 Procedure 都能类型安全地拿到。

3.3 客户端调用与类型推理的魔术

服务端 Router 定好了,前端不用写任何类型定义。在页面组件里直接这样用:

"use client"; import { api } from "~/trpc/react"; export default function PostList() { const { data, isLoading } = api.post.list.useQuery({ publishedOnly: true }); if (isLoading) return <p>加载中...</p>; return ( <ul> {data?.map((post) => ( <li key={post.id}> <h2>{post.title}</h2> <p>{post.author?.name}</p> </li> ))} </ul> ); }

这里有个很容易被忽视的爽点:data里的post.author?.name是有类型提示的,如果Post模型里没有author字段,编辑器立刻标红。因为 tRPC 的 Router 会通过inferRouterOutputs把返回值类型推导出来,前端 hooks 自动继承。

那 mutation 呢?更顺滑:

const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.list.invalidate(); }, });

invalidate是 tRPC 自动生成的缓存失效方法,改完数据后调一下,列表自动刷新,不需要写全局事件总线,也不需要手动 refetch。

3.4 自己定的三条"类型纪律"

类型安全不等于不会犯错,我在 t3code 里给自己立了三条规矩:

  • 不做裸 fetch 调用后端。只要页面需要数据,要么走 RSC 里引api.xxx.query,要么走客户端 hooks。裸 fetch 意味着你自己手写 TypeScript 类型,那是通向往返地狱的入口。
  • 所有 mutation 的输入都要过 zod。tRPC 不强制你写 input schema,但你不写,类型链在这里就断了,前端传什么都会被执行,运行时和类型就分离了。
  • 查询字段用 select 收窄。Prisma 的findMany如果不写 select,会把整个表对象带上,数据里常混着password_hash这种不该吐的字段。用select不只是性能问题,更是安全问题。

4. 实测排查:t3code踩过的三个坑与完整定位过程

4.1 NextAuth 回调解码报错:先看版本再看回调返回值

这个坑我印象特别深。t3code 早期接入 GitHub 登录时,本地跑得好好的,部署到服务器上之后,每次点登录都返回 500,服务端日志里只有一个Error decoding JWT的提示。

我的排查链路是这样的:

  1. 先确认 NextAuth 版本。create-t3-app 当时生成的是 next-auth 4.x,而我手动升级过依赖,结果装成了 5.0.0-beta,API 形态变了。罪魁祸首是package.json里的next-auth: ^5.0.0-beta,npm 装到了最新 beta。先把版本锁回 4.24.x,问题就消失了一半。
  2. 检查NEXTAUTH_SECRET是否写入.env并在 server 端存在。production 环境没有这个值,JWT 就解不开。本地能用是因为 dev server 会自动生成临时密钥。
  3. 最后查的是callbacks的返回值。我在jwt回调里加了session.user.role,但session回调里没同步这个字段,导致 session 类型和实际数据不完全对齐,虽然不至于直接报解码失败,但字段 undefined 很容易掩盖版本问题的真凶。

按这个顺序排完之后,登录就正常了。这个坑的教训其实是:NextAuth 升级大版本时,先别急着用新功能,第一步永远是看 breaking changes。

4.2 上传文件撞上请求体限制:base64不是首选方案

t3code 做用户头像上传时,我一开始偷懒,把文件转成 base64 塞进 tRPC mutation,结果文件大于 1MB 之后请求直接失败,报 413 Payload Too Large。

排查过程分三步:

  • 先确认是不是 Next.js 限制。App Router 的 Route Handler 默认请求体限制是 1MB,Server Actions 也有类似限制,tRPC 走的是 API 路由,同样受影响。
  • 再看是不是 base64 膨胀问题。base64 会把二进制体积增加约 33%,一个 1MB 的图片传上去实际是 1.33MB,直接撞线。
  • 最后确认 tRPC 的输入类型是 string,3MB 的字符串在内存里来回拷贝,既不优雅也不安全。

最终方案是改用预签名上传。正儿八经的做法是:前端请求一个getUploadUrlmutation,后端返回一个带签名的对象存储上传地址,前端直接往那个地址传二进制流,不走应用服务器。如果你的项目没有对象存储,退一步的方案是把上传拆成一个独立的 Route Handler,用formData()接收文件流,不要让文件以字符串形式进 tRPC。

4.3 一个隐蔽的Zod泛型推断问题:排查链路复盘

这个坑是我在给 t3code 加"草稿自动保存"功能时踩到的,症状很怪:一段明显能通过的api.post.saveDraft.useMutation调用,TypeScript 在编译时却报数据不兼容类型,而且错误指向 zod schema 里的一行refine。

下面是完整的排查过程:

  1. 先注释掉refine那段,重新编译,错误消失。这就确定问题出在自定义校验逻辑的类型推导上。
  2. 看看那行refine写了什么。我当时写的是refine((data) => data.titleOrContent.length > 0),这个函数返回的是boolean,但 zod 对refine的泛型推断会糅合输入类型和上一段 transform 的类型,一旦某个分支返回undefined或空对象,输出类型的联合里混进了失败态。
  3. 解决方式很直接:给refine加显式泛型参数,明确输入输出的对象形状。
const draftSchema = z .object({ title: z.string().optional().default(""), content: z.string().optional().default(""), }) .refine((data): data is { title: string; content?: string } => { return data.title.length > 0 || !!data.content; });

其实更深一层的原因是:React Query 和 tRPC 的结合在useMutation上做类型推导时,如果 schema 里出现了类型谓词(type predicate)缺失的 refine,整个 procedure 的 output 会被推成never | ...,TypeScript 随后就会报一个莫名其妙的守卫错误。排查这种问题唯一的技巧就一个字:拆。把 schema、procedure、hook 逐层拆开验证,就能把问题从"类型魔术失灵"缩小到具体一行。

5. 上生产前的性能与部署调优:t3code实测笔记

5.1 数据库连接、环境变量与缓存策略

t3code 部署到真实服务器之后,最常见的性能问题不是页面渲染慢,而是数据库连接被耗尽。Prisma 在 serverless 环境下的连接池管理、在 Node.js 进程下的连接数限制,是两套逻辑。

我在 t3code 里的实测做法:

  • 开发环境用DATABASE_URL直连;
  • 生产环境单独配一个DATABASE_URL_POOL,指向带连接池的入口。如果你用的数据库厂商支持 serverless 连接,就把directUrl留给迁移工具,主连接串走池化地址。

同时,tRPC 客户端默认的staleTime是 0,意味着每次进页面都会重新请求。我把它改成默认 60 秒:

// src/trpc/react.tsx export const api = createTRPCReact<AppRouter>(); // 在根 Provider 里配置,或者直接改 QueryClient 的 defaultOptions const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 }, }, });

这样列表页在客户端跳转时不会频繁打接口,体感会明显变快。但要注意:如果你的是实时性很强的数据,别照抄这个值。

5.2 Next.js渲染层的几个调优设置

t3code 的页面结构里,我尽量把纯展示的模块放在 Server Component 里,只有需要交互的部分标"use client"。这样做最大的收益不是"优雅",而是减少打包体积和客户端请求。

几个冷门但有效的设置:

  • next/font 替代外链字体:默认的 Google Fonts 外链会在运行时多一次第三方请求,用next/font可以字体文件自托管,消除布局偏移;
  • 图片用 next/image 的sizes属性:不写sizes,会默认加载最大尺寸图,移动端浪费很多带宽;
  • Link 组件代替 a 标签:App Router 里用 Link 才会触发 RSC 流式加载,不然每次都是整页刷新。

一条实际体感:t3code 的博客列表页,做了上述三个优化后,Lighthouse 性能分从 78 分涨到 95 分,代价只是一下午的配置调整。

5.3 Docker部署时的几个坑

t3code 最后是靠 Docker 部署到个人服务器上的。Dockerfile 的写法算是这套工程里最容易被低估的部分。我踩过的坑和对应的解法如下表:

坑表现解法
构建时漏跑 prisma generate容器启动后 Prisma 报错找不到查询引擎在npm run build前加npx prisma generate,并且把生成的 client 复制进生产镜像
包管理器锁文件不匹配pnpm 或 npm 在容器里重新解析依赖,版本漂移用官方镜像自带对应包管理器,或者拷贝 lockfile
原生依赖不兼容sharp 安装失败、Prisma query engine 二进制找不到用带 libc 的 alpine 镜像,或直接上 debian slim
容器进程跑在 root 下安全审计老亮红灯建一个非 root 用户,chown目录后切换 USER

一个多阶段构建的简化模板我给 t3code 长期用:

# 依赖层 FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci # 构建层 FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx prisma generate && npm run build # 运行时层 FROM node:20-alpine 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/public ./public COPY --from=builder /app/prisma ./prisma EXPOSE 3000 CMD ["sh", "-c", "npx prisma migrate deploy && npm run start"]

这里有个细节:prisma migrate deploy放在启动命令里,而不是构建阶段,因为容器启动时才能确定目标数据库地址,构建阶段的环境变量和线上不一致会造成迁移跑错库。

部署完别急着高兴,先进服务器看一眼日志。t3code 上线后第一个稳定的信号,是数据库连接数曲线不再陡峭,接口 P95 延迟在 300ms 以内。达到这个标准,这套全栈实践才算真的闭环了。

写在最后的小技巧:给t3code再加一对调试钩子

最后分享一个我实测里特别有效的操作。t3code 的 tRPC 链路在出问题时,错误信息经常藏在 React Query 的序列化错误里,不直接看原始响应根本看不出来。我给生产环境的日志接口加了一个 preprocessing 中间件,把所有 tRPC 请求的path、type、input摘要和错误消息打到标准输出:

// src/server/api/trpc.ts export const loggerMiddleware = t.middleware(async ({ path, type, input, next }) => { const start = Date.now(); const result = await next(); console.log(`${type} ${path} ${Date.now() - start}ms`, JSON.stringify(input ?? {})); return result; });

开发时一眼看清每个请求耗时,生产时配合日志采集也能快速定位慢查询。这套钩子不复杂,但排查效率翻倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询