
最近我把一个内部小项目用t3code这套方案重写了一遍。说是重写其实是把之前前后端分离、接口各写各的、类型全靠猜的旧代码彻底推倒重新用一套全栈框架搭起来。做完之后第一感受就是早该这么干了。如果你也是一个人既要写前端又要写后端或者团队里前后端协作经常因为接口字段对不上吵来吵去那t3code这套基于 T3 Stack 的组合值得你花十分钟看完这篇文章。t3code不是一个开源仓库的名字而是我基于 T3 技术栈TypeScript、Tailwind CSS、tRPC、Next.js、Prisma、NextAuth固化出来的一个全栈项目脚手架和开发规范。它解决的问题很直接让前端和后端共享同一套类型定义从数据库到 API 再到页面组件类型全程打通写接口不需要手写文档改字段不会漏掉调用方。适合中小型全栈项目、个人独立开发、快速原型验证以及想要从传统 REST 开发方式转向端到端类型安全的团队。整篇文章我会从技术选型逻辑讲到核心实现细节再给出可以照着操作的完整搭建步骤最后把我在实际开发中踩过的坑和排查思路列成清单。内容偏实战建议边看边动手。1. 内容整体设计与思路拆解1.1 为什么非要选 T3 这套组合先说选型逻辑。t3code的核心是 T3 Stack这套组合不是随便拼起来的它的每一个成员都解决一个具体问题合在一起刚好覆盖全栈开发中最容易出错的三个环节类型不一致、数据校验缺失、认证逻辑重复写。Next.js 提供 React 服务和 API 路由并且天然支持前后端一体化部署。tRPC 替代 REST 接口让前端直接调用后端函数连 URL 和 HTTP 方法都不用自己设计。Prisma 负责数据库操作schema 文件既是建表脚本也是 TypeScript 类型来源。Tailwind CSS 处理样式省去写 CSS 文件的麻烦。NextAuth 把 OAuth 和邮箱密码登录做成标准流程。这套组合最舒服的地方在于只要你按照规范写类型就不可能不一致因为类型本身就是从数据库模型反推出来的。如果你的项目只是简单展示页没有复杂业务逻辑那用 T3 有点重。但只要是涉及用户系统、数据写入、管理后台的业务型项目用t3code的收益非常明显。1.2 t3code 的目录结构与数据流t3code的项目结构沿用了 T3 App 的标准布局但我在实际使用中调整了几个关键目录的用途。src/server下放所有后端逻辑src/trpc放 tRPC 路由定义src/components放 UI 组件prisma/schema.prisma管数据模型。数据流动方向是数据库表结构由 Prisma Schema 定义生成 TypeScript 类型后被 tRPC Router 引用再通过createTRPCReact暴露给前端组件调用。前端调用api.post.create这类函数时输入输出参数都是强类型根本不存在两个端各维护一套接口定义的问题。这里要特别强调一下t3code规范中有一条硬性要求数据库模型改字段之后必须优先执行prisma generate再开发前端。很多人因为顺序反了导致前端类型提示一直不更新白白浪费时间排查。2. 核心细节解析与实操要点2.1 tRPC 的类型安全机制到底强在哪tRPC 是t3code中最核心的一个技术点它的价值容易被低估。tRPC 做的事情简单说就是让你在后端定义的函数在前端看起来就像是本地函数一样直接调用自动获得完整的输入输出类型推断不需要生成 SDK不需要手写请求路径也不需要维护接口文档。代码层面核心就三块。后端定义一个 Router// src/server/api/routers/post.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ getAll: protectedProcedure.query(({ ctx }) { return ctx.prisma.post.findMany({ where: { authorId: ctx.session.user.id }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(10) })) .mutation(async ({ ctx, input }) { return ctx.prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });这里的重点在于zod做输入校验不仅仅是运行时校验zod 的 schema 会通过TRPCClientError反馈到前端并在类型层面约束入参。前端调用只需要两行代码const { data } api.post.getAll.useQuery(); const createPost api.post.create.useMutation();useQuery和useMutation是 tRPC React 集成的核心 hooks它们利用 TypeScript 泛型把后端函数的类型直接拉到组件中。项目里常见的错误是后端 Router 用了createTRPCRouter但没把 Router 挂载到 appRouter 根上导致前端api对象上找不到对应方法。检查顺序先在src/server/api/root.ts确认根 Router 包含了所有子 Router再刷新页面看类型提示。2.2 Prisma 模型设计直接影响开发效率t3code的数据库层用 Prisma它是 ORM 工具里对 TypeScript 支持最好的一档。设计数据库模型时有一些经验值得分享。一个比较典型的模型定义// prisma/schema.prisma model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] posts Post[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String author User relation(fields: [authorId], references: [id]) authorId String createdAt DateTime default(now()) updatedAt DateTime updatedAt }设计模型时要特别注意三个细节。第一主键尽量用cuid()而不是自增整数cuid 没有遍历风险对 Distributed 环境也更友好。第二updatedAt记得加updatedAtPrisma 会自动维护这个时间戳。第三所有有关联关系的字段比如authorId必须做显示定义不要靠 Prisma 自动猜测。实际开发中我遇到过这样的问题改完 schema 后执行npx prisma db push结果发现数据库里现有数据不兼容导致迁移失败。对于开发阶段我建议直接用prisma migrate dev创建迁移历史不要依赖后续的db push行为。更好用的操作是配合prisma studio可视化查看数据快速核对关联关系是否正确。2.3 NextAuth 认证流程的完整闭环t3code中使用的认证方案是 NextAuth.js默认配置适配 GitHub、Google 等 OAuth也可以配置邮箱验证码登录。实际项目里大多数场景都需要自定义 session 回调把用户 ID 塞进 session方便后端取用。一个典型的认证配置// src/server/auth.ts import { getServerSession, type NextAuthOptions } from next-auth; import GitHubProvider from next-auth/providers/github; import { PrismaAdapter } from next-auth/prisma-adapter; import { prisma } from ~/server/db; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(prisma), providers: [ GitHubProvider({ clientId: process.env.GITHUB_ID ?? , clientSecret: process.env.GITHUB_SECRET ?? , }), ], callbacks: { session({ session, user }) { if (session.user) { session.user.id user.id; } return session; }, }, };配置分两个环境API Route 和 Server Component。API Route 里通过getServerSession(req, res, authOptions)解析 sessionServer Component 里用getServerSession(authOptions)。两种方式不能混用否则容易出现 session 拿不到的问题。前端调用受保护接口时要搭配protectedProcedure这个中间件逻辑很简单如果 ctx.session 为空抛 UNAUTHORIZED 错误如果不为空就把 session 注入到 ctx 中。这样才能保证/api之后所有链路都能拿到用户信息。认证态失效的表现一般是useSession返回 null或者接口报 401。排查时最先要看.env里 NEXTAUTH_SECRET 是否正确配置生产环境如果是多实例部署还要确认 secret 在所有实例间一致。2.4 Tailwind CSS 的实际使用经验样式层面t3code使用 Tailwind CSS这个选择主要是为了开发效率。特别是apply可将重复使用的样式抽象成自定义类配合 class 条件渲染很方便。少量样式代码示例export function Button({ variant primary, ...props }: ButtonProps) { const base rounded-md px-4 py-2 font-medium transition; const styles { primary: bg-blue-600 text-white hover:bg-blue-700, ghost: bg-transparent text-gray-600 hover:bg-gray-100, }; return button className{${base} ${styles[variant]}} {...props} /; }有个细节新手经常犯直接在 Tailwind 里使用动态拼接的 class 名称比如bg-${color}-500。Tailwind 的 JIT 编译器是通过扫描源码里的完整类名字符串来生成 CSS 的动态拼接出来的类名不会出现在源码中就不会被编译进去。正确做法是像上面那样维护一个对象把所有类名按键保存运行时直接映射。如果你看到某个颜色、宽度、间距样式没有生效第一反应就是去检查这个类名是不是被动态拼接过。这类问题出现频率很高且不好定位因为不报错、不警告。3. 实操过程与核心环节实现3.1 创建基础项目第一步是从脚手架创建项目。T3 官方维护了create-t3-app可以直接拉一个带完整配置的工程npx create-t3-applatest t3code-demo命令行会问你几个配置项TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。在t3code规范中这几项全部选择 Yes。如果你不需要认证可以临时不选 NextAuth但我建议即便初期用不到也把 NextAuth 选上因为后期加功能的成本会变高而创建时选上只是多点几行代码。创建完成后的目录结构大致如下t3code-demo/ ├── prisma/ │ └── schema.prisma ├── public/ ├── src/ │ ├── components/ │ ├── pages/ │ │ └── api/ │ │ └── trpc/ │ │ └── [trpc].ts │ ├── server/ │ │ ├── api/ │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ ├── trpc/ │ ├── env.mjs │ └── ... ├── .env └── package.json项目创建完成之后第一步操作不是看代码而是打开.env文件把DATABASE_URL、NEXTAUTH_SECRET、GITHUB_ID、GITHUB_SECRET全部配置好然后执行npx prisma db push把初始 schema 同步到数据库。数据库我用的 PostgreSQL你也可以改用 MySQL 或 SQLite开发调试阶段 SQLite 启动最快但上线前一定要切回 PostgreSQL 或 MySQL避免与生产环境不一致带来的麻烦。3.2 定义数据模型并生成客户端进入实际编码环节。先打开prisma/schema.prisma看看默认的 User 和 Account 模型是否齐全。默认脚手架已经带有完整的 NextAuth 适配模型包含 User、Account、Session、VerificationToken。在此基础上增加业务模型我以最经典的 Todo 应用来演示。在 schema 文件末尾添加model Todo { id String id default(cuid()) title String done Boolean default(false) userId String user User relation(fields: [userId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }然后在 User 模型中加入todos Todo[]保存后执行npx prisma migrate dev --name add_todomigrate dev会自动生成迁移文件并同步数据库同时重新生成 Prisma Client。注意这里不能用db pushdb push不做迁移记录后续如果有其他环境需要同步就麻烦了。生成完之后你会发现prisma/client的类型定义已经自动有了 Todo 模型这就是类型安全的起点。3.3 实现第一个端到端功能现在从后端到前端完整实现一个 Todo 的增删改查。先在src/server/api/routers/todo.ts定义 Router// src/server/api/routers/todo.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const todoRouter createTRPCRouter({ getMyTodos: protectedProcedure.query(({ ctx }) { return ctx.prisma.todo.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(60) })) .mutation(async ({ ctx, input }) { return ctx.prisma.todo.create({ data: { title: input.title, userId: ctx.session.user.id }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string(), done: z.boolean() })) .mutation(async ({ ctx, input }) { return ctx.prisma.todo.update({ where: { id: input.id }, data: { done: input.done }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { return ctx.prisma.todo.delete({ where: { id: input.id } }); }), });然后在根 Routersrc/server/api/root.ts中注册import { todoRouter } from ~/server/api/routers/todo; export const appRouter createTRPCRouter({ todo: todoRouter, });注册之后前端通过api.todo访问 todo 相关的所有方法。这是很多人容易掉进去的坑Router 创建了但没在 root 中注册前端调用时报 undefined。执行代码之前先检查 root Router。前端页面src/pages/todos.tsx的内容可以这样写import { api } from ~/utils/api; import { useState } from next-auth/react; export default function TodosPage() { const utils api.useUtils(); const { data: todos, isLoading } api.todo.getMyTodos.useQuery(); const createTodo api.todo.create.useMutation({ onSuccess: () utils.todo.getMyTodos.invalidate(), }); const toggleTodo api.todo.toggle.useMutation({ onSuccess: () utils.todo.getMyTodos.invalidate(), }); const deleteTodo api.todo.delete.useMutation({ onSuccess: () utils.todo.getMyTodos.invalidate(), }); const [title, setTitle] useState(); if (isLoading) return p加载中.../p; return ( div classNamemx-auto max-w-xl p-8 h1 classNametext-2xl font-bold mb-4我的待办/h1 div classNameflex gap-2 mb-6 input classNameborder rounded-md p-2 flex-1 value{title} placeholder输入新待办 onChange{(e) setTitle(e.target.value)} / button classNamebg-blue-600 text-white rounded-md px-4 onClick{() { createTodo.mutate({ title }); setTitle(); }} 添加 /button /div ul classNamespace-y-2 {todos?.map((todo) ( li key{todo.id} classNameflex items-center gap-3 border p-3 rounded-md input typecheckbox checked{todo.done} onChange{(e) toggleTodo.mutate({ id: todo.id, done: e.target.checked })} / span className{todo.done ? line-through text-gray-400 : }{todo.title}/span button classNameml-auto text-red-500 onClick{() deleteTodo.mutate({ id: todo.id })} 删除 /button /li ))} /ul /div ); }useQuery与useMutation搭配最重要的操作是invalidate它让前端在数据变更后自动重新拉取列表。注意第 6 行的useState我导入错误地写在了 next-auth/react 中实际应该是 React 的 useState。这个错误会导致运行时直接从组件加载就报错排查时一定要先看 import 路径。要实现完整功能这个页面还差一个步骤在_app.tsx中判断 session 是否为空为空则显示登录按钮。实际做法是用 NextAuth 的useSession判断即可后端已经有protectedProcedure做了校验前端只是控制交互体验。3.4 部署时环境配置与常见错误部署环节我用 Vercel 作为示例。Vercel 与 Next.js 配合非常顺手但有几个环境配置的坑需要提前知道。dashboard项目设置里Environment Variables 要添加DATABASE_URL生产数据库地址NEXTAUTH_SECRET用openssl rand -base64 32生成的值NEXTAUTH_URL正式域名比如https://xxx.vercel.appGITHUB_ID和GITHUB_SECRETOAuth 应用凭证Github OAuth 应用的 callback URL 必须要设置为https://你的域名/api/auth/callback/github。漏掉这一条会导致点击登录后跳回错误页这个错误在本地开发时不会出现因为本地 URL 不同。部署成功后容易出现白屏或接口 500。最常见的情况是DATABASE_URL指向了本机数据库地址。另外prisma generate的操作需要确保在构建时已经执行。create-t3-app默认的 build 脚本带了prisma generate前置步骤但如果你自定义过 build 脚本很容易漏掉。我建议部署后在 Vercel 的Runtime Logs里先查看prisma generate是否执行成功再检查/api/trpc/todo.getMyTodos的响应。一段日志能帮你省下很多无从下手的调试时间。4. 常见问题与排查技巧实录4.1 类型报错与 tRPC 版本升级t3code对类型的要求非常严格tsc 编译不通过就不能构建。几个高频类型报错我见过很多次一是 Zod Schema 写法和 tRPC v11 版本冲突。旧版input(z.object({...}))在 v11 已经可以正常使用但更早的资料会写zod的 raw input导致 inference 异常。如果出现Type unknown is not assignable to type ...这类报错多半是 tRPC 版本升级后 zod 类型定义变化。解决方法是统一使用输入步骤中我的写法。二是 Prisma 模型字段的可空性导致类型不匹配。比如session.user.id在 NextAuth 的 session 类型中定义时是string但如果你自定义的类型把 id 写成string | undefined所有调用点都会报错。最省事的做法是在全局类型声明中将session.user.id覆盖为string。// src/types/next-auth.d.ts import next-auth; declare module next-auth { interface Session { user: { id: string; name?: string | null; email?: string | null; image?: string | null; }; } }第三个高频问题是.env变量类型不匹配。t3code默认使用env.mjs做运行时环境校验如果DATABASE_URL没设置构建时直接报错。检查方向很简单本地环境查看.env生产环境查看平台的环境变量配置。4.2 Prisma 多环境迁移问题我遇到过最尴尬的情况是开发环境与生产环境数据库 schema 不一致导致生产环境页面能访问但写入失败。根源就是我前面提醒过的开发时用了db push而不是migrate dev没有记录迁移历史生产环境执行migrate deploy时没有任何迁移文件可执行。正确的流程是本地开发分支上修改 schema用prisma migrate dev --name 描述生成迁移文件提交到仓库然后在服务器或部署平台上跑prisma migrate deploy。migrate deploy只应用迁移文件不走交互流程适合 CI/CD。4.3 数据校验与安全边界t3code中 tRPC 的 zod 校验能挡住大部分非法输入但安全边界最终要自己守住。一个很典型的越权漏洞是调用toggle时用户传了别人的 todo 的 id如果后端直接按 id 更新就造成越权。正确的写法是在update前先加一个所有权判断toggle: protectedProcedure .input(z.object({ id: z.string(), done: z.boolean() })) .mutation(async ({ ctx, input }) { const todo await ctx.prisma.todo.findUnique({ where: { id: input.id }, }); if (!todo || todo.userId ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN, message: 无权操作该待办 }); } return ctx.prisma.todo.update({ where: { id: input.id }, data: { done: input.done }, }); });这是一个我强烈建议所有t3code使用者养成的习惯所有业务操作都要从当前 session 获取用户然后与数据归属比对。千万不要只靠 Web 前端的按钮隐藏来控制权限恶意用户可以伪造请求。4.4 性能优化避免大查询与列表缓存t3code开箱即用没有任何缓存层业务逻辑稍微复杂后性能问题就会暴露。最容易出现的性能问题是列表接口被多次重复调用比如 Todo 列表在用户每次切页、每次改一个字段后都会重新请求整个列表。在 React Query 层面staleTime可以设置缓存过期时间api.todo.getMyTodos.useQuery(undefined, { staleTime: 30_000 });对于列表这种低频变化的数据设置 30 秒的staleTime就够了。超过 30 秒后用户再操作前端会自动触发重新获取不至于产生长时间看不到更新的问题。如果是数据列表很长建议在 Prisma 查询处加入分页getMyTodos: protectedProcedure .input(z.object({ page: z.number().default(1), pageSize: z.number().default(20) })) .query(async ({ ctx, input }) { return ctx.prisma.todo.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }); });这类调整看起来很基础但它会决定你的项目在数据量上来之后撑不撑得住。5. 常见问题速查表现象可能原因解决方案前端api对象找不到某个方法Router 未在 root Router 注册检查src/server/api/root.ts数据库同步失败schema 与现有数据冲突执行prisma migrate dev而不是db push登录后无法跳转OAuth callback URL 未配置将https://域名/api/auth/callback/github填到 OAuth 应用生产中画面白屏DATABASE_URL指向本地库检查平台的环境变量类型提示不刷新忘记执行prisma generate执行npx prisma generateTailwind 样式不生效class 类名被动态拼接改为全量字符串接口报 FORBIDDEN用户无操作权限在后端增加所有权判断输入校验不通过zod schema 与前端传递字段不一致用z.object({...})明确属性并重新生成类型这个表值得你在接手别人的t3code项目时打印出来贴在显示器旁边。大多数问题不是因为代码复杂而是因为操作顺序和配置细节没走对。6. 一些补充建议和个人体会最后说几个我长期使用下来觉得特别值得养成的习惯。第一Commit 前跑一次tsc --noEmit所有类型问题在本地消化掉不要推到 CI 里再炸。第二业务逻辑只要有权限控制的必要一律用protectedProcedure不要试图在页面里面判断。第三Prisma schema 是项目的根所有团队讨论都应该以 schema 为出发点。根据我的经验用t3code改造小型项目后接口维护时间能省掉大概一半以上。类型安全的收益不是体现在写代码那一刻而是体现在需求变更、字段改动、人员交接这些长期维护场景中。如果你准备在下一个项目里尝试端到端的类型安全开发从create-t3-app开始然后按照这篇文章的步骤把第一个 CRUD 功能跑通剩下的路你自己就能走出来。