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

发布时间:2026/10/28 6:53:54
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 devPrisma 会自动生成迁移文件和对应的 Client。从这一步开始数据库的表结构已经变成了 TypeScript 类型的一部分前端页面能用.author.name这种链式访问靠的就是 Prisma Client 根据 Schema 生成的类型推导。2.2 tRPC Router 的拆分、聚合与鉴权中间件数据契约定好之后接下来是接口层。tRPC 的核心思路是前端写一个函数调用后端写一个 procedure整个过程类型自动推导不需要手动维护 API 文档因为类型本身就是文档。t3code 按业务域拆分 Router然后聚合到根 Router。以博客场景为例我会建一个 postRouterimport { 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.contexttypeof 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 classNamegrid 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.tsxui目录放通用基础组件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 --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 ENV NEXT_TELEMETRY_DISABLED1 RUN addgroup -g 1001 -S nodejs adduser -S nextjs -u 1001 COPY --frombuilder --chownnextjs:nodejs /app/.next/standalone ./ COPY --frombuilder --chownnextjs:nodejs /app/.next/static ./.next/static COPY --frombuilder --chownnextjs: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 buildTypeScript 错误被当空气放过去等部署完页面白屏才发现问题。t3code 里我把 CI 阶段设计成严格的「四道闸门」name: CI on: push: branches: [main] pull_request: jobs: ci: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: pnpm/action-setupv4 with: version: 9 - uses: actions/setup-nodev4 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 平台之后我遇到过一个经典的运营事故数据库连接数被耗尽了。现象是应用间歇性 500PostgreSQL 日志里全是 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 这个名字对我就意味着这件事代码不是终点理解才是。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询