提到 t3code 之前,先聊聊我最近被问得最多的一件事:为什么身边的团队越来越多地把 Next.js 和 tRPC 绑在一起用?答案其实很简单——类型安全这件事,一旦端到端跑通,开发体验是真的回不去了。t3code 是我维护的一套以 T3 Stack 为核心的全栈 TypeScript 项目脚手架工具链,目标很明确:用一条命令,把 Next.js + TypeScript + tRPC + Prisma + Tailwind 这套组合跑起来,省掉每次新项目都要重复的手工配置。你不需要记住几十个依赖的版本搭配,也不需要对着官方文档一步步复制粘贴,直接拿它生成一个能跑、能改、能部署的骨架。适合谁看?如果你正准备用全栈 TypeScript 起一个新项目,或者已经入坑 T3 Stack 但每次建项目都被配置折磨,这篇文章应该能让你少走很多弯路。
1. t3code 的定位:为什么需要用一套工具来封装 T3 Stack
1.1 T3 Stack 的四个组件到底解决了什么问题
先把 T3 Stack 拆开看。Next.js 负责页面渲染和 API 路由,TypeScript 负责全链路类型约束,tRPC 让前后端共享类型定义,Prisma 管数据库访问,Tailwind 管样式。这五个东西单拎出来任何一个都不算新,但组合在一起就产生了一个很微妙的化学反应:从前端组件里调用后端接口时,你不再需要手写接口文档,也不再需要猜返回值到底是什么结构。
我最早接触这套组合时也踩过弯。以前做全栈项目,最痛苦的就是接口联调阶段:后端把接口文档丢过来,前端照着字段名一个个手动补类型,稍不留神字段拼写错了一个,运行时才发现。T3 Stack 的核心思路就是把“手动对接”这一步彻底干掉——你用 Prisma 定义好数据库模型,tRPC 路由里写方法,页面上调用时,TypeScript 会直接把返回数据的类型推断到你的编辑器里。字段名错了?标红。参数漏了?标红。这种体验类比一下就像装修时有了全屋定制的设计图,不需要现场跟工人反复比划尺寸。
那 t3code 在里面扮演什么角色?简单的说,它把“选型决策”变成了“配置项”。你不需要自己纠结要不要用 tRPC、要不要上 Prisma,直接按需勾选,工具帮你把依赖版本、目录结构、基础代码全部准备好。
1.2 t3code 的工作方式:不是文档,是可交互的脚手架
很多脚手架的毛病是“生成完就结束”,目录结构摆在那里,但下一步该改哪里、怎么连接数据库、怎么加第一个接口,还是靠你自己悟。t3code 从一开始的定位就不是单纯的“模板复制器”,而是一条可交互的开发流水线。
它内部实际拆成了三步:第一步是收集需求,命令行里问你项目名叫什么、要不要鉴权、数据库类型选什么;第二步是生成骨架,按模块组合渲染出对应的目录和文件;第三步是安装依赖并做初始化检测,比如检查 Node 版本、数据库连接串是否合法、环境变量是否齐全。
这样做的好处是,同一个工具既能服务第一次接触 T3 Stack 的新手,也能服务已经有固定习惯的老手。新手可以直接用默认模板跑通第一个项目,老手则在交互式选区里把不需要的模块关掉,尽量保持项目干净。
1.3 社区里已有脚手架,为什么还要维护自己的封装
可能有人会问,社区里已经有 create-t3-app 这类工具了,自己再折腾一套 t3code 是不是重复造轮子?我的回答是:工具的本质是给工作流定规则,而每个人的工作流多少有点差异。create-t3-app 解决的是“从无到有”的问题,我需要的还有“从有到快”的部分,比如自动生成一个可运行的 CRUD 示例、内置 Dockerfile、部署配置模板,以及针对团队协作的一些约束。
t3code 并不是要替换某个现成工具,更像是在我自己的项目实践中沉淀出来的一套约定。它把 CRUD 生成、环境变量管理、Docker 部署三块我最常重复做的事情也收编进来了。所以这篇文章里的操作记录,本质上也是我日常开发中反复执行的那套流程。
2. 环境准备与初始化:从命令跑到看到页面
2.1 环境准备:先解决 Node 和数据库这两个拦路虎
在实际初始化项目之前,有几个前置条件需要确认,缺了哪一个都会在后面的步骤里突然跳出来报错。第一是 Node.js 版本,t3code 要求 Node.js 18 及以上,因为 Next.js 14 和 tRPC 10 的某些特性在老版本上跑不动。第二是包管理器,我建议直接用 pnpm,它比 npm 的磁盘占用低、安装速度快,而且在处理依赖提升问题的时候更规范,实测下来遇到“幽灵依赖”的概率小很多。
数据库方面,如果你选择启用 Prisma,那本地至少得有一个可用的数据库实例。最简单的方案是用 Docker 起一个 PostgreSQL:
docker run --name t3code-db -e POSTGRES_USER=t3code -e POSTGRES_PASSWORD=t3code -e POSTGRES_DB=t3code -p 5432:5432 -d postgres:16这里要注意端口冲突的问题。如果本机 5432 已经被占用,可以把宿主端口映射成 5433,后面连接串里对应改成 localhost:5433 就行。不想用 Docker 的,本地安装 PostgreSQL 或者用 SQLite 先顶着也可以,不过生产环境还是建议 PostgreSQL,尤其是后续要上并发写入和复杂查询的场景。
环境变量这块是新手最容易翻车的地方。t3code 生成项目后会自动创建一个 .env.example 文件,你需要先把它复制成 .env,再逐项填写:
DATABASE_URL="postgresql://t3code:t3code@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="随便用 openssl rand -base64 32 生成一个" NEXTAUTH_URL="http://localhost:3000"NEXTAUTH_SECRET 一定不要默认值,否则部署到线上会有安全问题。生成随机密钥的命令很简单:openssl rand -base64 32。
2.2 初始化一个真实项目:命令行交互过程实录
前置条件检查完,就可以开始正式初始化了。t3code 的入口命令长这样:
npx t3code init my-blog执行后,工具会先拉取最新模板,然后进入交互式问答。几个核心选项我实测下来是这样的:
- 项目名称:默认取参数里的 my-blog,也可以直接回车跳过。
- 需要鉴权模块吗?选 Yes 会引入 NextAuth.js,并提供一套基于 Credentials 登录的最小示例。
- 启用数据库吗?选 Yes 并指定 PostgreSQL 后,工具会生成 Prisma schema 和一条示例迁移。
- 使用 Tailwind CSS 吗?选 Yes 会把样式相关的配置全部预置好。
- 启用 ESLint 和 Prettier 吗?我的建议是都启用,团队协作时统一代码风格会省下大量不必要的冲突。
问答结束后,工具会先创建目录,再执行安装依赖。整个流程看网速和镜像源,通常两三分钟到十几分钟不等。依赖安装完成后,控制台会提示你进入项目目录并启动开发服务:
cd my-blog pnpm dev打开 http://localhost:3000,正常情况下会看到一个简单的首页,同时终端里会打印出 tRPC 健康检查的日志。走到这一步,说明骨架已经完全跑通了。
2.3 生成后的目录结构逐层拆解
t3code 生成的目录结构,我整理一份高频核心目录如下:
my-blog ├── prisma │ └── schema.prisma # 数据模型定义 ├── public # 静态资源 ├── src │ ├── pages │ │ ├── api │ │ │ └── trpc │ │ │ └── [trpc].ts # tRPC 的 HTTP 入口 │ │ └── index.tsx # 首屏页面 │ ├── server │ │ ├── api │ │ │ ├── routers # 业务路由都放这里 │ │ │ └── trpc.ts # tRPC 根路由与上下文 │ │ └── db.ts # Prisma 客户端单例 │ └── styles │ └── globals.css # Tailwind 入口样式 ├── .env # 本地环境变量 ├── .env.example # 环境变量模板 ├── docker-compose.yml # 本地数据库编排 └── Dockerfile # 多阶段构建这个结构的核心思想是“server 目录只放后端逻辑,pages 目录只做视图和路由”。第一次看可能会觉得文件比普通 Next.js 项目多了一些,但只要记住一条主线:页面发起请求 → tRPC 入口接收 → routers 里处理业务 → Prisma 访问数据库,整条链路就清楚了。
这里我强烈建议不要新项目一上来就乱改目录结构。工具生成的布局是经过实践验证的,等你对每个文件的作用足够熟悉之后,再按团队习惯调整也不迟。
3. 跑通第一个 CRUD:数据模型、tRPC 路由与页面调用
3.1 数据模型先行:用 Prisma Schema 定义 Todo
项目能跑起来只是第一步,真正体现 t3code 便捷性的是从零写一个功能。我用一个最经典的 Todo 示例来完整过一遍。首先打开 prisma/schema.prisma,定义数据模型:
model Todo { id String @id @default(cuid()) title String completed Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }这里我把 id 设计成 String 并用 cuid 生成,而不是自增整数,主要原因是分布式环境下字符串 ID 不容易产生冲突,而且泄露出去后不会暴露业务数据量。定义好模型后,执行迁移命令:
pnpm prisma migrate dev --name init_todo这个命令会做三件事:生成一份迁移 SQL、在数据库里执行迁移、重新生成 Prisma Client 类型。执行完,你会在 prisma/migrations 目录下看到带时间戳的 SQL 文件,这就是数据库结构的“版本记录”,团队协作时大家都能同步。
3.2 tRPC 路由:从 query 到 mutation
有了数据模型,接着在 tRPC 路由里暴露操作能力。t3code 把业务路由统一放在 src/server/api/routers 下,我新建了一个 todo.ts:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { db } from "~/server/db"; export const todoRouter = createTRPCRouter({ list: publicProcedure.query(async () => { return db.todo.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1) })) .mutation(async ({ input }) => { return db.todo.create({ data: { title: input.title }, }); }), toggle: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { const todo = await db.todo.findUnique({ where: { id: input.id } }); if (!todo) throw new Error("Todo 不存在"); return db.todo.update({ where: { id: input.id }, data: { completed: !todo.completed }, }); }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { await db.todo.delete({ where: { id: input.id } }); return { success: true }; }), });这里有几个值得展开的点。第一,publicProcedure 表示无需登录就能调用,项目接入鉴权后可以把部分操作改成 protectedProcedure。第二,zod 的 input 校验是运行时的兜底,就算有人绕过前端直接请求接口,非法参数也会在这一层被拦截。第三,这个文件本身没有直接暴露 HTTP 路由,真正暴露入口的是 src/pages/api/trpc/[trpc].ts,它把所有 router 合并统一挂载。
定义好 todoRouter 后,要把它挂到根路由上。打开 src/server/api/root.ts,在 createCallerFactory 创建实例时注册:
export const appRouter = createTRPCRouter({ todo: todoRouter, });这一步很容易漏掉。漏掉之后页面上调用 todo.list 会直接报“找不到路由”的运行时错误,而且类型检查不会给你任何提示。
3.3 页面组件里怎么调用后端
数据模型和后端路由就绪,接下来就是最爽的部分:前端调用。在 src/pages/index.tsx 里,直接用 tRPC 的 hooks:
import { api } from "~/utils/api"; export default function Home() { const utils = api.useUtils(); const { data: todos, isLoading } = api.todo.list.useQuery(); const createTodo = api.todo.create.useMutation({ onSuccess: () => utils.todo.list.invalidate(), }); const toggleTodo = api.todo.toggle.useMutation({ onSuccess: () => utils.todo.list.invalidate(), }); if (isLoading) return <div>加载中...</div>; return ( <div className="p-8"> <form onSubmit={(e) => { e.preventDefault(); const form = e.currentTarget; const title = new FormData(form).get("title") as string; createTodo.mutate({ title }); form.reset(); }} > <input name="title" className="border p-2" /> <button type="submit" className="ml-2 bg-blue-500 p-2 text-white"> 添加 </button> </form> <ul> {todos?.map((todo) => ( <li key={todo.id} className="flex gap-2 py-1"> <input type="checkbox" checked={todo.completed} onChange={() => toggleTodo.mutate({ id: todo.id })} /> <span>{todo.title}</span> </li> ))} </ul> </div> ); }注意 mutation 完成后的 invalidate 调用,它的意思是“让列表重新拉取”。这是 tRPC 生态里很巧妙的设计,你不需要手动改本地缓存状态,只要声明数据失效,框架会自动重新请求。对比传统 REST 接口,这里省掉了大量“请求完手动刷新列表”的样板代码。
页面跑起来后,打开浏览器添加一条 Todo,数据库里马上就能查到。整个过程类型是全程打通的:createTodo.mutate 的参数类型直接来自 zod 输入定义,todos 的元素类型直接来自 Prisma 模型。字段写错、类型对不上,编辑器里当场报错,绝对不带拖延的。
3.4 自动生成的鉴权示例:为多用户功能预留扩展点
t3code 在启用鉴权模块后,会额外生成一套最小的 NextAuth 配置,并在 tRPC 上下文里注入 session 信息。先看服务端上下文的部分:
import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { session, db, ...opts, }; };接着在 trpc.ts 里可以定义一个受保护的过程:
import { initTRPC, TRPCError } from "@trpc/server"; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, user: ctx.session.user } }); });这样在 todoRouter 里,把创建和删除改成 protectedProcedure,就可以保证只有登录用户才能操作。实际的用户体系接入还会涉及登录页、回调地址、会话过期等问题,但脚手架给你的是一套能跑通的最小闭环,大多数业务都可以基于这个闭环去扩展。
4. 构建与部署:开发完之后的完整交付链路
4.1 常用脚本一览
开发阶段常用的命令其实不多,我把 t3code 项目里最核心的几个脚本整理成了一张速查表:
| 命令 | 作用 | 备注 |
|---|---|---|
| pnpm dev | 启动开发服务 | 默认端口 3000 |
| pnpm lint | 运行 ESLint 检查 | 提交前建议跑一遍 |
| pnpm build | 执行 Next.js 生产构建 | 构建前会自动做类型检查 |
| pnpm start | 启动生产服务 | 需要先 build |
| pnpm prisma migrate dev | 开发环境执行迁移 | 会自动重新生成客户端 |
| pnpm prisma studio | 可视化查看数据库数据 | 浏览器打开数据管理界面 |
构建环节有一个常见认知误区:pnpm build 不只是打包前端资源,它还会对 tRPC 的路由做一次全量类型校验。如果服务端某个 procedure 返回类型有问题,构建时就会直接失败。这对质量保障来说是好事,但也意味着你在修改路由后不能只依赖开发模式的编译,还是得上线前跑一次完整构建。
4.2 Dockerfile 多阶段构建
自托管部署是 t3code 默认支持的场景。项目根目录预置的 Dockerfile 采用多阶段构建,实际生产里我基本不调整它:
FROM node:20-alpine AS base FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN corepack enable && pnpm install --frozen-lockfile FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx prisma generate RUN pnpm build FROM base AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json EXPOSE 3000 CMD ["pnpm", "start"]这里最关键的步骤是 npx prisma generate 必须在 builder 阶段执行,否则 Next.js 服务端会找不到 Prisma Client 的生成代码。另一个容易踩坑的是,如果你的 Prisma schema 里用了 PostgreSQL 的某些扩展,在容器里还要额外安装对应的系统依赖,比如 openssl 和 ca-certificates。
构建完成后,可以用 docker compose 把应用和数据库一起拉起来:
docker compose up -d --buildt3code 生成的 docker-compose.yml 默认包含 app 和 db 两个服务,app 依赖 db 的健康检查通过后才启动。这样能避免应用启动时数据库还没就绪导致的连接失败。
4.3 部署到托管平台与自托管的关键配置
如果不想自托管,部署到 Vercel 是最省心的方案。但有两个配置容易漏。第一是 Vercel 项目设置里的 Root Directory 要指向项目根目录,别把 monorepo 的根目录当成了项目目录。第二是环境变量要在 Vercel 后台上手动添一遍,.env 文件不会自动上传。
自托管则要注意反向代理层。Nginx 转发到 3000 端口时,如果部署在子路径下,需要额外配置 Next.js 的 basePath,否则静态资源的路径会全部 404。另外 NextAuth 部署后如果出现“回调地址不匹配”的问题,优先检查 NEXTAUTH_URL 有没有配成线上实际域名,这个字段在本地开发时往往被忽略,等一上生产就立刻暴露。
5. 实操中高频踩坑:问题现象、排查思路与解决办法
5.1 tRPC 类型不同步导致编辑器报错
现象是,修改了后端某个 procedure 的返回结构,但前端调用处的类型没有更新,编辑器仍然使用旧类型。这个问题的根源在于 tRPC 的类型是从服务端代码推断出来的,而 Next.js 的 dev server 在增量编译时偶尔会出现类型缓存没刷新的情况。解决办法很朴素:重启 dev server。大多数时候重启一次类型就同步了。如果没有解决,检查你的编辑器是不是开了多个 TS Server 实例,手动重启一下 TS Server 通常也能解决。
5.2 Prisma 数据库连接失败
这个问题的报错形式千奇百怪,常见的有 Unable to connect to the database、数据库不存在、鉴权失败。我的排查套路是从连接串入手,先确认 .env 里的 DATABASE_URL 是不是真的生效。因为 t3code 用 dotenv 管理环境变量,如果 .env 文件不存在,或者字段名拼写不精确,Prisma 会静默使用默认值,连接失败也就不奇怪了。
还有一个隐蔽的坑:使用 Docker 跑数据库时,容器端口映射错误。确认宿主端口和容器端口别搞反了。连接串里写 localhost:5432 但 Docker 映射到 5433,自然连不上。真正确认数据库可达,可以手动跑一条命令:
pnpm prisma db execute --stdin <<< "SELECT 1"能返回结果就说明 Prisma 层面的连接没问题,后续问题就基本锁定在业务代码了。
5.3 Tailwind 样式不生效
t3code 默认启用了 Tailwind,但如果你往项目里引入了 src 目录之外的文件,或者新创建了某个特殊后缀的文件,样式可能会突然失效。原因基本是 Tailwind 的 content 配置没有覆盖到对应文件路径。我在 tailwind.config.ts 里推荐的配置是这样的:
content: [ "./src/pages/**/*.{ts,tsx}", "./src/components/**/*.{ts,tsx}", ],如果自定义了类似 .tsx 之外的文件类型,记得把后缀加进 content,然后重启 dev server 并清一下浏览器缓存。
5.4 生产环境 NextAuth 回调地址报错
本地一切正常,部署到服务器后一登录就报 Redirect URI 不匹配。这个问题的本质是 NextAuth 校验回调地址是否在环境变量的允许列表里。生产环境必须确认两点:NEXTAUTH_URL 与线上访问域名完全一致,协议必须带 https;如果用了自定义回调地址,还需要在 authOptions 的 providers 配置里显式声明。Vercel 部署时如果要使用 VERCEL_URL 作为环境变量,需要特别小心,它和实际域名可能不带协议,尽量直接用 NEXTAUTH_URL 配置完整地址。
5.5 Docker 部署后页面能打开但接口全部 500
这个现象通常不是 tRPC 业务代码的问题,而是容器内缺少 Prisma 引擎所依赖的系统库。尤其使用 alpine 镜像时,缺少 openssl 和 libssl 会导致 Prisma 在运行时无法初始化,而构建阶段因为是纯 TypeScript 编译所以不会暴露。解决办法是 Dockerfile 的 runner 阶段安装系统依赖,或者在基镜像选择上放弃 alpine,直接用 node:20-slim。
写在最后
文章到了这里,t3code 从初始化到部署,从 CRUD 到排错,基本都过了一遍。我个人在实际使用中最大的体会是:这套工具省下来的不仅是配置时间,更是把技术选型的决策成本提前固定下来了。当你不再需要反复折腾“该用哪个 ORM、要不要上 tRPC”的时候,精力就能更多放到业务逻辑本身。最后再分享一个小技巧:拿到脚手架生成的项目之后,先把默认的示例路由跑一遍再动手改。确认整条链路没问题,再往里加自己的业务代码。这个习惯看起来浪费时间,实际却能帮你把“环境问题”和“代码问题”清楚切割开。如果哪天你在接入 t3code 时发现某个坑我上面没写到,也欢迎回头再翻翻这篇文章的评论区,咱们一起把这份实践清单补全。