news 2026/10/26 8:41:37

t3code:T3全栈模板如何打通类型安全与业务分层

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
t3code:T3全栈模板如何打通类型安全与业务分层

先说结论:如果你和我一样,每次开新全栈项目都要在 Next.js、TypeScript、tRPC、Prisma 之间重新接线,那么 t3code 就是一个可以少走弯路的起点。它不是官方 create-t3-app 的替代品,而是我在维护多个业务项目后整理出来的一套实践模板:保留 T3 技术栈端到端类型安全的核心,同时把服务端组件、认证上下文、数据更新缓存这些容易写乱的部分做成固定套路。这篇文章聊聊 t3code 为什么这样设计、核心代码怎么组织,以及实测里我踩过的几个坑。适合正在搭全栈模板、对类型安全有执念、或者刚接触 T3 技术栈的读者。

1. 为什么我会做一个叫 t3code 的项目模板

1.1 从“复制祖传项目”到“自己维护接线规范”

很多团队的老项目里都藏着历史包袱:早期为了赶进度把业务逻辑全堆在页面文件里,后来抽象了一层 lib,再后来引入 tRPC 就乱了,一个 router 文件长了上千行,schema 散落各处,连数据库字段改名都要全局搜索好几遍。我手上几个项目就是这么过来的,每次新项目启动,最省事的办法是复制那个最完整的仓库,然后删掉不需要的页面。但删除过程本身就是一场冒险:删掉一个依赖,另一处隐式引用马上崩;改了一个环境变量,构建时才发现还有一处硬编码。往往项目还没写业务,先花一下午清理环境配置、过时的依赖和冲突的类型定义。

t3code 就是想解决这个复制粘贴问题。它不是传统意义的脚手架——脚手架生成一次就结束了,后续改动还是会散。我更愿意把它看成一套“接线规范”:数据库表、API、页面组件、客户端缓存这四层之间用什么方式连起来,类型走到哪一步,都由同一个模式约束。新项目从模板出发,业务代码是干净的;老项目也能照着它重构,一步步把跳层调用改回来。我最初给自己定的目标是:从拉取模板到跑通一个带登录、带数据库、带列表页面的最小业务,控制在十分钟以内。现在 t3code 基本能做到。

1.2 命名与定位:t3code 就是“T3 技术栈的代码组织”

t3code 这个代号拆开看就是 T3 Stack + Code。T3 技术栈通常指 Next.js、TypeScript、tRPC、Tailwind CSS、Prisma 这套组合,核心吸引力是端到端类型安全:数据库表结构变了,TypeScript 编译会直接报错,不用靠记忆维护接口文档。但 T3 官方 create-t3-app 生成的目录其实偏扁平,沿用 starter 里示例的写法,适合小项目快速起步;业务一旦复杂,router、schema、组件之间怎么分层就需要自己立规矩。

我整理的 t3code 就是在 create-t3-app 基础上,额外定义了一套模块化目录和边界规则。它面向两类人:一类希望把全栈模板沉淀为团队标准的开发者,另一类是刚接触 tRPC、想看到一个完整链路如何工作的新手。它不做业务框架,也不封装黑盒,所有代码都摊开,你看得见每条数据是怎么走的。如果某天你不认同某个约定,完全可以只改自己的模块,其他部分不受影响。

2. 从 create-t3-app 出发:默认配置里我改了什么

2.1 保留的技术栈及联动关系

先放一张我保留的选型表,方便对照:

层技术作用
应用框架Next.js(App Router)页面、路由、服务端组件
语言TypeScript strict全链路类型约束
ORMPrisma数据库映射,生成类型
APItRPC前后端同构的远程调用
缓存TanStack Query异步状态管理和服务端缓存
样式Tailwind CSS原子化 CSS
认证NextAuth.jssession 管理与 OAuth 接入

这层组合的逻辑是:Prisma 生成的类型可以作为 tRPC 的输出类型来源,tRPC router 的输入用 zod 在运行时校验,校验规则和 TypeScript 类型可以在前后端共享;页面里用 TanStack Query 的 hooks 自动把 tRPC 调用变成带缓存的状态。每次数据从 MySQL 或 PostgreSQL 里查出来,直到渲染成 UI,TypeScript 类型都没有断过。这一点是 t3code 的立身之本,也是我选型时最先确认的前提:数据库层必须能生成强类型,API 层必须能复用这些类型,否则就退回到手写接口文档的旧路。

2.2 我调整掉的五个默认习惯

官方模板并不是所有默认项都适合长期维护,我做了五处调整:

