☰
端到端类型安全:用t3code打造高效TypeScript全栈脚手架
2026/10/7 5:56:37 网站建设 项目流程

我很少给个人项目起正经名字,但"t3code"这个代号是个例外。它是我过去两年反复打磨的一套 TypeScript 全栈脚手架——不是网上那种攒了几个 star 就丢掉的模板,而是一个能让我在接到需求后十分钟内开始写业务代码的固定工作流。名字里的 T3,懂的人立刻会想到 T3 Stack 那套组合(TypeScript + Next.js + tRPC + Tailwind),事实上也确实如此:t3code 的核心就是用端到端类型安全把前端、后端、数据库三层拧成一根绳,让类型从数据库一路流到浏览器,中途不掉链子。

这篇文章主要写给两类人:一是刚接触全栈 TypeScript、不知道从哪里下手的同学,二是已经在用 Next.js 但总觉得前后端类型对不上、REST 接口和类型定义一直在手动维护的朋友。我会把 t3code 的选型逻辑、目录结构、核心链路、认证模块,以及我踩过的一堆坑,全部拆开讲透。文章里的代码都是可以直接复制的,跟着走一遍,你也能搭出自己的 t3code。

1. t3code 的核心思想:端到端类型安全到底解决了什么

1.1 传统开发模式下类型是怎么断的

先说问题。很多团队的项目是"前端一套 TypeScript、后端一套 TypeScript、数据库一套表结构",表面上都在用 TS,其实三方各写各的。前端根据后端文档手动定义 interface User,后端在接口里手动校验字段,数据库的字段全靠 DBA 和开发者的自觉保持一致。结果是改一个字段,运气好改三个地方,运气不好只改一处,另外两处等着线上报错。

我自己以前就翻过车。一个用户资料接口,后端把字段 nickName 改成了 nickname,前端 interface 没同步,结果上线后所有用户昵称都显示 undefined。排查花了两小时,最后发现就是字段名大小写的问题。这种错误不算难修,但它反复出现,本质上是架构层面没有把"类型"当作贯穿全链路的第一公民。

1.2 T3 组合为什么能解决这个问题

t3code 采用的正是 T3 Stack 的思路:Next.js 负责应用框架,tRPC 负责 API 调用,Prisma 负责数据库访问,Tailwind CSS 负责样式。这套组合最狠的地方不在于单个框架多强,而在于四者叠起来之后,类型可以在整条链路上自动流动:

  • Prisma 根据 schema.prisma 生成数据库类型
  • tRPC 的 router 在服务端定义输入输出,客户端通过类型推断自动获得完整 API 类型
  • Next.js 的 Server Components 和 API Route 能直接消费这些类型
  • 前端组件用 useQuery 调用后端 procedure 时,返回数据的类型直接从服务端"飞"过来,不用手写一行 interface

用一句话概括:schema 是唯一的类型源头,剩下的全部由编译器自动推导。我经常跟同事打比方——以前前后端类型靠人力对暗号,t3code 里是两边直接看到对方的源码,谁也别想糊弄谁。

1.3 这到底省了什么

如果只看代码量,t3code 可能没省多少行,但省的是维护成本。我统计过,传统模式下一次中等规模的接口变更,平均要动五到七个文件:后端路由、校验逻辑、前端 API 封装、前端类型定义、Mock 数据、文档。t3code 模式下通常只动一个:要么改数据库 schema,要么改 router 里的输入输出定义。这个差异在项目初期不明显,等业务跑到三四个月、接口数量破百的时候,差别几乎就是天壤之别。

另一个容易忽略的好处是新人上手速度。t3code 里不存在"先读接口文档再猜类型"的过程,新同事直接在组件里调用 api.post.list,编辑器会把输入参数和返回结构全部提示出来。我观察过团队里两个刚入职的同学,用传统项目的需要一周才能独立改接口,用 t3code 的第三天就能自己加一个完整功能模块。

2. 从零搭建 t3code:技术选型与初始化的完整过程

2.1 为什么选 create-t3-app 而不是自己手写配置

t3code 的基底用的是 create-t3-app 生成的模板,再在此基础上做裁剪。有人会问,既然是打造自己的脚手架,为什么不从零开始手写配置?我的答案很直接:工具链的配置不是核心竞争力。Next.js 的路由、tRPC 的初始化、Tailwind 的 PostCSS 配置,这些属于"一次配置、长期稳定"的活,模板能帮你省下大量试错时间。真正值得花心思的是业务模块的组织方式、类型约定的规范、以及团队协作的流程,这些我会在模板之上自定义。

