
先交代一下背景这个叫 t3code 的项目是我最近为了验证 T3 Stack 全家桶体验而搭的一个全栈 TypeScript 应用。T3 Stack 本身不是新框架而是一套组件组合核心是 Next.js、tRPC、Prisma、Tailwind CSS再加一层可选认证方案。t3code 这个名字就是 T3 的缩写我的目的很直白看这套组合能不能真正解决前后端联调、类型同步、接口文档维护这些老问题。如果你是个独立开发者、前端转全栈的人或者只是对端到端类型安全有执念这篇文章的实操步骤和踩坑记录应该能帮你少走不少弯路。文章里的代码全部基于 create-t3-app 初始化后再修改我会把每一步的取舍都讲清楚方便你直接照抄到自己的项目里。1. 为什么是 T3 Stackt3code 背后的选型逻辑1.1 端到端类型安全解决的是谁的痛点在传统前后端分离开发里最磨人的不是写接口而是“对字段”。后端返回一个{ code: 0, data: { user: {...} } }前端要么自己定义一套 interface要么靠后端文档一旦后端改了字段名或嵌套结构前端还浑然不知直到接口 500 才反应过来。这种体验用一句话概括契约靠嘴类型靠抄。T3 Stack 里的 tRPC 就是冲着这个痛点来的。它不生成 REST 路由而是让你在后端定义一个 router前端把它当成一个本地函数仓库来调用。由于前后端共享同一份 TypeScript 代码tRPC 的类型能从前端调用处一路自动推断到后端的数据库查询结果不需要任何多余的 interface 声明。我拿生活里的例子打比方REST 就像两个人约定暗号每换一次暗号就得沟通一次tRPC 更像两个人共用一本字典查什么词结果都一样字典改了谁都看得到。当然端到端类型安全也有代价比如服务器和客户端代码必须在一个仓库里这对跨团队、跨仓库的微前端场景不太友好。但对于单仓库应用、个人作品、中小型 SaaS 来说这是一个收益远大于成本的方案。1.2 四个核心库的分工与配合先说 Next.js。它解决的是“应用怎么组织”的问题。现在新版默认走 App Router一个app/目录既能写服务端组件也能写客户端组件页面、布局、路由处理都放在一起。在 t3code 里我主要用它作为渲染层负责把 tRPC 查询回来的数据渲染成界面同时处理页面级的加载状态。tRPC 是这套状态的“胶水”。它替代的是你手写的 fetch 封装和 RPC 接口。传统接口调用的流程是“先写后端路由 - 再写前端请求函数 - 再定义返回类型”而 tRPC 把这三步缩成一步你在 router 里写了getAll前端就能直接api.task.getAll.useQuery()类型全是自动的。Prisma 是数据层。它最大的优势是 schema 即权威模型。你用一层文件描述数据库表、字段、约束然后 Prisma 生成对应的 TypeScript 类型和查询客户端。在 t3code 里Prisma 就是类型链条的最上游数据库字段一变后面所有引用它的地方都会在编译期报错。Tailwind 反而是最没存在感的一个但也是开发效率的关键。它负责样式不参与数据流。我实际用下来Tailwind 的最大价值不是省 CSS 代码而是让你在写组件的时候不用来回切文件所有布局调整都在 class 里完成这对草图阶段特别重要。这四个库并不是强绑定的关系你可以只把 Prisma 配 Express 用也可以只用 tRPC 不搭 Next.js。但放在一起时它们的类型流是贯通的Prisma 模型 - tRPC 路由 - 前端 useQuery 返回值一整条链路上几乎不需要手写类型。1.3 相比 REST 和 GraphQL我为什么选它REST 是老牌方案优点成熟、解耦清晰但落到每一个具体项目里你都要为“沟通成本”买单。GraphQL 类型更强但要额外维护 schema 和 resolver项目小了往往觉得太重。tRPC 正好卡在两者之间它不需要显式的 API schema因为 TypeScript 编译器本身就是 schema它也不需要 resolver因为一个普通的后端函数就是 resolver。我这个项目里有个很典型的场景前端需要“创建任务”并立刻刷新列表。如果走 REST我得写一个POST /api/task前端还要写 request 函数、定义请求体和响应体、处理错误码。走 tRPC只需要在 router 里写一个createmutation前端调用useMutation然后在onSuccess里调用invalidate()刷新缓存就完成了。这段代码里没有一处手写 HTTP 路径也没有任何 JSON 序列化配置。当然如果你的项目有第三方开放 API 需求那还是得上 REST 或 GraphQLtRPC 不适合做公开 API。t3code 的场景是内部应用、管理后台、全栈 Web 应用这一类场景正是 tRPC 的优势区间。2. t3code 项目结构与核心设计解析2.1 目录结构App Router 下的 tRPC 入口怎么摆我用 create-t3-app 初始化后的项目核心目录大概是这样的t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ │ └── route.ts │ │ ├── _trpc/ │ │ │ ├── client.ts │ │ │ └── server.ts │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── tasks/ │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── task.ts │ │ │ │ └── user.ts │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ └── db.ts │ ├── styles/ │ │ └── globals.css │ └── utils/ └── .env几个容易搞混的点一个是src/app/api/trpc/[trpc]/route.ts的作用。它是 tRPC 在 App Router 下的 HTTP 入口Next.js 会把所有命中/api/trpc/*的请求转发到这里再由 tRPC 内部根据路径分发到对应 router。另一个是src/app/_trpc/client.ts和server.ts这两个文件负责创建前端和后端两侧的 tRPC 实例。前端实例用来发起查询服务端实例用来在 Server Components 里直接调用 tRPC。注意别把这两个目录混在一起否则很容易出现“fetch failed”之类的错误。在src/server/api/trpc.ts里我做了两件事初始化 tRPC 上下文并暴露router、publicProcedure、protectedProcedure这几个基础对象。上下文里挂载了db和session两个东西后续所有 router 都能拿到。2.2 数据模型设计实战任务管理的小系统t3code 里我实现了一个简单的任务管理功能数据结构不算复杂但足够覆盖常见场景。prisma/schema.prisma长这样generator client { provider prisma-client-js } datasource db { provider sqlite url env(DATABASE_URL) } model User { id String id default(cuid()) email String unique name String? tasks Task[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Task { id String id default(cuid()) title String description String? status Status default(TODO) authorId String author User relation(fields: [authorId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt index([authorId, status]) } enum Status { TODO IN_PROGRESS DONE }几个设计决策值得展开说。首先id字段我没有用自增数字而是用cuid()。自增 ID 在分布式部署、数据合并、前端预生成 ID 这些场景下都不够方便CUID 是字符串且按时间有序既不冲突又能索引友好。其次是Task.authorId和author的关系Prisma 会自动生成外键前端查询时可以用include: { author: true }一次性把关联的用户信息取出来省一次联表查询的手工拼装。status字段我用了 Prisma 枚举。这里有个容易被忽略的好处枚举定义在数据库层生成的 TypeScript 类型会在 tRPC 的输入输出里反复使用前端写status: DONE时如果拼错了编译期直接报错不用等到运行时 500。index([authorId, status])是多列索引在“按用户查任务并按状态过滤”这种高频查询组合下很有用。开发阶段数据量小看不出差别但二进制日志和慢查询不会骗人该加的索引还是得加。2.3 tRPC Router 怎么拆才不糊成一团tRPC 的 router 有点像后端路由的控制器但比控制器更灵活。我按照业务领域把路由拆成task和user两个子模块然后在root.ts里合并成一个根 router// src/server/api/root.ts import { createTRPCRouter } from ~/server/api/trpc; import { taskRouter } from ~/server/api/routers/task; import { userRouter } from ~/server/api/routers/user; export const appRouter createTRPCRouter({ task: taskRouter, user: userRouter, }); export type AppRouter typeof appRouter;子 router 里我把涉及登录用户的操作全部换成protectedProcedure它底层是一个中间件先检查ctx.session.user不存在就抛UNAUTHORIZED错误。// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import { type Session } from next-auth; import SuperJSON from superjson; import { db } from ~/server/db; interface CreateContextOptions { session: Session | null; } export const createTRPCContext async (opts) { return { db, session: opts.session, }; }; const t initTRPC.contexttypeof createTRPCContext().create({ transformer: SuperJSON, }); export const createTRPCRouter t.router; export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这里我想强调一点中间件里的next({ ctx })不是必须返回一个新 ctx但如果你在中间件里做了额外的查询或权限校验最好把结果挂到 ctx 上后面的 resolver 就能直接用。比如我以后想加一个“当前用户是否是管理员”的判断可以在中间件里查完用户角色再传到 resolver比每个 mutation 里都重复判断要清爽得多。3. 从零初始化 t3code 的完整流程3.1 用 create-t3-app 初始化项目如果你想复现这个项目第一步很简单。我建议先准备一个空目录然后执行npm create t3-applatest t3code这个交互式命令会让你选择想集成的库。实际操作下来我的选择是Next.jsApp Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth.js。构建工具选 npm 或 pnpm 都行我测试时用的 pnpm包体积明显小一些链路速度也更快。初始化结束后项目自带一堆 Demo 文件和示例数据库配置。我的习惯是先跑一遍默认首页确保基础能跑通cd t3code npm run dev如果页面能正常显示说明 Next.js、Tailwind、tRPC 的初始接线没问题再开始清掉 Demo 代码。一个小提示create-t3-app 会生成.env.example文件里面只给了占位的DATABASE_URL你要在.env里填入真实值否则 Prisma 会直接拒绝启动。我初期有过一次疏忽把 URL 填成postgresql://...但本地没有启动 PostgreSQL导致开发环境跑不起来。后来干脆换成 SQLite日常工作完全没问题等要上线再切回 PostgreSQL改一行 provider 加一个数据库链接就行。3.2 数据库接入与 schema 落库开发阶段我用 SQLite连接串是DATABASE_URLfile:./dev.db只要你把.env配置好然后执行npx prisma db pushPrisma 会根据schema.prisma建好本地数据库文件。这个命令和prisma migrate dev的区别在于db push直接把当前 schema 同步到数据库不生成迁移记录适合模型还没定型、频繁改字段的阶段一旦上线后想保留变更历史就改用migrate dev。我建完模型后习惯顺手执行npx prisma generate确保 Prisma Client 更新。create-t3-app 会在依赖安装时自动生成一次但如果你新增了字段必须手动再跑一遍否则代码里查询新字段会直接类型报错。3.3 实现一个完整链路创建一个任务现在把数据库、tRPC、前端串起来。我先写后端 router// src/server/api/routers/task.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; const createTaskSchema z.object({ title: z.string().min(1).max(100), description: z.string().max(500).optional(), status: z.enum([TODO, IN_PROGRESS, DONE]).default(TODO), }); export const taskRouter createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) { return ctx.db.task.findMany({ include: { author: true }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(createTaskSchema) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), });这里有个关键点input用 Zod 做校验。tRPC 官方推荐 Zod schema因为它在运行时校验和 TypeScript 类型推断之间做得最好。z.enum([...])生成的值也恰好匹配 Prisma 里的Status枚举前端传进来的状态值在后端不会出现字符串与枚举不一致的问题。前端调用时更简单() api.task.create.useMutation这个 hook 自带类型推导// src/app/_trpc/provider.tsx use client; import { QueryClient, QueryClientProvider } from tanstack/react-query; import { httpBatchLink } from trpc/client; import { createTRPCReact } from trpc/react-query; import { type AppRouter } from ~/server/api/root; import SuperJSON from superjson; export const api createTRPCReactAppRouter(); export function TRPCProvider({ children }: { children: React.ReactNode }) { const queryClient new QueryClient(); const trpcClient api.createClient({ transformer: SuperJSON, links: [ httpBatchLink({ url: ${getBaseUrl()}/api/trpc, }), ], }); return ( QueryClientProvider client{queryClient} api.Provider client{trpcClient} queryClient{queryClient} {children} /api.Provider /QueryClientProvider ); }在组件里调用创建任务的 mutationconst createTask api.task.create.useMutation({ onSuccess: () { utils.task.getAll.invalidate(); }, }); createTask.mutate({ title: 写一篇 t3code 的实战记录, description: 把选型、结构、踩坑点都写清楚, status: TODO, });我特别喜欢invalidate()这个设计它不强制刷新整个页面只让 tRPC 标记相关 query 为过期然后把数据重新拉回来。整个过程不需要手写 reload也不会造成页面闪烁。你注意下useMutation返回的 mutate、isPending、error 这些状态都是跟类型绑定的写入的参数一错编辑器立刻亮红。3.4 前端渲染与 Tailwind 布局任务列表页面我用了客户端组件加载数据这样交互状态更自然。核心代码长这样use client; import { api } from ~/app/_trpc/client; export function TaskList() { const { data: tasks, isLoading } api.task.getAll.useQuery(); if (isLoading) return div classNametext-sm text-gray-500加载中.../div; return ( div classNamegrid gap-4 sm:grid-cols-2 lg:grid-cols-3 {tasks?.map((task) ( div key{task.id} classNamerounded-xl border border-gray-200 p-4 shadow-sm div classNameflex items-center justify-between h3 classNamefont-medium{task.title}/h3 span classNamerounded-full bg-gray-100 px-2 py-0.5 text-xs {task.status} /span /div p classNamemt-2 text-sm text-gray-600{task.description}/p div classNamemt-3 text-xs text-gray-400 创建者{task.author.name ?? 匿名} /div /div ))} /div ); }这里有个容易混淆的点api.task.getAll.useQuery()默认是客户端查询也就是页面先加载壳数据通过 HTTP 请求从/api/trpc获取。如果你想要服务端渲染比如首屏就要完整 HTML可以调用服务端的 caller。create-t3-app 会提供src/app/_trpc/server.ts里头暴露了一个serverClient对象你可以在 Server Component 里直接这样同步取值import { serverClient } from ~/app/_trpc/server; const tasks await serverClient.task.getAll();但要注意服务端调用不会走 HTTP而是直接执行 router 里的逻辑所以你不能在 Server Component 里使用useQuery也不能混用客户端 hooks。什么时候用客户端查询什么时候用服务端调用我的判断标准是这个数据是否需要实时刷新、是否依赖用户交互后的状态。实时性要求高就走客户端首屏 SEO 或者纯静态展示就走服务端。Tailwind 布局方面我用的都是最常规的类网格布局grid gap-4、卡片rounded-xl border p-4、小标签rounded-full bg-gray-100 px-2 py-0.5。没有写任何自定义 CSS所有视觉调整都在 JSX 里完成这也是 Tailwind 的爽点改布局不需要来回看样式表。4. 实操中踩过的坑与排查清单4.1 常见错误速查表这些是我在 t3code 开发过程中实际遇到过的错误直接整理成表格建议大家收藏。错误信息出现原因解决办法TRPCClientError: fetch failed前端 tRPC 的 url 没配到正确端口或本地服务没启动Server Component 里混用了客户端 hooks检查getBaseUrl()函数的地址拼接确认TRPCProvider挂在客户端组件中You must use one of serverClient or api.reactServerCallApp Router 下没有正确区分服务端和客户端 tRPC 实例在 Server Component 中用serverClient在客户端用apihooksPrismaP2002唯一约束冲突插入重复数据比如重复的 email 或任务序号先查询再插入或捕获 Prisma 错误码给用户一个友好提示Type string is not assignable to type StatusZod 校验返回 string但 Prisma 期望枚举对象用z.enum([TODO, IN_PROGRESS, DONE])代替z.string()Vercel deployment failed: module not found本地能跑部署时某些依赖未安装成功检查packageManager字段把源码上传完整后重新执行 install还有一个经常被忽略的修改schema.prisma后忘了prisma generate导致 Prisma Client 还在用旧类型。这个问题在本地不怎么显现因为db push会连带触发 generate但如果你切分支后只跑了db push客户端实例可能仍然是旧的最好看到 schema 变更就主动执行一次npx prisma generate。4.2 环境变量与多环境配置的坑T3 Stack 对环境变量的要求不算复杂但踩起来很常规第一是.env和.env.example要同步维护尤其是 DATABASE_URL 这种带敏感信息的变量不能提交到仓库第二是前端要用的变量必须以NEXT_PUBLIC_前缀开头比如NEXT_PUBLIC_APP_URL否则 Next.js 在构建时不会把变量打进客户端 bundle。我用过一段时间的DATABASE_URL在生产环境指向 PostgreSQL本地指向 SQLite因此要把两套连接串分别写到.env.local和.env.production里。注意 Next.js 的加载顺序.env.local优先于.env但不能同时加载到NEXT_PUBLIC_变量以外的场景。我建议少做花活把不同环境的配置写在对应的环境文件里并在.env.example里留好注释。还有一个安全提醒不要把数据库密码、NextAuth 的AUTH_SECRET直接写在代码里也不要把.env文件提交到 Git。我见过有人为了展示 demo 项目顺手把真实数据库连接串推上仓库后来被爬虫扫描到数据全被拉走了。密钥这种东西能走云平台的 Secret Manager 就走托管本地开发用.env就足够了。4.3 三个提升开发体验的小技巧第一个是 SuperJSON。tRPC 默认不处理Date对象而 Prisma 查出来的createdAt是真实 Date。没有配置 SuperJSON 时序列化会出现奇怪的时间串解析问题。create-t3-app 默认就集成了 SuperJSON所以这一点很多人没注意到。如果你想自己搭 tRPC记得创建 tRPC 实例时传入transformer: SuperJSON并在 HTTP Link 里也带上同一个 transformer。第二个是 Zod schema 复用。我在 t3code 里把很多校验 schema 放在src/shared/目录下前端用来给表单预校验后端用来给 input 做运行时校验。这样同一份校验规则不会在前后端写两遍。之前我在一个老项目里因为前后端校验不一致出现了“前端提示标题不能为空后端却通过了”的尴尬之后就再也不想写两份了。第三个是在 tRPC 中间件里加日志。我在protectedProcedure之前加了一个简单的logger中间件每次请求打一条[tRPC] ${path} 开始执行的日志。这在对线上问题排查时特别有用尤其是多个 mutation 并发导致数据不一致的时候你能看到调用顺序。export const loggerProcedure t.procedure.use(async ({ path, type, next }) { const start Date.now(); const result await next(); const durationMs Date.now() - start; console.log([tRPC] ${type} ${path} 耗时 ${durationMs}ms); return result; });别小看这几行代码在开发环境它就是调试神器能直接看到是哪个路由慢、哪次请求报错。上线前再决定是输出到控制台还是上报到日志平台。4.4 调试技巧服务端与客户端的模糊边界T3 Stack 这类全栈 TypeScript 方案最大的优点是类型共享但最大的认知陷阱也是分不清“这段代码跑在哪”。我一开始写 t3code 时在 Server Component 里直接 import 了客户端api实例然后调用useQuery结果浏览器直接报错说 hooks 只能在 client components 使用。后来又反过来把serverClient的调用放进客户端组件导致整个页面变成冷启动完全失去服务端渲染的意义。我的经验是遵循一个简单原则组件开头标注use client的一律只用apihooks没有标注的默认当成 Server Component取数时用serverClient。如果某个页面既要 SEO 又要交互就在页面层用 Server Component 取静态数据在子组件层用 Client Component 接受 props 并处理事件。这样边界清晰心智负担也少。5. 关于项目命名和后续扩展的一点个人体会说实话给项目起名这件事我纠结了挺久。t3code 这个名字既准确又简短但一开始总有朋友问“是跟某某平台有关系吗”。后来想通了项目名字只要自己团队能识别就行不用过度解释。真正重要的是这堆代码能不能稳定地跑下去能不能在踩坑之后快速定位问题。经过 t3code 这一次完整开发我最深的体会是T3 Stack 并不会让每个问题消失但它把“前后端类型不一致”这类问题提前到了编译期这比在线上才被用户发现舒服多了。后续我打算把 t3code 再往生产环境推一推主要是两点一是把 SQLite 换成 PostgreSQL 并补上正式的迁移记录二是增加基于角色的权限控制。权限这块我准备在 tRPC 中间件里写一个requireRole(ADMIN)的扩展这样每个 router 只需要声明自己的访问等级不用反复写鉴权逻辑。如果你也正在调研或使用这套技术栈我强烈建议从一个小工具型产品开始先把类型链路的通畅感体会到位再决定要不要扩展到更复杂的业务场景。