第一,把 tRPC router 从“按模型堆文件”改为“按业务域拆分”。官方 create-t3-app 示例里的 post router 适合演示,但真实项目里一个用户域除了用户信息还有积分、关注关系,全堆在 user.ts 里会爆炸。t3code 改成 features 目录,每个目录自治,router、schema、组件都放在一起。

第二,环境变量集中校验。以前 DATABASE_URL 漏配置,往往要运行到一半才报错。我在 src/env.js 里加了一层 zod 校验,启动前统一检查,缺哪个变量直接列出,而不是让 Prisma 或 NextAuth 给出一个上下文无关的报错。

第三,NextAuth 的 session 策略改为只在数据库存 session,JWT 里只放必要信息。这样多实例部署时不会出现 session 不同步,代价是每次请求多一次查询,但业务上值得。

第四,给 prisma generate 加了 postinstall 钩子。这个后面细说,也是协作踩坑最多的地方。第五,把全局错误处理统一到 tRPC 中间件里,而不是每个 resolver 里 try/catch。业务层只管抛 ZodError 或自定义业务错误,中间件负责格式化响应和记录日志。

调整的原则很简单:凡是“每个业务模块都会重复做”的事情,都往基础设施里收;凡是“只有某个模块才关心”的事情,都往 features 里放。模板代码不是越少越好,而是要让新业务代码保持重复、直白、不绕弯。

2.3 没有做选择的两个地方

我也看到网上很多人给 T3 模板加 monorepo、加 storybook、加 docker 编排,但 t3code 刻意没有默认集成。原因不是它们不好,而是模板一上来就引入太多概念,新人很难分清哪些是业务必须、哪些是工程化增强。monorepo 我保留最小支持:src 内部按 domain 分,但并没有拆成多个 package。等团队真的面临独立发布需求时,再拆也不迟。

还有一个地方是状态管理。T3 官方推荐使用 TanStack Query 处理服务端状态,再用 useState 或 zustand 处理客户端状态。我默认只存 TanStack Query,不配置全局 store。因为大部分全栈应用里,真正需要共享的客户端状态很少,一上来就上 store,最后往往变成缓存和业务状态混在一起,非常难调试。真有需要,框架也能随时加。

3. t3code 的目录层级与端到端类型流

3.1 一眼能看懂的目录结构

t3code ├── src │ ├── app # Next.js 页面路由 │ │ ├── (auth) # 登录、注册相关布局 │ │ └── dashboard │ ├── features # 业务域,按域独立 │ │ ├── posts │ │ │ ├── api # tRPC resolver 和 router │ │ │ ├── components # 页面用到的组件 │ │ │ └── schemas # zod 输入校验 │ │ └── users │ ├── server # 服务端基础设施 │ │ ├── api │ │ │ ├── trpc # tRPC 初始化、context、中间件 │ │ │ └── root.ts # 聚合所有子 router │ │ └── db # Prisma client 单例 │ ├── styles │ └── env.js # 环境变量校验 └── prisma └── schema.prisma

这个结构最核心的一点是把“基础设施”和“业务模块”分开。src/server 下的东西基本很少改动,属于平台;src/features 下每个目录是垂直切片,数据库访问、API 逻辑、UI 组件都离得很近。新人加入项目时,第一眼就能判断“我要改的帖子功能在哪”,不需要从 app 目录一路翻到 lib 再翻到 api。这也是 t3code 和 create-t3-app 默认结构最大的差异:默认结构适合展示技术栈,t3code 适合承载真实业务。

3.2 类型安全如何从数据库流到页面

这一节用一个小例子说明 t3code 的类型流。假设 posts 表有 title 和 content 两个字段:

model Post { id String @id @default(cuid()) title String content String authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) }

运行prisma generate后,Post 类型可以被 tRPC router 引用。现在写一个查询 resolver:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { db } from "~/server/db"; export const postsRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ cursor: z.string().nullish() })) .query(async ({ input }) => { const posts = await db.post.findMany({ take: 20, skip: input.cursor ? 1 : 0, cursor: input.cursor ? { id: input.cursor } : undefined, include: { author: { select: { name: true } } }, orderBy: { createdAt: "desc" }, }); return { items: posts, nextCursor: posts[20]?.id }; }), });

注意返回的 items 类型完全由 Prisma 推断出来,我没有手写。前端调用时:

const { data } = api.posts.list.useQuery({ cursor: null }); // data?.items 的类型:Post & { author: { name: string } }[]

