☰
用T3 Stack打造全栈脚手架:从设计到落地的完整实践
2026/10/26 17:17:46 网站建设 项目流程

t3code 这个项目,是我给自己维护的一套全栈脚手架起的内部代号。t3 指 T3 Stack 这套技术组合——Next.js、TypeScript、Tailwind CSS 加 tRPC,code 自然是代码的意思。说直白点,它的目标特别朴素:团队里每开一个新业务模块,不用再从建目录、配环境、写 CRUD 接口开始,而是直接套用一份已经验证过的骨架,在几分钟内把数据库表、API、页面一把打通。这篇文章就把 t3code 从设计思路到工程落地的完整过程拆出来讲,包括中间踩过的坑和最后的部署配置,适合正在做全栈选型、或者想自己沉淀一套内部脚手架的同学参考。

1. t3code 在做什么:一个脚手架项目从0到1的拆解

1.1 这个项目要解决的三个痛点

我所在的团队一直在做 To B 的 SaaS 产品,业务模块长得非常像:一张表单收集信息、一份列表展示数据、一组增删改查接口、一套登录鉴权。结果每个新模块都像是重新建一栋楼——先初始化项目,再写数据库表,再手写一堆 REST 接口,再接前端请求层,接完还要处理 loading、error、状态刷新这些边角料。光"把骨架搭起来、能跑通一个 CRUD"这件事,一个熟练的开发也要花上半天,而且每次做的事情几乎一模一样。

第二个痛点是类型不一致。前后端各自维护一套接口文档,前端用 Axios 写请求的时候,后端接口改了字段名,前端往往要等到联调才知道。To B 项目接口多的时候,这种返工非常消耗时间。第三个痛点是新成员上手成本高,项目里到处是约定俗成的目录结构和命名规范,却没有文档把规则固定下来,新同学连"新增一个页面需要动哪些文件"都要问一圈。

t3code 就是冲着这三个问题去的。它把"重复建楼"变成"拿着图纸拼装",把"前后端各自维护类型"变成"一份类型定义两边自动同步",把"团队里口口相传的约定"变成"项目模板里天然的约束"。整个设计的原则只有一个:凡是重复出现三次以上的代码,就应该被模板化。

1.2 为什么偏偏选 T3 这套组合

说实话,选型阶段我们对比过不少方案。传统一点的 REST 加 Express、后端模板渲染的 PHP、还有新兴的 BFF 层方案,最后定在 T3 Stack 上核心原因是它把"类型安全"贯穿到了极致,而类型安全恰好能省掉 To B 业务最多的人力消耗。

T3 Stack 的标准成员是 Next.js、TypeScript、Tailwind CSS、tRPC,外加可选的 Prisma 和 NextAuth。这里面的关键角色是 tRPC,它不搞 REST 的 URL 路由,而是让前端像调用本地函数一样调用后端方法。配合 TypeScript 的infer能力,后端返回什么类型,前端就自动拿到什么类型。改一个字段,如果前后端没对齐,编译期就会报错,根本不需要等到联调才发现。

为了打消大家的疑虑,我列一组当时对比的结论:

对比维度REST + AxiostRPC + Next.js
接口定义URL + 文档手动维护函数签名即接口
类型同步前端手写,靠联调验证编译期自动推导
请求层代码每个接口要封装一层自动生成,直接调用
文档成本需维护 OpenAPI 或手写文档类型本身可读,省去文档
小团队上手需要熟悉大量约定目录和生成代码统一

Prisma 负责数据库这一层,Schema 写清楚以后,模型类型也会被 tRPC 直接引用。也就是说从数据库表一路到前端组件,类型是一条直线传下来的,中间没有断点。这个体验在传统方案里很难想象:你改一个数据库字段,保存以后前端立刻能在编辑器里看到报错,而不是等线上出了 bug 才抓瞎。

Tailwind CSS 在这里不是主角,但它让模板的样式部分零成本——不写 CSS 文件、不纠结类名,页面组件的样式直接用工具类拼出来,对脚手架来说是一种"拿起来就能用"的体验。整套组合还有一个加分项:Next.js 自带的 API 路由和部署生态成熟,不用再单独搭建后端服务,一个仓库从头管到尾。

2. 环境准备和初始化:动手搭骨架

2.1 两条初始化路线,新手建议走第一条

如果你也想复刻 t3code 这套东西,最快的办法是直接用社区现成的 create-t3-app 工具,不建议一开始就手动从零搭。用脚手架最省时的原因很简单:tRPC 需要同时配置服务端路由、客户端 provider、Next.js 的 API handler,这三处的接线代码很琐碎,手写容易漏,而 create-t3-app 已经把这些接线全部打包好了。

