
做全栈开发这几年我最大的痛点不是业务逻辑写不出来而是前后端的数据类型就像两个平行世界——后端定义了接口前端还得手动维护一份类型定义改一个字段名要跑两个仓库。T3 Stack 就是冲着这个痛点来的而 t3code 则是我在这个技术栈下长期积累的一套完整实践总结从项目初始化、数据建模、路由编排到页面落地全部围绕“端到端类型安全”四个字展开。这套东西适合谁用如果你是刚接触 Next.js 想做全栈项目、又被各种 ORM 和 API 方案纠结到失眠的开发者t3code 可以给你一条顺畅的捷径如果你是已经写过几年接口的老手想见识下不用手写 REST API 也能做前后端交互的思路这篇文章里同样有值得一看的实操细节。我会把每一步选型背后的“为什么”也讲清楚而不是只丢给你一串命令。1. 整体设计与思路拆解为什么是 T3 Stack 而不是传统前后端分离1.1 T3 到底是什么先说清楚这个 T3 不是因为版本号而是 TypeScript、Tailwind、tRPC 三个单词的缩写组合。T3 Stack 的完整成员除了这三者通常还包括 Next.js、Prisma 和 NextAuth.js核心目标是让整个全栈项目从数据库到浏览器 UI 全程都由 TypeScript 守住类型边界。传统做法是前端一个 React 项目、后端一个 Express 项目中间靠 REST API 沟通。这样拆的代价是接口文档必须同步、请求和响应类型必须手工维护、部署环境至少要两套。T3 Stack 的思路则是用 Next.js 同时承担页面渲染和 API 处理用 Prisma 把数据库表结构同步成 TypeScript 类型再用 tRPC 让前端直接调用后端函数全程不需要生成 OpenAPI 文档也不用在前后端各写一份 interface。t3code 本质上就是这个选型思路的工程化落地。我见过很多人对 T3 Stack 感兴趣但实际搭项目时会在“App Router 还是 Pages Router”、“tRPC 能不能和 Server Action 混用”、“Prisma 到底要不要单独抽象一层 service”这些问题上纠结结果项目搭到一半就泄气了。1.2 类型安全能解决什么问题拿一个最简单的“创建文章”功能举例。传统 REST 流程里前端要记住 POST /api/post 这个地址还要保证传参里用title还是name和后端代码一致。一旦两边有一个字母拼错运行时不报错数据却进了数据库错误列。这种 Bug 在开发环境往往很难第一时间暴露等到前端页面加载不出来你才意识到类型错了。T3 Stack 的处理方式完全不同。数据库表用 Prisma Schema 定义生成出来的Post类型全项目通用tRPC 的createPost过程会从数据库层一路推导到前端调用处如果你传递了错误字段保存代码的那一刻 TypeScript 就会亮红。也就是说接口契约不再是文档而是直接写死在类型系统里。t3code 把这类实践都归纳成了可复现的模式。我实操下来的体会是类型安全不是一次配置就完事它需要你在建表、写路由、封装组件时都遵循同一套约定否则强势类型反而会变成一堆报错的麻烦。2. 核心细节解析与实操要点2.1 项目初始化create-t3-app 的选项怎么选初始化 T3 项目现在很简单一条命令搞定pnpm create t3-applatest如果你更喜欢 npm也可以执行npm create t3-applatest交互内容是一样的。需要特别注意的是勾选项因为 create-t3-app 会把这几个技术栈一次性装好后面再补会比较麻烦。我一般这样选择Next.js 搭配 App Router新项目推荐直接用 App RouterPages Router 属于老项目过渡场景。TypeScript 必选选框默认选中。Tailwind CSS 建议选择T3 的默认样式方案就是它。trpc 必选如果你跳过它整个 T3 Stack 最核心的端到端类型安全就不存在了。Prisma 建议选择如果你更习惯 drizzle 可以手动换但 create-t3-app 内置的是 Prisma。NextAuth.js 按需选择需要登录鉴权再勾不必提前引入。生成之后目录结构是这样的├── prisma │ └── schema.prisma ├── public ├── src │ ├── app │ ├── pages │ ├── server │ │ ├── api │ │ │ └── root.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── styles │ ├── trpc │ │ ├── react.tsx │ │ ├── query.ts │ │ └── server.ts ├── .env ├── next.config.js ├── package.json └── tsconfig.json这里我踩过一个小坑脚手架生成的src/server和src/trpc这两个目录是所有类型安全的根基尽量别擅自改名。特别是trpc/query.ts这个文件它是服务端路由类型与客户端 Provider 连接的桥梁把它改动后编辑器里的智能提示会略显混乱。2.2 Prisma 数据建模的核心要点T3 Stack 默认使用 Prisma数据模型写在prisma/schema.prisma里。一个最简单的文章模型长这样model Post { id String id default(cuid()) title String content String published Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt }这里需要注意三个细节。第一主键用default(cuid())而不是autoincrement()。T3 项目通常部署到 Serverless 环境自增主键在分布式场景下需要额外考虑冲突和协调成本而 cuid 生成字符串主键几乎不需要额外配置生成之后也不需要等待主键回读。第二每次修改schema.prisma后一定要执行迁移命令pnpm prisma migrate dev这个命令会同时做两件事生成 SQL 迁移文件并且重新生成 Prisma Client 类型。如果你只改表结构而不重新生成 Client前端那边引用Post类型时会出现类型过期、字段缺失的诡异现象。第三记得给PrismaClient做单例封装。Next.js 在开发环境的热更新机制下如果你的代码直接new PrismaClient()每改一次代码就会创建一个新的数据库连接实例连接数一多数据库就会被拖垮。create-t3-app 生成的src/server/db.ts已经处理了这个问题你在自己写脚本时也要注意。2.3 tRPC 路由设计模式tRPC 是 T3 Stack 的灵魂。它让你在函数级别调用后端逻辑不需要写 URL不需要手动处理 JSON更不需要定义 REST 资源。一个最简单的 tRPC 子路由通常写作import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; import { z } from zod; export const postRouter createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) { return ctx.db.post.findMany(); }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string(), })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, }, }); }), });publicProcedure表示公开可调用不需要登录。有鉴权需求时用protectedProcedure它会自动注入当前会话用户信息。input部分使用 zod 做运行时校验服务端校验和类型推导一起完成前端调用时也会获得同样精确的类型提示。我的建议是把 router 文件按业务模块拆分比如postRouter、userRouter、commentRouter然后在root.ts里合并export const appRouter createTRPCRouter({ post: postRouter, user: userRouter, comment: commentRouter, });不要在单个 router 文件里塞几十个 procedure那会让调试体验极其痛苦。2.4 前端调用的两种姿势T3 项目里调用 tRPC 有两种常见方式。Client Component 的方式是通过api对象use client; import { api } from ~/trpc/react; export function PostList() { const { data, isLoading } api.post.list.useQuery(); if (isLoading) return div加载中.../div; return ( div {data?.map((post) ( div key{post.id}{post.title}/div ))} /div ); }Server Component 的方式则是直接调用服务端函数比如在 App Router 的page.tsx里import { api } from ~/trpc/server; export default async function HomePage() { const posts await api.post.list(); return div{posts.length} 篇文章/div; }这两种方式的核心区别在于api.post.list.useQuery会经过客户端查询缓存适合需要响应式更新的场景await api.post.list()则在服务端直接执行适合数据不需要交互变化的场景。t3code 的准则是能用 Server Component 取数就先在服务端取只有在需要加载态、缓存更新时才切换到客户端查询。3. 实操过程与核心环节实现3.1 从零做一个带数据库的全栈功能为了把 t3code 的完整流程走一遍我准备做一个“文章发布器”的简单需求支持文章列表展示、支持输入标题和内容创建文章。麻雀虽小但数据库、后端逻辑、前端交互全都覆盖到了。第一步定义 Prisma 模型。在schema.prisma中准备一个Post模型model Post { id String id default(cuid()) title String content String createdAt DateTime default(now()) updatedAt DateTime updatedAt }执行迁移pnpm prisma migrate dev --name init_post这一步会创建数据库表并同步生成PrismaClient类型。开发环境我一般用本地 SQLite 文件因为不需要单独启动数据库服务。部署到线上时会切换到 PostgreSQL只需要改环境变量并重新迁移即可。3.2 编写 tRPC API在src/server/api/routers/post.ts中写路由import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ list: publicProcedure.query(({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }) ) .mutation(({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, }, }); }), });list用了publicProcedure因为列表谁都能看。create用protectedProcedure因为只有登录用户能发文。如果项目没有启用 NextAuth那你只能先用publicProcedure顶替但真正上线前要换回来。3.3 服务端数据绑定App Router 模式下page.tsx默认是 Server Component可以直接调用 tRPC Server APIimport { api } from ~/trpc/server; import { CreatePost} from ./_components/create-post; import { PostList } from ./_components/post-list; export default async function Home() { const posts await api.post.list(); return ( main classNamemx-auto max-w-2xl p-4 h1 classNametext-2xl font-bold文章发布器/h1 CreatePost / PostList initialPosts{posts} / /main ); }这里我把PostList设计成客户端组件但把服务端取到的posts作为初始数据传进去这样页面首屏直接就有内容客户端 hydrate 后又能使用查询缓存刷新数据。是不是有点冗余对但这是新手最容易忽略的优化点不要一上来就在客户端组件里useQuery拉接口导致页面先空白再加载。PostList组件的实现use client; import { api } from ~/trpc/react; type Post { id: string; title: string; content: string; createdAt: Date; }; export function PostList({ initialPosts }: { initialPosts: Post[] }) { const { data initialPosts } api.post.list.useQuery(undefined, { initialData: initialPosts, }); return ( div classNamemt-6 space-y-4 {data.map((post) ( article key{post.id} classNamerounded border p-4 h2 classNametext-lg font-semibold{post.title}/h2 p{post.content}/p /article ))} /div ); }注意这段代码里的类型Post是我手写的其实不太符合 t3code 的风格。更优雅的做法是直接从 Prisma 导入比如import type { Post } from prisma/client真正实现类型单一事实来源。3.4 表单提交与缓存刷新创建表单同样是客户端组件use client; import { useRouter } from next/navigation; import { api } from ~/trpc/react; import { useState } from react; export function CreatePost() { const router useRouter(); const [title, setTitle] useState(); const [content, setContent] useState(); const createPost api.post.create.useMutation({ onSuccess: () { router.refresh(); setTitle(); setContent(); }, }); return ( form onSubmit{(e) { e.preventDefault(); createPost.mutate({ title, content }); }} input value{title} onChange{(e) setTitle(e.target.value)} placeholder标题 classNamew-full border p-2 / textarea value{content} onChange{(e) setContent(e.target.value)} placeholder正文 classNamemt-2 w-full border p-2 / button classNamemt-2 bg-blue-600 p-2 text-white {createPost.isLoading ? 发布中... : 发布} /button {createPost.isError ( p classNametext-red-500发布失败请检查输入/p )} /form ); }这里的关键点是router.refresh()。因为列表数据的获取在 Server Component 里单纯靠客户端useMutation的onSuccess不会自动更新服务端渲染的列表。调用router.refresh()会重新从服务端拉取当前路由的数据这样一来新发布的文章才能立刻出现在列表里。3.5 完整功能验证步骤写完代码后我建议按下列顺序做一次完整验证启动开发服务器pnpm dev打开首页确认能看到空状态列表提交一篇文章观察表单重置、列表新增文章手动改一下数据库里某条数据的title刷新页面确保列表与数据库一致这个流程虽然简单但它能验证数据库、tRPC 后端、Server Component 取数、Client Mutation 更新四条链路都是通的。我见过太多项目在初期跳过端到端验证结果部署时才暴露问题。4. 常见问题与排查技巧实录4.1 常见错误速查表现象可能原因排查方向TypeScript 报错Object literal may only specify known properties传的参数和 zod input 类型不匹配检查 zod schema 字段名和前端调用字段名列表一直显示旧数据Router 缓存未失效在 mutation 的onSuccess里补router.refresh()请求返回 401tRPC procedure 用了protectedProcedure但未登录先检查 NextAuth 会话状态部署后 Prisma 找不到数据表线上数据库未执行迁移执行prisma migrate deployTailwind 样式不生效内容路径不在 Tailwind content 配置里检查tailwind.config.ts的content是否覆盖到该目录开发时每次刷新都新建数据库连接PrismaClient 未做单例看看src/server/db.ts是否用全局变量缓存4.2 Router 刷新失效的深坑我在 t3code 实践中遇到最多的坑就是使用 tRPC mutation 后调用router.refresh()但页面数据看起来没变。排查了很久发现根因是 Server Component 里的await api.post.list()和客户端组件里的useQuery同时存在。客户端useQuery的缓存优先级高于服务端刷新结果所以即使 server 刷新重新渲染了页面客户端拿到的新数据又被旧缓存顶掉了。解决方案有两种第一种列表只用服务端渲染客户端只做表单提交router.refresh()就能稳定生效。第二种列表必须用客户端useQuery和缓存表单提交后调用api.post.list.invalidate()主动让缓存失效。不要混用两种策略这是我踩过几次坑后的深刻教训。一个页面只选择一种数据获取模式理解起来也轻松。4.3 调试 tRPC 时的小技巧tRPC 是类型安全体系的核心但也意味着一旦类型对不上错误信息会比较难定位。我的调试顺序是先看浏览器 Network 面板。tRPC 的请求是一个 POST路径类似/api/trpc/post.create返回 JSON 里会带错误信息。再看服务端控制台。如果 zod 校验失败服务端会输出更详细的堆栈信息。最后看类型。如果 TS 报错说某个 property 不存在大概率是 Prisma Schema 改了但没重新生成 Client。还有一个小技巧在 tRPC 的 error 回调里临时输出整个错误对象而不是直接输出error.message因为 tRPC 的TRPCClientError有时会带shape字段直接看 message 可能会遗漏关键信息。4.4 数据库迁移的三个阶段开发环境我用prisma migrate dev它会重置本地数据库。这个命令在开发期很舒服但线上不能这么用线上要用prisma migrate deploy。我见过有人把migrate dev用于生产数据库结果直接把线上数据清空或者表结构重做这个教训很深刻。区分一下prisma migrate dev开发环境专用会执行迁移、重新生成 Client、可能 reset 数据库。prisma migrate deploy一般用于 CI/CD 或线上部署只应用迁移文件不会重置数据。T3 项目部署到 Vercel 时我会在构建命令里加上prisma generate prisma migrate deploy。不过也要注意Postgres 数据库需要确保迁移服务能访问到线上库像 Vercel 这种 Serverless 平台跑migrate deploy有时会有网络限制建议在本地执行迁移后再推代码。4.5 依赖版本陷阱T3 Stack 的生态更新速度很快尤其要注意 Next.js 和 tRPC 的版本兼容。create-t3-app 脚手架生成的版本通常是最新组合但如果你手动升级某个包比如把 Next.js 从 14 升到 15tRPC 可能需要跟着调整 import 路径。我的建议是生产项目尽量锁定版本不要隔三岔五无脑升级。如果你想体验新功能单独开分支试别在主线项目上升级依赖省得类型系统突然炸一片。5. 代码组织与项目扩展建议5.1 server 与 client 的代码边界t3code 里最重要的一条组织原则是能放服务端的代码不要放客户端。具体来说数据库操作只出现在src/server或 router 的 procedure 内部与数据库直接相关的类型不要在前端冗余定义调用 tRPC 获取数据时优先考虑 Server Component 直接取数只有需要交互、状态、动画、实时更新时才移动客户端这样设计之后整个项目的逻辑主线会变得非常清晰data flow 从数据库到服务端路由再到页面组件每个环节都经过类型校验不会再出现“前端 mock 数据跑得通联调后端就崩”的情况。5.2 如何扩展新业务模块模块一多路由文件会变多这时候项目容易乱。建议按业务域建目录比如src/server/api/routers/ ├── auth.ts ├── post.ts ├── user.ts └── comment.ts在root.ts里统一聚合。每个 router 内部可以重复使用publicProcedure或protectedProcedure如果需要鉴权变体可以单独封装一个带特定中间件的 procedure而不是在每个业务路由里重复写逻辑。5.3 部署前要检查的清单部署 T3 项目前我会例行检查下面几项.env文件的数据库地址、认证密钥是否正确Prisma schema 是否已经同步到线上数据库next build能不能通过类型检查是否启用了 NextAuth 的 NEXTAUTH_URL有没有把DATABASE_URL误提交到 Git 仓库这里有个很容易踩的坑prisma generate生成的 Client 不一定会提交到仓库线上构建时如果没执行 generate就会报找不到 Prisma Client。建议在构建命令里加上prisma generate或者把生成命令放到postinstall脚本。5.4 与其他全栈方案的横向对比用 t3code 的方法论和别的方案对比Next.js REST API需要手动维护类型和接口文档适合多端共用 API 的项目。T3 更适合同构全栈、快速迭代的单体项目。Next.js GraphQLGraphQL 灵活但迭代成本高需要 resolver、schema、generator 一堆配置。tRPC 在这些方面更轻量因为它不需要额外的 schema 语言直接用 TypeScript 类型。Next.js Server ActionsServer Action 很新处理表单提交很方便但复杂的查询、权限校验、复用还偏年轻。tRPC 生态更成熟而且和 React Query 搭配缓存和状态管理更顺手。这并不意味着 T3 一定最好只是它更适合整天和 TypeScript 打交道、不想在接口沟通上消耗太多精力的开发者。如果你团队里有纯前端、纯后端分工明确的人REST 可能更合适。个人体会是T3 Stack 最舒服的地方不在于某个单一库而在于它把整个链路拧成一股绳。t3code 的核心也不是复制粘贴代码而是理解那种“每个数据变动都带着类型”的开发节奏。最后再分享一个小技巧当你新建一个业务模块时按“先改 Schema、再写 router、最后接页面”的顺序推进你会发现类型系统会帮你找出绝大多数遗漏的字段这种被编译器提醒的感觉比任何测试都踏实。