T3code全栈骨架实践:从端到端类型安全到生产部署的完整指南
发布时间:2026/10/28 23:22:31 锦皓数字建站

t3code 是我最近一个月里一直在调的一个全栈工程骨架。项目代号就叫 t3code核心是把 T3 Stack 这套东西——Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth——真正揉成一个能直接开新项目的底座。之所以起这个名是因为我发现自己一直在重复做同一件事每次接一个小型全栈项目都要重新配鉴权、配数据库、配类型、配 API 路由。前后端分离那套玩法当然是好的但很多场景根本没必要让前端维护一份手工类型、后端再维护一份 schema两边各写各的联调时靠聊天记录对齐字段。t3code 想解决的就是这个问题。这篇文章我会把选型逻辑、工程骨架、类型链路、一次典型排障、以及生产部署的完整过程都记下来适合正在纠结要不要上 T3、或者已经上了但被细节卡住的人参考。1. 为什么我把T3 Stack当成中小型全栈项目的主选1.1 端到端类型安全不是噱头先说说我最看重的点端到端类型安全。很多人在没真正用过 tRPC 之前会觉得这不就是 RPC 换个皮嘛最后不还是 HTTP 调用。但实际用下来区别非常大。传统 REST 流程里后端定义接口前端要拿到接口文档或者 OpenAPI schema然后手动维护一份 TypeScript 类型。如果接口变了后端改了字段前端这边只有跑到运行时才发现运气好一点是编译期发现但更多时候是字段名写错、可空类型没处理等测试同学点出 bug 才知道。t3code 的做法是完全反过来。后端用 Prisma 定义数据库模型然后 tRPC 的 router 直接暴露业务方法TypeScript 的类型从 Prisma 的模型一直推到前端 React Query 的 hooks 上。中间不需要手写任何一份独立的类型文件。这个链条听起来简单真正跑起来才知道省了多少事改个数据库字段从前端调用处到后端写入处所有相关类型一并更新编译不过的地方就是需要改的地方。1.2 与主流方案的取舍对比我先把自己的取舍摆出来你大概就能理解 t3code 处在什么位置。方案类型安全程度开发效率适合场景短板REST OpenAPI 生成中需要额外生成链中前后端需约定文档公共 API、多端接入文档维护成本和联调成本高GraphQL高但有 schema 成本中偏下resolver 写起来重复杂数据图谱、BFF中小项目里过度设计tRPC极高零额外类型代码高一个函数即接口同构全栈、内部系统不适合对外公开 API这不是说 tRPC 要取代 REST。对外接口、第三方接入、跨语言调用这些场景 tRPC 并不合适它需要服务端和客户端都用 TypeScript 才能吃到完整红利。但如果是自己一个团队、一个前端、一个小型产品t3code 这套组合的摩擦系数明显更低。我的经验是中小型 SaaS、内部工具、后台管理系统直接上 tRPC 是性价比最高的做法。前期不需要写接口文档前端也不需要 mock server后端函数写完前端马上就能拿到类型和调用方式。1.3 这套技术栈的适用边界千万别把这套东西神化。t3code 适合的场景我归类为三类快速原型、内部工具、以及业务逻辑不复杂的对外 Web 应用。反过来如果你要做的是开放平台要给几百个第三方开发者提供 API那老老实实走 REST 或 GraphQL如果是跨技术栈的微服务也不适合tRPC 的端到端类型安全只在 TypeScript 全覆盖时才有意义。我见过有人非要在 Next.js 前后端之外再套一层 Java 服务然后 tRPC 只接到 Next 这一层结果类型链路从 Java 那边就断了等于多了个中间转换层不划算。所以用 t3code 之前先确认一下你的 Client、Server、Database 这三层是否都愿意统一在 TypeScript 生态里。2. 搭建t3code骨架时容易被跳过的环节2.1 脚手架选项的含义t3code 是用 official 脚手架初始化的命令就一行pnpm create t3-applatest t3code但这一行里藏了很多决定后期体验的选项很多人直接一路回车后面就会开始在 TypeScript 配置上踩坑。脚手架会问你选 App Router 还是 Pages Router。我的建议是无脑 App RouterT3 的新模板已经把 App Router 作为默认方向Server Component、Server Action 这些能力都在 App Router 下才完整。模板还会问 Prisma 还是 Drizzle。如果你熟悉 SQL 且想要更轻的迁移机制Drizzle 不错但如果想让数据库模型直接参与类型推导、又不想花太多时间调 query builderPrisma 更稳。t3code 用的是 Prisma原因很简单团队里不是每个人都精通 SQL 方言Prisma 的 schema 可视化程度高出问题的概率低。还有一个选项是 NextAuth。我建议明确选上。很多人在原型阶段不想做登录于是去掉 NextAuth等到后面要加权限时才发现整个 tRPC 的 protectedProcedure 体系已经写好了再回头接入要改很多地方。t3code 的模板里auth 是嵌在 middleware 和 context 里的一开始就接上省事得多。2.2 目录边界约定脚手架默认生成的目录结构如果直接用项目一大就会乱。t3code 里我做了几个约定这个习惯建议你抄走。src/ app/ api/trpc/[trpc]/route.ts (main)/workspace/page.tsx layout.tsx server/ api/ routers/ workspace.ts member.ts root.ts auth.ts db.ts trpc/ react.tsx server.ts关键约束是src/server下面的东西只跑在服务端不允许从 Client Component 导入。Prisma client、NextAuth 配置、数据库连接这些一旦被 bundle 进客户端代码轻则体积暴涨重则把数据库凭据暴露到浏览器里。src/trpc/react.tsx是专门给前端用的 provider 和 hookssrc/trpc/server.ts是给 Server Component 或 Server Action 里做直接调用的。这个分层不是为了好看是真正阻止类型污染和安全隐患的物理边界。2.3 环境变量与运行时配置t3code 的环境变量我集中在一个.env文件管理脚手架会生成.env.example作为模板。最需要注意的是NEXTAUTH_URL、NEXTAUTH_SECRET这两个。很多本地开发没问题、一上服务器就登录失败的情况都出在这里。DATABASE_URLpostgresql://user:passlocalhost:5432/t3code NEXTAUTH_SECRETyou-should-generate-a-random-one NEXTAUTH_URLhttp://localhost:3000生产部署时NEXTAUTH_URL必须改成对外的 https 地址NEXTAUTH_SECRET用openssl rand -base64 32生成一次不要直接复制脚手架默认值。后面我会详细讲一次因为代理配置导致登录链路崩溃的排查过程这里先记住认证相关的环境变量是所有环境变量里最容易因为部署拓扑变化而出问题的。3. 一次请求的类型闭环从Prisma到React组件的完整链路3.1 用Zod定义输入边界t3code 里业务逻辑从来不是直接接收前端传来的裸数据。每个 procedure 的入参都要过一个 Zod schema这个 schema 既是运行时校验器也是编译期类型的唯一来源。举个例子我要创建一个 workspacePrisma 模型很简单model Workspace { id String id default(cuid()) name String ownerId String members Member[] createdAt DateTime default(now()) }对应的 tRPC routerimport { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const workspaceRouter createTRPCRouter({ create: protectedProcedure .input( z.object({ name: z.string().min(3).max(32), }) ) .mutation(async ({ ctx, input }) { return ctx.db.workspace.create({ data: { name: input.name, ownerId: ctx.session.user.id, }, }); }), });这段代码的信息量比表面看起来大。protectedProcedure意味着这个调用自动带上用户登录态的检查z.object里的规则会在请求到达函数体之前先执行校验而ctx.session.user.id则来自 NextAuth session直接由服务端 context 注入前端永远不可能传一个假的 ownerId 来提权。这就是“类型安全 安全边界”合体的典型写法。3.2 tRPC Router如何把类型送到前端我把 workspace router 挂到根 router 上之后前端不需要额外生成任何代码。脚手架里的src/trpc/react.tsx导入了根 router 的类型import { type AppRouter } from ~/server/api/root; import { createTRPCReact } from trpc/react-query; export const api createTRPCReactAppRouter();注意createTRPCReactAppRouter()这里的泛型。它把整个后端 router 的类型图暴露给了前端类型系统。于是你在任何 Client Component 都能这样写use client; import { api } from ~/trpc/react; export function CreateWorkspaceButton() { const utils api.useUtils(); const createWorkspace api.workspace.create.useMutation({ onSuccess: () { utils.workspace.list.invalidate(); }, }); return ( button onClick{() createWorkspace.mutate({ name: 新项目, }) } 创建 /button ); }这里api.workspace.create.useMutation的入参类型已经自动推导成{ name: string }返回值的类型也直接从 Prisma 模型推导过来。写错了字段名IDE 会立刻红波浪线。没有手写 interface没有类型断言没有any。老实说我第一次跑通这个链路时是有点起鸡皮疙瘩的——数据库模型到前端按钮的点击参数中间隔了 Prisma、Server Context、tRPC Router、React Query 四层类型一个没丢。3.3 Client组件的调用与乐观更新类型链路通了只是第一步交互体验才是用户感知的东西。t3code 里我所有 mutation 都配合了 React Query 的乐观更新。还是上面的 workspace 创建例子如果只是invalidate后拉取列表网络快的时候还好慢的时候按钮点了半天没反应用户会以为没点上。优化做法是先用旧列表加上新建项渲染出结果等服务端确认后再用真实数据覆盖。const createWorkspace api.workspace.create.useMutation({ onMutate: async (newWorkspace) { await utils.workspace.list.cancel(); const previousData utils.workspace.list.getData(); utils.workspace.list.setData(undefined, (old) [ ...(old ?? []), { id: temp-id, name: newWorkspace.name, ownerId: current-user }, ]); return { previousData }; }, onError: (_err, _input, context) { utils.workspace.list.setData(undefined, context?.previousData); }, onSettled: () { utils.workspace.list.invalidate(); }, });这段代码里有几个注意点。utils.workspace.list.cancel()是取消正在进行的查询避免旧数据覆盖新数据setData(undefined, ...)第一个参数传 undefined 表示操作的是整个列表查询onError里一定要回滚到previousData否则界面会出现幽灵数据。这个模式我强烈建议写进团队的代码规范里因为乐观更新的 bug 非常隐蔽不会报错就是偶尔列表闪一下或者出现奇怪字段排查半天才发现是回滚没做完整。3.4 类型安全的三条红线在 t3code 上写代码写久了我总结出三条不能碰的红线。第一不要在 Client Component 里直接 importsrc/server下的东西尤其是 Prisma client。这会打破服务端类型的隔离边界。第二不要在 procedure 的 input 里使用 Prisma 的类型作为唯一校验必须用 Zod。因为 Prisma 类型不包含运行时校验前端可以直接传一个超大字符串进来把数据库写穿。第三不要写as any绕过类型错误。t3code 的类型链路是闭环的一旦出现类型错误99% 是代码逻辑本身有问题用 any 掩盖的结果就是类型安全彻底失效后面所有人都在补窟窿。4. 白屏和401背后的链路排查一次完整的排障记录4.1 症状登录后全部请求401t3code 在本地跑得很欢我打包部署到一台服务器上时出现了让人头疼的症状访问首页没问题静态内容能看到但只要一点登录按钮走完 OAuth 流程后页面整个白屏打开 DevTools 一看所有/api/trpc/*的请求全部返回 401连创建 workspace 的按钮都渲染不出来——因为列表数据拉不到页面直接进入 error 状态。这种症状最迷惑的地方是本地完全正常部署环境才挂浏览器 Network 面板没有 CORS 报错也不是 500就是整整齐齐的全 401。我开始以为是 tRPC 的 protectedProcedure 没有拿到 session于是查 session 存储查 JWT 策略折腾了半小时没头绪。4.2 顺着请求链路逐段取证后来我冷静下来从头走了一遍请求链路。t3code 部署在前面是 Nginx后面是 Docker 里的 Next.js 服务。浏览器访问的是https://example.comNginx 把请求转发到容器里的http://127.0.0.1:3000。排查第一步我用 curl 模拟了一次请求curl -I https://example.com/api/trpc/workspace.list响应头里 Set-Cookie 字段看起来正常session cookie 有设置。但接下来我发现一个细节NextAuth 回调完成后给的 cookie 里Secure属性是缺失的。然后我又看了 Nginx 的 access log发现上游请求的协议被记录为 HTTP而不是浏览器入站的 HTTPS。也就是说Next 在生成所有绝对链接、回调地址、CSRF token 时都以为当前站点是http://example.com而浏览器实际使用的是https。这个差异导致什么后果呢NextAuth 的 CSRF 校验是基于协议和域名组合的。浏览器从 https 入站Next 却按 http 生成校验值登录回调时两边对不上session 建立失败后续每一个需要鉴权的 tRPC 调用都拿不到会话于是齐齐 401。4.3 根因代理转发遗漏与Cookie属性错位问题定位到 Nginx 没有把原始的X-Forwarded-Proto传给上游。默认配置里如果只写了location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; }那么 Next.js 拿到的X-Forwarded-Proto是空值或 http。Next 会按照这次内部转发的协议来理解整个外部请求Cookie 的 Secure 属性、绝对地址的重写逻辑全都会偏。再加上 NextAuth 在同一份配置里依赖NEXTAUTH_URL判断外部地址多个因素叠加在一起就出现了“登录成功但会话立刻失效”的诡异局面。顺带说一句部署隐藏在 CDN 后面的场景更常见。CDN 到 Nginx 是 httpsNginx 到 Next 是 http中间只要有任何一跳没有把协议头传下去Next 就会判断错误。4.4 修复步骤与事后加固修复方案分两层。第一层是 Nginx 把该传的头补全。location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }第二层是 Next.js 这边显式声明外部地址。在 t3code 的 Docker 启动环境里设置NEXTAUTH_URLhttps://example.com让认证模块不再依赖请求头去猜。改完之后我还加了事后加固使用 NextAuth 的cookies配置显式指定 session cookie 的secure: true保证在 https 环境下 Cookie 永远不会被非加密通道带走。同时把协议判断的逻辑做成探针脚本部署后自动请求一次登录页检查最终回跳地址是否为 https这个脚本帮我后来躲过了至少三次 CDN 配置改动引起的回归问题。这次排障给我最大的教训是本地环境测不出来代理链路问题。你在 localhost 跑得再好也不代表 Nginx、CDN、Docker 网络这一串链路没问题。部署完第一件事一定要检查协议头和回调地址不要一上来就怀疑业务代码。5. 跑上生产的最后一公里构建、镜像与数据迁移5.1 Standalone输出与多阶段Dockerfilet3code 默认的 Next.js 构建方式体积很大会把整个node_modules都塞进镜像。生产部署我切换到 standalone 输出模式在next.config.js里加一句const nextConfig { output: standalone, };构建之后node_modules里多出一个.next/standalone目录里面只包含运行所需的最小代码。配合多阶段构建镜像体积能缩小到原来的三分之一左右。t3code 的 Dockerfile 我整理成了这样FROM node:20-alpine AS deps RUN corepack enable 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 . . ENV NEXT_TELEMETRY_DISABLED1 RUN pnpm build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENVproduction RUN addgroup -g 1001 -S nodejs adduser -S nextjs -u 1001 COPY --frombuilder /app/public ./public COPY --frombuilder --chownnextjs:nodejs /app/.next/standalone ./ COPY --frombuilder --chownnextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 CMD [node, server.js]这个 Dockerfile 有几个值得注意的细节。corepack enable是为了在镜像里启用 pnpmadduser -S nextjs -u 1001是为了让容器以非 root 用户运行COPY --frombuilder /app/public ./public这行不能省否则静态资源全部 404。如果你用的是 npm 而不是 pnpm把第一段改成npm ci --frozen-lockfile就行其他逻辑一样。5.2 包体积治理的实测做法T3 Stack 默认会引入不少依赖但真正打进首屏 JS 的只是其中一部分。我用next/bundle-analyzer分析过 t3code 的产物体积发现几个可以立刻优化的点。第一个是 Prisma Client 在 Server Component 与 Client Component 之间的重复打包。确保只在src/server/db.ts里实例化 client并且所有数据库操作都走src/server/api/routers这样 Prisma 不会进入浏览器 bundle。第二个是图表库、富文本编辑器这类重型第三方库不要直接 import 到页面顶部。t3code 里我用next/dynamic做按需加载import dynamic from next/dynamic; const WorkspaceEditor dynamic( () import(~/components/editor), { ssr: false } );这样编辑器代码只在需要渲染时下载首屏会明显变快。第三点是给数据库请求相关的模块加上import server-only标记。这个包会确保任何文件在客户端 bundle 里被引用时直接报构建错误从机制上杜绝服务端代码泄漏到前端。5.3 数据库迁移与启动流程生产环境数据库迁移这块我一直强调一件事不要在应用启动时自动执行prisma migrate dev。migrate dev是开发环境用的生产环境应该用prisma migrate deploy。t3code 的容器入口脚本我设计成两个步骤#!/bin/sh npx prisma migrate deploy node server.js这样容器启动时先跑迁移迁移成功再启动业务服务。为了容错我在编排配置里加了一个 healthcheck服务启动后请求/api/health返回 200 才认为容器就绪。如果迁移失败容器会一直处于非就绪状态编排系统会自动重启不会出现服务先起来、但数据库 schema 还是旧的这种错位状态。这套流程跑了几轮之后我最大的体会是部署不是把代码丢上去就行构建、镜像、迁移、协议头、Cookie任何一环掉了链子前面再完美的类型安全都白搭。t3code 走到现在我对它的定位早就不是“一个模板”而是一套从代码到运行时都打通的全栈基线。类型闭环让开发阶段省心部署细节让上线阶段省心这两件事加起来才是它真正值得留下的原因。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。