实际操作的时候只需要一条命令:

npm create t3-app@latest

按提示输入项目名,然后选择需要的模块。我建议基础组合选 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma。如果业务里有登录需求,再把 NextAuth 加上。其余像是 ESLint 和 Prettier 可以保持默认选上,格式化统一对团队协作很有价值。

初始化流程走完以后,跑一下npm run dev,默认会开在 3000 端口。但这里有一个容易忽略的点:npm run dev能跑起来不代表数据库通了。如果你选了 Prisma,需要先配置数据库连接串,再执行npx prisma migrate dev生成表结构,页面里凡是依赖数据库数据的接口,在表建好之前都会报错。这一步在官方 README 里写得很隐晦,我第一次跑的时候页面上全是红屏,折腾了十几分钟才发现是数据库没建表。

如果你是想深入掌握每一处原理的同学,也可以走手动搭建路线:用create-next-app创建 Next.js 项目,然后逐个安装@trpc/server、@trpc/client、@trpc/react-query、@tanstack/react-query,再手动创建三个关键文件——服务端入口、路由聚合、客户端 provider。手动搭的好处是你能看清楚 tRPC 的数据流是怎么走的,以后出问题排查起来心里更有数。但我的建议很直接:第一次用,走 create-t3-app;搭完以后,再把生成的文件逐个读一遍。这样既有结果,又懂原理。

2.2 目录结构读懂了就成功一半

t3code 的模板目录结构是这个样子的:

src/ ├── pages/ │ ├── api/ │ │ └── trpc/[trpc].ts │ └── index.tsx ├── server/ │ ├── api/ │ │ ├── routers/ │ │ │ ├── todo.ts │ │ │ └── root.ts │ │ └── trpc.ts │ └── db.ts ├── styles/ │ └── globals.css └── utils/ └── api.ts

很多新手进来以后第一反应是:这些文件分别都是干嘛的?我拆开讲一下。server/目录是纯后端的代码,server/db.ts负责单例初始化 Prisma Client,server/api/trpc.ts是 tRPC 的"总闸",在这里创建 router 和 procedure,并且定义每个请求的上下文(ctx)——比如把当前登录用户塞进去。server/api/routers/下放的是业务路由,每个文件对应一个业务域,比如todo.ts放待办事项的接口,user.ts放用户信息的接口。

pages/api/trpc/[trpc].ts是 Next.js 和 tRPC 之间的桥,Next.js 的所有 API 请求都会经由这个文件打到 tRPC 的处理函数上。utils/api.ts则是前端的入口,它导出api这个对象,前端组件里写api.todo.list.useQuery()就是靠它完成的。

这个结构的表达能力很强:想看某个业务域的接口,直接进routers里对应的文件;想加全局中间件(比如日志、鉴权),只改trpc.ts一个文件;想在页面里调数据,用utils/api.ts进来的类型一定是全的。清楚了这些文件的关系,后面写任何模块都顺了。

2.3 环境变量和数据库连接的坑位

T3 模板对环境变量非常敏感,初始化完成之后第一件事应该是配置.env。模板会给你一个.env.example文件,里面列出了所有需要用到的变量——最常见的是DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL。注意.env和.env.example的差异,前者是真实配置,写入.gitignore;后者是占位说明,可以提交到仓库,方便其他成员复制。

DATABASE_URL 的格式取决于你选的数据库。以 PostgreSQL 为例:

DATABASE_URL="postgresql://username:password@localhost:5432/t3code?schema=public"

这里有一个新手经常犯的错:拿本地数据库的 IP 填到线上环境。本地开发可以写localhost,部署到服务器以后就必须要改成对应数据库实例的真实地址。如果数据库在云上,还要确认安全组和防火墙是否放行了对应端口的访问。否则现象很统一——本地开发好好的,一到线上就报Can't reach database server。

另一个容易忽略的点是 Prisma 要求数据库用户对publicschema 有建表权限,哪怕是本地开发也不要图省事用管理员账号一锅端。团队里多人开发时,我更建议每人一个本地 PostgreSQL 实例,用各自的 DATABASE_URL,而不是共享一个开发库。共享库容易出现一个人执行 migrate 把表结构改了,另一个人的 schema 就和新代码对不上的情况。类型更安全的代价,就是对数据库状态的一致性要求更高。

3. 核心功能实战:用待办事项打通全链路

