t3code 这个名字我盯了很久前阵子终于抽时间把它从想法落到了代码上。简单说它是我用 T3 Stack 这一整套现代全栈方案从零搭建并跑通的一个实战项目核心栈包括 Next.js、TypeScript、Tailwind CSS、Prisma、tRPC 和 NextAuth.js。整套组合突出的就一件事端到端类型安全前后端共享一套类型定义改一个字段编辑器里所有引用点立刻给你标红再也不用靠猜字段名过日子。如果你正处在“想做个完整全栈项目但又厌烦 API 层繁琐重复”的阶段这篇内容值得你花十分钟读完我会把从初始化、数据建模、接口设计到部署落地的完整过程包括那些官方文档不会写的坑一次性讲清楚。1. 整体设计与思路拆解在真正敲键盘之前我更想先聊聊为什么偏偏是这套组合。很多人一提到全栈项目第一反应是 React Express MySQL 这种老三样接口自己写、联调用 Postman、类型定义靠人肉同步。这种模式不是不能用而是当项目规模过了三五个人、十几个接口的阶段前后端类型断裂带来的心智负担会成倍上升。t3code 选择 T3 Stack 的原因也正在这里它把类型安全从设计层面贯穿到了数据访问、接口调用和页面渲染的每一个环节。1.1 t3code 到底解决什么问题t3code 本身不是一个所谓的“新框架”而是把我日常项目里最高频的一类业务场景——包含用户登录、结构化数据管理、列表筛选和实时更新的典型 Web 应用——用 T3 Stack 重新实现了一遍。它的核心诉求是我能不能在尽量不写重复代码的前提下让前端直接获得后端接口的完整类型提示答案是能而且体验非常顺滑。传统 REST 方案里后端写完接口前端要拿着接口文档或者 Postman 的导出结果去复制类型定义。一旦后端把某个字段从status改成state前端编译期完全看不出来直到线上报错才被发现。t3code 用 tRPC 这件事把“远程调用”变成了“导入一个函数”前端直接调用后端定义好的 procedure参数和返回值类型全部自动推导。数据库里的字段经过 Prisma 成了 TypeScript 类型再经过 tRPC 传到前端全程没有任何手工维护的类型或接口描述文件。1.2 为什么选择 T3 Stack 而不是其他方案选型时我也想过要不要用 NestJS 这类更重型的后端框架或者直接用 GraphQL。后来都否掉了。NestJS 功能强但对我来说接入成本和配置复杂度都太高一个待办事项级别的小项目根本用不着依赖注入、守卫、管道这套体系GraphQL 的 schema 定义、resolver、缓存和客户端代码生成也是一条陡峭的学习曲线。T3 Stack 里最打动我的是它的“约定式”结构。create-t3-app脚手架生成的项目目录非常干净app目录交给 Next.jsprisma目录管数据库server目录集中放 tRPC 和鉴权逻辑src/trpc处理调用链路。每个文件落在哪、职责是什么基本一眼就看明白不需要花时间建立复杂的架构认知。等于把过去需要架构师拍板的问题用一套社区验证过的默认值替你解决了。这类组合还有一个特别实际的好处渐进式学习。你不必一开始就精通所有六项技术。我最初只把 Next.js 当作 React 框架用后面才一步步接入 Prisma、tRPC。T3 Stack 的各个模块边界清晰拆开用也不影响想加进来的时候再打开对应开关就行。这种渐进的平滑感对新手或者想把手头项目慢慢改造的团队来说友好度远高于一次到位的大换血方案。1.3 t3code 的项目模块规划动手之前我先在脑子里给 t3code 画好了模块边界。整个项目被我拆成四个核心部分认证与用户模块、数据模型层、业务 API 层、前端展示层。认证交给 NextAuth.js它和 tRPC 的上下文机制天然能配合登录用户的信息可以直接塞进每个 API 请求的上下文中数据模型层用 Prisma 定义包括用户表和任务表外键关系也在这里体现业务 API 层则通过 tRPC router 暴露给前端比如listTasks、createTask、toggleTask这几个 procedure前端页面按 Next.js App Router 的约定组织服务端组件负责初始数据的读取客户端组件负责交互和实时更新。这种模块划分的优点是每个部分都可以单独替换。未来如果不想用 NextAuth 了只需要修改server/auth.ts这一个文件然后把 tRPC 上下文里的 session 来源换掉前端一行都不用动。同样如果想把 Prisma 换成 Drizzle只要保持对外暴露的数据方法签名不变上层的 tRPC 和前端依然能正常工作。模块之间的松耦合是我在规划阶段刻意追求的目标事实证明后面调试起来确实省了不少心。2. 核心细节解析与实操要点规划完架构就要把方案落实到具体代码里。这一节我用 t3code 里比较关键的几个环节做例子把每一步的操作细节和背后的为什么讲清楚。2.1 初始化脚手架的关键选择T3 Stack 提供了官方的脚手架命令行工具用npx create-t3-applatest就可以启动初始化流程。t3code 建立时我遇到一排交互问题其中几个选择直接决定了项目形态。比较关键的是“App Router 还是 Pages Router”。我选了 App Router因为它对应 Next.js 未来主推的方向服务端组件默认减少了不少客户端 JavaScript 的加载量而且在 RSC 模式下页面首屏可以直接在服务端调用数据库和 tRPC用户看到的 HTML 就已经包含完整数据体验比传统客户端渲染好一截。代价是调试时要注意哪些地方能跑客户端 hook、哪些地方不行后面我会把这个问题展开细说。另一个选择是包管理器。脚手架会询问 pnpm、npm 和 yarn我推荐 pnpm。它的硬链接机制在存储多个项目时优势明显安装速度也快至少在 t3code 里安装依赖的时间比 npm 快了不少。项目构建时 pnpm 默认的依赖隔离也更严格对我们这种想保持环境纯净的场景是加分项。还有数据库选型。脚手架默认给的是 SQLite方便本地快速跑起来t3code 在 SQLite 上开发了一段时间后面部署时切到了 PostgreSQL。如果你的项目一开始就知道将来要部署到生产环境建议直接在初始化时选 PostgreSQL避免后面因为方言差异调整 schema。SQLite 和 PostgreSQL 之间的大多数基础类型是兼容的但例如Json、DateTime之类字段的处理略有不同越早切换沟通成本越低。这些选择其实没有一个绝对正确关键是你至少要知道每个选项意味着什么。2.2 Prisma 数据建模的核心原则数据建模是每个全栈应用逃不掉的一环。t3code 的核心业务是任务管理所以我的prisma/schema.prisma里定义了User和Task两个模型。Task通过createdById这个外键关联到User代表“每个任务都属于某个用户”。字段命名上我坚持用“动词过去式 名词”的语义比如createdAt、updatedAt这样在业务代码里读起来跟自然语言一样顺。它还支持createdBy这个关系字段Name 和 BelongsTo 关系一目了然。建模时有个细节容易被忽略索引。很多新手只关注主键和关系却忘了查询频率高的字段要加索引。在 t3code 里Task模型我会给status字段加一个普通索引因为列表页经常按状态筛选。如果数据量涨到几十万条没有索引的WHERE status PENDING可能瞬间拖垮数据库。加索引的代价只是写入时多一些开销但对读多写少的业务场景完全值得。定义好 schema 之后执行npx prisma migrate dev --name init就能创建并应用第一次迁移。注意这条命令同时会执行prisma generate把数据库类型映射成 TypeScript 类型。如果你改了 schema 之后忘记跑生成脚本编辑器里引用 Prisma Client 的地方就会大面积报错。这个习惯我在 t3code 踩过一次坑后面已经变成肌肉记忆了任何 schema 变更之后先跑 migrate再检查类型是否更新。2.3 tRPC API 设计的类型安全逻辑tRPC 是 t3code 里最让我觉得“值回票价”的部分。它不是一个框架而是一个基于 TypeScript 的 RPC 调用层。核心概念是 router 和 procedure一个 router 可以包含多个 procedure每个 procedure 要么是 query要么是 mutation。在 t3code 里任务列表的 API 长这样import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const taskRouter createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) { return ctx.db.task.findMany({ where: { createdById: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { title: input.title, status: PENDING, createdById: ctx.session.user.id, }, }); }), });这段代码最大的特点是没有单独的 API 路由文件、没有请求方法定义、没有响应的类型定义文件。list和create的入参和出参都被 TypeScript 自动推导出来前端调用时直接use trpc.task.list.useQuery()编辑器里能看到完整的返回类型。zod在这里负责运行时校验z.string().min(1).max(100)能拦住空字符串和超长内容前端输入非法数据时后端会直接返回校验错误不需要自己写if (!title) return error之类的胶水代码。protectedProcedure是个值得关注的点。普通 procedure 允许匿名用户调用而 protectedProcedure 在调用前会检查上下文中是否存在登录用户。实现方式并不复杂本质就是一个高阶函数在真正执行查询逻辑之前先验证 session如果没登录就抛出一个类似“UNAUTHORIZED”的错误。这样既省掉了每个业务接口里重复的权限判断也让路由定义一眼就能看出哪些接口需要登录安全性边界非常清晰。2.4 前端消费 API 的最佳实践t3code 的前端代码集中在app目录下。我在列表页里用了use trpc.task.list.useQuery()来读取任务数据然后通过const utils trpc.useUtils()拿到更新缓存的方法。创建任务时执行await utils.task.list.invalidate()这一句的作用是让 tRPC 自动重新拉取列表数据等效于把“手动刷新页面”这个动作自动化了。用这个模式写 CRUD 应用前端代码量能减少非常多也不需要 Redux 或状态管理库来维护服务端数据。在 App Router 模式下还有一个常见场景服务端组件里也想读数据库。比如页面首屏想直接展示任务数量。这时不能直接使用useQuery因为它是个 React hook只能在客户端组件里跑。正确的做法是使用 tRPC 的server caller在服务端直接调用一个 procedure。t3code 里我写了一个trpcServer工具专门用来在 Server Component 里发起调用import { createCaller } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; async function getTaskStats() { const caller createCaller(await createTRPCContext({ headers: await headers(), })); return caller.task.stats(); }这里每次请求都会创建一次 caller开销可以接受因为 Next.js 的 Server Component 本身也是按请求执行的不会造成跨请求数据污染。如果项目里同一请求内多次使用 caller可以考虑用React.cache()做请求级缓存。至于页面上的交互组件——比如“已完成/未完成”切换按钮——则必须在客户端组件里通过trpc.task.toggle.useMutation()执行。这种“服务端取数、客户端交互”的分工是我在实际开发里体会到的最顺滑的 App Router 玩法。3. 实操过程与核心环节实现这一节我把 t3code 从空白目录到部署上线的完整流程录成一份可以直接参照的“抄作业指南”。你不需要有基础的 T3 使用经验只要照着步骤走几乎每一步都能落下能用的东西。3.1 完整落地步骤一览从零到一的基本操作顺序如下运行npx create-t3-applatest t3code进入交互模式。选择 TypeScript、Tailwind CSS、Prisma、NextAuth.js、tRPC 全部启用。选择 App Router 作为路由策略其余按喜好选 pnpm 和 ESLint。配置.env文件本地开发时DATABASE_URL指向本地 PostgreSQL 或 SQLite。编辑prisma/schema.prisma加入Task模型。执行npx prisma migrate dev生成迁移文件并应用到数据库。在src/server/api/routers下新建任务相关的 router 文件并挂载到根 router。在app/page.tsx里搭建列表页客户端组件接入 tRPC 的useQuery和useMutation。配置 NextAuth.js 的 provider 和 prisma adapter。本地pnpm dev跑起来从登录到增删改查完整走一遍。部署到 Vercel配置环境变量跑prisma migrate deploy和prisma generate。这套流程里的每一步我都在 t3code 上实际执行过。下面挑几个容易出问题的环节单独拆开讲。3.2 数据库模型与迁移的完整操作我建了一个比最小待办事项稍微丰富一点的模型让例子更贴近真实场景。设计思路是一个用户能创建多个任务每个任务有标题、描述、状态和优先级。对应 schema 如下model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] tasks Task[] } model Task { id String id default(cuid()) title String description String? status TaskStatus default(PENDING) priority TaskPriority default(MEDIUM) createdAt DateTime default(now()) updatedAt DateTime updatedAt createdById String createdBy User relation(fields: [createdById], references: [id]) index([status]) index([createdById]) } enum TaskStatus { PENDING IN_PROGRESS COMPLETED } enum TaskPriority { LOW MEDIUM HIGH }这里用default(cuid())生成主键而不是用自增整数是因为 cuid 在分布式部署时不需要依赖数据库生成序列对未来的水平扩展更友好。TaskStatus和TaskPriority是枚举数据库里存储的就是可读性的字符串例如PENDING、COMPLETED调试时直接看库里的记录就能明白业务状态不用通过中间表去查外键。枚举类型在 TypeScript 和 Prisma 里都能得到正确的类型推导前后端用起来都非常舒服。定义好 schema 后执行npx prisma migrate dev --name add_task_modelmigrate dev这条命令很智能先自动比较 schema 和当前数据库的差异生成迁移 SQL然后应用它接着再执行prisma generate。迁移文件生成在prisma/migrations目录下对应每个历史节点都能回滚。我要提醒的一点是尽量不要手改迁移 SQL 来“修数据”应该始终回到 schema 文件上改模型再让 migrate 帮你生成新迁移。手改迁移文件很容易让本地和生产环境的状态不一致到时候排查问题会非常头大。3.3 NextAuth.js 接入与鉴权流程t3code 的认证部分我选的是邮箱 magic link 配合第三方 OAuth。这里用 NextAuth.js 的 Credentials Provider 和 OAuth Provider 都可以但为了贴近真实环境我建议用 OAuth 或者邮箱验证码这类“无密码”方案免得还要自己维护密码加密和重置逻辑。脚手架默认生成的server/auth.ts已经包含了一个标准的 NextAuth 配置框架你只需要把 provider 装上去并处理好 Prisma adapter。使用 Prisma Adapter 的目的是让 session、account、user 这些认证相关数据能落到数据库里。在 NextAuth 配置中加一行adapter: PrismaAdapter(prisma)然后确保AUTH_SECRET环境变量存在。这个变量非常重要它负责给 session 加密签名本地开发和部署时必须保持一致一旦丢失所有用户的登录态都会失效。更稳妥的做法是把AUTH_SECRET设置成一个随机生成的长字符串不要用AUTH_SECRETsecret这种大家都能猜到的值。鉴权落地到 tRPC 时需要把 NextAuth 的 session 注入到 tRPC 的上下文里。脚手架生成的文件里其实已经把这一步做完了核心逻辑在src/server/api/trpc.ts中export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db: prisma, session, ...opts, }; };有了 session 在手protectedProcedure 就能判断当前请求是否来自合法登录用户。这样的配置让业务代码非常干净写接口的时候只要关心数据本身不需要在每个 procedure 里手动查数据库确认当前用户是谁。3.4 前端任务列表页面的串联实现拿到任务数据之后怎么把它展示成可用界面t3code 的首页我拆成了两部分一个服务端组件负责渲染页面骨架一个客户端组件负责交互。骨架部分用 Tailwind 直接写样式重点是不需要引入组件库也能保持视觉效果干净。代码大致是export default async function Home() { const taskCount await getTaskCount(); return ( main classNamecontainer mx-auto max-w-2xl p-4 h1 classNametext-2xl font-boldt3code 任务面板/h1 p当前共有 {taskCount} 个任务/p TaskList / /main ); }TaskList是客户端组件负责真正展示任务列表和触发新增、切换状态、删除等操作。它内部使用trpc.task.list.useQuery()获取数据再通过 mutation 更新数据。注意客户端组件的文件头里要写上use client否则 Next.js 会默认按 Server Component 处理hooks 直接报错。这算是我自学 App Router 时踩过的坑现在写任何带交互的组件之前第一反应都是先想清楚它在哪个环境运行。状态切换按钮这部分我用了一个乐观更新的技巧。任务状态从“未完成”切到“已完成”时如果等接口返回再更新 UI用户会明显感觉到延迟。tRPC 的 mutation 支持onMutate回调可以在网络请求还没完成时先改本地 UI后面再通过onSettled触发重新拉取校准数据。这种细节上的打磨对体验提升非常明显尤其是网络抖动严重的场景用户会觉得应用“跟手”。3.5 部署到生产环境的注意事项部署部分t3code 最终放到了 Vercel 上。整个流程很简单推代码到仓库、在 Vercel 控制台导入项目、配置环境变量再等构建。真正容易翻车的是三个地方。第一环境变量。DATABASE_URL、AUTH_SECRET这些必须在 Vercel 的控制台里设置而不是只放在本地的.env文件里。构建阶段如果读不到这些变量Prisma Client 初始化就会失败整个部署直接红屏。第二Prisma 的构建流程。Vercel 构建过程中默认不会跑prisma migrate所以生产数据库的表结构必须手动执行prisma migrate deploy来同步。如果跳过这步部署成功后接口一访问就会报数据表不存在。第三Prisma Client 的生成。用 pnpm 部署时因为依赖会被提升或隔离有时候构建阶段没有正确执行prisma generate导致PrismaClient引用错误。解决办法是在package.json的 postinstall 脚本里加上postinstall: prisma generate确保安装依赖后自动生成客户端。这些坑单个看都不大但连在一起很容易让第一次部署的人卡住半小时以上。4. 常见问题与排查技巧实录整个 t3code 开发过程中我记录了不少让自己“拍大腿”的瞬间这里挑几个最有代表性的整理成速查表当然也会把排查思路一并讲清楚。熟读这些内容你至少能少踩我踩过的一半坑。4.1 Prisma 报错找不到环境变量或客户端未生成现象本地明明配置了DATABASE_URL数据库也能连但npx prisma migrate或运行时总是提示Environment variable not found: DATABASE_URL。原因可能有两个。一是.env文件位置不对。prisma默认从项目根目录读取.env如果你把它放到了src里面它根本找不到。二是你改了.env之后没有重启进程。Next.js 的 dev server 有缓存很多环境变量只在启动时读取一次。除此之外跑完prisma generate后也要重启 dev server让 Prisma Client 的生成代码被重新加载。遇到这个问题首先检查.env的路径和命名是否精确匹配DATABASE_URL然后停掉 dev server 重新启动九成情况都能解决。4.2 服务端组件里无法调用 hookInvalid hook call在 App Router 模式下页面文件app/page.tsx默认是 Server Component。如果你直接在页面里写const utils trpc.useUtils()很快就看到Invalid hook call报错。React 的 hooks 只能在客户端组件里运行。排查方法是看文件头部是否有use client标记。如果这个页面需要同时具备“服务端取数”和“客户端交互”两种能力正确的结构是拆分成两个组件外层服务端组件负责读取初始数据内层客户端组件负责交互。有些新手会把整个页面都标成use client这样能解决问题但也意味着整页的 JS 都会打进客户端 bundle失去 App Router 的优化优势。建议尽量保持“外壳服务端、核心交互客户端”的分工结构。4.3 NextAuth Session 类型与 Prisma User 模型不一致这个问题的出现场景是Prisma 的User模型里没有某个字段但代码里通过session.user.xxx直接访问TypeScript 竟然没有报错运行时却是undefined。这是因为 NextAuth 默认的 session 类型定义比较宽松或者你手动给类型“补”了不只存在于数据库的字段。解决方案很简单打开src/types/next-auth.d.ts这类类型增强文件把Session[user]的类型明确指向 Prisma 生成的User模型的部分字段例如id和name。这样 TypeScript 就会严格检查 session 里所有字段杜绝运行时才发现属性不存在的尴尬。另一个常见坑是NextAuth 在未登录状态下session.user本身可能为null所以要养成先判断 session 再访问 user 的习惯或者在 protectedProcedure 里做完鉴权后让上下文里的 session 类型变成“非空”类型。4.4 tRPC mutation 后页面数据不更新我在 t3code 刚写完新增任务的功能时遇到一个问题接口调用成功数据库里多了一条记录但页面的任务列表就是没变化。原因是我在 mutation 成功之后没有让相关查询失效。正确做法是调用utils.task.list.invalidate()告诉 tRPC“这个查询的缓存数据已过期请重新拉取”。如果你的页面里还有别的关联查询比如任务统计记住也要一起失效。更精细的做法是使用setData做手动缓存写入直接在成功后把新任务塞进现有列表里这样列表能瞬间更新不需要等待网络请求。但注意手动更新缓存时你要确保新数据的结构与查询返回值完全一致否则后面排序或筛选时可能出错。4.5 部署后报 500 错误数据库还没有迁移本地开发时数据库表是手动跑 migrate 创建的部署之后很多人会忘记生产数据库也要同步。如果 Vercel 环境变量里的DATABASE_URL是正确的但接口请求返回 500登录后台日志大概率能看到 “relation does not exist” 之类的报错。这就是生产数据库没有建表。解决办法是在部署环境里执行一次npx prisma migrate deploy。Vercel 项目根目录如果配置了 postinstall 脚本可以在构建时顺便跑掉postinstall: prisma generate prisma migrate deploy。但这有个风险如果生产数据库服务在构建阶段不可用构建就会失败所以我更倾向于在部署平台上单独配置一条命令或者在确认数据库可连接后手动执行一次迁移而构建阶段只保留prisma generate。4.6 常见问题速查表问题现象可能原因快速解决方案环境变量找不到.env文件位置不对或 dev server 未重启确认路径、重启进程表或列不存在未执行迁移运行prisma migrate deployPrismaClient 报错未执行 generate添加 postinstall 脚本prisma generate页面 Hook 调用报错在 Server Component 中使用 hooks拆分组件并加use clientSession 字段访问 undefined类型定义过宽使用 NextAuth 类型扩展严格约束字段操作后列表不刷新未失效查询缓存调用utils.task.list.invalidate()前端与后端类型不一致手工复制类型移除重复定义统一用 tRPC 推导我最终跑完 t3code 这遍流程后最大的感受是这套组合其实不像网上说的那么“炫技”它就是一套非常务实、把类型安全和工程结构做到极致的工作流。前端写代码时像在调本地函数后端加接口不用重复造轮子数据库字段改动也敢直接下手因为出错的地方编译器会立刻告诉你。如果你正好也有计划做一个中小型的全栈项目我建议直接照着这套方案试一次哪怕只是把 t3code 跑通你也会对“类型安全”这四个字有完全不一样的理解。后面我还会继续往这个项目里扩展更多业务模块比如任务评论、数据导出这些到时候再来分享新的实操记录。