t3code:基于T3 Stack的全栈类型安全脚手架实践与避坑指南

发布时间:2026/10/28 6:53:52
t3code:基于T3 Stack的全栈类型安全脚手架实践与避坑指南

1. 为什么把项目命名为 t3code:从重复搭架子的疲惫说起

1.1 一次新项目的「前置劳动清单」

做过几年全栈开发的人大概都有体会,真正写业务之前,最消耗耐心的往往不是业务本身,而是开新项目时那一整套「基础设施前置劳动」。我给自己做过一次统计,每次从零开始一个全栈应用,前三天的产出基本固定在几件事上:初始化 Next.js、配置 Tailwind、接入 ORM、写好数据库连接、搭一套接口层、封装前端请求、配好环境变量、顺手把 lint/typecheck 的脚本调通。等这些东西全跑顺,一次普通的信息管理类项目,已经从周一到周三了。

更烦人的是,这些工作具有极高的重复性。我在第二个项目里踩过的 TypeScript 类型报错,在第五个项目里大概率还要再踩一遍;第三个项目里解决的 CORS 配置问题,第六个项目换了域名又重新冒出来。这些事不复杂,但非常损耗状态,因为它们打断的是「写业务」的思路。也就是在第七次重复劳动的时候,我决定把这些工作整理成一套自己的样板工程,给它起了个名字叫 t3code。

这个项目对内是我自己的全栈脚手架,对外则是一套可以照着抄的工程实践集合。它不长在「某某框架」之上,而是把多套成熟工具按照类型安全这条主线串起来。文章后面我会从数据契约、接口层、前端工程化、部署链路逐个拆开讲,中间穿插我在开发 t3code 过程中被折磨过的几个坑。如果你最近也在考虑从零搭一套全栈模板,或者刚接触 T3 社区这套技术栈,这篇文章能帮你省掉不少试错时间。

1.2 T3 是什么,t3code 又是什么

先说清楚「T3」这个称呼。T3 Stack 最早来自社区里一个很流行的全栈起手式,名字取自三样东西首字母的组合:Tailwind CSS、TypeScript、tRPC。后来随着脚手架逐渐完善,Prisma、NextAuth 这些也被放进了默认组合里。严格来说 T3 不是一个官方框架,而是一批偏好类型安全的开发者共同认可的技术倾向。

t3code 的命名就是这么来的。它代表了我对这套技术倾向的理解和落地——以 TypeScript 为语言底座,用 Prisma 定义数据模型,用 tRPC 承担前后端接口层,用 Tailwind 解决样式问题,再加上 Next.js 作为应用骨架。我特意把 T3 改成 t3code,就是想强调它最终沉淀下来的是一套代码组织方式,而不是某个工具的命令行工具。

我第一次接触 create-t3-app 的时候,被它的「前后端类型闭环」惊艳到了:数据库里的一个字段如果改名,前端页面马上就会报类型错误。这种体验一旦习惯,再回到手写 REST 接口的日子会非常难受。但默认脚手架的问题是它什么都给你配好了,但没告诉你为什么这么配、哪些配置可以拆、拆了之后影响什么。所以我做 t3code 的第一原则就是「可解释」——每一个文件都值得被讲清楚用途,而不是黑盒一样扔在那里。

1.3 t3code 的边界:该包含什么,不该包含什么

做样板工程最怕两件事:一是塞进去太多东西变成大杂烩,二是为了通用性把一切都抽象化,最后改配置比写业务还难。我在 t3code 里明确给自己划了条边界——它只解决「从零到能写第一个业务接口」这个问题,不解决「这个业务应该怎么做」。

具体来说,t3code 包含四块内容:数据库层的 Prisma Schema 与迁移流水线;接口层的 tRPC Router 组织方式;认证模块基于 NextAuth 的最小实现;前端层的 Tailwind 配置、API 客户端封装和基础组件。不包含的东西更多:不内置状态管理库、不引入 UI 组件库、不做微前端、不搞 monorepo。后三件事一旦引入,整个样板的学习曲线会陡增,而且很容易把新手直接劝退。我在写 t3code README 的时候加了一句话:「你可以删掉任何一部分,剩下的依然能跑。」这是我最想传达的——样板工程应该像搭积木,而不是砌墙。

2. 数据契约先行:Prisma Schema 与 tRPC 路由的正确打开方式

2.1 用 Prisma Schema 先画业务边界

我在 t3code 里坚持的第一条纪律是:先写数据库模型,再写接口,最后写页面。很多全栈项目翻车都是反着来的,页面写得差不多了,发现字段跟数据库对不上,回头改 Schema 又牵扯迁移和前端类型,改动成本直接翻几倍。