如果哪天后端把 content 字段改成 contentHtml,前端编译立刻失败,而不是等到运行时才发现字段缺失。这就是 t3code 最看重的链路价值。我第一次体会到这种爽感是在一次数据库重构里:把 user 和 profile 表合并,改完 schema,前后端差不多二十个文件直接编译报错,沿着报错一路修完,没有漏掉任何引用。换成传统 REST,这种改动至少要抓一个测试环境跑一遍才能放心。

3.3 tRPC router 如何避免“一团乱麻”

业务一多,人们容易把 router 写成一个巨大的对象,所有 procedure 平铺在同一个文件里。t3code 的做法是每个域一个子 router,在 root.ts 里聚合:

import { createTRPCRouter } from "~/server/api/trpc"; import { postsRouter } from "~/features/posts/api/posts.router"; import { usersRouter } from "~/features/users/api/users.router"; export const appRouter = createTRPCRouter({ posts: postsRouter, users: usersRouter, }); export type AppRouter = typeof appRouter;

这样前端调用链路由前缀自然分组:api.posts.list、api.users.profile。如果你还想更细,可以在域内继续开子 router,比如posts.comments.list,但建议不要超过两级,否则调用链太长,IDE 自动补全也会失去优势。还有一个容易被忽视的好处:子 router 之间可以相互引用,但引用方向要控制好。比如 posts router 可以调用 user router 的某个查询来补充作者信息,但千万不要让 users router 反过来依赖 posts router,否则业务域之间的耦合会悄悄长出来。

3.4 页面层如何安全消费类型

页面层消费类型有两种方式:客户端组件用 hooks,服务端组件用 createCaller。很多人第一次接触时会被“我到底该在组件里 useQuery 还是直接拿数据”绕晕。t3code 的约定是:如果这个页面需要交互、需要数据刷新,就走客户端 hooks;如果这个页面是内容型、偏静态,就走服务端 caller 取一次数据,渲染完交给客户端。这样既不会在服务端组件里触发 hooks 错误,也不会让每个页面都变成巨大的 client component。

4. 核心业务模块串联:认证、查询、更新一条龙

4.1 把 NextAuth 的会话塞进 tRPC Context

t3code 里的认证不是孤立的,而是渗透到每个查询。关键代码在 tRPC context 里:

import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, ...opts, }; };

然后在 trpc.ts 里定义 protectedProcedure,它是普通 procedure 加上一层会话检查中间件:

export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new Error("UNAUTHORIZED"); } return next({ ctx: { session: ctx.session } }); });

这样 posts.list 只需用 protectedProcedure,未登录请求会被中间件拦下。相比之下,很多人习惯在页面里判断 session 再决定调不调接口,那只能挡住 UI,挡不住直接请求 API 的人。t3code 把认证边界放在 procedure 层面,所有需要登录的 API 都从这一层统一保护,才叫真正的认证边界。这个细节在单体模板里看着不起眼,一旦将来要接第三方客户端或写自动化测试,你就会发现 API 层自带鉴权有多省事。

4.2 一个完整业务 CRUD:从 schema 到组件

接着 posts 模块,再看更新操作。t3code 的更新流程是:组件里先调用 mutation hooks,成功后让相关查询缓存失效,UI 自动刷新。核心代码:

const utils = api.useUtils(); const createPost = api.posts.create.useMutation({ onSuccess: async () => { await utils.posts.list.invalidate(); }, }); function submitNewPost() { createPost.mutate({ title: "hello", content: "world" }); }

posts.create 的定义用 zod 做输入校验:

import { z } from "zod"; export const createPostSchema = z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }); export const postsRouter = createTRPCRouter({ create: protectedProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), });

运行时校验、权限判断、数据库写入在这一个 resolver 里完成,客户端不需要再重复校验。你可能会问:为什么不让组件直接调用 Next.js 的 route handler?因为那样要额外维护 API 路由和请求/响应类型;tRPC 把这一层透明化了,调用就是函数调用,类型不会丢。模板的价值就在这种地方:它不是送你一个函数,而是帮你在源头减少一类问题。

4.3 客户端更新后的缓存同步

TanStack Query 常用的坑是 mutation 成功后没有让 list 查询失效,导致新数据不出现。t3code 把 invalidate 写进每个 mutation 的 onSuccess 里,而不是组件里临时写。因为如果每个页面各写各的,改一处漏一处;写在业务模块的 hooks 里,可以做到“新增后刷新列表”这个行为全局一致。我见过很多项目刚开始没问题,后来加了一个列表筛选条件,忘记把失效范围带上,用户提交新内容后列表一直停留在旧状态,就是这类问题。

某些高频场景,比如列表加载很慢,全量 invalidate 会闪烁。可以退一步用 setData 手动把 mutation 返回值写入缓存:

