第一次看到t3code这个项目名,我第一反应是:这又是一套 T3 Stack 模板吧。但真正把它跑起来之后,我意识到它并不是换了个名字的老套路。T3 Stack 这几年讨论度一直不低,核心就是拿 TypeScript 当底座,用 Next.js 扛应用框架,用 Tailwind CSS 处理界面样式,再用 tRPC 把前后端调用串成一个类型闭环,最后把 Prisma、NextAuth、Zod 一起塞进同一套工程体系里。这样组合下来,全栈开发从“拼接技术栈”变成了“建立一套端到端类型安全的开发范式”。
这篇文章我想把 t3code 这套体系从设计思路到实操落地完整讲一遍,包括初始化选型、目录结构、tRPC 路由设计、Prisma 数据模型、NextAuth 集成、部署方案,以及我实际踩过的一些坑。不管你是准备上全栈车的新手,还是被类型推导折腾过多次的老手,只要想了解“类型安全全栈”到底是什么体验,这篇文章都值得往下看。
1. 为什么把 T3 Stack 做成一站式代码模板
1.1 t3code 要解决的全栈协作痛点
传统前后端分离项目里,最折磨人的不是写业务逻辑,而是“接口对齐”。后端定义了一百个接口,前端对着文档手写类型,字段拼错了直到运行时才报错。前后端各自有一份类型定义,时间一长文档过期,类型漂移,谁也不敢保证改一个字段名会不会弄崩上下游。t3code 的出发点其实就是解决这个痛点:让一份类型定义从数据库一路贯穿到浏览器组件。
T3 Stack 并不是什么新发明,它本质上是对一套已被验证过的技术组合的收拢。Next.js 负责页面和数据请求,TypeScript 负责在编译期卡住错误,tRPC 负责让 API 调用像调用本地函数一样自然,Prisma 负责把数据库表变成类型安全的查询客户端,Zod 负责在边界上校验数据。这套组合的最大价值在于:你不需要自己写接口文档,不需要手工维护类型定义,只要沿着 tRPC 的调用链写下去,类型会自动跟着走。
1.2 类型安全贯穿“数据库到组件”的完整链路
用一个具体场景说明。传统的全栈项目里,Todo这个概念至少存在三份:数据库表字段一份、后端 DTO 一份、前端 interface 一份。任何一份更新了,另外两份没跟上,项目就跑得踉踉跄跄。t3code 的思路是只定义一次 Prisma 模型,再通过 tRPC 的 router 把数据库查询、类型推断、前端调用全部绑定起来。前端拿到什么数据结构,后端返回什么,数据库存了什么,三者在编译期就被校验了。
我自己调试过不少全栈项目,最深的体会是:类型安全带来的不只是“少写代码”,更是一种心理安全感。以前迭代一个需求,改完后端接口还要去前端同步类型,生怕哪里漏了。现在在 t3code 里改完 Prisma schema,跑一次prisma generate,业务代码里所有相关类型自动更新,有冲突编译器会直接报出来。这种体验很像开车时多了一套车道偏离预警,你不用时刻盯着线,系统会在跑偏的时候提醒你。
1.3 t3code 适合哪些场景,不适合哪些场景
t3code 适合的是需要快速搭建完整全栈应用的场景,比如个人项目、内部工具、SaaS 产品原型,以及教学演示。它对单服务端 + 多个客户端的模式很友好,尤其适合你既写前端又管数据库的全栈开发者。一套模板初始化下来,目录清晰,路径别名配好,环境变量校验配好,ESLint 和 Prettier 已经就位,省去了大量“搭工程”的时间。
但它也不是万能的。如果你的业务主要是对外开放 API 给第三方,或者是复杂的高并发微服务架构,那 tRPC 就不是最优选择,REST 或 GraphQL 可能有更成熟的生态。另外 t3code 对团队里所有成员的 TypeScript 水平有要求,如果大多数人对泛型、类型推导还不熟,这套体系的维护成本会明显上升。所以我在选型时对团队的判断是:宁可前期花时间补 TypeScript 基础,也不要带着一帮人陷入类型泥潭。
2. 上手前的准备工作与初始化配置
2.1 环境依赖与脚手架选型建议
一套可复现的 t3code 环境,软件版本不能太随意。我的建议是 Node.js 20 LTS 起步,包管理器用pnpm。npm 虽然也能用,但 install 速度慢,磁盘占用大,在大型全栈项目里差距还是很明显的。如果你的本机还没装 pnpm,一条命令就能搞定:npm install -g pnpm。另外数据库方面,开发环境直接用 SQLite 最省心,生产环境再用 PostgreSQL,后面我会讲怎么切换。
初始化项目直接用官方脚手架,命令是:
pnpm create t3-app@latest t3code交互式提示会让你勾选技术模块,这里我的选择是:
- Next.js:App Router
- TypeScript:必选
- Tailwind CSS:必选
- tRPC:必选
- Prisma:必选
- NextAuth.js:按需,需要登录就选
- ESLint + Prettier:必选
脚手架跑完之后会提示安装依赖并初始化 Git,然后让你校验环境变量。整个过程大概三两分钟,就拿到了一个带有完整基础结构、路径别名、环境变量校验、基础样式、ESLint 规则和 tRPC 骨架的新项目。
2.2 目录结构逐层解析:别被花哨的写法吓到
初始化完的目录乍一看挺密密麻麻,但理清楚之后其实很简单。最上层有app和src两个入口,app是 Next.js App Router 的页面目录,src底下放了三块核心逻辑:src/env.js是环境变量校验,src/trpc是 tRPC 客户端封装,src/server是数据库、鉴权、API Router 的服务端逻辑。
具体到一个新克隆下来的 t3code 项目,你会看到这些关键目录:
app/api/trpc/[trpc]/route.ts:Next.js 把请求交给 tRPC 的桥接层。app/api/auth/[...nextauth]/route.ts:NextAuth 的认证请求入口。src/server/db.ts:Prisma Client 的实例创建与导出。src/server/auth.ts:NextAuth 配置,包括凭证、数据库适配器。src/server/api/trpc.ts:tRPC router 的初始化文件,所有 procedure 的基础封装。src/server/api/routers/:按业务拆分的 router 定义,比如 post.ts、todo.ts。src/server/api/root.ts:把所有子 router 合并成 appRouter。src/trpc/server.ts:服务端调用 tRPC 的 caller 封装。src/trpc/react.tsx:客户端 React Hooks 封装,基于 @tanstack/react-query。prisma/schema.prisma:数据模型定义。
这套结构的好处是约定大于配置。你知道哪一层该放什么文件,找代码时不用翻半天。
2.3 环境变量:为什么 t3code 要用 Zod 卡校验
新项目跑起来的第一关就是环境变量。t3code 默认用src/env.js里的 Zod schema 做校验,启动时如果不满足就拒绝运行。这样会让很多新手抓狂:明明只是少了个NEXTAUTH_SECRET,项目直接起不来,报错很刺眼。
但这是故意这么设计的。环境变量是最容易出幺蛾子的地方:拼写错了、类型错了、某个环境忘了配,经常导致线上故障,原因还特别难查。用 Zod 做启动校验,本质是把问题卡在构建阶段而不是运行阶段。项目里需要读哪些环境变量,schema 里写清楚,少一个跑不了,你就不用担心运行时出现“某个值为 undefined”的诡异 bug。
如果你选用了 NextAuth,.env里至少要有:
DATABASE_URL="file:./db.sqlite" NEXTAUTH_SECRET="your-super-secret-key" NEXTAUTH_URL="http://localhost:3000"这些变量在本地开发时还可以随便填,部署到线上之后一定要换成真值。我见过不止一次线上数据库连接失败,原因就是把生产环境的DATABASE_URL指向了本地 SQLite 文件。
2.4 Tailwind 与全局样式:模板里的默认设计够用吗
t3code 的样式层直接用 Tailwind CSS,模板里自带了一个基础布局和浅色/深色切换,视觉上非常简洁。如果你不想从零调整全局样式,直接用默认的配色和布局就够看了。但有一点要注意,Tailwind 的类名建议在组件内部用语义化的方式组织,不要为“省时间”把一长串className甩在一个 div 上。项目一大,满屏的类名堆叠会严重影响可维护性。
我习惯的做法是先抽一组const cardClass = "rounded-xl bg-white p-6 shadow-sm",把常用的样式片段提取成变量,再在 JSX 里引用。这样视觉调整只改一个地方,不会满项目搜索某个类名。早期图省事直接在组件里手写类名,后来要统一改圆角样式时,大到想删库重建。别小看这类约定,它们才是 t3code 真正帮你节省维护成本的地方。
3. 动手实现:做一个完整的待办管理模块
3.1 定义 Prisma 数据模型:把业务对象落到数据库
这一步是所有业务的源头。假设我们要在 t3code 里做一个带账号体系的待办事项模块,那就先定义 User 和 Todo 两个模型。打开prisma/schema.prisma,调整数据模型:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? todos Todo[] } model Todo { id String @id @default(cuid()) title String completed Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id]) }定义完之后,在终端执行:
pnpm prisma migrate dev --name init这会创建数据库表,并且生成类型安全的 Prisma Client。这一步之后,你的ctx.db.todo就会带有完整的 TypeScript 类型提示,字段名拼错了,编辑器直接红线提示。
对新手来说,我特别强调一点:Prisma schema 是模型定义的唯一事实来源,不要试图手改 Prisma Client 的生成代码,也不要绕过 migrate 直接改数据库。任何改动先改 schema 再跑迁移,才能保证类型和表结构长期同步。
3.2 tRPC Router 与 Procedure:定义接口的现代姿势
在 t3code 中定义后端接口,不需要写路由路径,不需要声明请求方法,只需要写一个 router,然后导出这个 router。比如创建src/server/api/routers/todo.ts:
import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; export const todoRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.todo.findMany({ orderBy: { createdAt: "desc" }, }); }), getById: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { return ctx.db.todo.findUnique({ where: { id: input.id } }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1, "标题不能为空") })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .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 }, }); }), });这里有两个核心概念。query是普通数据查询,mutation是写操作,两者在调用链上共用同一个类型模型。每个 procedure 都可以用 Zod 对入参做校验,前端传{ title: "" }时,还没进业务逻辑就会被拦截。publicProcedure 是公开访问,protectedProcedure 要求登录用户,内部通过 session 获取 userId,这种权限控制方式非常直观。
然后需要在src/server/api/root.ts里注册这个 router:
import { todoRouter } from "~/server/api/routers/todo"; export const appRouter = createTRPCRouter({ todo: todoRouter, }); export type AppRouter = typeof appRouter;AppRouter这个类型是关键中的关键。它把整个后端接口的类型结构暴露出去,前端可以直接拿它推断所有可用调用。后面你在前端写api.todo.getAll.useQuery()时,编辑器能自动提示路径和入参类型,靠的就是这个类型导出。
3.3 前端组件接入:useQuery 与 useMutation 的正确用法
定义好 router 之后,前端调用出奇地简单。t3code 在src/trpc/react.tsx里已经封装好了 React Query 集成的 Hooks,所以你在组件里不用自己写fetch,也不用拼 URL。
创建一个客户端组件app/todos/page.tsx:
"use client"; import { api } from "~/trpc/react"; export default function TodoPage() { const utils = api.useUtils(); const { data: todos, isLoading } = api.todo.getAll.useQuery(); const createTodo = api.todo.create.useMutation({ onSuccess: async () => { await utils.todo.getAll.invalidate(); }, }); const toggleTodo = api.todo.toggle.useMutation({ onSuccess: async () => { await utils.todo.getAll.invalidate(); }, }); if (isLoading) { return <div className="p-8 text-gray-500">加载中...</div>; } return ( <div className="mx-auto max-w-xl p-8"> <form onSubmit={(e) => { e.preventDefault(); const form = new FormData(e.currentTarget); const title = String(form.get("title") ?? ""); createTodo.mutate({ title }); e.currentTarget.reset(); }} className="mb-6 flex gap-2" > <input name="title" placeholder="输入待办标题..." className="flex-1 rounded-lg border border-gray-200 px-4 py-2 text-sm" /> <button type="submit" className="rounded-lg bg-black px-4 py-2 text-sm text-white"> 添加 </button> </form> <ul className="space-y-2"> {todos?.map((todo) => ( <li key={todo.id} className="flex items-center gap-3 rounded-lg border p-3"> <input type="checkbox" checked={todo.completed} onChange={() => toggleTodo.mutate({ id: todo.id })} /> <span className={todo.completed ? "text-gray-400 line-through" : ""}>{todo.title}</span> </li> ))} </ul> </div> ); }使用useQuery时,返回的data类型就是后端 query 返回的类型,mutate的入参类型也是后端定义好的。写完这两个组件,前后端接口就已经“勾搭”上了,哪里的字段对不上,编译期就会提示。
有个细节值得注意:每次 mutation 成功后,我调用了utils.todo.getAll.invalidate()。这是 React Query 缓存失效的标准做法,它会让当前所有getAll的缓存失效并重新请求。如果你忘了这一步,新增待办之后,页面上可能看不到新数据,因为 React Query 认为原缓存仍然有效。这是新手非常容易踩的坑。
3.4 服务端调用 tRPC:createCaller 在 Server Component 里的角色
App Router 下,页面默认都是 Server Components,它们不能直接使用useQuery。那如果有的页面必须在服务端拉数据再渲染呢?t3code 提供了一个干净的方案:src/trpc/server.ts里的createCaller。
import { createCaller } from "~/trpc/server"; export default async function TodoListPage() { const caller = createCaller(await createTRPCContext()); const todos = await caller.todo.getAll(); return ( <ul> {todos.map((todo) => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ); }createCaller让你像调用后端函数一样直接在服务端发起查询,它不需要前端网络请求,也不用担心 hooks 规则。这个模式在写首屏、做 SEO、或是不需要即时交互的页面时特别实用。
我的建议是尽量把纯展示的组件用 Server Component + createCaller 来实现,只有需要交互的部分(表单提交、按钮点击、输入联动)才标记为"use client"并用 useMutation/useQuery。这样既能保留类型安全,也能减少客户端 JavaScript 的体积,首屏加载更快。
3.5 NextAuth 鉴权:把登录用户串进业务逻辑
一个有账号体系的模块,必然离不开认证。t3code 默认的 NextAuth 配置已经写好了 GitHub 登录的基本套路,但如果你想做更完整的用户名密码登录,或者想快速测试,可以用 Credentials Provider 临时顶一下。
在src/server/auth.ts中注册 Credentials Provider:
import Credentials from "next-auth/providers/credentials"; providers: [ Credentials({ credentials: { email: { label: "邮箱", type: "email" }, password: { label: "密码", type: "password" }, }, authorize: async (credentials) => { const email = credentials?.email ?? ""; const password = credentials?.password ?? ""; // 这里写你的用户校验逻辑 const user = await db.user.findUnique({ where: { email } }); if (!user) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ]在 tRPC 层,protectedProcedure会自动读取ctx.session。任何没有 session 的请求都会直接被拒绝。这样你的 mutation 里就能安全使用ctx.session.user.id,不会出现“用户身份为空还往库里插了一条数据”的情况。
测试阶段最省事的做法是直接去app/api/auth/session拿 session,或者写一个“模拟登录”按钮。但上生产之前,一定要把 NextAuth 的 secret 换成强随机字符串,否则服务重启一次,登录状态就失效一次。
3.6 部署:从本地 SQLite 切换到 PostgreSQL 的关键操作
本地开发用 SQLite 很舒服,但部署到 Vercel 或其他 Serverless 平台时,SQLite 是行不通的。Serverless 函数没有持久化文件系统,SQLite 文件会随每次实例重启丢失或者被多个实例并发写坏。所以上生产前必须切换数据库。
切换步骤:
- 修改
prisma/schema.prisma的 datasource provider 为postgresql。 - 找一个 PostgreSQL 实例,比如 Neon、Supabase 或者云服务商的 RDS。
- 把
DATABASE_URL改为postgresql://...连接串。 - 执行:
pnpm prisma migrate deploy- 重新执行
pnpm prisma generate,让 Prisma Client 匹配新的 datasource。
如果是 Vercel 部署,记得在后台把DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL都配成生产环境变量。很多线上问题都出在这里:本地跑得欢,push 上去之后一片红,一查是环境变量没有设置。
4. 高频踩坑记录与排查手册
4.1 启动时报 NEXTAUTH_SECRET 缺失怎么办
这是 t3code 项目最常见的启动错误之一。报错信息类似[next-auth][error][NO_SECRET],虽然有些场景下 NextAuth 会自动生成临时 secret,但一旦使用数据库 adapter,缺少 secret 会让你登录状态在服务重启后全部失效。
解决办法是生成一个强随机 secret,写入.env。我一般直接用 OpenSSL 生成:
openssl rand -base64 32然后粘到.env里的NEXTAUTH_SECRET。生成一次,所有环境共用,不要每次部署都换,否则已登录用户全部被踢下线。
4.2 Prisma 报 pending 或 Client 版本不一致
开发环境跑prisma migrate dev后,有时编辑器仍然报类型错误,或者在运行时出现PrismaClientInitializationError。这类问题九成是因为 Prisma Client 生成文件过期。解决办法很简单,重新生成:
pnpm prisma generate还有一个容易忽略的点:项目新增依赖后,特别是切换 Node 版本后,node_modules/.prisma里的二进制文件可能和当前系统不匹配。如果你刚才从 Node 18 切到 Node 20,且看到和二进制相关的报错,删掉node_modules重新安装通常能解决。
4.3 tRPC 请求失败且网络上没有对应请求记录
如果你在浏览器 Network 面板里能看到请求发出,但控制台报失败,先确认 tRPC 的 API route 是否正常挂载。一个检查项是app/api/trpc/[trpc]/route.ts里是否导出了handler:
import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; export const dynamic = "force-dynamic"; export const GET = (req: Request) => fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, createContext: createTRPCContext, }); export const POST = GET;模板里一般已经写好了,但如果你自己改造过目录,很容易漏掉export const POST = GET,导致 POST 请求全部 404。
4.4 React Query 缓存不更新:invalidate 到底该怎么用
当你用 mutation 更新了数据,页面却没有变化,原因基本是缓存没有失效。tRPC 的 React Query 集成中,invalidate是在 query key 层面工作的。utils.todo.getAll.invalidate()对应的是getAll这个 query,如果你同时有getAll和getById两个查询,只 invalidate 前者,后者不会自动更新。
一个更稳妥的写法是前缀 invalidate:
await utils.todo.invalidate();这样todo命名空间下所有 query 都会刷新。但要注意粒度,如果数据刷新频繁且数据量大,全量 invalidate 会带来额外请求。我的经验是先精确 invalidate,观察请求数,再决定是否需要放大范围。
4.5 环境变量注入后仍报校验失败
有时.env里已经写好了变量,但 tRPC 或 NextAuth 还是说环境变量缺失。这时要区分几个层:
- 是否在根目录创建了
.env而不是.env.local。 - 是否有 Spaces 或引号夹在变量值两边。
- 是否重启了 dev server。Next.js 的 env 读取是启动时加载的,改完
.env必须重启开发进程。 - 是否部署平台没有同步环境变量,尤其是在 Vercel 上,本地
.env不会自动上传。
如果都检查了还不行,就把src/env.js里的 schema 先临时放宽,加上console.log看实际取值,再逐步排查。这也是最快定位方式。
4.6 常见问题速查表
把上面这些经验整理成一张表,方便以后对照排查。
| 问题现象 | 常见原因 | 排查方向 | 解决方法 |
|---|---|---|---|
| 启动报 missing secret | 环境变量缺失 | 检查.env文件与平台配置 | 生成随机 secret 写入所有环境 |
| Prisma 类型错误或 Client 初始化失败 | client 生成文件过期 | 重跑 generate | pnpm prisma generate |
| tRPC POST 404 | API route 未导出 POST | 查看app/api/trpc/[trpc]/route.ts | 补上export const POST = GET |
| 页面数据不更新 | mutation 后无 invalidate | 查看组件中的缓存策略 | 精确或前缀 invalidation |
| 本地正常,部署后数据库连不上 | 环境变量未配置 | 检查部署平台设置 | 配置DATABASE_URL与 secret |
| NextAuth 登录后刷新失效 | secret 变更或未设置 | 检查 secret 是否持久 | 固定 secret,重启服务 |
这张表的价值在于,很多问题一开始都表现得很玄学,但对着表一看,其实都是环境变量或缓存失效这种“低级问题”。遇到报错先别急着改代码,从下往上的排查习惯能省很多时间。
5. 关于 t3code 后续扩展的一点真实建议
把 t3code 这套组合完整跑通之后,我最直观的变化不是代码写得快,而是改代码时敢下刀了。以前迭代一个接口,前后端各改一遍,出错还要猜是类型对不上还是逻辑写岔了。现在 tRPC 把类型和实现绑在一起,改一端没同步另一端,编译器会直接拦截。这种安心感是传统全栈项目很难给你的。
如果你准备在这个基础上继续扩展,我个人的建议是先把 Prisma 的 relation 用熟练,尤其是嵌套查询和分页策略,这是业务复杂度上来之后最大的挡路石。其次可以研究 Zod 和 tRPC input 的复合校验,很多接口层参数设计其实可以做得非常细。最后,不要迷信一份模板解决所有问题,t3code 的价值在于帮你把正确的基础设施一次配好,而真正“值钱”的代码,永远是那些被类型安全保护着的业务逻辑。