Prisma 的 Schema 文件在这里承担的就是「数据契约」角色。一个典型的 t3code 博客场景,Schema 会是这样:

generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? 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 tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }

有几个细节值得展开说。ID 我用的是 cuid() 而不是自增数字,原因有两个:一是分布式环境下生成自增 ID 要额外处理并发,二是业务接口里一旦暴露了自增 ID,等于把数据量信息泄露给外部。cuid 是字符串,前端传参也没有类型转换的麻烦。

@updatedAt和@default(now())这种字段看起来不起眼,但它们是审计数据变更的基础。我在早期项目里吃过亏,表里没有 updatedAt,出了问题完全对不上数据是什么时候变的。所以 t3code 里每条业务表都强制带 createdAt 和 updatedAt。

写完 Schema 之后,跑一次npx prisma migrate dev,Prisma 会自动生成迁移文件和对应的 Client。从这一步开始,数据库的表结构已经变成了 TypeScript 类型的一部分,前端页面能用.author.name这种链式访问,靠的就是 Prisma Client 根据 Schema 生成的类型推导。

2.2 tRPC Router 的拆分、聚合与鉴权中间件

数据契约定好之后,接下来是接口层。tRPC 的核心思路是:前端写一个函数调用,后端写一个 procedure,整个过程类型自动推导,不需要手动维护 API 文档,因为类型本身就是文档。

t3code 按业务域拆分 Router,然后聚合到根 Router。以博客场景为例,我会建一个 postRouter:

import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure, } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ tag: z.string().optional() })) .query(async ({ ctx }) => { return ctx.db.post.findMany({ where: { published: true, tags: tag ? { some: { name: tag } } : undefined, }, include: { author: { select: { name: true, email: true } }, tags: true, }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(200), content: z.string().optional(), tagNames: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tagNames.map((name) => ({ where: { name }, create: { name }, })), }, }, }); }), byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id }, include: { author: true, tags: true }, }); }), });

注意list和create分别用了publicProcedure和protectedProcedure,这两者的区别就在鉴权中间件。protectedProcedure在服务端先检查 session,没有登录的用户在进入 procedure 之前就会被拦下。

这个中间件的定义长这样:

import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; export const t = initTRPC.context<typeof createTRPCContext>().create({ transformer: superjson, }); 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?因为我希望后续的 procedure 拿到的 session 一定是带 user 的,让 TypeScript 帮我们保证「登录后才能访问的数据里一定有 userId」。这种在类型层面消除隐患的做法,是 tRPC 比传统中间件强大许多的地方。

2.3 为什么我不选手写 REST 或 GraphQL

做 t3code 的过程中,不止一个人问我为什么不用 REST,毕竟生态成熟、团队熟悉、工具链多。我的回答是:REST 本身没有错,但在全栈 TypeScript 项目里,手写 REST 意味着要维护双份类型,或者用 OpenAPI 生成器去同步类型。业务一复杂,接口参数一多,类型漂移的问题迟早爆出来。

GraphQL 我也认真考虑过,它解决的是「前端按需取数」的问题,但付出的代价是 schema 编排、resolver 分层、缓存策略这些额外的心智负担。对于大多数内部管理系统、内容平台、工具类应用,用 tRPC 这种 RPC 风格完全够用,而且它的编码体验更接近写本地函数。

下面这个表格是我当时做的选型对比:

维度手写 RESTGraphQLtRPC
类型安全需额外维护或生成强,但配置成本高原生最强,自动推导
学习曲线低高中低(会写函数就会用)
前端取数灵活性靠多个 endpoint 组合最好一般(按 procedure 粒度)
服务端缓存/监控生态成熟较复杂尚在成长
代码量偏多(控制器+路由+类型)非常多极少

一句话总结我的取舍:在团队已经全面 TypeScript 化的情况下,tRPC 用最低的成本拿到了最强的类型安全,这最符合 t3code 的价值观。如果你在 REST 上已经积累了完善的内部工具链,那不必为了换而换;但如果是全栈新项目,tRPC 绝对值得认真考虑。

3. 前端工程化细节:Tailwind 配置、状态分层与组件策略

3.1 服务端状态与客户端状态:别把所有数据都塞进全局 Store

前端工程师第一次接触 tRPC 的时候,最容易犯的毛病是把所有查询结果都往全局状态里塞。原因也简单,以前用 REST + Redux 的时候,页面上用到的数据都要手动 dispatch action 去拉,拉到之后存在 store 里,其他页面才能复用。

