资讯动态

T3 Stack 全栈基座:用 tRPC 实现端到端类型安全

发布时间:2026/10/26 23:27:05 来源:尧图企业网站定制
做全栈最烦的一件事不是业务逻辑写不出来而是前后端各写各的类型对不上、字段找不着、接口改了前端崩一片。我这次想聊的 t3code就是我自己针对这个问题攒下来的一套全栈项目基座整套东西围绕 T3 Stack 组织核心就一句话端到端类型安全让类型定义从数据库一路走到页面组件中间不落地任何手工维护的接口文档。这套项目能解决的大概是三类问题第一中小型团队从零起项目不用再花两周时间纠结技术选型第二内部工具、后台管理系统这类 CRUD 密集的应用可以做到“改一个字段类型错误直接爆红在编辑器里”第三副业开发者想快速验证一个想法需要一套开箱即用且不会在半年后变成技术债的脚手架。如果你正在评估 Next.js、TypeScript、Tailwind、tRPC 这套组合到底怎么配合这篇内容可以给你一份能直接照着做的参考。1. 为什么 t3code 把票投给了 T3 Stack 这套技术组合1.1 T3 Stack 的四个核心成员分别解决什么问题T3 Stack 不是某种新框架它是一组技术选型的组合方案。t3code 选了其中最主流的一组Next.js 做应用框架TypeScript 做全链路类型约束Tailwind CSS 负责样式tRPC 负责前后端接口层。四个成员各有各的定位配合起来之后代码从数据库到页面基本是一条直线。先说 Next.js。它解决了 React 应用“怎么组织页面、怎么做路由、怎么渲染”的问题。t3code 里的页面既是 Server Component 也是客户端组件的混合结构数据请求可以直接在服务端完成SEO 和首屏体验都能兼顾。更关键的是Next.js 内置了 API 路由和中间件能力不需要额外开一个 Node 服务t3code 的后端逻辑直接跑在同一个进程里。然后是 TypeScript。这层其实不用多说t3code 里不管是数据库模型、路由参数还是组件 props全部都有类型。它最大的价值不是“有类型”而是“类型能用在一个完整链路里”改了一个 Prisma model 字段编辑器会同步提醒你页面上坏了哪一行。Tailwind CSS 处理样式。原子类的方式一开始看着像 CSS 内联用习惯了会发现它最大的好处是不用频繁切换文件也不会出现“改一个全局样式影响三个页面”的灵异事件。t3code 里配合 shadcn/ui 的组件模型既有设计感又不锁死自定义能力。最后是 tRPC。这是整套选型的灵魂。它让前端直接“调用”后端函数调用方式像使用本地函数一样不用手写 REST 路径不用维护接口文档类型自动跟着走。后面我会专门拆这一块。1.2 和 REST、GraphQL 方案对比之后为什么选它t3code 在最早期也考虑过传统的 REST 方案前端用 axios后端写 controller然后在前端目录里维护一堆interface User { ... }。这套思路的问题不是不能用而是类型漂移。后端返回了created_at前端类型里写的是createdAt字段一旦多起来这种错误根本防不住。也认真评估过 GraphQL。GraphQL 确实强大尤其是复杂关联查询一次请求拿到所有嵌套数据。但它的代价是学习成本和工具链复杂度要维护 schema要写 resolver要考虑缓存策略查询一多还得操心性能。对 t3code 面向的“中小型业务系统”来说这是不必要的重量级方案。tRPC 的取舍思路完全不同。它不需要 schema 语言不需要 codegen不需要额外的类型描述文件。后端定义了一个查询函数前端通过 TypeScript 的 import 类型直接获得完整入参和出参类型。这不是技术上的“花活”而是把“类型”这件事简化到了不能再简。维度REST 手写类型GraphQLtRPC类型安全依赖手工维护易漂移强但需要 schema 评审强且类型直接来自代码学习成本低中高低调试体验要看网络面板看工具链插件像调用本地函数适合场景老项目迁移复杂聚合查询内部系统 / 快速迭代这套对比之后我确定了一个判断t3code 的核心目标是“最低成本地保持类型一致性”而不是“实现最复杂的查询能力”。想清楚这一点选型其实并不难。1.3 t3code 的模块划分思路按业务边界垂直切t3code 在项目结构上没有采用传统的“前端目录 后端目录”的横切法而是按业务模块垂直划分。每个模块内部自己包含 tRPC router、数据模型、页面组件和工具函数。以任务管理为例它的目录结构大致是这样的src/ modules/ tasks/ router.ts schema.ts actions.ts components/ TaskList.tsx TaskForm.tsx pages/ index.tsx [id].tsx auth/ router.ts components/ LoginForm.tsx这么设计的好处是当你想把任务模块整体复制到另一个项目或者判断某块功能的影响范围时不需要在几十个文件夹里来回翻。新增功能时永远遵循“先加模块再加散件”的节奏业务边界稳定了代码自然不容易烂。这种结构也带来一个需要适应的点t3code 里没有单独的routes.ts汇总文件路由的挂载分散在各模块的 router 里再统一聚合到根 router。刚开始会不太习惯但跑通两个模块之后你会发现模块自洽的感觉确实舒服。2. 端到端类型安全t3code 的关键技术细节拆解2.1 tRPC 是如何做到“像调用本地函数一样”调后端的tRPC 的原理其实不复杂它的底层就是 HTTP POST 请求加 JSON 序列化。但它把“请求定义”这件事整个藏了起来。你在后端定义一个 router前端用一个createTRPCProxyClient创建客户端代理之后所有 procedure 都变成了客户端上的一个可调用属性。来看 t3code 里的一个最小示例。假设有个taskRouter// server/api/routers/task.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const taskRouter createTRPCRouter({ list: protectedProcedure .input(z.object({ page: z.number().default(1) })) .query(async ({ ctx }) { const tasks await ctx.db.task.findMany({ orderBy: { createdAt: desc } }); return { tasks }; }), create: protectedProcedure .input(z.object({ title: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { title: input.title, userId: ctx.session.user.id } }); }) });前端调用时如果是 Server Component直接 import 服务端侧的 caller// app/tasks/page.tsx import { api } from ~/server/api/root; export default async function TasksPage() { const { tasks } await api.task.list({ page: 1 }); return TaskList initialTasks{tasks} /; }注意这里list的入参对象{ page: 1 }是有类型约束的page必须是 number多传一个pageSize会在编辑器直接报错。返回的tasks也带着完整的 Prisma 类型前端拿到的数据结构和数据库 schema 严格一致。这就是 tRPC 和传统 REST 最大的体验差异协议层不存在了你面对的是一个个“有类型的过程函数”。提示tRPC 本身不限制必须用 zod 做输入校验但这套组合里 zod 几乎是标配。它能保证运行时校验和类型推断使用同一份 schema避免“静态类型对运行时报错”的尴尬。2.2 数据层选型用 Prisma 管理模型、迁移和类型t3code 的数据层选择是 Prisma。选它的理由不复杂第一schema 文件非常直观一个 model 对应一张表第二迁移流程清晰开发环境prisma migrate dev一键完成第三它会根据 schema 自动生成完整的 TypeScript 类型这是端到端类型安全最关键的基础。任务模块的数据模型设计成这样的model User { id String id default(cuid()) name String? email String unique tasks Task[] } model Task { id String id default(cuid()) title String description String? status String default(todo) createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id]) tags Tag[] } model Tag { id String id default(cuid()) name String tasks Task[] }执行npx prisma migrate dev --name init之后Prisma 会生成 SQL 迁移文件并且自动刷新prisma/client的类型。如果你在页面里写了task.nonExistentFieldTypeScript 编译器会立刻标记出来。这种体验是纯 SQL 手写 mapper 很难给的。t3code 的开发环境默认使用 SQLite零配置起步等需要上线的再切到 Postgres连接串一换大部分代码不用改。这不是偷懒而是符合“快速验证优先”的原则。生产环境我建议使用 Postgres尤其是并发写入和 JSON 字段支持能力更稳。2.3 认证与权限NextAuth 和中间件守卫的配合t3code 的认证层用的是 NextAuth.js也就是现在常说的 Auth.js。它支持 GitHub、Google 等 OAuth 登录也支持邮箱密码的 Credentials Provider。项目里用 JWT 作为 Session 策略好处是无状态API 不需要额外的 session 存储部署到多实例容器时不用专门配置共享缓存。中间件负责路由守卫。Next.js 中间件可以配置哪些路径需要登录// middleware.ts import { withAuth } from next-auth/middleware; export default withAuth({ pages: { signIn: /login } }); export const config { matcher: [/dashboard/:path*, /tasks/:path*] };这样/tasks下的页面如果没有登录态会自动跳到/login。但要注意中间件只能做页面层级的保护真正的数据访问还需要在 tRPC 层用protectedProcedure做二次校验。t3code 里的做法是所有涉及用户数据的请求都统一走受保护 procedure里面会做 session 用户存在性检查并且把ctx.session.user.id注入上下文中避免每次都要在业务代码里重复取用户。重要JWT 方案一定要设置环境变量NEXTAUTH_SECRET否则部署后用户登录状态经常会“莫名失效”实际上是不同实例之间密钥不一致导致的。这个坑我在部署章节还会专门说。2.4 界面层方案Tailwind 原子类与 shadcn/ui 的组件模型界面方面 t3code 没有选择重型组件库而是用 Tailwind CSS 写原子类配合 shadcn/ui 的组件复制模式。所谓复制模式就是组件源码直接拷贝进项目里你可以完全掌控它没有样式被锁在依赖包内部。日常开发里我一般先搭页面结构再用 Tailwind 快速的调整间距、颜色和响应式。比如一个按钮button classNamerounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 创建任务 /button这样写的直观感受就是“快”改样式不用切文件不用找 className 命名的规律。配合 shadcn/ui 的表单、弹窗、表格组件CRUD 页面的效率提升非常明显。样式这块也顺便聊下暗黑模式。Tailwind 4.0 之后的dark:前缀配合 CSS 变量t3code 只在根组件维护一个 theme 状态所有颜色都走语义化变量夜间模式切换不需要给每个页面单独写逻辑。如果你要复刻这套组合建议一开始就设计好颜色变量不要写到后面再回头改。3. 从零搭建 t3code一个任务模块的完整落地过程3.1 初始化项目与目录约定第一步是用脚手架初始化。t3code 选择的官方脚手架直接帮你把 Next.js、TypeScript、Tailwind、Prisma、NextAuth、tRPC 全部配置好npm create t3-applatest过程中会问你要启用哪些功能我建议按 t3code 需要全选NextAuth、Prisma、Tailwind、tRPC。生成完项目之后第一件事是确认tsconfig.json里的路径别名能正常工作。t3code 统一使用~作为src的别名这样不管文件嵌多深import 看起来都很清爽。3.2 设计任务数据模型并完成首次迁移按照前一章给出的 Prisma 模型先编辑prisma/schema.prisma然后执行迁移npx prisma migrate dev --name init迁移命令会自动生成 SQL 和 client 类型。注意如果你是先改 schema 再跑命令中间可能会遇到 Prisma 要求确认重命名的地方。开发环境里建议随时跑npx prisma studio看一眼数据确认表结构符合预期。3.3 编写任务 CRUD 的 tRPC 路由t3code 的 CRUD 不会为每个操作单独建文件而是集中在一个taskRouter里。前面展示了list和create这里补全更新和删除update: protectedProcedure .input(z.object({ id: z.string(), title: z.string().min(1).optional(), status: z.enum([todo, doing, done]).optional() })) .mutation(async ({ ctx, input }) { const { id, ...data } input; return ctx.db.task.update({ where: { id }, data }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.task.delete({ where: { id: input.id } }); })有一点要特别提醒这些 procedure 虽然看起来像普通函数但它们内部依然运行在服务端永远不要把“端到端类型安全”误解成“前端可以直连数据库”。所有数据库操作都是在 Next.js API 层执行的前端拿到的只是序列化后的 JSON。3.4 用 Server Component 把页面接起来t3code 的页面推荐直接放在app/router下配合 Next.js 的 App Router。服务端组件里可以直接调用 tRPC 的 caller 完成数据预取然后传给客户端组件。这样首屏不会有 loading 状态数据在 HTML 里已经在了。如果需要交互比如表单提交可以使用 React Hook Form 配合 zodResolver。t3code 里的新建任务表单核心是这样的use client; import { useForm } from react-hook-form; import { zodResolver } from hookform/resolvers/zod; import { z } from zod; import { api } from ~/trpc/react; const formSchema z.object({ title: z.string().min(1, 任务标题不能为空) }); export function TaskForm() { const utils api.useUtils(); const createTask api.task.create.useMutation({ onSuccess: () utils.task.list.invalidate() }); const form useForm({ resolver: zodResolver(formSchema), defaultValues: { title: } }); return ( form onSubmit{form.handleSubmit((data) createTask.mutate(data))} input {...form.register(title)} / button typesubmit提交/button /form ); }useUtils().task.list.invalidate()的作用是让列表查询自动刷新。tRPC 内部维护了查询缓存数据变更之后只需要让对应的 key 失效页面不用手动 setState也不会出现“改了数据列表不更新”的尴尬。注意Server Component 里不能直接使用api.task.create.useMutation这类客户端 hooks。t3code 的约定是把“需要交互的组件”都加上use client服务端组件只负责数据获取和布局。3.5 Docker 部署的关键步骤t3code 的 Docker 部署我折腾过几个版本最终固定了一套多阶段构建方案。第一阶段装依赖第二阶段构建第三阶段只保留产物和运行所需文件FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci FROM node:20-alpine AS builder WORKDIR /app COPY --fromdeps /app/node_modules ./node_modules COPY . . RUN npx prisma generate RUN npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV production COPY --frombuilder /app/.next ./.next COPY --frombuilder /app/node_modules ./node_modules COPY prisma ./prisma COPY package.json ./ EXPOSE 3000 CMD [npx, prisma, migrate, deploy, , npm, run, start]部署前记得在环境变量里配好DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL。如果数据库是外部托管的迁移命令必须跑在启动之前不然新代码读旧表结构很容易 500。我曾经因为没有把prisma目录复制进 runner 阶段导致生产环境迁移找不到 schema 文件这个问题在下一章展开讲。4. 部署与排查我在 t3code 里踩过的 5 个高频坑4.1 tRPC 客户端的“幽灵类型报错”这个问题几乎每个用 tRPC 的人都撞过改了后端 router 之后前端调用处报错提示类型不匹配但代码逻辑看起来完全没问题。多数情况下是 Next.js 的 dev server 缓存了旧的类型声明。解决方式不复杂重启 dev server 就好如果还不行就删掉.next目录再跑。更隐蔽的一种情况是你在某个路由里 import 了一个类型但该类型并没有重新导出。tRPC 的类型系统依赖AppRouter的类型聚合如果新路由没有挂载到根 router 上前端自然看不到。所以遇到类型报错时先检查根 router 有没有同步。4.2 部署后登录状态不稳定或者接口返回 401t3code 使用 JWT session部署后如果出现“有时能登录有时又跳回登录页”九成是密钥配置问题。多实例部署时每个实例都用自己的随机NEXTAUTH_SECRETA 实例签发的 token 到了 B 实例就验不过。解决办法就是通过环境变量统一设置一个稳定的 secret。另外一个容易踩的点是NEXTAUTH_URL。容器内部访问自己要用http://localhost:3000对外回调地址要用公网域名。如果配错了OAuth 登录流程会在回调环节直接失败。排查这类问题先看环境变量再查中间件的 matcher 是否误伤了静态资源。4.3 Prisma 在生产环境找不到 schema 文件这个坑我很早就踩过。Dockerfile 里构建阶段执行过prisma generate但运行镜像里没有把prisma/目录复制进去启动阶段执行prisma migrate deploy时报错说找不到schema.prisma。正确做法是像前面 Dockerfile 示例里那样在 runner 阶段显式COPY prisma ./prisma。如果数据库操作提示 “client is not generated”那就要在构建阶段加一步npx prisma generate并确认node_modules/.prisma/client被打包进了产物。很多情况下npm prune --production会把 Prisma CLI 删掉所以要单独保留它或者把迁移放到 CI 里执行。4.4 多人同时跑迁移导致数据库锁表小团队项目通常只有一个人负责数据库变更但如果 CI 和本地同时跑prisma migrate devPostgres 就可能在_prisma_migrations表上锁表报错信息通常是migration already exists或database is locked。这个问题的最佳解是约定本地一律用migrate dev部署环境一律用migrate deploy。另外给生产数据库开启一个最小连接数的连接池限制能减少并发带来的连接风暴。4.5 高频问题速查表为了再压缩一点排错的时间我把 t3code 里实际遇到过的问题整理成了速查表按“问题现象 → 最可能的根因 → 处理动作”的顺序排列。问题现象根因方向处理动作前端调用 tRPC 方法类型不同步dev server 缓存或路由未挂载重启 dev server确认根 router 更新部署后登录回调失败NEXTAUTH_URL 不是对外地址改为公网回调地址并重启多实例下 session 反复失效NEXTAUTH_SECRET 不一致统一通过环境变量注入同一密钥启动报 Prisma schema 不存在镜像未复制 prisma 目录在 runner 阶段显式 COPY prisma前端能进页面但列表 401中间件只保护路由未保护 API全面改用 protectedProcedure 校验最后想单独说一个小习惯给 t3code 的根 router 加一个不需要认证的healthprocedure返回当前服务状态和数据库连通性。运维阶段排查问题时这个接口能快速区分“进程挂了”“数据库挂了”“网络不通”三种情况。小项目看着不起眼等事情多起来就知道它多值钱。如果你正在搭自己的全栈基座强烈建议把这条路走通它会给你剩下一整年的纠结时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