做全栈开发这些年,我每次打开一个新项目之前,最先头疼的往往不是业务逻辑,而是环境初始化。Next.js 装好了没有?Tailwind 的配置和 PostCSS 对不对?tRPC 要不要单独处理 CORS?Prisma 的 client 有没有重新生成?这些事单独拎出来都不难,但串在一起,就是一个反复消耗精力的黑洞。t3code 就是为了跳过这个黑洞,我维护了一套全栈代码生成工具:以 T3 技术栈(TypeScript + Tailwind + tRPC)为底座,把 Next.js、Prisma、NextAuth 这些常用件焊成一个可复用的模板,再提供持续生成路由、页面片段的能力。
如果你已经会一点 Next.js,不太想手动组合这几个库,那么这篇文章里的完整操作会很适合你。我会从设计思路讲到实际跑通,尽量把能踩的坑都提前标出来。
1. t3code是什么:把“T3技术栈”变成一套可落地的代码生成器
1.1 从T3三件套说起
T3这个缩写,我第一次看到的时候还以为是“第三代”的意思,后来才知道它对应的是一套具体的技术组合:TypeScript、Tailwind CSS、tRPC。三个首字母正好拼成 T3。这个技术栈在 React 全栈社区里流行,很大程度是因为它把“类型安全”刻进了前后端通信的骨子里。TypeScript 管代码静态类型,Tailwind 负责样式但不破坏组件化,tRPC 则让你在写 API 的时候像调用本地函数一样直接,类型还能从服务端自动推断到客户端。
这里需要说明,T3 并不是某个公司或者官方团队发布的标准,它更像一套社区总结出来的“最佳实践配方”。t3code 这个名字,就是“T3 + code”:在这个配方之上做代码生成和工程化封装。它做的事情归纳起来有三件:
- 快速创建一个完整可运行的 Next.js 项目,带好 tRPC、Prisma、NextAuth、Tailwind 的全部默认配置;
- 读取你的 Prisma 数据库模型,自动生成对应的 Zod 校验、tRPC router 骨架和基础 CRUD;
- 提供一个统一的目录约定,让团队里每个人新开功能的时候,都照着同样的模板补代码。
1.2 它和create-next-app、create-t3-app有什么区别
很多人会问,已经有 create-next-app 和 create-t3-app,为什么还要折腾 t3code?我画过一张对比表,自己看着更直观:
| 方案 | 能做什么 | 不够好的地方 |
|---|---|---|
| create-next-app | 初始化一个 Next.js 项目 | 不处理数据库、鉴权、tRPC |
| create-t3-app | 一次性生成 T3 全家桶模板 | 只有初始化,没有后续增量生成 |
| t3code | 初始化 + 持续生成代码 | 需要跟随社区版本更新维护模板 |
核心区别在于“持续生成”。实际开发时,你不可能只在第一天用一次模板,后面每新增一个数据表,都要手写 router 和类型导出。t3code 把“初始化”和“生成”拆成两层,前期用模板解决项目骨架,后期用 generate 解决重复代码。比如新增一个Post模型,我只需要一条命令就能生成基础 CRUD 的路由骨架,剩下的事情是填业务逻辑,不是复制粘贴样板代码。
1.3 适合谁用
从我实际使用经验来看,t3code 最适合这几类人:
- 中小型全端项目:团队十来个人以内,大家需要一个统一的技术底座。
- 内部工具和管理系统:不想把大量时间花在搭建上,业务迭代才是重点。
- 想学“类型安全”的开发者:t3code 生成出来的结构很清晰,拆开看就是一份不错的全栈教程。
不太适合的场景也不是没有:大型微前端、老项目迁移、对 Next.js 内部机制有极端定制需求的团队。如果这些你都不碰,那 t3code 的思路完全可以抄走,哪怕你不直接用我这个项目。
2. 核心设计思路:为什么t3code要把“类型安全”放在第一位
2.1 单一数据源:一切从Prisma Schema出发
开发全栈应用最怕什么?最怕“两边各写一套类型”。后端定义了一个User对象,前端再手写一个不完全一样的 interface,然后沟通靠默契,Debug 靠运气。t3code 内部最核心的原则,就是单一数据源。你在prisma/schema.prisma里定义 model,这个文件是数据库结构的唯一真相。t3code 会基于这份 Schema 去生成 Zod schema、生成 router 骨架、生成前端调用时的默认类型。业务里如果有额外的校验规则,再用z.object补充,但数据库字段本身不需要第二份定义。
比如这样一段 Schema:
model Post { id String @id @default(cuid()) title String content String status String @default("DRAFT") authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }我敲完这段模型,接着执行npx t3code generate:post,它会在src/server/api/routers/post.ts里生成一个带有list、byId、create、update、delete的 router,并且在对应位置引入 Zod 校验。之后我只需要根据业务把生成的代码补完。生成的代码不追求一次完美,但它已经保证了类型链路不会断。
2.2 三层切片:模型层、接口层、视图层
t3code 的目录约定可以理解成三个同心圆:
- 模型层:
prisma/schema.prisma和prisma/seed.ts,负责定义数据库结构。 - 接口层:
src/server/api/routers/*.ts和src/server/api/trpc.ts,负责暴露 tRPC 方法。 - 视图层:
src/features/*和src/components/*,负责 React 组件和页面。
数据流向是:模型层 → 接口层 → 视图层,反向不允许。客户端不能直接 import 服务端的 Prisma Client,所有跨进程访问都走 tRPC 暴露出来的方法。这样做的好处是,你可以很方便地把视图层拆出去做桌面端或者移动端,只要接口层保持稳定。
很多同学第一次看到 tRPC 会觉得:这不就是把一个对象函数从服务端“传”到客户端吗?对,它本质上就是一个带类型提示的 RPC。但实际操作中,tRPC 的价值不只是省掉 REST 的 URL 拼接,而是让接口变更立刻被编译器发现。比如我把createPost的入参从{ title, content }改成{ title, content, status },前端所有调用点马上报错,不会等上线之后才发现问题。
2.3 鉴权不再散落各处,而是收进Procedure
t3code 生成的trpc.ts默认提供两种 procedure:publicProcedure和protectedProcedure。我见过太多项目,在每个 router 里手动判断if (!session),写到后面要么漏掉,要么判断逻辑不一致。t3code 把鉴权做进了中间件:
import { initTRPC, TRPCError } from "@trpc/server"; import { Context } from "./context"; const t = initTRPC.context<Context>().create(); export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: ctx.session, }, }); });这之后,写需要登录的接口只需要把publicProcedure换成protectedProcedure,其余逻辑不用动。如果将来要改鉴权规则,只需要改这一个文件。这条经验是我在项目里踩了不少坑才总结出来的:鉴权不能散养,必须集中收敛。
3. 实操:用t3code跑一个带鉴权的文章管理页面
3.1 初始化项目
在本地装好 Node.js 18 以上版本,然后直接执行:
npx t3code create blog-admin cd blog-admin cp .env.example .env npm install npx prisma db push npm run dev第一条命令会创建一个基于 T3 技术栈的 Next.js 项目,默认启动文件就在src/pages/api/trpc/[trpc].ts。这里要注意:这条 API 路由必须放在src/pages/api/trpc下面,不能放到src/app里,至少 t3code 默认生成的是 Pages Router 版本。如果你用的 App Router,也有一套对应的模板,但不要混着放。
接下来看下生成的目录结构:
src/ ├── pages/ │ └── api/trpc/[trpc].ts ├── server/ │ ├── api/ │ │ ├── routers/ │ │ │ ├── post.ts │ │ │ └── user.ts │ │ ├── root.ts │ │ ├── context.ts │ │ └── trpc.ts │ ├── auth.ts │ └── db.ts ├── utils/ │ └── api.ts ├── features/ │ ├── theme/ │ └── posts/ └── styles/ └── globals.css这套结构不是随随略写出来的,它基本来自社区认可的 T3 约定,再加上我日常项目里的一些微调。server目录放所有服务端逻辑,features目录放业务页面,utils只放 tRPC 客户端实例。目录约定是团队协作的第一道守门员,完全值得花时间定清楚。
.env文件至少需要这三个变量:
DATABASE_URL="postgresql://root:password@localhost:5432/blog" NEXTAUTH_SECRET="please-change-me" NEXTAUTH_URL="http://localhost:3000"NEXTAUTH_SECRET在生产环境一定要用强随机字符串,开发环境可以先用随便写的占位符。DATABASE_URL根据你的本地数据库调整。
3.2 定义Post模型并生成服务端代码
现在把刚才的Post模型写进prisma/schema.prisma,然后执行:
npx prisma db push npx t3code generate:postdb push会把模型同步到本地数据库,重点是让 Prisma Client 生成出来。generate:post则会读取模型并生成基础 router。生成后的post.ts类似这样:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "../trpc"; const createPostSchema = z.object({ title: z.string().min(1, "标题不能为空"), content: z.string().min(1), status: z.enum(["DRAFT", "PUBLISHED"]).default("DRAFT"), }); export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); }), create: protectedProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), });注意create用的是protectedProcedure,所以方法里可以直接拿到ctx.session.user.id。这个session是哪里来的?答案在context.ts:
import { getServerSession } from "next-auth"; import { authOptions } from "@/server/auth"; import { db } from "@/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, }; };每次 tRPC 请求进来,都会先执行createTRPCContext,把数据库实例和登录会话塞进ctx。所以路由方法里不需要再重复初始化 Prisma Client。
最后把 router 挂载到根节点src/server/api/root.ts:
import { createTRPCRouter } from "./trpc"; import { postRouter } from "./routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;AppRouter这个类型非常重要,它会作为前端的“接口契约”。
3.3 前端调用:把服务端类型“拿到”客户端
在src/utils/api.ts里,t3code 默认生成这样一段:
import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "@/server/api/root"; export const trpc = createTRPCReact<AppRouter>();这里没有定义任何业务类型,所有类型都来自AppRouter。这意味着只要路由里改了入参或返回值,前端编译立刻会报错。
接着写一个文章列表页面:
"use client"; import { trpc } from "@/utils/api"; export default function PostList() { const posts = trpc.post.list.useQuery(); if (posts.isLoading) return <p>加载中...</p>; if (posts.isError) return <p>加载失败</p>; return ( <ul> {posts.data?.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }创建文章也一样简单:
const createPost = trpc.post.create.useMutation({ onSuccess: () => { utils.post.list.invalidate(); }, }); createPost.mutate({ title: "标题", content: "正文", status: "DRAFT", });invalidate是 tRPC 自带的缓存失效方法,调用成功后刷新列表,不需要手动刷新页面。这个体验比传统 REST + axios 清爽很多。
3.4 为什么默认选择Postgres和NextAuth
t3code 默认的数据库是 PostgreSQL,登录方案是 NextAuth。有人会问能不能用 SQLite、能不能不接登录?都可以,只不过默认值本身有讲究。
| 选项 | 适合场景 | 我的建议 |
|---|---|---|
| SQLite | 本地 demo、单机学习 | 零配置文件,但别直接上生产 |
| PostgreSQL | 线上项目 | 和 Prisma 兼容最稳,并发能力强 |
| NextAuth | 标准全栈项目 | 生态成熟,支持 GitHub/Google OAuth |
| 不接鉴权 | 纯内部 API | 省配置,但业务上要自己想办法 |
PostgreSQL 是生产环境最常用的选择,t3code 默认选它,并不是因为它“最流行”,而是因为 Prisma 对 PostgreSQL 的映射最完整,比如enum、array、jsonb这些字段类型都能正确对应。NextAuth 则解决了大多数项目的登录需求,支持凭据登录和 OAuth,并且能跟 tRPC 的 session 上下文直接打通。
4. 实际跑通后我记录的常见问题与排查心得
4.1 每次都报“Cannot find module '@prisma/client'”
这个问题最常见。原因很简单:修改了schema.prisma之后,没有重新生成 Prisma Client。t3code 生成的package.json一般不会自动加 postinstall 脚本,但我会建议你手动加一行:
"postinstall": "prisma generate"这样每次npm install之后都会自动生成 client,避免 CI 或者新同事 clone 代码后一脸懵。如果你已经跑起来了,出现这个错误时先手动执行:
npx prisma generate然后重启开发服务。
4.2 tRPC v10和v11的API差异
用 tRPC 最容易被坑的是版本问题。v10 里创建 React 客户端用的是:
import { createReactQueryHooks } from "@trpc/react";到了 v11,变成了:
import { createTRPCReact } from "@trpc/react-query";两者导出的模块名不一样,网上很多教程还停留在 v10,直接抄过来就会报TypeError: (0 , _react.createReactQueryHooks) is not a function。t3code 的模板里已经锁定了 v11 的写法,所以看到这类报错时,先检查你的依赖版本是不是被其他包不小心升级过。包管理器锁文件不要轻易删,这个文件能避免很多隐性问题。
4.3 NextAuth在middleware里拿不到session
这是一个典型的运行环境问题。Next.js 的 middleware 跑在 Edge Runtime,而 Prisma 默认跑在 Node.js 运行时。如果你用getServerSession在 middleware 里读 session,经常会得到null,但页面里又能读到。t3code 生成的authOptions默认使用 JWT session,不依赖数据库查 session,这样 middleware 可以用getToken读取:
import { withAuth } from "next-auth/middleware"; import { getToken } from "next-auth/jwt"; export default withAuth({ pages: { signIn: "/login" }, });如果你确实要用数据库 session,那 middleware 里不要碰 Prisma,只做路由级别的前置检查,真正的 session 校验放到 tRPC 的protectedProcedure里。
4.4 Tailwind样式有时完全不生效
这个坑多半出在两个地方。一个是tailwind.config的content没有覆盖到实际的组件路径。t3code 生成的配置默认是:
content: ["./src/**/*.{ts,tsx}"],如果你的组件放在src/app或者src/features下面,这个./src/**是能覆盖到的。但如果有额外目录,记得手动加进去。另一个是样式顺序问题:全局 CSS 里同时引入 Tailwind 指令和自定义覆盖样式时,@layer的优先级容易混乱。建议把自定义覆盖放在@layer utilities之后,避免隐藏样式冲突。
4.5 tRPC接口返回404或405
如果在浏览器请求/api/trpc/post.list返回 404,先检查 API 路由文件位置。Pages Router 版本必须在src/pages/api/trpc/[trpc].ts,并且文件里导出了createNextApiHandler。如果你用的是 App Router,需要放到src/app/api/trpc/[trpc]/route.ts,并且基于fetchRequestHandler导出GET和POST两个方法。t3code 默认只生成其中一套,你要清楚自己项目用的是哪个路由模式,不要混在一起。
为了快速排查,我整理过一个速查表:
| 现象 | 常见原因 | 处理 |
|---|---|---|
| Prisma Client找不到 | 没执行 prisma generate | 执行并加 postinstall |
| tRPC hooks不是函数 | 版本 v10/v11 混用 | 统一依赖版本 |
| middleware里session为null | Edge Runtime不支持Prisma | 改用JWT + getToken |
| Tailwind不生效 | content没覆盖组件目录 | 修正 tailwind.config |
| API无响应 | 路由文件位置不对 | 核对Pages/App Router |
5. 维护t3code过程中的几点个人心得
5.1 版本锁定比新特性更重要
真正让团队效率翻倍的,不是用了某个跨版本新特性,而是每个人电脑上跑的依赖版本完全一致。t3code 的模板里粘贴了一份锁文件,我自己也要求主项目锁文件不能被随意升级。很多看起来玄乎的兼容问题,最后都是版本不一致导致的。
5.2 生成代码不是黑盒,要允许开发者改
t3code 生成的是骨架,不是最终答案。它不是要把开发者绑死在固定模板里,而是把你从重复劳动中解放出来。我每次生成代码后,都会把生成的 router 打开看一遍,很多业务规范就是在这一步顺手加进去的,比如列表分页、软删除、权限校验。模板给你的是一个可靠的起点,不是终点。
5.3 先跑通一个真实场景,再考虑抽象
如果你也想做类似的全栈脚手架,我的建议是:先拿一个最简单的业务场景,比如“用户登录后写一篇文章”,把数据库 Schema、tRPC router、前端页面全部跑通,再去考虑怎么抽象成模板。抽象过早容易陷入“为了通用而通用”的泥潭。t3code 的名字看起来只是一个代号,但当它跑起来的时候,你会真正理解那句老话:好的工具不是替代思考,而是把重复劳动交给代码。