t3code 的做法完全不同。它的核心原则是:服务端数据交给 tRPC 的 query 去管,客户端只保留 UI 状态。举个例子,博客列表页面长这样:

import { useMemo, useState } from "react"; import { api } from "~/utils/api"; export function PostListPage() { const [keyword, setKeyword] = useState(""); const { data: posts, isLoading, refetch } = api.post.list.useQuery({}); const filtered = useMemo(() => { if (!posts) return []; return posts.filter((post) => post.title.includes(keyword)); }, [posts, keyword]); if (isLoading) return <div>加载中...</div>; return ( <div> <input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="按标题筛选" /> <div className="grid gap-4"> {filtered.map((post) => ( <PostCard key={post.id} post={post} /> ))} </div> </div> ); }

这里posts是服务端数据,由useQuery负责缓存、重新拉取、失效;keyword是客户端状态,用useState管理。两者各司其职,页面代码一目了然。

在 t3code 里,我完全没有引入 Redux 或 Zustand。这不是说状态管理库没用,而是对一个以「服务端数据展示 + 表单交互」为主的样板来说,引入全局状态库只会让新手分不清数据该放哪。你真正需要全局共享的状态,通常只剩用户登录信息、主题模式、多语言这类低频数据,这些用 React Context 就能解决。

3.2 Tailwind 的工程化:设计令牌不是把颜色堆进 config

样式层面,t3code 用 Tailwind,但不只是装个依赖那么简单。很多人写完这行配置就开干了:

module.exports = { content: ["./src/**/*.{ts,tsx}"], theme: { extend: {}, }, };

这样写当然能跑,但项目一几十个页面,你会发现颜色不统一、间距靠感觉、暗色模式无从下手。我在 t3code 里用「设计令牌」的思路来组织 Tailwind 配置,把视觉规范收敛到 config 里,组件里不出现游离的值。

一段精简版的配置长这样:

import type { Config } from "tailwindcss"; const config: Config = { darkMode: "class", content: ["./src/**/*.{ts,tsx}"], theme: { extend: { colors: { surface: { DEFAULT: "hsl(var(--surface) / <alpha-value>)", muted: "hsl(var(--surface-muted) / <alpha-value>)", }, primary: { DEFAULT: "hsl(var(--primary) / <alpha-value>)", hover: "hsl(var(--primary-hover) / <alpha-value>)", }, }, borderRadius: { card: "0.75rem", button: "0.5rem", }, fontFamily: { sans: ["var(--font-sans)", "system-ui", "sans-serif"], }, }, }, plugins: [require("@tailwindcss/typography")], }; export default config;

留意这几个地方:颜色用 HSL 变量加<alpha-value>占位符,这样bg-surface/50这种透明度写法依然有效,同时换主题只需改 CSS 变量。字号、圆角、字体这类设计参数,统一放到 theme 里,组件里就写rounded-card、text-sm这种语义化类名。这套方案的收益是重构视觉时不用全局搜索魔法值,改一处配置,全站生效。

提示:设计令牌的核心目标是让「改版」这件可怕的事变得可控。如果你在组件代码里写了大量#F5F5F5、text-[14px],说明令牌还没真正落地。

3.3 组件库取舍:没在 t3code 里引入重型 UI 框架

这是我在 t3code 里做过的最「反共识」的决定之一:不内置任何一个流行 UI 组件库。刚搭脚手架的时候,我确实想把某个知名组件库加进去,省得自己写按钮、弹窗、表格。但后来想清楚了一个问题:组件库是服务于业务的,而 t3code 定位是服务于开发者怎么组织代码。如果我把它绑定到某套组件的类名体系和样式方案上,等于给使用者预设了设计风格,这跟样板工程「可拆可改」的目标冲突。

t3code 的组件层只保留极少数自带的基础元素,比如 Button、Input、Card,它们的代码都很短,纯粹是为了确立组件的文件组织范式:

src/components/ ui/ Button.tsx Input.tsx Card.tsx post/ PostCard.tsx PostForm.tsx

ui目录放通用基础组件,post目录放业务组件。这个约定的价值在于:当你需要引入真实世界的 UI 框架时,可以只把ui目录换成shadcn/ui或者别的方案,业务组件完全不用动。

我并不是反对组件库,而是反对「样板工程替使用者做设计决策」。如果你已经明确知道自己要 Ant Design 还是 MUI,完全可以自己装;但 t3code 作为起点,必须保持设计层面上的中立。

4. 构建与部署链路:Docker 镜像、环境变量与 CI 流水线的实战取舍

