t3code 全栈 TypeScript 类型安全实践:tRPC + Prisma + Tailwind 工程化指南

发布时间:2026/10/26 9:28:44
t3code 全栈 TypeScript 类型安全实践:tRPC + Prisma + Tailwind 工程化指南 1. 项目缘起与核心定位第一次看到“t3code”这个名字我下意识以为是某个新出的低代码平台或者代码生成工具。翻了一圈资料、也自己动手跑了几轮之后才明白它更像是一个围绕“T3 技术栈”构建的代码组织与工程化实践方案——T3 指的是 TypeScript、tRPC、Tailwind 这一套组合拳而 code 则强调它最终落地到的是“怎么写代码、怎么组织代码、怎么让代码在端到端之间保持类型安全”。说白了t3code 解决的是一个很具体的痛点全栈 TypeScript 项目里前后端之间的类型契约总是容易断。你可能用 Prisma 定义了数据库模型用 Zod 写了校验用 React 写了页面但中间那层 API 调用往往是靠手写 fetch 或者 axios 拼出来的类型对不上、字段改了忘了同步、运行时才报错——这些事我踩过太多次。t3code 的思路就是把这条链路用类型系统串起来让编译器帮你抓错而不是等用户点出 500。它适合谁如果你正在做一个中小型全栈项目团队里有人懂 TypeScript又不想引入 GraphQL 那套相对重的 schema 管理那 t3code 这套模式非常值得参考。哪怕你只是前端出身、后端只会写点简单的 Node 接口也能顺着它的结构把项目搭起来。反过来如果你的项目是纯静态页面、或者后端是 Java/Go 那种强类型语言且已有成熟契约管理那 t3code 的收益就没那么明显了。我个人的判断是t3code 不是一个库而是一种“约定 工具链”的组合。它的价值不在于某个 API 多神奇而在于它把“类型安全”这件事从数据库一路贯穿到了 UI 组件中间没有断点。下面我就按自己实际搭项目的顺序把整套思路拆开讲。2. 整体架构设计与选型逻辑2.1 为什么是 TypeScript tRPC Tailwind 这三件套先说说选型背后的逻辑。市面上全栈方案很多Next.js 自带 API Routes、Remix 有 loader/action、NestJS 有装饰器那一套为什么 t3code 偏偏强调这三样TypeScript 是地基。没有 TS后面所有类型推导都无从谈起。我见过不少项目嘴上说用 TS实际上any满天飞那跟写 JS 没区别。t3code 要求你把类型当回事尤其是strict模式要打开noUncheckedIndexedAccess也建议开——这个选项能帮你抓出数组越界访问我一开始嫌它烦后来发现它救了我好几次。tRPC 是粘合剂。它的核心卖点就是“没有 schema 文件、没有代码生成步骤类型直接从服务端推导到客户端”。传统 REST 你要么手写类型、要么用 OpenAPI 生成中间总有一道同步工序。tRPC 把这道工序省了你在服务端定义一个 procedure客户端useQuery的时候类型自动就出来了改字段名编译器立刻报错。这个体验一旦用过就回不去。Tailwind 是表现层。有人觉得 Tailwind 是“把 CSS 写进 className”不够优雅。但从工程角度看它解决了两个实际问题一是样式和组件同文件改样式不用跳来跳去二是设计系统约束间距、颜色都从配置里来团队协作时不会出现十种不同的蓝色。t3code 里 Tailwind 不是必须的但它和这套“快速迭代、类型驱动”的风格很搭。注意tRPC 并不是银弹。如果你的 API 需要被非 TS 客户端比如移动端原生、第三方合作方调用那 tRPC 就不合适还是得走 REST 或 GraphQL。t3code 的适用边界是“前后端都是你自己控制的 TS 项目”。2.2 目录结构怎么划分才不乱我试过好几种目录组织方式最后稳定下来的结构是这样的src/ server/ api/ routers/ user.ts post.ts root.ts trpc.ts db/ client.ts auth/ pages/ 或 app/ components/ utils/ styles/关键点在于server 和 client 的边界要清晰。server/api下面只放服务端代码绝对不能被客户端 import 到否则数据库连接串就泄露了。tRPC 的trpc.ts里定义 context 和 procedure 基类root.ts把各个 router 合并成appRouter然后导出AppRouter类型给客户端用。这里有个细节只导出类型不导出实现。客户端import type { AppRouter } from ~/server/api/root这样打包时不会把服务端代码带进去。我一开始没注意结果 bundle 里混进了 Prisma体积暴涨排查了半天。2.3 数据流是怎么走的整个数据流可以这样理解数据库Prisma→ 服务端 proceduretRPC→ 客户端 hookReact Query→ 组件渲染。每一层都有类型约束改任何一层上下游都会报错。举个具体例子。数据库里 User 表有个name字段Prisma 生成的类型是string。服务端 procedure 返回user对象类型自动推导。客户端const { data } trpc.user.getById.useQuery({ id })此时data的类型就是{ id: string; name: string; ... } | undefined。如果你在组件里写data.nameTS 会提醒你可能 undefined逼你做空值处理。这个链条里没有任何手写类型全靠推导。这种设计的好处是重构成本极低。我想把name拆成firstName和lastName改 Prisma schema、跑迁移、改 procedure 返回值然后编译器会把所有用到name的地方标红一个个改就行。坏处是类型推导有时会变慢项目大了之后 IDE 会卡这个后面讲优化。3. 核心细节解析与实操要点3.1 tRPC 的 procedure 到底怎么写tRPC 的核心概念是 procedure分 query读和 mutation写。我一般会在trpc.ts里先定义好基础 procedureimport { initTRPC, TRPCError } from trpc/server; import { type Context } from ./context; const t initTRPC.contextContext().create(); export const router t.router; export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user } }, }); });protectedProcedure这个中间件很关键它把“登录校验”这件事收敛到一处。所有需要登录的接口都用它不用每个接口里重复写if (!user) throw。而且next里重新构造 ctx 之后后续 procedure 里ctx.session.user的类型就是非空的不用再!断言。然后写具体 routerimport { z } from zod; import { protectedProcedure, publicProcedure, router } from ../trpc; export const postRouter router({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(50).default(10) })) .query(async ({ ctx, input }) { return ctx.db.post.findMany({ take: input.limit, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(200), content: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), });这里 Zod 负责输入校验校验失败会直接返回错误不会进到业务逻辑。我特别喜欢这个设计因为以前写 Express 的时候校验散落在各个 handler 里容易漏。现在 input 定义即校验规则还能推导出 TS 类型一举两得。3.2 客户端调用与缓存策略客户端这边tRPC 配合 React Query 使用。先建一个 trpc clientimport { createTRPCReact } from trpc/react-query; import type { AppRouter } from ~/server/api/root; export const trpc createTRPCReactAppRouter();然后在根组件挂 Provider配置 httpBatchLink。这里有个优化点用 superjson 做序列化这样 Date、Map、Set 这些类型能正确传输不然 Date 会变成字符串用的时候还得手动转。调用的时候const { data, isLoading } trpc.post.list.useQuery({ limit: 20 }); const createPost trpc.post.create.useMutation({ onSuccess: () { utils.post.list.invalidate(); }, });invalidate这步很重要。mutation 成功后列表数据已经过期了得手动让它重新拉。我一开始忘了写结果创建完帖子列表不刷新还以为是 bug。React Query 的缓存机制是“stale-while-revalidate”默认 staleTime 是 0但你不主动 invalidate它不会立刻重拉。实操心得对于变动不频繁的数据比如分类列表可以设staleTime: 5 * 60 * 1000减少请求。对于实时性要求高的保持默认并配合 invalidate。3.3 数据库层与类型同步Prisma 这块schema 定义完跑prisma generate会生成类型到node_modules/.prisma/client。tRPC 的 ctx 里注入db实例procedure 里直接用返回的类型自动就是 Prisma 的类型。有个坑要注意Prisma 返回的 Date 对象经过 tRPC 传输后在客户端是 Date 还是 string取决于你有没有配 superjson。没配的话是 string你在组件里调date.getFullYear()会报错。配了 superjson 就还是 Date。这个我踩过排查的时候一脸懵。另外不要直接把 Prisma model 整个返回给客户端。比如 User 表有passwordHash字段你findMany直接返回就把哈希泄露了。正确做法是用select明确指定要返回的字段或者写一个publicUserSelect常量复用。3.4 环境变量与配置管理t3code 这套东西依赖几个环境变量DATABASE_URL、NEXTAUTH_SECRET如果用 NextAuth、NEXTAUTH_URL等。我建议用t3-oss/env-nextjs这个包来做校验它能在启动时就检查环境变量是否齐全、格式对不对而不是等到运行时才报错。import { createEnv } from t3-oss/env-nextjs; import { z } from zod; export const env createEnv({ server: { DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), }, client: { NEXT_PUBLIC_APP_URL: z.string().url(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, }, });这样env.DATABASE_URL用的时候类型是 string不用!断言。而且部署时如果忘了配某个变量构建阶段就失败了不会等到线上才炸。4. 完整实操流程与关键环节4.1 从零初始化项目我一般用create-t3-app这个脚手架起步它会把 Next.js、tRPC、Prisma、Tailwind、NextAuth 都配好。命令是npm create t3-applatest my-app交互式选择里我通常勾选 TypeScript、Tailwind、tRPC、Prisma、NextAuth。App Router 看项目需求新项目我倾向选 App Router虽然有些库兼容性还在完善但大势所趋。初始化完之后目录结构已经搭好了。接下来改.env把DATABASE_URL指向本地 Postgres。我用 Docker 起一个docker run --name t3-postgres -e POSTGRES_PASSWORDpassword -p 5432:5432 -d postgres:16然后DATABASE_URLpostgresql://postgres:passwordlocalhost:5432/mydb。跑npx prisma db push把 schema 同步到数据库。4.2 定义第一个业务模型假设做一个简单的博客先改prisma/schema.prismamodel Post { id String id default(cuid()) title String content String published Boolean default(false) authorId String author User relation(fields: [authorId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }跑npx prisma db push和npx prisma generate。注意db push适合开发阶段快速迭代生产环境要用prisma migrate生成迁移文件这样有版本记录、可回滚。4.3 写 router 并接入在src/server/api/routers/post.ts里写上面提到的 postRouter然后在root.ts里注册export const appRouter createTRPCRouter({ post: postRouter, user: userRouter, });客户端就能trpc.post.list.useQuery()了。这里有个细节App Router 下 tRPC 的路由 handler 要放在app/api/trpc/[trpc]/route.ts用fetchRequestHandler处理。Pages Router 则是pages/api/trpc/[trpc].ts。两种模式配置不一样别搞混。4.4 页面里消费数据写一个列表页use client; import { trpc } from ~/utils/trpc; export default function PostList() { const { data, isLoading, error } trpc.post.list.useQuery({ limit: 20 }); if (isLoading) return div加载中.../div; if (error) return div出错了{error.message}/div; return ( ul {data?.map((post) ( li key{post.id}{post.title}/li ))} /ul ); }注意data?.map里的可选链因为 query 返回类型是T | undefined。这是类型系统在提醒你处理加载态不是找麻烦。4.5 表单提交与乐观更新创建帖子的表单用 mutationconst utils trpc.useUtils(); const createPost trpc.post.create.useMutation({ onMutate: async (newPost) { await utils.post.list.cancel(); const prev utils.post.list.getData({ limit: 20 }); utils.post.list.setData({ limit: 20 }, (old) [ { ...newPost, id: temp, createdAt: new Date() } as any, ...(old ?? []), ]); return { prev }; }, onError: (_err, _newPost, ctx) { if (ctx?.prev) utils.post.list.setData({ limit: 20 }, ctx.prev); }, onSettled: () { utils.post.list.invalidate(); }, });这是乐观更新的写法提交时先把新数据塞进缓存用户立刻看到效果失败了回滚最后无论成败都 invalidate 拉真实数据。这个体验比“转圈等接口”好很多但要注意临时 id 的处理真实 id 回来后要替换否则 React key 会冲突。5. 常见问题与排查技巧实录5.1 类型推导慢或 IDE 卡顿项目大了之后tRPC 的类型推导会变得很重VSCode 里 hover 一个变量要等好几秒。我试过几个办法拆分 router不要把所有 procedure 塞一个文件按业务域拆开每个文件小一点。用inferRouterOutputs提取类型需要显式类型时用它而不是让 TS 去推导整个 router。升级 TS 版本TS 5.x 在类型推导性能上有优化我升到 5.3 之后明显好转。限制strict范围如果实在卡可以对server目录单独放宽某些选项但我不推荐会失去类型保护。5.2 数据库连接池耗尽开发时热重载频繁每次重载都新建 Prisma Client连接池很快满了。解决办法是用全局单例const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这样热重载时复用同一个实例。生产环境不需要因为不会热重载。5.3 tRPC 错误信息不清晰默认情况下tRPC 在生产环境会把错误信息脱敏只返回INTERNAL_SERVER_ERROR。开发时想看详细堆栈要在trpc.ts里配errorFormatterconst t initTRPC.contextContext().create({ errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, });这样 Zod 校验失败时客户端能拿到具体哪个字段错了方便表单展示错误提示。5.4 常见问题速查表问题现象可能原因排查方向客户端类型是 any没导出 AppRouter 类型或 import 路径错检查import type { AppRouter }请求 404tRPC handler 路径配错App Router 用[trpc]/route.tsDate 变字符串没配 superjson在 links 里加 superjson登录态拿不到context 没注入 session检查createContext实现mutation 后列表不刷新没 invalidate在 onSuccess/onSettled 里调用构建时报 Prisma 类型缺失没跑 generate在 build 脚本前加prisma generate避坑技巧部署到 Vercel 这类平台时postinstall脚本里要加prisma generate否则构建时找不到 Prisma Client 类型。这个坑我踩过本地好好的一部署就挂。5.5 关于鉴权的几个细节NextAuth 和 tRPC 配合时getServerSession要在 context 里调用。App Router 下传req和res的方式和 Pages Router 不同容易搞错。我的做法是封装一个createContext内部统一处理procedure 里只管用ctx.session。另外不要在客户端组件里直接调getServerSession那是服务端 API。客户端要用useSessionhook或者干脆通过 tRPC 的protectedProcedure来间接判断——如果没登录请求会返回 UNAUTHORIZED客户端据此跳转登录页。6. 性能优化与扩展思路6.1 请求批处理与缓存tRPC 的httpBatchLink会把同一时间发出的多个请求合并成一个 HTTP 请求减少网络往返。这个在页面初始化时同时拉多个 query 的场景下效果明显。配置httpBatchLink({ url: ${getBaseUrl()}/api/trpc, transformer: superjson, })配合 React Query 的缓存相同 query key 的请求在 staleTime 内不会重复发。我一般把列表类 query 的 staleTime 设 30 秒详情类设 5 分钟用户主动刷新时再 invalidate。6.2 服务端渲染与预取Next.js App Router 下可以在 Server Component 里预取数据然后传给 Client Component。tRPC 提供了createServerSideHelpers来做这件事const helpers createServerSideHelpers({ router: appRouter, ctx: await createContext(), transformer: superjson, }); await helpers.post.list.prefetch({ limit: 20 });然后在客户端用trpc.post.list.useQuery()时数据已经在缓存里了首屏不会闪加载态。这个对 SEO 和体验都有帮助但要注意预取的数据要能被序列化Date 对象经过 superjson 处理没问题。6.3 后续可以怎么扩展这套结构搭好之后扩展方向挺多的。比如加文件上传可以用 tRPC 的 mutation 接收 base64 或者预签名 URL加实时通知可以配合 WebSocket 或者 Server-Sent EventstRPC 有 subscriptions 支持但配置稍复杂加权限分级可以在 procedure 中间件里根据 role 判断比在每个接口里写 if 优雅得多。我个人的体会是t3code 这套模式最大的价值不是省了多少代码而是把“类型安全”变成了默认状态。你不需要刻意去维护类型编译器会推着你走。刚开始可能觉得约束多、不自由但项目跑到后期重构和加功能的时候那种“改一处、编译器告诉你哪里要跟着改”的踏实感是动态类型语言给不了的。最后分享一个小技巧如果你觉得 tRPC 的useQuery返回的data总是T | undefined很烦可以封装一个useQuerySafehook在isLoading为 false 且data存在时才渲染子组件把空值处理收敛到一处。这样业务组件里就能直接拿到非空数据代码干净不少。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询