T3code全栈骨架实践:从端到端类型安全到生产部署的完整指南

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

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-app@latest t3code

但这一行里藏了很多决定后期体验的选项,很多人直接一路回车,后面就会开始在 TypeScript 配置上踩坑。脚手架会问你选 App Router 还是 Pages Router。我的建议是无脑 App Router,T3 的新模板已经把 App Router 作为默认方向,Server Component、Server Action 这些能力都在 App Router 下才完整。模板还会问 Prisma 还是 Drizzle。如果你熟悉 SQL 且想要更轻的迁移机制,Drizzle 不错;但如果想让数据库模型直接参与类型推导、又不想花太多时间调 query builder,Prisma 更稳。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 和 hooks,src/trpc/server.ts是给 Server Component 或 Server Action 里做直接调用的。这个分层不是为了好看,是真正阻止类型污染和安全隐患的物理边界。

2.3 环境变量与运行时配置

t3code 的环境变量我集中在一个.env文件管理,脚手架会生成.env.example作为模板。最需要注意的是NEXTAUTH_URL、NEXTAUTH_SECRET这两个。很多本地开发没问题、一上服务器就登录失败的情况,都出在这里。

DATABASE_URL="postgresql://user:pass@localhost:5432/t3code" NEXTAUTH_SECRET="you-should-generate-a-random-one" NEXTAUTH_URL="http://localhost:3000"

生产部署时NEXTAUTH_URL必须改成对外的 https 地址,NEXTAUTH_SECRET用openssl rand -base64 32生成一次,不要直接复制脚手架默认值。后面我会详细讲一次因为代理配置导致登录链路崩溃的排查过程,这里先记住:认证相关的环境变量是所有环境变量里最容易因为部署拓扑变化而出问题的。

3. 一次请求的类型闭环:从Prisma到React组件的完整链路

3.1 用Zod定义输入边界

t3code 里,业务逻辑从来不是直接接收前端传来的裸数据。每个 procedure 的入参都要过一个 Zod schema,这个 schema 既是运行时校验器,也是编译期类型的唯一来源。举个例子,我要创建一个 workspace,Prisma 模型很简单:

model Workspace { id String @id @default(cuid()) name String ownerId String members Member[] createdAt DateTime @default(now()) }

对应的 tRPC router:

import { 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 = createTRPCReact<AppRouter>();

注意createTRPCReact<AppRouter>()这里的泛型。它把整个后端 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 症状:登录后全部请求401

t3code 在本地跑得很欢,我打包部署到一台服务器上时,出现了让人头疼的症状:访问首页没问题,静态内容能看到,但只要一点登录按钮,走完 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.com,Nginx 把请求转发到容器里的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 是 https,Nginx 到 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_URL=https://example.com,让认证模块不再依赖请求头去猜。

改完之后我还加了事后加固:使用 NextAuth 的cookies配置,显式指定 session cookie 的secure: true,保证在 https 环境下 Cookie 永远不会被非加密通道带走。同时把协议判断的逻辑做成探针脚本,部署后自动请求一次登录页,检查最终回跳地址是否为 https,这个脚本帮我后来躲过了至少三次 CDN 配置改动引起的回归问题。

这次排障给我最大的教训是:本地环境测不出来代理链路问题。你在 localhost 跑得再好,也不代表 Nginx、CDN、Docker 网络这一串链路没问题。部署完第一件事一定要检查协议头和回调地址,不要一上来就怀疑业务代码。

5. 跑上生产的最后一公里:构建、镜像与数据迁移

5.1 Standalone输出与多阶段Dockerfile

t3code 默认的 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 --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 CMD ["node", "server.js"]

这个 Dockerfile 有几个值得注意的细节。corepack enable是为了在镜像里启用 pnpm;adduser -S nextjs -u 1001是为了让容器以非 root 用户运行;COPY --from=builder /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 走到现在,我对它的定位早就不是“一个模板”,而是一套从代码到运行时都打通的全栈基线。类型闭环让开发阶段省心,部署细节让上线阶段省心,这两件事加起来,才是它真正值得留下的原因。