4.1 环境变量:用 zod 把配置变成可校验的契约

环境变量是全栈应用最容易出事故的地方,而且事故往往发生在部署之后。最常见的问题是:本地开发时 DATABASE_URL 写的是 localhost,部署后忘了改,数据库连接直接失败,页面白屏,然后你花了半小时才发现是环境变量没配。

t3code 的方式是用 zod 在应用启动时校验环境变量,一旦缺少或格式错误,直接报错退出,而不是带着错误配置运行时半死不活。我在src/env.mjs里写的校验大致长这样:

import { z } from "zod"; const envSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]), DATABASE_URL: z.string().url(), NEXTAUTH_URL: z.string().url().optional(), NEXTAUTH_SECRET: z.string().min(1), MAX_CONNECTIONS: z.coerce.number().int().positive().default(10), }); const parsed = envSchema.safeParse(process.env); if (!parsed.success) { console.error("环境变量校验失败:", parsed.error.flatten().fieldErrors); throw new Error("Invalid environment variables"); } export const env = parsed.data;

注意两个细节。一是NEXTAUTH_URL在开发环境经常不配,所以用.optional()兜底;二是MAX_CONNECTIONS用了z.coerce.number(),因为环境变量在 shell 里永远是字符串,直接z.number()会把字符串 "10" 判成类型错误。只有启动时把环境变量校验一遍,后续业务代码里才能放心使用env.DATABASE_URL,安全感和可维护性都会提升一大截。

4.2 把 t3code 装进 Docker:基于 Next.js standalone 的镜像实践

t3code 的部署目标很多,但我优先保证 Docker 镜像这一条最通用的路径是顺的。Next.js 14 以上默认支持output: "standalone",这个选项会把服务端代码、依赖和公共资源打成一个自包含的目录,非常适合多阶段构建。

我调整后的 Dockerfile 可以精简成下面这样:

# ---- 依赖安装阶段 ---- FROM node:20-alpine AS deps RUN apk add --no-cache libc6-compat 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 ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/public ./public USER nextjs EXPOSE 3000 CMD ["node", "server.js"]

多阶段构建的逻辑是:第一阶段只装依赖,第二阶段利用缓存跑构建,第三阶段只拷贝构建产物。这样的镜像体积小很多,因为node_modules和源码都不会进到最终镜像。有几个注意点值得单独说:一是libc6-compat是 Alpine 环境下跑原生模块需要的兼容库,没有它 Prisma 的二进制可能启动失败;二是运行时阶段要创建非 root 用户,这在部署平台有安全审计时是硬性要求;三是 Prisma 的generate必须发生在构建阶段,可以通过prisma generate命令配合postinstall钩子,或者直接在构建脚本里显式调用。

如果部署平台是 Vercel,那 Docker 路径就不是必须的,直接用平台内置构建即可。但 t3code 的目标是尽量不被平台绑架,Docker 化是我保留的最低通用路径。

4.3 CI 流水线:类型检查、构建与数据库迁移的顺序

我见过很多项目的 CI 流水线只有一句npm test或者npm run build,TypeScript 错误被当空气放过去,等部署完页面白屏才发现问题。t3code 里我把 CI 阶段设计成严格的「四道闸门」:

name: CI on: push: branches: [main] pull_request: jobs: ci: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - name: 数据库迁移检查 run: npx prisma migrate dev - name: 类型检查 run: pnpm typecheck - name: Lint 检查 run: pnpm lint - name: 生产构建 run: pnpm build

为什么把 Prisma 迁移放在最前面?因为如果 Schema 变更还没有生成迁移文件,后续的构建和类型检查都会基于旧的 Client,测试结果没有意义。prisma migrate dev在 CI 环境里会自动生成迁移,并重新生成 Client,这一步过了,后面的检查才有意义。

这里有一个实际教训:不要在 CI 里跑prisma migrate deploy,因为deploy会把变更直接应用到生产数据库,而 CI 通常是每次 push 都会触发,很容易造成非预期的生产变更。正确的做法是 CI 只负责「检查迁移是否存在」,真正的生产迁移放到部署流程里单独执行。

5. 落了地才知道的坑:t3code 开发过程中的四个头疼时刻

5.1 升级 tRPC 之后,日期全变成了字符串

这是我在 t3code 迭代中碰到的最诡异的一个问题。某次升级 tRPC 相关依赖后,前端页面突然显示一串串数字时间戳,控制台也不报错,数据格式看着完全正常,就是渲染出来的日期不对劲。

