资讯动态

用T3技术栈打造类型安全的全栈应用:t3code实践指南

发布时间:2026/10/14 5:22:09 来源:尧图企业网站定制
世界上有两种前端开发一种改接口文档改到麻木另一种让编译器帮你改接口文档。我坚定地想成为第二种于是有了 t3code 这个项目。t3code 不是某个官方框架是我给自己一套基于 T3 技术栈的全栈 Web 应用模板起的代号。T3 来自 TypeScript、Tailwind CSS、tRPC 三个名字的首字母由知名开发者 Theo 带火的一套组合通常配套 Next.js、Prisma 和 NextAuth。这套东西解决什么问题一句话用 TypeScript 把类型从数据库一路钉到 React 组件REST 时代那种前端等后端文档、后端等前端反馈的循环在这套体系里基本不存在。这篇文章我不会复述官方文档而是从我做 t3code 的前后决策讲起。容易跑偏的地方、上线前踩的坑、以及最终我把这套模板用在自己的小组件库里的实际感受都会说完。如果你是中小规模 Next.js 全栈应用、前端后端都由同一拨人维护这条路线值得你花半小时了解。1. 为什么我把新项目代号定为 t3code1.1 之前写接口时折磨我的三件事在进入 t3code 之前我一直用 Next.js 写全栈应用。API 层最常规的做法是在app/api下写 Route Handler。手动定义请求和响应类型。前端用 fetch 或 axios 调用再自己写一套类型断言。听起来不复杂但项目一变大问题就来了。第一类型没人保证同步。后端接口返回的Post结构改了前端调用处可能还以为是老结构。如果前端做了类型断言编译器会提示错误但最常见的操作是前端复制一份接口返回值类型两边的类型直接失去了血缘关系。改一处忘记改另一处运行时才会炸。第二每个接口都要自己写参数校验。前端传一个page参数后端得先判空、判类型、判范围。接口一多这种防御式代码铺得到处都是看着很稳实际没人维护。第三中间层太啰嗦。SEO 需求、登录态校验、权限判断散落在各个 API 函数里上下文管理靠拷贝粘贴。时间久了API 层的可读性和业务逻辑一样糟糕。我不否认 REST 在很多成熟团队里依然是合理选择但对我这种十几人小组、全栈由同一个人扛的项目来说它实在笨重。每次改一个字段要在后端类型、前端类型、接口文档、字段映射四个地方同步改漏掉一个就埋下一颗雷。1.2 t3code 的底层逻辑让类型成为唯一真相t3code 的核心逻辑只有一条不管你数据源头是什么所有访问路径上的类型都从同一个地方推导出来。数据库模型由 Prisma Schema 定义Prisma Client 根据 Schema 生成完整类型tRPC 的 procedure 定义了前端可以调用的函数签名前端通过类型推导拿到返回类型。两者叠加后我在前端组件里写const { data } api.post.getAll.useQuery(); // data 的类型就是后端返回的 Post[]完全由数据库模型推导如果哪天 Prisma Schema 里的Post表删掉了一个字段前端引用该字段的地方会同时报错。不是运行时不是联调时是编译时。这种改一处全链路提示的体验是传统前后端分离项目很难带来的。后面所有章节的内容本质上都是围绕让类型成为唯一真相这句话展开的。t3code 这个名字就是这种信仰的速记。2. t3code 技术栈逐个拆解谁在承担哪一层这一节先把 t3code 用到的主要组件过一遍。不是罗列特性而是说明每一项在这套体系里承担的职责。2.1 Next.js应用骨架与渲染边界t3code 的应用层建立在 Next.js 的 App Router 之上。这个选择在 T3 生态里几乎是默认的原因是它同时提供了服务端组件Server Components支持页面可以直接访问数据库减少一次人工 API 请求。路由处理器Route HandlerstRPC 的 HTTP 出口挂在这里。基于 Node.js 的服务端部署能力适合将整套应用发布到支持 Node 函数的平台。我的建议是如果你打算长期把前端静态导出output: exportT3 这套组合可能不太适合因为 tRPC 需要运行时的 HTTP 层。但如果你的目标运行环境是 Node.jsApp Router 就是最自然的选择它让服务端取数、客户端交互这个分工变得非常清晰。2.2 tRPCAPI 层的收缩器tRPC 做的事情可以理解成给前后端共用的函数调用通道加上类型约束。传统 RPC远程过程调用容易让人想起 SOAP/XML-RPC 那一代厚重的做法但 tRPC 是现代 TypeScript 原生的轻量实现。你不用写 OpenAPI 规范不用生成 client SDK也不需要 GraphQL 那一套 schema 语言代码本身就是协议// 服务端定义 import { z } from zod; import { createTRPCRouter, publicProcedure } from /server/api/trpc; import { prisma } from /server/db; export const postRouter createTRPCRouter({ getAll: publicProcedure.query(async () { return await prisma.post.findMany({ where: { published: true }, orderBy: { createdAt: desc }, }); }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) { return await prisma.post.findUnique({ where: { id: input.id } }); }), });前端在 tsx 文件里const { data } api.post.getById.useQuery({ id: abc }, { enabled: !!id });这里的api是从/trpc/react导出的 React 实例它会基于服务端appRouter的类型自动推导出所有可用 query/mutation。你永远不需要手写任何 API client 代码。tRPC 在 t3code 里承担的是唯一 API 入口职责。相比在app/api下散落一堆 REST 路由所有模块的 router 都被聚合在src/server/api/routers里统一挂到root.ts下。每个模块只需要关心自己的 procedure 和校验规则剩下的交给框架。2.3 Prisma数据库访问与迁移枢纽Prisma 在 t3code 里的定位是数据访问层。它读prisma/schema.prisma然后生成类型安全的数据库客户端。一个简单的Post模型model Post { id String id default(cuid()) title String content String published Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt author User relation(fields: [authorId], references: [id]) authorId String } model User { id String id default(cuid()) name String? email String unique posts Post[] }写模型跑npx prisma migrate dev再npx prisma generate。之后代码里所有涉及数据库查询的地方字段是否拼写正确、筛选条件是否合法都会在编译期检查。很多人会把 Prisma 单纯当成 ORM但它在 T3 里更重要的价值是Schema 成为前后端类型的共同原点。这也是我在 t3code 中不愿意换成别的底层库的核心原因迁移流程和内建的 studio 工具对我来说都足够成熟团队里最菜的新人也能上手。2.4 Tailwind CSS样式层的工程化如果你之前只用过 CSS Modules 或者 styled-components第一次打开 Tailwind 写出来的页面代码可能会比较吓人div classNameflex min-h-screen items-center justify-center bg-gray-100 px-4 div classNamew-full max-w-2xl rounded-xl bg-white p-8 shadow-lg h1 classNametext-2xl font-bold text-gray-900t3code Blog/h1 /div /div没有传统classNamecard这种语义类名取而代之的是大量原子类。刚上手会觉得是把 CSS 都塞进了 HTML但写过一段时间后你会发现它解决的恰恰是传统 CSS 最头疼的问题选择器优先级冲突和复用时的样式互相污染。t3code 里的 Tailwind 配合cn()工具clsxtailwind-merge使用我能把按钮这类高频组件封装得足够灵活又不至于臃肿import { cn } from /lib/utils; export function Button({ className, children, ...props }) { return ( button className{cn( rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700, className )} {...props} {children} /button ); }Tailwind 本身的版本也在快速迭代v4 的 CSS-first 配置更轻快不过在 t3code 里我仍用了稳定的 v3 配置原因只一个团队里的同事更熟悉tailwind.config.ts这套固定的配置文件。工具不必永远最新稳定协作优先。3. t3code 开发实录从初始化到完整模块跑通这一段按我实际的操作顺序把 t3code 从空目录到能跑的流程记录下来。命令和文件结构都来自我最近一次搭建你可以照着做。3.1 脚手架与目录调整T3 社区提供了一个创建模板的工具create-t3-appnpm create t3-applatest t3code交互式选择时我会勾选Next.js、Tailwind CSS、tRPC、Prisma、NextAuth.js。数据库我建议先用 SQLite 跑本地开发等部署前再切到 PostgreSQL两者在 Prisma schema 里只需要改 provider 和连接字符串。生成后的目录结构大致是t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts │ │ └── layout.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── root.ts │ │ │ └── trpc.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── react.tsx │ │ └── server.ts │ └── env.js有几个文件的作用值得讲一下src/server/db.ts导出 Prisma Client 的单例实例避免开发模式下数据库连接被反复创建。src/server/api/trpc.ts创建createTRPCContext、createCallerFactory和publicProcedure是每个 router 的基础。src/trpc/react.tsx创建 tRPC 的 React 实例同时把QueryClient和 Provider 包装起来。src/app/layout.tsx所有页面包裹在 Provider 里。template 文件生成后我做的第一件事是清理默认的示例代码然后立刻npx prisma db push把空库建好接着开始写业务模型。3.2 定义数据库模型与第一个 tRPC router我以一个典型的博客模块为例。在prisma/schema.prisma里加 Post / Category / Tag 模型。为了让示例贴近真实模型里尽量带上常用字段model Post { id String id default(cuid()) title String slug String unique excerpt String? content String published Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt category Category? relation(fields: [categoryId], references: [id]) categoryId String? tags Tag[] } model Category { id String id default(cuid()) name String unique posts Post[] } model Tag { id String id default(cuid()) name String unique posts Post[] }注意我故意把categoryId设计成可选的这更贴近真实内容系统文章可以先不归档后续再补。跑迁移并生成客户端npx prisma migrate dev --name init接下来在src/server/api/routers/post.ts里定义一个仅查询公开文章的接口import { z } from zod; import { createTRPCRouter, publicProcedure } from /server/api/trpc; import { prisma } from /server/db; export const postRouter createTRPCRouter({ listPublished: publicProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), cursor: z.string().optional(), }) ) .query(async ({ input }) { const posts await prisma.post.findMany({ where: { published: true }, take: input.limit 1, orderBy: { createdAt: desc }, include: { category: true, tags: true }, ...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}), }); let nextCursor: string | undefined; if (posts.length input.limit) { const nextItem posts.pop(); nextCursor nextItem?.id; } return { items: posts, nextCursor }; }), });这个 procedure 我写成分页查询靠cursor做游标分页返回的nextCursor直接给前端做加载更多。整个过程没有额外定义任何 response typePrisma zod 联合推导出了所有类型。把postRouter挂到root.tsimport { createTRPCRouter } from /server/api/trpc; import { postRouter } from /server/api/routers/post; export const appRouter createTRPCRouter({ post: postRouter, }); export type AppRouter typeof appRouter;AppRouter类型被前端引用这就是整套类型安全的起点。3.3 前端连通从页面到 API 再到组件在src/app/page.tsx里写首页并调用接口。import { api } from /trpc/server; export default async function HomePage() { const posts await api.post.listPublished({ limit: 10 }); return ( main classNamemx-auto max-w-3xl px-4 py-10 h1 classNametext-3xl font-boldt3code 博客/h1 PostList initialPosts{posts.items} initialCursor{posts.nextCursor} / /main ); }这里直接用服务端的 caller 在 Server Component 里同步取数。好处是首屏数据直接在服务端渲染完成不用经历客户端发请求的闪烁。对于需要交互的列表我把它做成 Client Component使用 React Query 的 useInfiniteQueryuse client; import { api } from /trpc/react; export function PostList({ initialPosts, initialCursor, }: { initialPosts: AwaitedReturnTypetypeof api.post.listPublished.query[items]; initialCursor?: string; }) { const query api.post.listPublished.useInfiniteQuery( { limit: 10 }, { getNextPageParam: (lastPage) lastPage.nextCursor, initialData: { pages: [{ items: initialPosts, nextCursor: initialCursor }], pageParams: [undefined], }, } ); return ( div {query.data?.pages.map((page) page.items.map((post) ( article key{post.id} classNamemb-6 rounded-lg border p-4 h2 classNametext-xl font-semibold{post.title}/h2 p classNametext-gray-600{post.excerpt}/p {post.category ( span classNametext-sm text-blue-600{post.category.name}/span )} /article )) )} {query.hasNextPage ( button onClick{() query.fetchNextPage()} classNamemt-4 加载更多 /button )} /div ); }useInfiniteQuery的类型完全来自服务端 procedure 的输入输出。getNextPageParam里读到的lastPage.nextCursor如果拼错了编辑器当场就会划线。3.4 接入 NextAuth 认证后的权限边界博客模块通常需要登录后写文章。T3 模板自带 NextAuth但配置有几个细节值得留意。我的做法是添加一个 GitHub provider然后把创建文章设为登录后可用import { z } from zod; import { protectProcedure } from /server/api/trpc; export const postRouter createTRPCRouter({ create: protectProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), slug: z.string().regex(/^[a-z0-9-]$/), }) ) .mutation(async ({ ctx, input }) { return await ctx.db.post.create({ data: { title: input.title, content: input.content, slug: input.slug, authorId: ctx.session.user.id, }, }); }), });protectProcedure是一个扩展后的 procedure内部先检查ctx.session没有用户直接抛错误import { TRPCError } from trpc/server; export const protectProcedure publicProcedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: ctx.session } }); });纯前端页面里登录状态用useSession().status判断未登录时隐藏编辑器。这解释了前面说的上下文管理权限逻辑被收敛在 procedure 的 middleware 中业务代码不用到处写if (!user) throw。权限判断在于路由设计而不在业务代码这是我做 t3code 过程中体会最深的一点。4. 上线前我踩过的坑排查思路与修复办法这一部分是在 t3code 真实开发中遇到的四个印象比较深的问题。每个问题我都会按照现象 - 定位 - 解决的顺序写尽量还原当时的排查过程。4.1 改了 Prisma 模型后前端报错出现幽灵字段有次我给Post模型增加了一个readingTime Int?字段跑完prisma generate后我在 tRPC router 的查询里用select指定了返回字段但漏写了readingTime。当时前端还没有任何组件引用这个新字段所以整体的类型检查完全通过页面看起来也正常——直到产品经理提出新文章详情页应该展示预计阅读时间我才发现接口的返回结构里根本没有这个数据。排查思路先看 Prisma Client 生成的类型prisma.post.findMany的select里没readingTime说明漏写了字段。再看浏览器网络面板接口返回 JSON 里确实没有readingTime字段。类型系统在前端还没有引用它时不会报错一旦前端开始引用tsc 立刻会以属性不存在的方式提示你返回结构缺字段。修复很简单把select列表补上readingTime再重新编译一次。这类问题在传统 REST 里只能靠肉眼看 JSON 或文档但在 t3code 里只要你真去用它编译期就会拦下来。4.2 Server Component 里的 tRPC 调用被重复执行我一开始在 Server Component 里直接调api.post.listPublished.query()发现服务端日志里同一个查询出现了两三次。查了一圈原来是页面组件同时被当成服务端取数和客户端上下文的一部分导致数据流被重复执行。定位路径检查src/app/page.tsx是否被标记了use client。检查 Provider 是否包裹了所有页面导致客户端也重新发起了一次取数。最终发现原因是我在布局里同时引用了服务端 caller 和客户端 hooks同一份数据被渲染两次。解决方式统一把首屏数据获取放在 Server Component 里客户端组件只接收经过序列化的 props。业务上尽量让一次数据流只走一条路线不要既在服务端 caller 又挂在客户端 React Query 上。这个原则在团队协作尤其重要——两种取数方式并存时新人很容易误用造成额外的请求浪费。4.3 Prisma 迁移与线上数据库不同步本地开发顺手跑了prisma migrate dev --name add-reading-time但部署到生产后应用启动时一访问数据库就报错说找不到readingTime字段。原因是我漏了生产环境的迁移操作。部署前必须执行npx prisma migrate deploy npx prisma generatemigrate dev是开发模式的命令会在本地创建迁移记录并打数据migrate deploy才是生产环境应用迁移的正确选择。这个坑很多人踩因为本地一切正常部署时只看 build 是否通过忽略了数据库 schema 的同步。提示CI/CD 的构建脚本里prisma generate应该放在next build之前执行否则生产环境的 Prisma Client 可能还是旧类型。4.4 Tailwind 动态类名不生效在写加载更多的按钮时我用了模板字符串拼接类名的写法const color variant primary ? bg-blue-600 : bg-gray-600; button className{mt-4 ${color}}加载更多/button结果bg-blue-600在本地正常部署到 Vercel 后样式消失。原因很经典Tailwind 的 JIT 模式会扫描源码中的类名运行时拼出的字符串不在扫描范围内所以没有生成对应 CSS。修复有两条路把完整的类名字符串写进源码不用拼变量。在tailwind.config.ts里用safelist显式声明动态类名。如果你在团队里维护设计系统建议从一开始就把按钮颜色变化用完整类名映射表管理不要在业务代码里动态拼 Tailwind 类。这个坑在 T3 里不会报错纯粹是样式悄悄消失型问题排查起来非常耗时间。四个问题其实可以概括成一张表坑根因修复方式Prisma 新字段前端拿不到用了select没包含新字段在 select 列表补上字段并重编译Server Component 查询重复执行服务端 caller 与客户端 hook 同一数据流叠加统一由 Server Component 取数props 传给子组件线上数据库报错缺字段只在本地跑了migrate dev部署前置migrate deployprisma generateTailwind 类名不生效运行时拼接类名躲开了 JIT 扫描用完整类名或safelist5. t3code 从个人 Demo 变成团队基建时的调整最后聊一聊我把 t3code 从个人玩票推向小团队日常开发后做的一些调整。这几个点不是模板自带的但到了第二个人开始用你的模板时几乎必然遇到。5.1 环境变量的治理T3 模板使用环境变量校验工具来管理环境配置。我补充了自定义变量比如文件上传的存储配置和预热脚本地址。统一的模式是在src/env.js里集中声明凡是类型不合法应用在启动阶段直接失败而不是运行时悄悄崩。import { createEnv } from t3-oss/env-nextjs; import { z } from zod; export const env createEnv({ server: { DATABASE_URL: z.string().url(), AUTH_SECRET: z.string().min(32), }, client: { NEXT_PUBLIC_APP_URL: z.string().url(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, AUTH_SECRET: process.env.AUTH_SECRET, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, }, });这里强调一点生产环境不要试图用一个.env.local里的大杂烩来骗过校验部署平台的变量列表要和env.js完全一致否则重启服务你就会看到启动时的一堆红色报错。变量一多把声明、默认值、示例值分开放能省掉后面无数次为什么线上连不上数据库的抓狂时刻。5.2 团队协作时的 Router 划分约定项目变大后把所有 procedure 塞进 root router 不是好习惯。我的约定是按业务域拆文件userRouter、postRouter、commentRouter等。每个 router 文件只依赖同一业务域对应的 model。交叉查询比如文章列表需要带上作者昵称就在查询里用include处理不要让前端额外发 N 个请求。mutation 统一返回操作后的实体而不是返回{ success: true }这样前端可以直接用返回值更新缓存。这套约定其实和 RESTful 接口设计里的资源划分逻辑相通只是 tRPC 把类型约束加得更死了反倒是好事——放错位置的方法编译器会告诉你这个 route 不存在。5.3 监控和日志该从哪里看最后提一嘴监控。t3code 这类单体 Next.js 应用日志天然分三处服务端 Server Component 的日志平台控制台的标准输出。tRPC 内部抛出的 TRPCError需要自己加 logger 中间件。数据库慢查询Prisma Client 里的log: [query]配置。export const prisma new PrismaClient({ log: process.env.NODE_ENV development ? [query, error, warn] : [error], });生产环境我一般只保留 error 级日志避免把查询记录刷爆。排查核心业务问题时再临时把 query 日志打开。加一个简单的 tRPC logger 中间件也有必要它能记录每个 procedure 的耗时长尾。我在 t3code 里加过一个不到 20 行的中间件输出每个 query 的方法名和耗时上线初期排查接口慢的问题全靠它。t3code 做到现在最大的感受不是新工具真香而是类型安全这件事一旦用起来就很难回头。如果你也在前后端接口对接上磨了很久我建议先拿一个小模块试试 tRPC把 Prisma Schema、zod 校验和前端组件串在一起跑一遍。中间有些配置确实需要耐心但只要第一个模块通了后面所有页面的打开方式都是一样的改模型改 router改组件编译器会告诉你还有哪里没跟上剩下的事情交给 TypeScript 就好。最后再说一个我自己顺手的小实践给每个外部 API 请求都戴上 zod 解析这样即使将来哪一天后端返回结构变了你的应用也不会把脏数据一路带进界面。

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

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

免费获取报价 →
↑