3.1 先写数据模型,页面和接口才有根基

写任何模块我都坚持一个顺序:先数据模型,再接口,最后页面。原因是 Prisma 的模型定义一旦生效,tRPC 那边就能立刻通过类型推导拿到完整的字段列表,前端调接口时编辑器会有字段提示——这个顺序充分利用了类型传导的优势。如果你先写页面再用什么字段去反推模型,反而会丢掉类型校验的保护。

用最典型的待办事项业务来演示,prisma/schema.prisma里定义一张表:

generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Todo { id String @id @default(cuid()) title String completed Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

这里的细节值得展开说。主键选cuid()而不是自增数字,是为了避免通过自增 ID 猜测业务量,也方便在分布式环境下生成唯一 ID。createdAt和updatedAt建议任何业务表都加上,后端排查数据问题时这两个时间戳能救命。定义好之后执行:

npx prisma migrate dev --name init_todo

Prisma 会自动生成 migration SQL 并更新客户端类型。这里产生的prisma/migrations目录一定要提交到代码仓库,它是数据库结构的版本史,团队成员git pull之后只需要执行npx prisma migrate deploy就能把本地库迁移到目标状态。如果你把它忽略掉,换一台机器拉代码后始终无法生成对应的表,运行起来全是未定义的表名报错。

3.2 tRPC router:比手写REST清爽太多

数据模型敲定以后,下一步是在src/server/api/routers/todo.ts里写接口。tRPC 的写法是把接口拆成 procedure——读操作用query,写操作用mutation,配合 zod 做输入校验,代码长了是这样:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const todoRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { const todos = await ctx.db.todo.findMany({ orderBy: { createdAt: "desc" }, }); return todos; }), create: publicProcedure .input( z.object({ title: z.string().min(1).max(100), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.todo.create({ data: { title: input.title }, }); }), toggle: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const todo = await ctx.db.todo.findUnique({ where: { id: input.id }, }); if (!todo) { throw new Error("待办不存在"); } return ctx.db.todo.update({ where: { id: input.id }, data: { completed: !todo.completed }, }); }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.delete({ where: { id: input.id } }); }), });

写完 router 之后,还必须在routers/root.ts里把子路由挂到根路由上面:

import { createTRPCRouter } from "~/server/api/trpc"; import { todoRouter } from "~/server/api/routers/todo"; export const appRouter = createTRPCRouter({ todo: todoRouter, }); export type AppRouter = typeof appRouter;

这一步是最容易漏的——只写了业务 router 但没挂载,前端调用api.todo.list的时候会发现类型根本不存在的。挂载本身的意义是给每块业务一个独立的命名空间,这样接口多了以后不会互相覆盖。

输入校验用 zod 不是锦上添花,而是必须的。REST 方案里写参数校验要在中间件里写一堆 if/else,tRPC 直接在 procedure 上声明,校验规则本身就是类型定义的来源。z.string().min(1)在运行时拦截空字符串,在编译期也会把前端传来的参数类型收窄成规范范围。两层校验同时生效,逻辑上更严谨。另外要提醒一个细节:错误处理别用throw new Error,约定统一抛自定义的 appError 并带业务错误码,前端 catch 以后才能分类处理,比如区分"参数错误"和"权限不足"。

3.3 前端调用:类型安全是怎么一路传过来的

前端页面用起来就三个步骤:引入api对象、调用对应 hook、处理状态。我把完整的待办页面拆开演示:

import { useState } from "react"; import { api } from "~/utils/api"; export default function TodoPage() { const [title, setTitle] = useState(""); const utils = api.useUtils(); const { data: todos, isLoading } = api.todo.list.useQuery(); const createTodo = api.todo.create.useMutation({ onSuccess: () => { setTitle(""); utils.todo.list.invalidate(); }, }); const toggleTodo = api.todo.toggle.useMutation({ onSuccess: () => utils.todo.list.invalidate(), }); const deleteTodo = api.todo.delete.useMutation({ onSuccess: () => utils.todo.list.invalidate(), }); if (isLoading) return <div>加载中...</div>; return ( <div className="mx-auto max-w-md p-4"> <div className="flex gap-2"> <input value={title} onChange={(e) => setTitle(e.target.value)} className="flex-1 rounded border p-2" placeholder="输入待办事项" /> <button className="rounded bg-blue-500 px-4 py-2 text-white" onClick={() => createTodo.mutate({ title })} > 添加 </button> </div> <ul className="mt-4 space-y-2"> {todos?.map((todo) => ( <li key={todo.id} className="flex items-center justify-between border p-2"> <span className={todo.completed ? "line-through opacity-60" : ""}> {todo.title} </span> <span className="flex gap-2"> <button onClick={() => toggleTodo.mutate({ id: todo.id })}> 切换 </button> <button onClick={() => deleteTodo.mutate({ id: todo.id })}> 删除 </button> </span> </li> ))} </ul> </div> ); }