初始化命令很简单:

npm create t3-app@latest t3code

安装过程中会有几个交互选项,我通常这样选:

  • TypeScript:必选,这是整个方案的基石
  • Next.js:必选,App Router 模式
  • tRPC:必选,这是类型链路的中枢
  • Prisma:必选,数据库 ORM
  • Tailwind CSS:推荐保留,样式部分能少写很多 CSS
  • NextAuth.js:按需勾选,我建议一开始就选上,后面加认证比改造省事得多
  • Drizzle:如果团队更偏好轻量 SQL,可以替代 Prisma,但 t3code 默认还是 Prisma,原因后面细说

2.2 目录结构的设计逻辑

生成后的目录结构,有几个关键文件需要理解(我根据自己的习惯做了小幅调整):

t3code/ ├── prisma/ │ └── schema.prisma # 唯一的数据库类型源头 ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ │ │ │ ├── post.ts │ │ │ ├── user.ts │ │ │ └── root.ts │ │ ├── db.ts # Prisma Client 单例 │ │ └── auth.ts # NextAuth 配置 │ ├── trpc/ │ │ ├── server.ts # tRPC 服务端初始化 │ │ └── client.ts # 客户端调用封装 │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ │ └── route.ts │ │ ├── _components/ │ │ └── (pages)/ │ └── styles/ │ └── globals.css ├── .env └── package.json

这种分层的核心逻辑是"server 侧的类型可以被 client 侧引用,client 侧的类型永远不能反向污染 server"。很多团队写 TypeScript 全栈容易犯的错就是把所有类型堆在 src/types 里,结果前后端共用一个文件,改起来互相踩脚。t3code 里不设全局 types 目录,每个模块的类型要么从 Prisma 推导,要么在 router 定义处就近声明,类型跟着业务走,比集中管理清楚得多。

2.3 环境变量与首次启动的几个注意点

第一次启动前,.env 里至少要配 DATABASE_URL。我用的是 PostgreSQL,这里有一个常见的坑:连接串里带特殊字符没做 URL 编码,比如密码里的 @ 符号,会导致 Prisma 连不上库,却报一个莫名其妙的认证错误。别问我怎么知道的,问就是踩过。

配置好之后执行:

npx prisma db push npm run dev

db push 会把 schema.prisma 里的模型同步到数据库,适合开发环境快速迭代。生产环境的迁移建议用 npx prisma migrate dev 生成完整的迁移历史,这一点后面讲坑的时候还会展开。

3. 核心链路拆解:tRPC 和 Prisma 怎么让类型"飞"起来

3.1 从 schema 到客户端类型的完整流向

t3code 最值得细看的是这一条链路:schema.prisma → Prisma Client → tRPC router → 客户端 Hook。我直接用一个具体例子说明,比如做一个简单的帖子列表功能。

首先在 prisma/schema.prisma 里定义模型:

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

然后在 server/api/routers/post.ts 里定义 procedure:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/trpc/server"; export const postRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const posts = await ctx.db.post.findMany({ take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); return posts; }), });

注意两个细节:一是 zod 负责输入校验,这相当于把"接口文档"直接写进了代码里;二是 Prisma 的返回类型自动被 tRPC 捕获,客户端拿到的类型就包含了 author.name 这样的嵌套字段。

接着在客户端组件里调用:

"use client"; import { api } from "~/trpc/client"; export function PostList() { const { data, isLoading } = api.post.list.useQuery({ limit: 10 }); if (isLoading) return <div>加载中...</div>; return ( <ul> {data?.map((post) => ( <li key={post.id}> {post.title} — {post.author.name} </li> ))} </ul> ); }

这里 data 的类型是编辑器自动推断的,你 hover 上去会看到完整的 Post 结构,author 字段的 name 类型是 string。整个过程没有手写哪怕一行 interface。如果哪天你在 schema 里给 Post 加一个 coverUrl 字段,重新跑 prisma generate,客户端组件里立刻就能拿到这个新字段,编译器会帮你找出所有遗漏的地方。这就是端到端类型安全的实际体验,也是我坚持用 t3code 的最核心原因。

3.2 为什么是 tRPC 而不是 REST