排查链路是这样的:先看前端渲染函数,发现它拿到的createdAt已经变成了字符串;再打开数据库,确认存的是时间戳;然后把问题定位到序列化环节,发现旧版本里前端的 tRPC client 和服务端的 transformer 都是用superjson,它能自动把 Date 对象序列化成字符串再还原;升级之后,服务端initTRPC里的transformer: superjson配置没丢,但前端某个辅助文件里引用的 tRPC client 是直接从旧路径导入的,导致前后端的序列化逻辑不一致。

解决办法很简单:统一让前端 client 从同一个 trpc 工具文件导出,确保 transformer 配置前后端一致。但排查过程浪费了几小时。这个坑的预防办法是在升级依赖时,多留意 breaking changes 里关于 transformer 的说明,不要只盯着 API 名称变化。

5.2 Prisma 在 Serverless 环境把连接数打满了

把 t3code 部署到 Serverless 平台之后,我遇到过一个经典的运营事故:数据库连接数被耗尽了。现象是应用间歇性 500,PostgreSQL 日志里全是 connection limit exceeded。

原因不复杂:Serverless 平台会为每次请求启动一个隔离的执行环境,t3code 默认的 Prisma Client 在每个环境里都会创建新的数据库连接池。请求一多,连接数就指数级增长,数据库根本扛不住。

我当时的处理方案有两步。第一步是在代码层面做连接池的全局复用,确保同一个执行环境内的多次请求共享同一个 Prisma Client 实例;第二步是为生产环境设置了连接数上限,防止单个实例无限申请连接。如果你也遇到类似问题,建议优先考虑 Prisma 官方的连接池方案,或者直接改用 Postgres 的专用驱动以降低连接资源占用。这个问题帮我养成了一个习惯:任何数据库访问层的代码,都要考虑「一次请求会不会创建多个连接」,而不是本地跑通了就结束。

5.3 SSR 预取数据的「过期」问题

在 t3code 里,列表页默认走客户端查询,首次打开时会有一个加载态。为了让首屏体验更好,我试过在 SSR 阶段调用api.post.list.prefetch把数据预取好。这个方案在开发环境非常完美,页面一打开就有数据。

但部署之后问题来了:用户访问页面时总是看到旧数据,刷新也刷不出新内容。原因是 tRPC 的prefetch会把服务端查询结果写入缓存,而这个缓存在客户端是有一定持久性的,于是前端useQuery直接读了旧缓存,根本没发新请求。

解决方案是区分「首次渲染的预取」和「用户主动刷新时的重新拉取」。前者配合 React Query 默认缓存策略没问题;后者需要显式调用refetch,或者调整缓存时间。在这个场景下,我建议能 SSR 预取的别懒,但要清楚它的语义只是「填充初始缓存」,而不是「让数据永远新鲜」。

5.4 一些看起来小、实际上很关键的默认值

最后说几个 t3code 里不起眼但影响巨大的默认配置。

一是 ID 生成策略。前面提过我用cuid(),但如果你的业务里 ID 会被放到 URL 或者外部系统里做展示,建议直接换成cuid2(),避免某些场景下的可预测性问题,同时它还更短。

二是时区问题。Prisma 里@default(now())用的是数据库服务器时区,如果你的容器和数据库不在同一时区,createdAt的显示就会跟本地时间差几个小时。处理方式是把前端展示逻辑统一封装一个时间组件,内部用指定时区的格式化函数处理,而不是在数据库层做转换。

三是NEXTAUTH_URL的坑。本地开发这台机器上忘记配置的时候,NextAuth 会提示需要登录回调地址,很多人被这个报错卡住。我在.env.example里给了完整的范例注释,提醒自己也是提醒后来者,样板工程的.env.example一定要当正经文档来写,而不是随手塞几个名字。

这些坑单个看都不大,但它们共同构成了 t3code 最有价值的部分:它不是一套「装完了就能跑」的死模板,而是一份带着哪些配置会踩坑的记录。我每次往 t3code 里加一个新的默认配置,都会顺手把「为什么是这个值」写进注释或 README,因为我知道,几周后的自己大概率会忘掉当初的决策理由。

写到这里,我其实没有把 t3code 当成一个完成了的东西。它每隔一段时间就会因为工具链升级、新项目的需求、或者某天凌晨踩到一个新坑而继续调整。如果你也想搭一份自己的样板工程,我的建议是先别急着把全家桶都装进来,装一个你真正用过的、能讲清楚原理的工具,哪怕它很小。样板工程的价值从来不是「看起来全面」,而是「每一行都能被解释」。t3code 这个名字对我就意味着这件事:代码不是终点,理解才是。