这段代码看起来平平无奇,甚至闭着眼睛都能写出来,但它背后有一个值得炫耀的点:todos的每一项直接带有id、title、completed这些字段的类型提示,你写todo.completed的时候如果字段名拼错了,编辑器和后续的prisma migrate dev都会直接给红波浪线。数据变了以后,invalidate()会让 React Query 自动重新拉取,不用手写任何刷新逻辑。整个调用链从model Todo到ctx.db.todo再到todos.map,类型是同一个来源,这是我在这个项目里觉得最值回票价的地方。

4. 部署与工程化:让模板真正能在团队里用

4.1 Docker 多阶段构建,镜像从1GB瘦到200MB

脚手架没有部署方案是不完整的。t3code 早期直接npm run build && npm start,服务器上还得装 Node 环境,每次发布要先git pull再构建,完全依赖服务器状态。后来我改用 Docker 多阶段构建,环境隔离做干净了,镜像体积也从最初的 1GB 左右降到了 200MB 上下。

我用的 Dockerfile 大概是这样的:

FROM node:20-alpine AS deps RUN apk add --no-cache libc6-compat WORKDIR /app COPY package.json pnpm-lock.yaml* ./ RUN corepack enable && 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 npx next build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV production ENV NEXT_TELEMETRY_DISABLED 1 COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static EXPOSE 3000 CMD ["node", "server.js"]

有两个地方需要额外注意。第一,next build默认不会输出standalone目录,必须在next.config.mjs里显式加output: "standalone"。这个配置能让你只拷贝运行时需要的文件,是镜像瘦身的主要功臣。第二,构建阶段不要写死环境变量。DATABASE_URL、NEXTAUTH_SECRET这类部署相关的变量应该由容器运行时注入,也就是docker run -e或者 docker-compose 的environment字段来传。如果写死在镜像里,换环境就得重新构建,还容易泄漏敏感信息。

我实际发布流程用的是 docker-compose,配置文件里把数据库和 Next.js 应用编排在一起,本地验证通过后直接docker-compose up -d。对于核心业务,数据库最好独立部署,别放在同一个 compose 文件里长期运行,否则数据库迁移和应用的启动顺序容易打架。

4.2 CI 流水线配置:类型检查放在构建前面

团队协作之后,光有模板还不够,得保证每个人提交的代码不会把主干搞坏。我在项目里配了 GitHub Actions,核心逻辑是:每次 push 和 pull request 都跑一遍类型检查和构建。这两件事顺序有讲究——类型检查快,构建慢,先用快的检查兜底,能少跑很多次全量构建。

CI 脚本里有一段核心配置:

name: CI on: push: branches: [main] pull_request: jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: 8 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm prisma generate - run: pnpm type-check - run: pnpm lint - run: pnpm build

prisma generate这一步千万别省。Prisma 的客户端类型是根据 Schema 实时生成的,CI 环境里如果只执行pnpm install,node_modules/.prisma/client里没有和 Schema 对应的最新类型,type-check会报出一大堆莫名其妙的类型错误。团队里有人新增了字段,本地跑了migrate dev会顺带 generate,但 CI 不会,所以必须在 CI 里显式做一次。

还有一个经验是把pnpm-lock.yaml固定进仓库。锁文件的意义在于所有人安装的依赖版本完全一致,否则今天你本地装到某个中间版本没问题,明天同事装到最新版可能就构建失败,这类问题排查起来非常折磨。

4.3 三个容易被忽略的优化项

赶在大家以为项目已经可以上生产之前,我还要补充三个容易忽略的优化点,它们不会直接影响功能,但决定了项目在真实环境里跑得稳不稳。

第一是 Prisma 连接池。默认情况下每个 Prisma Client 实例维持自己的连接,而 Serverless 环境下多个实例叠加会打爆数据库连接数。我给生产环境的 DATABASE_URL 加上了连接池参数,比如在 PostgreSQL 的连接串后面补?connection_limit=10&pool_timeout=5,或者在中间加一层 PgBouncer。本地开发不用管,但上生产一定要有连接池规划,否则并发一上来数据库直接拒绝连接。

