1. 先说清楚:t3code 到底是什么
我最近一直在折腾全栈项目,绕不开一个词:t3code。如果你也混迹前端技术社区,应该对T3 Stack有印象——这套技术栈最近两年火得不行,GitHub 上的讨论度、招聘 JD 里出现的频率都很高。而 t3code 这个词,其实就是社区里对 T3 Stack 相关实践代码、脚手架产物、以及围绕这套技术栈写出来的应用代码的统称。大家平时说"我去看下 t3code",潜台词就是"我去参考一下 T3 体系下的最佳实践怎么写"。
简单来说,T3 Stack的核心是 TypeScript First——从数据库到 API 层到前端组件,全链路用 TypeScript 串起来,把类型安全做到极致。它通常包含五件套:Next.js(全栈 React 框架)、Tailwind CSS(原子化 CSS 框架)、tRPC(端到端类型安全的 API 层)、Prisma(TypeScript 原生的 ORM)、NextAuth.js(认证方案),再加上Zod(运行时数据校验)。
它解决的核心痛点是:传统全栈项目里,前端和后端各写各的类型定义,API 联调靠"口头约定 + 文档 + 跑 HTTP 看返回",改一个字段要前后端同时改两遍,改漏了就等着线上报错。T3 Stack 用一套 TypeScript 类型把前后端全部打通,你在前端调用后端的函数,就像调用本地函数一样,参数和返回值的类型都自动推导,写错了直接编译报错,根本不给运行时出问题的机会。
这篇东西适合谁?想找一个现成的全栈技术选型模板的中高级前端,想了解 tRPC 到底解决了什么问题的后端同学,以及准备从零搭一个全栈项目、但不想把时间都耗在胶水代码上的独立开发者。我尽量把这些概念拆开揉碎,讲清楚每个环节的来龙去脉和避坑点,而不是甩你一份 README 让你自己悟。
2. 技术栈拆解:为什么偏偏是这五个家伙
2.1 Next.js 是骨架,但不是普通的页面框架
很多人对 Next.js 的印象停留在"React 的服务端渲染框架",实际上它是一个全栈应用框架。T3 选它,核心原因是App Router 模式下,API Route 和页面组件可以共用一套 TypeScript 类型和模块体系。
你在app/api/xxx/route.ts里写一个接口,跟在前端组件里调用它,理论上共享同一个环境和同一套类型导入路径。配合 tRPC 之后,你甚至不需要显式写 HTTP 路由——tRPC 会把 RPC 请求打到 Next.js 暴露的单一 API 端点(通常挂在app/api/trpc/[trpc]/route.ts),业务逻辑全都收拢到 router 里,页面组件不关心请求怎么传,只关心调用什么函数、拿到什么类型。
实际体验下来,App Router 的Server Components 和 Client Components 的边界是最需要花时间理解的地方。我刚开始写的时候就踩了坑:默认所有组件都是 Server Component,里面不能直接用useState,要手动加"use client"指令。但 T3 项目里 tRPC 的客户端调用本身依赖 React Context,所以涉及数据交互的组件基本都得标记成客户端组件。这个动作本身不复杂,但会影响你整个目录组件拆分的方式。
2.2 tRPC 是灵魂,端到端类型安全的魔法
tRPC 是整个 T3 体系最容易被低估的一环。它的原理不复杂:后端定义一组 router 和 procedure,前端通过类型推导获得这组 router 的类型签名,然后用类似trpc.post.list.query()这样的调用方式触发请求。
举个贴近生活的类比:你点外卖的时候,App 里的菜单和商家的实际库存如果各记各的,那你经常遇到"下单时还有、支付后没货"的尴尬。tRPC 等于把菜单和库存数据库看成同一套系统,你在客户端能看到的菜品,一定是服务端确认能出餐的。类型安全到了 HTTP 边界依然生效,而且请求和响应都要过 Zod 校验,跑到运行时也有保障。
tRPC 的效率也很高。它默认走的 HTTP 协议,而不是 WebSocket,所以部署和常规 REST 接口没有任何区别,CDN、负载均衡、日志中间件全部照旧。但它省掉了一整层 RESTful API 设计——不需要给每个资源设计 GET/POST/PUT/DELETE 路由,不需要在接口文档里维护请求和响应结构,因为代码本身就是文档。
2.3 Prisma 是数据层的定海神针
Prisma 在 T3 生态里承担数据库访问层的角色。你要是写过几年后端,一定懂那些年被 MyBatis XML 映射文件支配的恐惧;Prisma 的 schema 文件会自动同步生成类型安全的查询客户端,写错字段名编译都过不去。
T3 项目通常用 SQLite 起步,方便本地调试,部署前可切换到 PostgreSQL 或 MySQL。我在本地开发时一直用 SQLite,后来上生产切到 PostgreSQL,只改了DATABASE_URL和 provider,然后执行一次prisma migrate deploy,迁移脚本全部复用,几乎没有额外成本。当然,SQLite 和 PostgreSQL 在部分类型上有一点差异(比如DateTime的行为),但只要不在 schema 里写太数据库特有的类型,切换基本无感。
Prisma 的查询 API 设计我个人非常喜欢。写嵌套查询的时候,include语法非常直观:
const userWithPosts = await prisma.user.findUnique({ where: { id: userId }, include: { posts: { orderBy: { createdAt: 'desc' }, take: 10 } } });这和手写 SQL JOIN 相比,可读性和修改成本都低了一大截。
2.4 NextAuth.js 是认证层的老熟人
认证是每个业务系统绕不开的环节。NextAuth.js 配合 T3 的推荐用法,最爽的是它把 "登录态" 直接挂到 tRPC 的 Context 上。你在 tRPC 的 createContext 里读 session,然后在 procedure 里校验登录状态,整个过程类型安全且不打断业务逻辑的线性感。
T3 模板默认集成的是Discord Provider,因为作者 Theo 自己用 Discord 社区,但实际要接入 GitHub、Google 或邮箱验证码登录,只是换 provider 配置的事。我最喜欢它的getServerSession函数——在服务端拿 session 不会产生额外请求,也不依赖客户端状态,特别适合做权限型页面的服务端渲染判断。
有一点必须提醒:NextAuth v4 和 v5 的配置方式差异很大,v5 里变成了auth.ts统一入口,很多旧教程已经不适用。如果你用最新模板初始化项目,大概率拿到的是 v5 版本的生态,注意不要照抄 v4 的代码。
2.5 Tailwind CSS 和 Zod:一个管样式,一个管安全
Tailwind CSS 不用多说,T3 选它一方面是因为开发效率,另一方面是它和组件库的解耦设计让 T3 模板保持轻量。Zod 则是 tRPC 的黄金搭档——tRPC 的输入校验默认依赖 Zod,你在 procedure 里定义input: z.object(...)时,这个 Schema 会被自动推导成 TypeScript 类型,贯穿前后端。
举个例子,如果你定义了一个创建文章的 input schema:
createPost: t.procedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().min(10), })) .mutation(async ({ ctx, input }) => { // 这里的 input 类型已经被 Zod 推导了 })前端拿到这个过程函数,类型签名里就包含title和content的结构和约束。如果你传进去一个number类型的 title,在编辑器里立刻标红,不需要等到发请求。
3. 完整实操:从零初始化一个 t3code 项目
3.1 项目初始化
建议直接用 T3 官方脚手架create-t3-app。环境要求是 Node.js 18+,这点卡得很死,版本太低会直接报错。
npx create-t3-app@latest my-t3-app交互过程中它会问你选装哪些模块。我自己的建议:初次尝试的话,全选,尤其不要跳过 tRPC 和 Prisma,因为这两个才是 T3 的精髓。选完之后它会自动初始化 Git 仓库、安装依赖、生成 Prisma Schema 并跑初始迁移,整个过程大概 2 到 5 分钟。
初始化完成后,你会得到这样一个目录结构(核心部分):
app/api/trpc/[trpc]/route.ts— tRPC 的 HTTP 端点,前端的请求都汇聚到这里server/api/root.ts— 根 Router,聚合所有子 Routerserver/api/routers/post.ts— 示例帖子 Router,官方模板就带一个基础 CRUDserver/db.ts— Prisma 客户端实例prisma/schema.prisma— 数据模型定义trpc/react.tsx— 前端的 tRPC Provider 配置env.js— 环境变量校验文件(基于 Zod)
3.2 Environment 变量管理
T3 模板带了一个很妙的机制:环境变量的运行时校验。它的env.js文件用 Zod 定义了哪些环境变量必须存在、哪些是可选。比如DATABASE_URL没有配置时,启动服务会直接抛错,而不是等你跑到数据库查询时才一脸懵。
这个设计对协作开发非常友好。新人克隆仓库后,哪怕不看文档,启动失败时能立刻看到是哪几个环境变量缺失。我接手过不少老项目,最头疼的就是"README 写得花团锦簇,结果环境变量全靠猜",T3 这套机制治好了我的精神内耗。
3.3 核心依赖文件速写
快速看下server/api/routers/post.ts的官方示例代码:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ hello: publicProcedure .input(z.object({ text: z.string() })) .query(({ input }) => { return { greeting: `Hello ${input.text}`, }; }), create: publicProcedure .input(z.object({ name: z.string().min(1) })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { name: input.name, }, }); }), });注意这里的publicProcedure,表示任何人都能调用,不需要登录态。需要保护的路由就换成protectedProcedure,它会自动检查 session,未登录直接抛 UNAUTHORIZED 错误。
然后把app/_components/post.tsx里的调用方法展示一下:
const latestPost = api.post.getLatest.useQuery(); api.post.create.useMutation({ onSuccess: () => { utils.post.getLatest.invalidate(); }, });前端调用api.post.getLatest,末尾的.useQuery()是 React Query 的强化版本。你不需要手动处理 loading、error 这些状态,全都由 React Query 帮你管了。这就是 t3code 的核心体验:编程逻辑完全本地化,请求和响应类型零断层。
4. 我用 t3code 写第一个业务模块的全过程
4.1 需求描述
我拿一个常见的 demo 场景来拆解:做一个简单的"待办事项管理"功能,包含三个动作:查询列表、新增待办、标记完成。数据存 SQLite,不需要用户系统,纯公开接口。这个场景麻雀虽小五脏俱全,正好能把 T3 的核心链路走一遍。
4.2 建表与迁移
打开prisma/schema.prisma,加一个 Todo 模型:
model Todo { id Int @id @default(autoincrement()) title String completed Boolean @default(false) createdAt DateTime @default(now()) }然后跑迁移命令:
npx prisma migrate dev --name add_todo这一步会自动生成迁移文件、更新 Prisma Client 类型。如果之前启动过 dev server,此处建议重启一次,因为 Prisma Client 的类型更新后,编辑器可能需要重新加载才认。
4.3 创建 tRPC Router
在server/api/routers下新建todo.ts,注册到根 Router:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const todoRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.todo.findMany({ orderBy: { createdAt: 'desc' } }); }), create: publicProcedure .input(z.object({ title: z.string().min(1).max(200), })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.create({ data: { title: input.title } }); }), toggle: publicProcedure .input(z.object({ id: z.number().int(), completed: z.boolean(), })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.update({ where: { id: input.id }, data: { completed: input.completed } }); }), });这里toggle的输入设计我有意把整条 completed 传进来,而不是只传 id 让后端取反。原因是"取反"这个逻辑依赖客户端当前状态,如果两个客户端同时操作同一数据就会互相覆盖。API 层做一个"幂等更新"比"翻转更新"更安全。
更新根 Routerserver/api/root.ts:
import { todoRouter } from "~/server/api/routers/todo"; export const appRouter = createTRPCRouter({ post: postRouter, todo: todoRouter, });4.4 前端页面接入
新建app/todos/page.tsx,在前端组件里直接连 API:
"use client"; import { api } from "~/trpc/react"; import { useState } from "react"; export default function TodosPage() { const [title, setTitle] = useState(""); const utils = api.useUtils(); const todosQuery = api.todo.getAll.useQuery(); const createMutation = api.todo.create.useMutation({ onSuccess: () => utils.todo.getAll.invalidate(), }); const toggleMutation = api.todo.toggle.useMutation({ onSuccess: () => utils.todo.getAll.invalidate(), }); const handleAdd = () => { if (!title.trim()) return; createMutation.mutate({ title }); setTitle(""); }; return ( <main className="max-w-xl mx-auto p-8"> <h1 className="text-2xl font-bold mb-4">待办列表</h1> <div className="flex gap-2 mb-4"> <input className="border p-2 flex-1" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="输入待办内容" /> <button onClick={handleAdd} className="bg-blue-600 text-white px-4 py-2 rounded" > 添加 </button> </div> <ul className="space-y-2"> {todosQuery.data?.map((todo) => ( <li key={todo.id} className="flex items-center gap-2 border p-2"> <input type="checkbox" checked={todo.completed} onChange={(e) => toggleMutation.mutate({ id: todo.id, completed: e.target.checked, }) } /> <span className={todo.completed ? "line-through opacity-60" : ""}> {todo.title} </span> </li> ))} </ul> </main> ); }核心点在api.useUtils()的返回值utils.todo.getAll.invalidate()。这句话的意思是:增删改成功之后,主动让getAll这个查询失效,React Query 会自动重新拉取最新列表。这个模式我称之为"更新后失效、查新数据",整个 T3 模板的 CRUD 样例都是这个套路,极大减少了手动管理缓存状态的心智负担。
4.5 改动验证
跑npm run dev,打开http://localhost:3000/todos,你会看到页面直接渲染待办列表。新增一条数据后,列表自动刷新,整个过程你都不用刷新浏览器页面。为什么这么顺滑?因为 mutate 的 onSuccess 触发 invalidate,React Query 知道getAll的数据已经过期,于是带着新请求重新执行,数据重新渲染。链条清晰、类型完整,没有断点。
5. 关于部署和生产环境:Vercel 与数据库迁移实战
5.1 部署前要做的检查
用 t3code 写出来的应用属于 Next.js 项目,最省心的部署方式是 Vercel。但在点击 Deploy 之前,有几个事项提前处理好,否则很容易卡在环境配置上。
第一,数据库服务。如果你一直用本地 SQLite,部署前要把数据库切成远程的 PostgreSQL。因为 Vercel 的函数是无状态的,本地文件系统不可持久化,SQLite 文件会在冷启动时直接消失。你可以选 Vercel Postgres、Railway、Supabase 或者 Neon,只要提供连接字符串就能接上。
第二,迁移文件。Vercel 构建时会跑prisma generate,但它不会自动跑迁移。常见做法是在构建命令里加上prisma migrate deploy。构建命令建议写成:
"build": "prisma generate && prisma migrate deploy && next build"注意顺序:先 generate 生成 Client,再 deploy 把 migration 推给数据库,最后才 next build。因为 next build 阶段可能已经要导入 Prisma Client 了,顺序错了类型链接会断掉。
5.2 环境变量配置清单
在 Vercel 的 Environment Variables 面板里,至少需要配置这几个:
DATABASE_URL远程 PostgreSQL 的连接串NEXTAUTH_SECRET用于加密 session 的密钥,可以用openssl rand -base64 32生成NEXTAUTH_URL生产环境的域名地址(v4)或AUTH_URL(v5)- 各种第三方 Provider 的 ID 和 Secret
NEXTAUTH_SECRET这个变量特别容易被忽略。本地开发时模板可能帮你内置了,但生产环境不配置的话,NextAuth 会直接拒绝启动,而且不会给很友好的报错提示。
5.3 部署后常见的"本地好好的线上挂了"问题
最典型的:本地连 SQLite 一切正常,部署到 Vercel 后页面白屏或接口 500。排查路径如下:
- 先在 Vercel 的 Function Logs 里看报错内容。大概率是"Can't find database file"或者环境变量没读取到。
- 确认
DATABASE_URL的值为远程库连接串,且允许外部 IP 访问(有些云数据库默认白名单不开外网)。 - 检查 CORS——如果你有独立的 API 域名或者前后端分离,需要在 tRPC 或 Next.js 层配置跨域。T3 模板默认同源部署,不开 CORS 也没问题;但如果你要给它配自定义域名映射,就要额外处理。
另外一旦部署流程跑通,后续命令都会自动执行。后续更新数据库 schema,本地改完后运行prisma migrate dev生成新迁移文件,push 到仓库,Vercel 会自动跑 build,构建命令里的migrate deploy会把增量迁移应用到生产库。这个流水线用顺手了之后,你会发现数据库版本管理其实没那么吓人。
6. 常见问题避坑指南
6.1 前端组件调用 useQuery 报错:必须是客户端组件
很多新手把模板的app/page.tsx改成自己页面时,直接写在服务端组件里,然后调用useQuery,立即报错Hooks can only be called inside of the body of a function component。排查方法很简单:查看组件文件顶部是否声明了"use client"。T3 模板已经在封装层处理好布局,但你自己新建的页面默认是 Server Component,不声明这个指令就没有 Hooks 运行环境。
页面级别组件加"use client"没问题,但如果你希望部分组件保持服务端渲染以享受 SEO 红利,建议拆成内外两层——外层 Server Component 负责拉静态数据和 metadata,内层 Client Component 负责交互。
6.2 include 字段写错导致前端类型拿不到
Prisma 类型推导很强,但有一个容易踩的坑:前端用 tRPC 调用返回的数据里,想访问某个关联表字段,却发现自己没在 Prisma 查询里include这个关联。类型会直接报错,不会静默返回 undefined。这一点我觉得反而省事,它逼着你显式声明取数范围,避免传统 ORM 那种隐式 N+1 查询问题。
// 假设要取 todo 关联的 user ctx.db.todo.findMany({ include: { user: true } })确保字段名和 Prisma schema 里的关系名一致,大小写和驼峰都要对得上。
6.3 tRPC 请求全部挂掉,但页面静态部分正常
这种时候优先检查 API Route 的路径是否被中间件拦截了。NextAuth 的中间件如果配了 matcher,可能会把/api/trpc也拦掉,导致未登录跳转。推荐的做法是中间件 matcher 显式排除 tRPC 端点,或确认 tRPC 的 procedure 使用protectedProcedure来处理鉴权,而不是依赖 NextAuth 中间件拦截 API 链路。
6.4 迁移文件冲突:多人协作时 migrate dev 常常打架
一个真实场景:团队两个人都改了 schema,各自本地跑prisma migrate dev,生成了相同序号的迁移,push 之后必冲突。我的习惯是开发时尽量用prisma db push来快速同步 schema,不需要生成迁移文件;只有快发布时才统一收敛成一次正式的migrate dev。这种模式在单人项目和早期团队非常实用,等真的很多人同时动 schema 时,再考虑引入专门管理迁移的人或流程。
6.5 NextAuth session 在服务端拿不到
如果你在 Server Component 里直接调getServerSession()拿不到 session,很可能是没有正确配置authOptions到 app router 的对应文件。v5 版本里要确认/auth.ts的导出是否正确,且trpc的 context 创建是否从auth()读取 session 而不是getServerSession()读旧配置。我遇到过不少把 v4 配置硬搬到 v5 导致 session 永远为 null 的情况,分布在不同版本间,迁移的时候注意版本差异。
7. 我的真实体会
整个 t3code/T3 Stack 用下来,最强烈的体感是:它把那根"类型红线"从数据库一路画到了浏览器。你换个复杂项目试试就知道,传统 REST API 全链路的问题往往暴露在联调和上线之后:字段命名不一致、缺字段、类型错配。T3 把这些问题前置到编码阶段,大部分低级错误在敲键盘的时候就给你拦截了。
有一个细节我印象特别深:刚用 T3 时,我在前端写错了一个 input 字段名,编辑器直接红波浪线告诉我这个属性不存在,并且给出了可选列表。那一刻我突然理解了"端到端类型安全"的含义——不是测试覆盖率提升多少,不是文档写得多好,而是类型系统本身就变成了一个活生生的接口文档和一个永不疲倦的代码审查员。
如果你之前没接触过 tRPC,建议从今天这个小 todo 例子开始跑一遍:初始化一个模板项目,改一个 model,写一个 procedure,再在前端查一下数据,感受一下那条类型链路是怎么贯穿下来的。等你顺手了,再去把认证、权限、部署串起来,整个全栈开发的自信水平会上一大截。
最后再分享一个经验:T3 生态更新速度快,模板也在持续变化,别把网上两年前的教程当真。跑项目时如果遇到报错,第一反应去看官方文档和仓库的 release notes,很多报错的答案都写在版本更新说明里。我踩过最长的一次坑,就是照着老教程配 NextAuth v4 最终在 v5 项目里反复横跳,折腾了一下午。认准版本、再动手,能帮你省下大量时间。