api.posts.create.useMutation({ onSuccess: (newPost) => { utils.posts.list.setData({ cursor: null }, (old) => ({ items: old?.items ? [newPost, ...old.items] : [newPost], nextCursor: old?.nextCursor ?? null, })); }, });

这是针对乐观更新的优化。日常项目建议先跑通 invalidate,等真的慢了再考虑手写缓存。提前优化是模板最容易犯的错,t3code 默认把最简单可靠的版本给你,把复杂选项留到需要时再打开。

4.4 SSR 或静态生成时怎么预取数据

除了客户端 hooks,t3code 也支持在服务端预取数据。一种做法是直接用 createCaller,另一种是用 TanStack Query 的 prefetch 把数据写进 hydration。我大多数时候用前者,因为代码更直白:服务端 caller 拿到数据后传给页面组件,配合useState做初值,减少一次请求。但如果你要做的页面里有大量依赖同一批数据的交互组件,prefetch + hydration 能让数据在客户端激活时立即可用。两种方式都会接触,才能真正理解类型安全在哪一层断掉。

5. 实测里绕不过去的坑:服务端组件、类型生成与环境变量

5.1 服务端组件里别直接用 query hooks

App Router 普及后,我见过不少人尝试在 Server Component 里直接写api.posts.list.useQuery(),一运行就报错。原因很简单:TanStack Query 的 useQuery 是客户端 hooks,依赖 React context,服务端组件环境没有这个运行上下文。t3code 提供两条路:要么把该组件标记为 client component 并用 hooks;要么在服务端直接调用 tRPC 的 caller,把取好的数据作为 props 穿下去。

服务端调用 caller 的代码大概是:

import { appRouter } from "~/server/api/root"; const caller = appRouter.createCaller(await createTRPCContext({ headers: req.headers })); const posts = await caller.posts.list({ cursor: null });

这条路适合对 SEO 敏感、又不想引入额外 API 请求的页面。需要权衡的是,caller 是直连函数调用,绕过了 HTTP 层,意味着不会经过超时、限流,所以不要把太重或不可信的外部调用放到这条链路。我习惯在模板注释里写明这一点,提醒后来的人别把这里当成通用 API 层。

5.2 类型生成时机:postinstall 与 pre-commit

t3code 团队里最容易出现的协作事故:A 改了 prisma schema,push 到仓库,B pull 下来直接tsc --noEmit,一堆类型错误。原因是 B 的 node_modules 里还是旧版 Prisma Client。解决办法是在 package.json 里加:

{ "scripts": { "postinstall": "prisma generate", "precommit": "prisma validate && tsc --noEmit" } }

postinstall 保证 npm install 之后类型一定是新的;precommit 保证提交前 schema 语法和类型检查都会过一遍。注意 postinstall 在某些 CI 缓存 node_modules 的场景下可能不执行,所以 CI 流程里最好显式跑一次prisma generate。这个坑我们实测定过:缓存一开,功能都正常,但类型就是旧,特别迷惑人。排查了半天还以为是环境问题,最后才发现是 CI 把 node_modules 整块缓存了,连 postinstall 都被跳过了。

5.3 部署时环境变量的作用域陷阱

t3code 用 zod 做环境变量校验,这是好事,但也会把问题暴露在 build 期:如果构建机的环境变量不完整,Next.js 编译直接失败。常见错误是把 DATABASE_URL 放在运行时变量里,而构建时虽然不需要真实连接数据库,但 Prisma 的某些生成步骤可能读取变量,CI 里就会出现 missing environment variable。我的习惯是:构建阶段需要的变量统一放在 build env,运行阶段需要的变量放在 runtime env,两边重复也没关系,别指望 Next.js 会自动帮你合并。

还有一个客户端环境变量前缀问题:只有NEXT_PUBLIC_开头的变量会被注入浏览器,像 NEXTAUTH_SECRET 这种绝对不能加前缀,加了你就是把密钥发到每一个访客的浏览器里。这个属于老生常谈,但我每次看到新项目都会再强调一次。t3code 的 env schema 里也会区分这两类变量,在文件顶部写清楚,防止有人顺手把敏感配置放到 NEXT_PUBLIC 里。

5.4 另外两个容易被忽略的小问题

Tailwind 的内容扫描配置要包含 features 目录。如果你和我一样把业务组件迁移到 src/features 下,但 tailwind.config 的 content 只写了一个具体目录,漏掉新目录,样式就会“随机消失”,很难排查。建议统一用./src/**/*.{ts,tsx},别自作聪明。遇到过一个人把 content 精确到了两个目录,后来加了一个 chart 目录,整个图表页面的样式全没了,他还以为是 Tailwind 升级的问题。

