如果你最近在折腾全栈项目,大概率已经瞄到过t3code这个名字。它不是某个框架的代号,也不是公司内部工具,而是一套非常完整的项目模板与最佳实践集合,围绕前端圈这两年讨论度很高的 t3 stack 搭建。t3 stack 四个字母对应四样东西:Next.js、TypeScript、Tailwind CSS、tRPC,其中 tRPC 是灵魂,它把前后端的接口类型直接打通,让你在写业务代码时能真正体会到“编译器帮我盯着接口”是什么感觉。
这个项目适合谁?如果你是刚写完几个前端页面、想往全栈方向迈一步的开发者,t3code 能让你用最短路径把一个带数据库、带登录、带接口的完整应用跑起来;如果你是在公司里经常要搭内部后台、管理端的人,它这套目录结构和规范几乎是开箱即用的脚手架;就算你是纯后端,看完也能理解为什么现在前端同事喊着“类型安全”喊得那么起劲。这文章我会从项目拆解、技术选型、实操流程到部署避坑,完整过一遍,保证你照着能复现。
1. 项目整体设计与技术选型
1.1 先拆开名字:t3 stack 到底由什么组成
t3code的根基是 t3 stack,名字里的三个 t 分别指向三个技术:Next.js、TypeScript、Tailwind CSS,加上一个 x 因素——tRPC。社区里也有人把它叫 “The T3 Stack”,意思是“一套务实、渐进式、类型安全的 React 全栈方案”。
这里我先把每一块的作用说清楚。Next.js 负责页面渲染和路由,App Router 模式下服务端组件和客户端组件可以混着用;TypeScript 让项目里所有变量、函数、接口都有类型约束;Tailwind CSS 解决样式问题,写起来快,也不需要在设计稿和样式文件之间反复横跳;tRPC 则是连接前后端的传输层,它比 REST 更轻,比 GraphQL 更简单,核心思路是“函数调用即接口”。
很多人第一次看到 t3 stack 会问:为什么选 Next.js 而不是 Vite 或 Remix?老实说我当初也纠结过。后来在项目里实测下来,Next.js 的 App Router 加上服务端组件,正好和 tRPC 的服务端 caller 配合得特别好。你可以在服务端组件里直接调用查询逻辑,把数据渲染到页面上,也可以在前端组件里调用 mutation 接口,代码写起来就像在调本地函数。相比 Vite 那种纯 SPA 的方案,Next.js 自带的后端能力和部署生态能少踩不少坑。
1.2 用 t3code 你能得到什么
把t3code跑起来之后,你会得到一个现成的全栈骨架,里面有这些功能:
- 基于 Next.js App Router 的完整目录结构,页面、路由、布局文件都已经挂好;
- tRPC 路由器(router)体系,区分了 根路由器、子路由器和业务调用入口;
- Prisma 数据库模型与迁移配置,默认支持 SQLite 或 PostgreSQL,改一个环境变量就能切换;
- NextAuth 登录认证,Google、GitHub、邮箱密码等 provider 的接入口都列好了;
- Tailwind 样式与基础组件,写业务页面时不用从零布局。
这套骨架的定位不是“玩具脚手架”,而是能直接当公司内部项目底子的那种。我做个人项目的时候试过从零搭一套差不多的东西,光是把 tRPC、Prisma、NextAuth 三者之间的类型和配置接对,就花了一个周末。所以后来遇到 t3code 这类模板,我的态度就是:能抄的作业就别重新发明轮子。
2. 核心思路拆解:为什么类型安全能提升开发效率
2.1 前后端类型同步到底是怎么实现的
tRPC 听起来很新奇,但原理不复杂。它把后端定义的路由和前端调用方通过 TypeScript 类型推导关联起来。后端写一个bookmark.create的 mutation,前端api.bookmark.create.useMutation()就能拿到完整的 input 类型和返回值类型。
这背后的机制是:前端从后端导出一个类型对象,TS 编译器在编译期把这个类型对象展开,所以前后端共用同一份类型定义。换句话说,后端把参数改成必填,前端代码立刻飘红报错,根本不需要等联调才发现问题。
我自己之前写传统 REST 接口时,最头疼的就是接口文档和真实代码不一致。前端拿到的字段名少了下划线,或者后端把一个字段从字符串改成了数字,前端只能等运行时崩了才知道。有了 tRPC 这层类型约束,这类问题在编译期就会被拦截,这个体验一旦用过就回不去了。
2.2 对比传统方案:到底少了哪几层胶水代码
咱们拿“获取文章列表”这个场景对比一下。传统 REST 方案下,你要做这些事:
- 在前后端之间定一个接口协议,比如
GET /api/posts; - 后端写路由,返回 JSON 数据;
- 前端维护一个 API 请求函数,里面写
fetch("/api/posts"); - 手动定义返回数据的 interface,或者从后端文档复制粘贴;
- 如果接口字段变了,你得两边都改。
GraphQL 方案下,前端要写查询语句、处理缓存、维护 fragment 和 type codegen,复杂度也不低。tRPC 的做法要简单得多:后端定义一个getAll: publicProcedure.query(...),前端直接api.post.getAll.useQuery(),没有 URL、没有请求函数、没有手写类型,代码量少一半,类型还不出错。
当然,tRPC 也不是万能的。它需要前后端在一个 TypeScript 项目里,对于完全独立的前后端仓库就不太合适。但 t3code 本身就是一个 Next.js 全栈项目,天然满足这个前提。所以我一直觉得 t3 stack 适合的场景是“一个人或一个小团队包揽前后端”的项目,这也是它效率高的原因。
3. 实操过程与核心环节实现
3.1 环境准备与项目初始化
实操部分我假设你用一台干净的 macOS 或 Linux 环境,Node.js 版本 18 以上,包管理器用 pnpm,实测下来它在处理 monorepo 和依赖安装时比 npm 快不少,也不容易产生幽灵依赖。
初始化命令其实很简单,官方脚手架直接拉模板:
npx create-t3-app@latest t3code执行后会出现几个交互选项。我会建议这么选:
- TypeScript:必选,t3 的灵魂就是类型安全;
- Tailwind CSS:选上,项目默认的样式方案就是它;
- tRPC:选上,核心依赖;
- Prisma:选上,因为大多数业务项目都要连数据库;
- NextAuth:如果你的项目需要登录,就选上,不需要可以后期再加;
- 数据库:开发阶段选 SQLite 最省事,零配置,后面上线再换 PostgreSQL。
初始化完成后,项目会自动安装依赖并生成一套标准的目录结构。进入根目录你会看到src/app(页面)、src/server(服务端业务)、src/trpc(tRPC 入口)、prisma(数据库模型)这几个核心目录。我的习惯是先把prisma/schema.prisma打开看一眼,确认数据库 provider 是不是按预期设置的,因为后面所有的数据表都从它生成。
3.2 从零定义一张表和一套 tRPC 接口
接下来我用一个很常见的“个人收藏夹”功能来演示完整流程:用户可以保存一条网址和标题,列表按时间倒序展示。
先在 Prisma 里定义数据模型。打开prisma/schema.prisma,加一个收藏记录模型:
model Bookmark { id String @id @default(cuid()) url String title String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User? @relation(fields: [userId], references: [id]) userId String? }如果项目里启用了 NextAuth,User模型是现成的,收藏记录和用户之间的关联直接写上去。然后执行:
npx prisma migrate dev --name init_bookmark这个命令会根据 schema 生成数据库表,同时把 Prisma Client 更新到最新。看到 “Your database is now in sync with your schema” 的提示,就说明表建好了。
接着在 tRPC 里创建对应路由。src/server/api/routers/目录下已经有一个示例 router 文件,我新建一个bookmark.ts:
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; import { prisma } from "~/server/db"; export const bookmarkRouter = createTRPCRouter({ list: publicProcedure.query(async () => { return prisma.bookmark.findMany({ orderBy: { createdAt: "desc" }, take: 50, }); }), create: protectedProcedure .input( z.object({ url: z.string().url(), title: z.string().min(1).max(200), }) ) .mutation(async ({ ctx, input }) => { return prisma.bookmark.create({ data: { url: input.url, title: input.title, userId: ctx.session.user.id, }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const bookmark = await prisma.bookmark.findUnique({ where: { id: input.id } }); if (!bookmark || bookmark.userId !== ctx.session.user.id) { throw new Error("没有权限删除该收藏"); } return prisma.bookmark.delete({ where: { id: input.id } }); }), });这段代码里有两个细节值得注意。第一,input用 zod 做了运行时校验,参数进到后端前就会先过滤一遍非法数据;第二,删除收藏时做了归属校验,避免用户一串请求把别人的数据删掉。这些都是实际项目里最基本的防护逻辑,模板本身不帮你写,但目录结构已经把放代码的位置留好了。
新 router 写完之后,记得去src/server/api/root.ts里注册:
import { bookmarkRouter } from "./routers/bookmark"; export const appRouter = createTRPCRouter({ bookmark: bookmarkRouter, });所有准备工作到这里就结束了。前端调用api.bookmark.list.useQuery()会立刻拿到返回的收藏列表,类型和后端 Prisma 返回的字段完全一致。
3.3 前端页面接入 tRPC 调用的完整示例
页面部分我直接在src/app/page.tsx里写一个最小示例,展示列表和新增按钮。这个组件是客户端组件,因为要用到 tRPC 的 hooks:
"use client"; import { api } from "~/trpc/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; export default function HomePage() { const router = useRouter(); const [url, setUrl] = useState(""); const [title, setTitle] = useState(""); const { data: bookmarks, refetch } = api.bookmark.list.useQuery(); const createBookmark = api.bookmark.create.useMutation({ onSuccess: () => { refetch(); setUrl(""); setTitle(""); }, }); return ( <main className="mx-auto max-w-2xl p-8"> <h1 className="text-2xl font-bold">我的收藏夹</h1> <form onSubmit={(e) => { e.preventDefault(); createBookmark.mutate({ url, title }); }} className="mt-6 space-y-3" > <input className="w-full rounded border p-2" placeholder="https://example.com" value={url} onChange={(e) => setUrl(e.target.value)} /> <input className="w-full rounded border p-2" placeholder="标题" value={title} onChange={(e) => setTitle(e.target.value)} /> <button className="rounded bg-blue-600 px-4 py-2 text-white" type="submit"> 保存 </button> </form> <ul className="mt-8 space-y-2"> {bookmarks?.map((bookmark) => ( <li key={bookmark.id} className="border-b pb-2"> <a href={bookmark.url} target="_blank" className="font-medium text-blue-600"> {bookmark.title} </a> <span className="ml-2 text-sm text-gray-500">{bookmark.url}</span> </li> ))} </ul> </main> ); }这里面的核心调用是api.bookmark.list.useQuery(),它会自动管理请求缓存、加载状态和错误重试。新增成功后调用refetch()刷新列表,不用自己写fetch、不用处理 JSON 序列化、不用定义类型,前后端类型自动对齐。如果不是写这篇文章,我根本意识不到这一串操作用传统方式要做多少重复劳动。
4. 工具选型与应用场景延伸
4.1 团队协作场景下 t3code 的目录规范
之前我在一个小团队里带过两个前端把一个内部工具从零搭起来。我坚持用了 t3code 作为基底,最大的感受是分工边界变得很清晰。目录约定好了:src/server/api/routers放业务接口,src/server/db.ts统一导出 Prisma 实例,src/app只放页面和组件。新同事上手时不需要问“接口在哪里写”“类型定义放哪个文件”,看一眼目录结构就明白。
对团队协作来说,类型安全的价值会被放大。前后端不再是两拨人对着接口文档各自干活,而是所有人共享同一套类型系统。后端改了接口字段,前端编译直接报错,Review 的时候少了很多“接口签没签对”的拉扯。tRPC 官方的说法是“去代码生成器化”,没有 Swagger 同步步骤,也没有客户端代码生成步骤,在多人协作时尤其省心。
4.2 适合用 t3code 做的项目类型
从应用场景看,tRPC + Next.js + Prisma 这套组合最适合“业务逻辑集中、需要实时刷新、用户量不大但功能复杂”的应用。我实际做过和见过比较成功的案例有这么几类:
- 公司内部管理后台:表格、筛选、增删改查,数据多但并发请求不高;
- 个人作品集加博客:前端展示加内容管理,自带按需加载和服务端渲染;
- 小型 SaaS 产品原型:要做登录、付费、数据面板,但又想快速上线验证;
- 团队知识库与工具集合:需要多人登录、各自维护数据,字段之间的关系比较多。
这类项目的共同特点是:数据库结构经常调整、页面交互逻辑多变、没有专职后端。t3code 把重复的胶水代码降到最低,让开发者把精力集中在业务逻辑上。
当然,如果你做的是公开的、高并发的 C 端产品,或者需要给第三方提供 OpenAPI 接口,那 tRPC 可能不是最优选项。它本身不暴露 REST 风格的路由,外部调用者没法直接消费你的接口。这时候老老实实加一层 API 兼容层,或者直接用传统 BFF 方案,会更合适。工具选型没有银弹,关键是搞清楚自己项目的真实约束。
5. 常见问题与排查技巧实录
5.1 类型不同步:改了 router 前端迟迟不更新
这是我在 t3code 里遇到过的头号问题。后端 router 加了一个新接口,前端api对象上却看不到,或者 TS 一直报旧类型。这种情况大概率不是代码问题,而是类型生成服务没有刷新。
解决方式并不复杂:停掉当前next dev进程,重新跑一次。tRPC 的类型工具会在 dev server 启动时重新拉取 router 类型并生成。如果你不想重启,也可以在另一个终端跑tsc --noEmit强制触发一次类型检查。
另一个老生常谈的原因是路径写错。src/trpc/react.ts导出的是 React 专用的api对象,src/trpc/server.ts导出的是服务端专用 caller。这两个混用会触发类型“不存在某个属性”的报错。经验之谈:凡是带useQuery、useMutation这种 hooks 的代码,务必从~/trpc/react导入;凡是await直接调用的,从~/trpc/server导入。
提示:改完 router 后如果前端类型死活不刷新,优先排查 dev server 是不是卡在旧进程里。我踩过一次是 pnpm 的缓存导致类型一直没更新,最后清掉
.next目录重启才解决。
5.2 服务端组件里调用 tRPC 报错
App Router 模式下,默认的页面组件是服务端组件,这意味着你不能在里面用 hooks。有人会把api.post.list.useQuery()直接写在page.tsx里,然后得到 “useQuery is not a function” 的错误。
正确姿势是用src/trpc/server.ts提供的 caller。在服务端组件里直接调:
import { api } from "~/trpc/server"; export default async function PostListPage() { const posts = await api.post.list(); return <PostList posts={posts} />; }这里api.post.list()是一个普通 async 函数,返回值类型完整,渲染到页面上之后再进行客户端交互。记住一个原则:服务端组件的职责是取数、渲染、传数据给客户端组件,客户端组件的职责是交互、状态管理和 mutation。
5.3 部署后白屏或者接口 404
本地跑得好好的,一上 Vercel 就白屏,这类问题十次有八次和环境变量有关。t3code 默认使用 NextAuth,部署时必须在环境变量里配置NEXTAUTH_SECRET。如果不配置,NextAuth 在服务端初始化时会抛错,整个页面的请求直接挂掉。你可以用:
openssl rand -base64 32生成一个随机字符串作为 secret 填进控制台。
还有DATABASE_URL。本地用 SQLite 时这个变量可能是空的或没写,部署后 Prisma 连不上数据库,接口自然 404。我的建议是开发阶段就把.env文件里的变量写齐,上线前再去控制台覆盖。另外很多云数据库要求连接地址走 SSL,Prisma 的连接串里要带上sslmode=require参数,这个容易漏。
5.4 mutation 之后数据不刷新
tRPC 底层的缓存系统是 TanStack Query(原 react-query)。创建数据成功后,列表并不会自动更新,你需要主动触发失效重取。标准做法是:
const utils = api.useUtils(); const createBookmark = api.bookmark.create.useMutation({ onSuccess: () => utils.bookmark.list.invalidate(), });utils.bookmark.list.invalidate()会精确地让bookmark.list这个 query key 失效,下一次组件重新挂载时就会重新拉取最新数据。如果你只想要局部刷新,也可以把refetch()传下去。这个知识点不属于 t3code 独有的,但确实是你把骨架跑起来之后第一个会遇到的问题。
6. 我把 t3code 真正用起来之后的几点体会
模板和脚手架最大的价值不在“帮你写完代码”,而在“帮你决定代码应该放在哪里”。t3code 这套目录结构,是社区里很多人反复用过之后沉淀下来的共识。我复用了几次之后,最大的感受就是做内部工具时几乎不用思考接口层怎么组织,打开routers/bookmark.ts直接写业务就行。
如果你准备上手,我的建议是别急着把每个功能都加进去。先把 Prisma 建一张表、写一个 router、再在前端拉一个列表完整跑通,然后在这个最小闭环上加认证、加缓存、加部署。先慢后快,后面自然就顺了。
最后分享一个小技巧。用 t3code 做项目时,我一般会在prisma目录下开一个seed.ts,里面塞一些容易复用的测试数据,比如几十条收藏记录、几个测试账号。这样每次环境重建之后,一条命令npx prisma db seed就把数据铺好,开发时交互体验会好非常多。脚手架本身没有强制这个流程,但你一旦试过,就再也不想回到手动造数据的日子了。