第二是 Next.js 的路由分包。T3 Stack 默认全部打进同一个包,页面一多首屏 JS 体积涨得很快。我在next.config.mjs里开启了modularizeImports并按页面做了动态加载,具体做法是把非首屏的组件用next/dynamic包起来。效果很直观,首屏 JS 从 160KB 降到了 100KB 左右,对于内部后台系统来说体感差别明显。

第三是日志和可观测性。t3code 早期在线上出了错完全盲查,后来我在服务端包了一层 logger,把每个请求的耗时、路径、错误堆栈都输出成结构化 JSON,配合日志平台搜索。Debug 线上问题的时间从小时级降到了分钟级。这一点和具体技术栈无关,但脚手架里预留好 logger 的接口,比业务代码写了一半再回头来补要省事得多。

5. 实战踩坑全记录:问题、原因和排查方法

5.1 高频问题速查表

脚手架用了大半年,我把团队成员问得最多的问题整理成了这张速查表。全部都是我自己遇到或者帮别人排过的真实情况。

现象根本原因解决方案
npm run dev页面报数据库连接错误DATABASE_URL 配置不对或数据库表未迁移检查.env连接串,执行npx prisma migrate deploy
prisma migrate dev打开 shadow database 失败数据库用户没有建库权限,或使用了一些不兼容的数据库服务给用户开放建 shadow database 权限,或用 Prisma 支持的托管 PostgreSQL
前端调用api.todo.list报类型不存在子路由没挂载到 root router 上回到root.ts检查是否引入了todoRouter
type-check在本机通过但 CI 失败CI 里没有执行prisma generateCI 流水线中加入pnpm prisma generate
Docker 构建时 OOM 或内存占用过高Next.js 构建是重内存任务,默认容器内存不够设置NODE_OPTIONS=--max-old-space-size=2048,或给构建阶段分配更多内存
TS 报错提示某个返回类型是unknowntRPC 版本与 React Query 版本不兼容统一升级@trpc/*和@tanstack/react-query到配套版本
线上出现大量connection refused后端实例数超过数据库连接上限为 DATABASE_URL 加连接池参数,或引入 PgBouncer
页面改样式不生效且热更新丢失.next缓存损坏删除.next目录重新npm run dev

这张表里频率最高的其实是最后一条。Next.js 跑久了以后偶尔会出现热更新失灵的情况,很多人第一反应是改配置,实际上是.next缓存里的东西和新代码对不上。rm -rf .next后一切正常。这条经验别嫌糙,真能省很多时间。

5.2 事后复盘的三条血泪经验

复盘整个 t3code 项目,我最想分享的不是哪段代码写得漂亮,而是几个曾经让我抓到秃头的教训。

第一条:数据模型变更要在需求评审阶段就做,不要在页面写完之后做。我吃过一次大亏,业务模块只剩前端细节没收尾,结果产品突然要求给待办加一个优先级字段。模型倒是好改,但改动牵一发动全身——前端表单要加控件、列表要加排序、详情要加展示,光接口相关文件就动了十几个。如果一开始模型设计时就预留priority Int @default(0)这样的扩展字段,后面至少少改一半文件。

第二条:tRPC 的路由粒度要控制好,不要把所有接口塞进一个trpc.ts。我见过有人图省事把用户、订单、商品全写进一个 router 文件,一个文件几百行,每次改接口都用全局搜索找位置。正确的做法是一个业务域一个 router 文件,文件之间保持独立,测试也好写。这条规则是从目录结构层面防止代码腐化,比口头约定管用。

第三条:版本升级别盲目追新。t3code 中间有一次把整个 T3 Stack 从旧版本升到新版本,因为 tRPC 的 API 变化太大,几乎所有 router 文件都要改,这个成本让我们整个迭代周期变慢了不少。现在我的态度是:小版本跟随、大版本谨慎评估、被锁定的核心版本一定要看 changelog。脚手架最重要的属性是可预测,稳定性优先于花哨。

最后再分享一个自己的体会。t3code 做出来以后,团队里新模块的开发速度确实快了很多,但我最意外的是它对人的影响:新来的同事看一遍模板就能照着写代码,不用再问"这个接口该放哪、变量命名该用什么风格"。一种技术方案的最终价值不是代码量上省了多少,而是它让团队的默认路径变得一致了。如果你也在考虑要不要搞一套自己的脚手架,我的建议是小步快跑——先挑一个重复最多的业务场景做模板,验证顺手了再逐步扩展开来。千万不要第一步就想做一个万能框架,那种东西最后往往是没人用的。

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

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

立即咨询