另一个是 eslint 和 prettier 的版本冲突。create-t3-app 的默认模板对 eslint 版本比较敏感,直接升级到 eslint 9 可能会遇到 parser 不兼容。t3code 目前的处理是把 eslint 版本锁在模板经过验证的版本上,不追最新。工具链的价值在于稳定,不在于每次都踩第一个坑。毕竟模板存在的意义是让团队成员少踩坑,而不是成为新技术的试验田。

5.5 网络层错误处理别漏

最后补一个不常被注意的点:tRPC 默认的 HTTP 错误处理比较简洁,但业务里经常需要区分“参数校验失败”“未登录”“无权限”“服务端异常”。t3code 在中间件里统一映射这些错误,给前端返回稳定的结构:

export const errorMapper = (error) => { if (error instanceof ZodError) { return { statusCode: 400, message: "参数错误" }; } if (error.message === "UNAUTHORIZED") { return { statusCode: 401, message: "请先登录" }; } return { statusCode: 500, message: "服务端异常" }; };

前端在 onError 里只根据 statusCode 弹提示,而不是每个页面各写一套。这样哪怕以后要接接入层,错误格式也不会乱。如果你一开始没做这层映射,后面加接口时到处复制错误处理代码,会更痛苦。

最后说几句个人体会。t3code 最初只是我自己的一个实验仓库,用来验证“能不能把业务模块从页面里彻底切出去”。用了大半年,最大的收获不是省了几次脚手架时间,而是团队讨论代码位置时有了统一依据:数据库改动往 prisma 放,API 逻辑往 features/api 放,页面只负责组合数据。这种边界感比任何框架特性都重要。如果你也想做一个属于自己的模板,建议不要一开始就堆满功能,先把一条用户故事从头到尾用类型安全的方式跑通,再慢慢加认证、权限、部署。t3code 的内容我也会持续整理,但核心原则不变:看得见的类型流,拆得开的业务域。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 8:41:34

零售智能客服系统架构设计与客户数据分析实战

1. 零售行业智能客服与客户数据分析概述在零售行业数字化转型浪潮中,智能客服系统与客户数据分析的结合正成为提升运营效率的关键利器。我曾在多个零售项目中部署过这类系统,它们不仅能将客服响应速度提升3-5倍,更重要的是能通过对话数据挖掘…

作者头像 李华
网站建设 2026/10/26 8:41:34

零售业智能客服与数据分析核心技术解析

1. 零售行业智能客服与客户数据分析概述 在零售行业数字化转型浪潮中,智能客服与客户数据分析已成为提升运营效率与用户体验的核心抓手。作为一名深耕零售科技领域多年的从业者,我见证了从传统人工客服到AI驱动的智能交互的完整演进过程。如今的智能客服…

作者头像 李华
网站建设 2026/10/26 8:41:13

AI文献综述写作工具:技术原理与应用实践

1. 百考通文献综述写作功能解析:AI如何重塑学术写作范式作为一名在学术写作领域深耕多年的研究者,我见证了从手工查阅卡片到EndNote管理文献,再到如今AI辅助写作的技术演进。百考通的文献综述写作功能,本质上是通过自然语言处理(N…

作者头像 李华
网站建设 2026/10/26 8:39:57

前端开发全流程学习地图:从代码到上线的六阶段实战指南

1. 这不是“教程合集”,而是一张能带你走出学习迷宫的活地图你是不是也经历过这样的循环:买了一堆编程课,跟着敲完代码,合上电脑就忘;收藏了几十个“前端学习路线图”,却连第一个月该学什么都说不清楚&…

作者头像 李华
网站建设 2026/10/26 8:39:29

HSV颜色空间在OpenCV中的原理与应用实战

1. 为什么需要HSV颜色模式?在图像处理领域,RGB是最基础的颜色表示方式,但它存在一个致命缺陷——颜色信息与亮度信息高度耦合。举个例子,当我们需要识别一个红色物体时,在RGB空间中不仅要考虑R通道的值,还要…

作者头像 李华
网站建设 2026/10/26 8:37:53

技术趋势分析三层过滤法与未来风向标

1. 技术趋势洞察的底层逻辑技术趋势分析不是简单的信息收集,而是一个系统性工程。从业15年来,我总结出技术趋势研判的"三层过滤法":第一层是技术成熟度曲线,判断某项技术处于炒作周期的哪个阶段;第二层是产业…

作者头像 李华