我知道很多人会质疑:tRPC 不是 REST,也没有 OpenAPI 规范,团队协作和大规模公共服务怎么办?我的观点是:看场景。如果是一个对外提供服务的后端,或者需要给多个异构客户端(iOS、Android、第三方)提供 API,REST + OpenAPI 确实更合适。但 t3code 的定位是"一个团队从零到一的业务系统",服务端和客户端都是我们自己人,这时候 tRPC 的收益非常明显:省去手写 API 层、省去联调、省去类型同步,改动一个字段全链路自动更新。

用 tRPC 不代表放弃规范。t3code 里我保留了每个 router 的 zod schemas 作为事实上的契约文档,新同学入职后看 router 文件就能理解整个 API 结构,比看一版过时的接口文档有用得多。如果未来真的需要开放 API,完全可以在 tRPC router 之上再包一层 REST 适配器,但那是后话,架构上不要一开始就为不确定的需求买单。

3.3 批量查询和可变操作的类型保护

useQuery 之外,tRPC 还提供 useMutation 处理写操作,类型保护同样全程生效:

const createPost = api.post.create.useMutation({ onSuccess: () => { utils.api.post.list.invalidate(); }, }); createPost.mutate({ title: "测试", content: "正文" });

如果标题和正文的类型或者必填性不对,编辑器当场报错。onSuccess 里执行 invalidate,自动让列表重新请求,不用手动刷新页面。这套组合拳在传统 REST + React Query 下也能实现,但 t3code 里所有类型都是自动对齐的,写起来几乎没有"类型摩擦"。团队里用 React Query 的老手一开始不太习惯这种"少了一堆类型定义"的感觉,用了两周之后都说回不去了。

4. 认证模块:NextAuth、tRPC 和 Prisma 三者的正确咬合方式

4.1 认证是整个脚手架最值得固化的部分

新手照教程搭 T3 应用,最难啃的往往是认证,因为涉及三套体系的交叉:NextAuth 负责会话和登录流程,Prisma 负责用户表存储,tRPC 负责在 procedure 里校验登录态。t3code 中我把这三者的咬合方式固定下来,后续所有新模块都照这个模式写,省掉了大量重复决策。

4.2 服务端 session 注入

核心在 server/auth.ts 和 trpc/server.ts 的配合上。tRPC 的 createTRPCContext 里用 getServerSession 拿到 NextAuth 的 session,然后挂到 context 上:

// src/trpc/server.ts export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, ...opts, }; };

在这个基础上,定义 protectedProcedure:

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

这么做的目的不是多写几行代码,而是让"该模块是否需要登录"成为一个路由级别的显式声明。比如获取当前用户的接口用 protectedProcedure,公开的列表接口用 publicProcedure,可读性极强。路由注册时,哪些接口受保护、哪些公开,在文件里一目了然,审查代码的时候不需要猜。

4.3 凭据登录与安全细节

t3code 默认支持 GitHub OAuth,我额外加了一组 Credentials 登录(邮箱+密码),这样在自己的部署环境里不依赖第三方 OAuth 也能有完整登录流程。Credentials 方案下的密码存储只有一个正确选择:bcrypt 哈希。

import { compare } from "bcryptjs"; export const login = publicProcedure .input(z.object({ email: z.string().email(), password: z.string() })) .mutation(async ({ ctx, input }) => { const user = await ctx.db.user.findUnique({ where: { email: input.email }, }); if (!user || !user.passwordHash) { throw new TRPCError({ code: "UNAUTHORIZED" }); } const ok = await compare(input.password, user.passwordHash); if (!ok) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return user; });

我特别强调一点:登录接口的报错信息不要区分"用户不存在"和"密码错误",统一返回"账号或密码不正确"。这种安全习惯很多人知道,但实现的时候容易图省事直接抛不同错误,等于把用户枚举的漏洞开着。t3code 里我在 zod 校验阶段做了统一处理,避免后面业务代码再犯同样的毛病。

4.4 前端守卫与中间件的取舍

页面级的登录守卫,我使用的是 Next.js 中间件而不是在每个页面里手写判断。在 middleware.ts 里配置白名单逻辑,未登录用户访问需要认证的页面时直接重定向到 /login。注意中间件里不能直接用 tRPC 的 server 端 context,我一开始在这上面踩过坑,花了将近一个小时才搞明白是中间件运行在 Edge Runtime,访问不到完整的 Node 环境。解决方案是中间件里只做轻量的 token 存在性判断,精确的用户权限校验交给页面内的 protectedProcedure 去完成。这个经验写在这里,希望能帮你少走弯路。

