全栈TypeScript实战:T3技术栈与tRPC类型共享的完整落地指南 t3code 这个名字是我给一个全栈工程随手起的代号。T 在这里指 TypeScript、Tailwind 和 tRPCcode 则是这份代码仓库本身——整个项目从数据库表结构到页面上的按钮全部跑在同一套类型体系里。最初做这个项目的动机很直接我想验证一个经常听人说的观点——全栈 TypeScript 可以让前后端共享类型从而省掉接口文档和一大部分联调成本。t3code 就是这个验证的产物一个带登录、数据增删改查和标签过滤的灵感收集工具。如果你正在学 T3 技术栈或者在一个传统前后端分离项目里受够了接口对齐的折磨这篇内容应该能让你少走几段弯路。下面所有流程都是我在实际搭建过程中一步步踩过来的包括那些只在真实项目里才会遇到的报错和诡异行为。我会尽量把关键步骤说得具体让即使没碰过 tRPC 的人也能照着把项目跑起来。1. t3code 是什么一个全栈 TypeScript 实战工程1.1 从名字说起为什么叫 t3codeT3 是社区里对一套 TypeScript 技术栈的简称严格说包含三个都从 T 开头的技术TypeScript、Tailwind CSS、tRPC。这三样东西表面上分工不同——语言、样式、接口调用但它们的内核是同一个让开发者少写重复代码、少在类型之间来回转换。Tailwind 负责样式tRPC 负责前端像调用本地函数一样调用后端逻辑TypeScript 负责把所有类型串起来。我给仓库起名 t3code 的时候项目官方脚手架叫什么不重要重要的是这个代号覆盖了整套组合T3 技术栈加上实际运行时的代码本身。随着项目往深处做你会发现这个命名其实是准确的——当 tRPC 把后端路由变成了前端可导入的函数代码仓库里就只有一份真正意义上的“接口定义”那就是 TypeScript 类型。1.2 这个项目要解决什么问题当时我手头有个实际需求把散落在聊天记录和备忘录里的灵感统一收集到一个地方能打标签、能筛选、能控制公开和私有。听起来功能不复杂但按传统思路做要先拆成两个项目一份写接口文档一份做 Mock 数据前后端再对字段类型光联调可能就得耗掉两天。t3code 换了一种方式数据库模型用 Prisma 定义tRPC 直接基于 Prisma 的类型生成路由前端组件再通过 tRPC 客户端调用这些路由。这个链条里每一步的类型都是自动推导出来的。我在 Prisma 里给字段加一个约束前端组件里的类型立刻跟着变我在 tRPC 路由里改一个返回结构调用方代码里马上出现编译错误。类型本身就是契约接口文档变成了可选的补充。这个项目能帮你解决的问题很明确如果你正在评估全栈 TypeScript 方案是否适合团队或者你想亲手体验一次“从数据库到按钮”的完整闭环t3code 可以作为一套可运行的样板。它不是生产级框架但覆盖了绝大多数项目中反复出现的核心模式认证、增删改查、权限控制、列表刷新。1.3 谁适合参考这个项目写了一些 React 但还没独立搭过全栈应用的前端开发者可以拿 t3code 当跳板。你不需要先精通 Next.js 再开始跟着步骤跑起来重点观察类型是怎么从后端流到前端的。如果你已经在维护一个传统的前后端分离项目想给团队引入更省事的协作方式这个项目的代码风格和目录结构也值得参考。当然有一点需要提前说明零基础直接上手会有点吃力。至少得知道 React 组件、useState、TypeScript 基本类型这些概念否则很容易被“在客户端调用服务端函数”这种思维转换卡住。我建议先把 React 和 TS 基础过一遍再来看 t3code 的代码理解成本会低很多。2. 技术选型与项目骨架2.1 为什么选 T3 而不是自定义 Next.js 项目有人可能会问直接用 create-next-app 搭一个 Next.js 项目再自己接 Prisma 和 tRPC不也一样吗能一样但没必要。create-t3-app 这个脚手架的价值在于把默认配置和最佳实践提前绑好了。你不需要研究 tRPC 的 context 怎么组织、Prisma 实例怎么保持单例、NextAuth 的 session 怎么注入 tRPC它默认生成的样板代码已经把链条打通。我当时选择 T3 还有一个更实际的考虑脚手架自带的路由拆分方式很清晰。src/server/api/routers 下面是按业务域拆分的路由文件src/server/api/trpc.ts 统一管理 context 和 middleware前端只需要在服务端组件里调 createCaller或者在客户端组件里用 api.xxx.useQuery。这套约定是社区反复验证过的比自己脑补一套目录结构要可靠得多。用 T3 也避开了最常见的“配置地狱”。路径别名、ESLint 规则、Next.js 的 App Router 配置、Tailwind 的 content 配置脚手架初始化时全都处理好了。我不用在项目一开始就为 tsconfig 里的 paths 字段头疼也不用担心 next.config 漏了什么。2.2 t3code 的目录结构与职责脚手架生成后t3code 的目录大致是这样t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── _components/ │ │ ├── api/auth/[...nextauth]/route.ts │ │ ├── ideas/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ │ └── globals.css │ ├── trpc/ │ │ ├── react.tsx │ │ └── server.ts │ └── env.mjs ├── .env ├── next.config.mjs ├── tailwind.config.ts └── tsconfig.json重点理解这几个位置。prisma/schema.prisma 是数据模型唯一真源src/server/api/trpc.ts 负责创建 tRPC 实例、定义公共和受保护的 proceduresrc/server/api/routers 里放业务路由src/trpc/react.tsx 是前端 React Query 的封装客户端组件从这里导出 api 对象使用。这个结构最大的好处是单向依赖。页面组件只依赖 routers 暴露的接口routers 只依赖 Prisma 和 context谁都不会反向去碰别人。项目变大之后想替换某个数据源或者给某个流程加中间层改动范围是可控的。2.3 t3code 的功能清单与数据模型t3code 最终实现的功能不算多但足以覆盖典型的业务闭环通过 GitHub OAuth 登录使用 NextAuth 管理会话创建灵感条目包含标题、描述、标签、公开状态编辑和删除自己创建的灵感首页展示最新公开灵感我的页面只展示当前用户的内容按标签筛选列表数据模型围绕用户和灵感两张表展开。User 表由 NextAuth 默认逻辑管理Idea 表记录内容字段和作者外键。因为 tags 需要多值我直接用了 Postgres 的数组类型这也是后面踩坑最多的地方之一。如果用 SQLiteString[] 这个写法根本跑不起来所以我从一开始就把数据库定为 PostgreSQL。3. 从零到可用t3code 的搭建过程3.1 脚手架初始化与关键选项初始化命令很简单npm create t3-applatest t3code交互式选项里我按这个思路勾选Next.js 选 App RouterTypeScript 必选Tailwind CSS 选上tRPC 和 Prisma 都选NextAuth 选上。ESLint 和 Prettier 建议都开少一个都会让多人协作时格式冲突。src 目录和路径别名按默认来不需要额外改动。跑完初始化之后脚手架会生成一个自带 Post 示例的可运行项目。这个示例存在的意义是验证整套链路通畅页面能渲染、Prisma 能连库、tRPC 路由能调用。我建议不要急着删先在本地把 npm run dev 跑起来看到示例页面再动手改造这样后续出了问题能区分是环境问题还是代码问题。初始化完成后别忘了装依赖cd t3code npm install然后去 .env 里填 DATABASE_URL。如果你本地还没装 Postgres直接用一个远程实例也行。我第一次用本地 Postgres 的时候启动服务后连库没问题但 schema 里有数组字段必须在迁移前确认数据库版本支持 Postgres 12 及以上的数组操作。3.2 Prisma 数据模型设计我在 prisma/schema.prisma 里把示例 Post 模型改成 Idea 模型核心代码如下model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] ideas Idea[] } model Idea { id String id default(cuid()) title String db.VarChar(80) description String db.VarChar(1000) tags String[] isPublic Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) }这里有几个细节值得多说一句。title 和 description 用 VarChar 并限制长度是为了在数据库层面就挡住异常数据前端再配合 zod 校验形成双重保障。tags 用 String[] 是因为 Postgres 原生支持数组类型查询时可以用 any 操作符做包含判断比关系表简单得多。isPublic 默认 false保证用户创建的灵感默认只有自己可见避免隐私数据意外泄露。作者外键上用了 onDelete: Cascade用户注销时他创建的灵感会一并删除。这块如果业务上需要保留匿名数据建议改成 SetNull但那样的话 authorId 要做成可空前端取数据时也要处理 author 为空的情况。t3code 的场景里用户内容跟着账号走比较合理所以选了级联删除。改完 schema 后执行一次迁移npx prisma migrate dev --name init_idea这条命令会生成迁移文件并同步数据库。如果报连接错误先检查 DATABASE_URL 是否正确再确认 Postgres 允许 TCP 连接最后再看是不是 SSL 参数问题。这些我后面在踩坑部分细说。3.3 tRPC 路由与受保护接口tRPC 是 t3code 里最核心的一层。脚手架生成的 src/server/api/trpc.ts 默认已经处理了 context、superjson 和错误格式化我要做的是在 trpc.ts 里定义一个受保护的 procedureconst enforceUserIsAuthed t.middleware(({ ctx, next }) { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure t.procedure.use(enforceUserIsAuthed);这段逻辑的核心是把鉴权集中到 middleware 里。任何需要登录才能调用的接口只要用 protectedProcedure 定义用户没带有效 session 就会被拦截根本走不到业务代码。我写 create 和 delete 的时候只需一句export const ideaRouter createTRPCRouter({ getAll: publicProcedure .input(z.object({ tag: z.string().optional() })) .query(async ({ ctx }) { return ctx.db.idea.findMany({ where: { isPublic: true, ...(input.tag ? { tags: { has: input.tag } } : {}), }, orderBy: { createdAt: desc }, take: 30, }); }), create: protectedProcedure .input( z.object({ title: z.string().trim().min(1, 标题不能为空).max(80), description: z.string().trim().min(1).max(1000), tags: z.array(z.string().max(30)).max(8).default([]), isPublic: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) { return ctx.db.idea.create({ data: { title: input.title, description: input.description, tags: input.tags, isPublic: input.isPublic, authorId: ctx.session.user.id, }, }); }), });publicProcedure 用于公开列表游客也能看。create 用 protectedProcedure确保只有登录用户能创建。zod 的输入校验放在路由层而不是只靠前端表单意义在于任何绕过前端直接调接口的调用都会被这里挡住。title 和 description 的 trim 只去掉首尾空格不会改动正文内部避免用户手滑多打空格造成空标题。getAll 里的 take: 30 是粗暴的限流避免一次性拉太多数据。真实项目里这个位置应该换成 cursor 分页后面优化部分我会展开说。tags 用 has 操作符是 Prisma 针对 Postgres 数组字段的过滤语法等价于 SQL 里的数组包含判断。3.4 前端组件如何调用接口t3code 的前端页面分两类服务端组件直接通过 tRPC server 调用客户端组件走 React Query。以创建灵感的表单为例它必须标注为客户端组件因为要用到表单状态和 mutationuse client; import { api } from ~/trpc/react; export function NewIdeaForm() { const utils api.useUtils(); const createIdea api.idea.create.useMutation({ onSuccess: async () { await utils.idea.getAll.invalidate(); }, }); const [title, setTitle] useState(); const [description, setDescription] useState(); return ( form onSubmit{(e) { e.preventDefault(); createIdea.mutate({ title, description, tags: [], isPublic: true }); }} {/* 表单控件 */} /form ); }这里的 api.idea.create.useMutation() 会自己管理请求状态pending、error、success 都有对应字段。最有用的是 onSuccess 里调 utils.idea.getAll.invalidate()它会自动让列表重新拉取。我不用手动刷新页面也不用写事件总线通知列表组件React Query 的缓存体系帮我搞定了数据同步。如果页面是服务端组件可以改成调用 createCallerimport { api } from ~/trpc/server; const ideas await api.idea.getAll({ tag: undefined });createCaller 在服务端直接执行函数体不走 HTTP首屏渲染少了一次网络请求。这个组合非常好用服务端组件负责首屏数据客户端组件负责交互两边用的是同一套路由和同一套类型。3.5 从表单提交到数据库写入的完整链路把前面几段串起来看用户在表单里输入标题并点击提交之后完整流程是这样的浏览器里的 React 表单收集输入useMutation 被触发把数据发给 Next.js 内置的 tRPC HTTP 端点tRPC 路由先解析输入zod 校验失败会直接返回 400 并且带上字段错误校验通过后middleware 检查 session没登录就抛 UNAUTHORIZEDmutation 执行 db.idea.createPrisma 把数据写入 Postgres响应返回给前端useMutation 的 onSuccess 触发invalidate 让列表重新请求React Query 拿新数据刷新 UI整个链条里没有手写任何一个 HTTP 路由没有定义过一份接口文档也没有在前后端各自维护一份类型。Prisma 的模型、tRPC 的返回类型、前端组件的 props由编译器保证一致。我第一次跑通这个流程时印象很深以前写接口要花大量时间对齐字段名和类型现在改一个字段编译器的报错会一路指到必须改的地方。4. 踩坑记录常见问题与排查实录4.1 Prisma 迁移和数据库连接坑第一个坑出现在我第一次执行 migrate。本地 Postgres 连不上日志显示连接超时。检查后发现是我在 DATABASE_URL 里漏写了端口Postgres 默认是 5432我填成了 5433。这个问题的排查方式很朴素先尝试数据库客户端能否连上再用 Prisma 的日志确认连接串解析后的目标地址。还有一个更隐蔽的坑Prisma 的 migrate 命令需要直连数据库而运行时用连接池地址更合适。如果你用的是 Neon 这类托管 Postgres你会在后台看到两个连接地址一个带连接池一个不带。连接池地址用于运行时请求直连地址用于迁移。如果环境变量里只放了连接池地址migrate 可能反复报错部署阶段也在这一步卡住。我当时在 .env 里分成 DATABASE_URL 和 DIRECT_URL 两个变量migrate 时明确用直连地址问题才彻底解决。4.2 tRPC 类型没有更新tRPC 的类型推导依赖 TS 编译器对路由的引用解析。我遇到的情况是给 ideaRouter 加了一个新的 query结果前端 api.idea.newQueryName 一直报不存在。我反复检查代码路由肯定加了导出的 root.ts 也引入正确最后发现是 .next 目录里的缓存没刷新。这种问题的处理方式不算复杂但容易让人慌。先停掉 dev server删掉 .next 目录重新跑rm -rf .next npm run dev如果你用的是 VS Code可能还需要重启 TS Server命令面板里选 TypeScript: Restart TS Server。这是 tRPC 最典型的体验问题本质是类型生成依赖 dev server 的模块缓存。等你用久了会发现凡是“代码明明改了但类型没变”的报错第一反应先清缓存比检查代码更高效。4.3 NextAuth 在服务端取不到 sessiont3code 的登录流程正常情况下不会出问题但我把 User 模型改成带 ideas 关系之后出现过 session 里 user.id 取不到值。原因是 NextAuth 的 session 回调默认只保存 name、email、image 这些基础字段id 需要手动扩展。t3 脚手架生成的 auth.ts 里有 session 回调的处理逻辑session: ({ session, user }) ({ ...session, user: { ...session.user, id: user.id, }, }),如果某次调整模型或手误把这段覆盖掉了受保护接口的 ctx.session.user.id 就会变成 undefinedPrisma 写入外键时会直接报错。另外有个非常隐蔽的坑NEXTAUTH_SECRET 没有配置时preview 环境登录可能时好时坏刷新页面后 session 消失。生产部署一定要显式设置 NEXTAUTH_SECRET不能依赖默认值。4.4 部署阶段的环境变量那点事部署到 Vercel 时环境变量数量和本地不一样。除了 DATABASE_URL还需要 NEXTAUTH_URL、NEXTAUTH_SECRET以及 OAuth 应用的 Client ID 和 Client Secret。我在本地用 localhost 配置过的 GitHub OAuth 应用放到生产环境后回调地址完全不同忘记改 OAuth 回调就会一直登录失败。DNS 解析和 HTTPS 证书由 Vercel 处理这个问题不大。真正的坑是 migrate 时机。构建和部署时Prisma migrate dev 不能执行因为 migrate dev 会尝试和 schema 历史交互还可能弹出交互式提示。生产环境应该用npx prisma migrate deploymigrate deploy 会按照迁移文件按序执行适合自动化部署流程。你可以把它写进 Vercel 的 build 命令或 predeploy 钩子里也可以单独跑一次保证数据库结构先于应用代码更新。还有一个很多人忽略的点线上环境如果 schema 改了但忘了生成 client运行时会提示 Prisma Client 版本和 schema 不匹配。部署脚本里建议在 migrate deploy 之后执行 prisma generate。4.5 数组字段与数据库方言的限制tags 字段用 String[] 在 Postgres 上没有任何问题但如果想在 CI 或本地用 SQLite 快速跑测试这一行直接会让 migration 失败。我刚开始准备了一个 SQLite 分支来加速开发结果 prisma migrate dev 报错不支持数组类型只能重新换回 Postgres。解决方案有两个。一是坚持用 Postgres本地用 Docker 起一个临时实例。二是改用逗号分隔的字符串字段存的时候拼接取出时拆分牺牲一部分查询能力。t3code 选了前者因为标签过滤用数组的 has 操作符写起来太方便了。如果你要写单元测试可以考虑跑一个 Postgres 测试容器而不是依赖 SQLite 的内存模式。5. 性能优化与后续扩展5.1 查询与缓存优化t3code 跑通后我做的第一轮优化是给列表加 select 字段裁剪。数据库里 description 可能很长但列表页只需要标题、标签、发布时间详情页才需要全文。Prisma 的 findMany 支持 select 选择字段少取一个大数据字段传输量和 JSON 解析时间都能降下来。接着是分页。前面用 take: 30 硬编码限制了条数条目多了之后只能看到最早或最新的 30 条。改成 cursor 分页的标准做法是让客户端传一个 lastCursor服务端用 skip: 1 加 cursor 进行定位input: z.object({ cursor: z.string().optional(), tag: z.string().optional(), }), query: async ({ ctx, input }) { return ctx.db.idea.findMany({ where: { isPublic: true, ...(input.tag ? { tags: { has: input.tag } } : {}), }, orderBy: { createdAt: desc }, take: 30, skip: input.cursor ? 1 : 0, cursor: input.cursor ? { id: input.cursor } : undefined, }); },cursor 分页比 offset 分页的优势在后端offset 越往后查询越慢cursor 基于索引定位深翻页性能损失小得多。前端的 React Query 可以配合 useInfiniteQuery 的 getNextPageParam 自动加载下一页。最后给查询设置合理的 staleTime。列表数据不是实时性要求很高的场景可以把 staleTime 设为 30 秒甚至 60 秒减少用户来回切换页面时的重复请求。这个配置在 create-t3-app 生成的 api 实例里可以直接设置queryClient: new QueryClient({ defaultOptions: { queries: { staleTime: 30_000 } }, });别小看这个值团队小项目里省掉的大量无效请求都来自这个字段的默认值 0。staleTime 设为 0 意味着每次组件挂载都会发请求实际上很多数据根本不需要这么新鲜。5.2 从 t3code 继续长成更复杂的项目t3code 做完整套闭环之后再往复杂方向扩展有两条很自然的路径。第一是接入全文搜索。目前 tags 用数组过滤已经能支撑标签场景但标题和描述的模糊搜索用 SQL Like 在数据量上来之后性能会很差。Postgres 自带全文索引配合 pg_trgm 可以做模糊搜索加速如果数据量继续涨再考虑 Meilisearch 或 Typesense 这类独立搜索引擎。tRPC 层只需要新增一个 search 路由前端复用现有组件。第二是抽象服务层。现在路由里直接写了 Prisma 调用虽然方便但业务逻辑多了之后路由文件会越来越重。可以把校验、权限、业务规则拆到 service 函数里tRPC 路由只负责输入输出和调用 service。这个演进方式和传统后端里的 service 层思路一致区别只是类型在前后端之间是共享的。我个人在 t3code 项目上最深的体会是全栈 TypeScript 真正的收益不在少写了多少行代码而在类型系统把大量低级错误提前挡在了编译阶段。字段名写错、返回结构对不上、参数缺失这些都是以前联调时来回扯皮的来源现在写代码的过程中就能发现。如果你也想检验自己是否真正理解了这套技术栈的协作方式我建议把手上某个已经跑通的小项目用 T3 重写一遍重写时遇到的那些类型对不上的报错会比任何教程都更能帮你建立全链路思维。