5. 实际开发中踩过的坑:问题、排查链路与修复方案

5.1 版本不匹配引发的"幽灵编译错误"

create-t3-app 模板生成后,node_modules 里各种依赖是锁定版本的。一段时间后我手动升级了 Next.js 到最新版,结果 TypeScript 编译直接报了一堆奇怪的类型错误——Router 类型对不上、session 类型不存在之类的。排查过程是这样的:先跑 tsc --noEmit 确认是类型错误,不是运行时错误;再看报错文件,发现全集中在 node_modules/.prisma/client 和 @trpc 相关声明里;最后用 npm ls 检查依赖树,发现 @trpc/next 还停留在旧版本,而新版 tRPC 的类型定义已经改了响应结构。

解决方式很典型:把整组相关依赖升级而不是逐个升级。t3code 里我现在统一用 pnpm 管理依赖,并且升级时遵循"Next.js 和 tRPC 必须同批升级"的原则,因为这两个库的 API 版本关联性非常强,分开升很容易出现一半新一半旧的尴尬状态。

5.2 Prisma 连接数太多导致数据库报警

有一次项目部署后,PostgreSQL 的连接数一直往上飙,最后把 max_connections 打满了。排查链路:先看应用日志,没有明显报错;再看数据库 pg_stat_activity,发现大量 idle 状态的连接来自应用进程;最后定位到问题——我在业务代码里每次调用都通过 PrismaClient 的新实例访问数据库,而 Prisma 的客户端实例本身带连接池管理,必须保持单例。

修复方式一句话:全局共享一个 PrismaClient。t3code 的 db.ts 里用 globalThis 挂载单例,避免开发模式下的模块热更新反复创建实例:

import { PrismaClient } from "@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;

这种写法是 Prisma 官方文档推荐的标准姿势,但很多教程不会强调"为什么",核心原因就是热更新环境下模块实例生命周期不稳定,不这么写很容易爆连接。

5.3 Hydration 不一致:服务端渲染和客户端状态打架

使用 Next.js App Router 时,客户端组件里如果有依赖 session 或本地时间等逻辑,有可能出现服务端 HTML 和客户端首次渲染不一致的问题。我遇到的具体场景是:用户头像旁的欢迎语,服务端渲染时 session 已经有了,客户端 hydration 时却因为轻微的时序差异给出了不同结果,页面刷一下闪动。

这不是 t3code 独有的问题,但我在模板里加了处理约定:所有依赖浏览器专属状态的组件,要么放到 useEffect 之后再渲染,要么使用第三方提供的 ClientOnly 封装。另外一个重要经验:不要在组件渲染逻辑里读 localStorage 或 window 对象,Next.js 编译时不会报错,但运行时一定会让你痛苦。

5.4 分页查询的分页陷阱

写 Post list 的时候我遇到一个逻辑错误:游标分页用的 cursor,在数据量小的时候看不出问题,数据量大了以后发现首页和最后一页的数据有重复。排查后发现原因:cursor 分页的排序字段必须唯一。我用 createdAt 排序,但同一时刻插入的帖子会存在相同的 createdAt,导致游标定位不精确。

修复方案:把排序改成复合条件,先按 createdAt 排序,再按 id 兜底,因为 id 是唯一的:

orderBy: [{ createdAt: "desc" }, { id: "desc" }], cursor: input.cursor ? { id: input.cursor } : undefined,

这里有一个细节值得注意——cursor 传的是 id,但排序不止一个字段,Prisma 依然能正确工作,因为它会基于 id 定位记录后再按复合排序继续。这个坑在官方文档里并不显眼,实际业务只要不是单机小玩具,早晚会遇到。

5.5 环境变量在部署平台的配置遗漏

t3code 模板里包含了很多环境变量,比如 DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL 等。我在部署平台上部署时忘记配置 NEXTAUTH_SECRET,结果登录功能在本地一切正常,部署后一访问就报错。排查过程:先看构建日志,构建通过;再看运行时日志,发现 NextAuth 抛 MissingSecret 错误;检查环境变量列表,确实漏了。这种错误很低级,但它提醒我:脚手架里最好加一个启动时的环境变量校验脚本,缺失时直接打印清晰的错误提示,而不是让框架在运行时用晦涩的报错折磨你。我后来在 t3code 里加了 src/env.js,用 zod 对环境和公开变量做统一校验,启动即报错,不拖泥带水。

5.6 踩坑清单汇总

问题现象根因解决方式
编译报一堆类型错误Next.js 与 tRPC 版本不同步核心库同批升级,用 pnpm 锁定
数据库连接数打满PrismaClient 被反复创建全局单例 + globalThis 挂载
页面刷新内容闪动SSR 与客户端 hydration 不一致浏览器专属状态延迟到 useEffect 后渲染
分页数据重复/遗漏排序字段不唯一,游标定位不准复合排序 createdAt + id
部署后登录报错NEXTAUTH_SECRET 未配置增加 env 校验脚本,启动即提示

6. 用 t3code 落地一个真实需求:给帖子加上标签筛选

6.1 需求描述与数据库设计

读到这里,你可能已经对 t3code 的骨架有了整体感觉。我再完整演示一个功能的开发闭环,这样你能看到一个需求从数据库到前端的完整落地过程。需求很简单:给帖子加标签,并且列表页支持按标签筛选。

首先改 schema:

model Tag { id String @id @default(cuid()) name String @unique posts Post[] } model Post { id String @id @default(cuid()) title String content String tags Tag[] createdAt DateTime @default(now()) }

这是一个多对多关系,Prisma 会自动创建中间表。跑 npx prisma migrate dev --name add_tags 生成迁移。这一步之后,Post 类型里自动多出 tags 字段,前端能直接使用。

6.2 在 router 里扩展筛选逻辑

接着改 postRouter,在 list 的 zod 输入里加上 tagId 可选字段:

list: publicProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), cursor: z.string().optional(), tagId: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where = input.tagId ? { tags: { some: { id: input.tagId } } } : {}; const posts = await ctx.db.post.findMany({ where, take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: [{ createdAt: "desc" }, { id: "desc" }], include: { author: { select: { name: true } }, tags: true }, }); return posts; });

6.3 前端组件与类型联动

前端改动的地方在于把 tagId 传给 useQuery,并且做一个标签下拉筛选项。当用户切换标签时,通过 router 的 invalidate 自动重新拉取数据。整个过程我不需要去后端重新定义一遍 PostWithTags 类型,也不需要去 types 目录里手写接口——前端组件中 data 的类型自动包含了 tags 字段,编辑器里直接提示。这就是 t3code 给我最大的实际感受:一个功能从需求到上线,大脑里要维护的"约定"变少了很多,更多精力能放在业务逻辑本身上。

7. 最后聊聊我对这套工作流的实际体会

最后说点题外话。t3code 并不适合所有项目,这一点我必须讲清楚。如果你们公司要做的是一个重度 REST 风格、需要长期对外提供 API 的开放平台,或者客户端是多端异构的,那 tRPC 不一定是最优解。t3code 最适合的场景是"一个团队、一个 Web 应用、Node/TS 技术栈、数据库由自己控制的业务系统"——这类项目占了中小团队绝大部分需求,t3code 的价值密度是最高的。

我这两年最大的体会是:脚手架的价值不在于初始化那十分钟,而在于它帮你把最优实践固化下来,让团队里的每个人默认就走在同一条路上。新人来了不用纠结"接口层怎么封装""类型放哪个目录""认证逻辑怎么写",打开 t3code 看一眼既有模块,照着写就行。如果哪天你也想搭建自己团队的全栈脚手架,我建议不要盲目照抄任何一套模板(包括我这套),而是把你们项目里反复出现的痛点列出来,再有针对性地选型。比如我们团队最痛的是类型不同步和联调成本,所以 t3code 把类型链路做成了闭环;如果你们的痛点是不确定的并发量,那选型逻辑可能完全不一样。

最后分享一个小技巧:我在 t3code 里给每个主要 procedure 都写了简短注释,标注"输入来源、输出用途、调用方模块"。这听起来很基础,但半年后回来看代码,这些注释帮我省了大量"这函数到底是给谁用的"的回忆成本。类型的闭环解决的是编译期正确性,而注释解决的才是人与人之间的协作效率,两者缺一不可。希望 t3code 这套思路能给你带来一点启发,哪怕只是让你意识到"原来类型可以这样贯穿全栈"这件事,我觉得就值了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询