☰
t3code全栈实践:打通TypeScript到数据库的类型安全链路
2026/10/28 7:16:21 网站建设 项目流程

前后端联调最磨人的问题是什么?不是接口报错,而是“接口没报错但前端拿到的东西跟预期完全对不上”。我过去维护一个内部管理系统,后端改了订单状态的枚举值,前端照样拿旧枚举去判断,线上出了几天问题才被业务发现。后来我把新项目切到 t3code 这套全栈编码方式,这类“类型断层”带来的隐性故障基本销声匿迹。t3code 不是什么神秘框架,它就是围绕 Next.js 组合出来的 TypeScript、Tailwind CSS、tRPC 三件套实践,再加上 Prisma、Zod 这些配套工具。这篇文章以我从零搭一个带登录、带数据库的 Todo 应用为例,把初始化、数据链路、认证接入、上线部署和选型边界一次讲透。如果你准备用这套技术栈做实际项目,照着走能省下不少弯路。

1. t3code究竟在解决什么问题:前后端的“类型断层”

1.1 名字里藏着的三件事

很多人以为 t3code 是某个公司的内部代号,其实“T3”这个缩写来自三件套:TypeScript、Tailwind CSS、tRPC。这套组合由 Theo Browne(t3.gg)在 2022 年前后整理成可复用的脚手架,后来演变成官方 CLI 工具 create-t3-app,并围绕 Next.js 做了深度整合。三个字母分别对应全栈开发里三个最容易翻车的环节:类型系统、样式方案、API 通信。核心思想其实一句话:让数据从数据库 Schema 出来之后,一路带着类型注解穿过服务端、API、客户端,直到被 React 组件消费,中间不允许断掉。

我第一次跑起来 create-t3-app 时,看到 src/trpc/react.tsx 里导出的 api 对象竟然能自动推导后端 router 的全部入参和出参,第一反应是“这也太魔法了”。后来拆开源码才发现,它本质上就是 TypeScript 类型编程的成果:后端定义路由和校验,前端通过类型引用拿到同一份“契约”。对比我自己以前写过的手写 axios 封装、手写 interface 定义,那真是两个时代的产物。

1.2 为什么类型断层会吃掉开发效率

传统前后端分离的场景下,API 是两段代码之间的一张“口头契约”:后端定义接口,前端拿到接口文档再手写一份 TypeScript 类型。两边各自维护,一旦字段改名、类型变化、枚举调整,文档往往先过期,随后就是运行期报错。最常见的连锁反应是:后端改了一个字段名,前端没有报编译错误,等到运行时拿到 undefined,又得在 network 面板里逐条比对。

t3code 打破这种局面的方式很直接:数据库 Schema 生成 Prisma 类型,Prisma 模型注入 tRPC router 的返回类型,tRPC 再通过依赖注入让 React Hook 自动推导全部入参和出参。模型改了,IDE 里相关的调用位置会同步标红,问题在编写期就暴露,根本轮不到运行期。这比任何设计稿和接口文档都可靠。

常见的对比情况可以看这张表:

环节传统方式t3code 方式
契约定义手写接口文档 + 前端手写 TypeScript 接口Prisma Schema + tRPC 输出类型推导
输入校验后端手写校验,前端另做一套逻辑Zod 作为 tRPC procedure 的输入管线
API 调用fetch + 手写类型断言类函数调用,类型自动匹配
变更反馈运行期报错编译期报错

1.3 模板不等于绑架

这里要澄清一个误区:很多人以为用了 t3code 就必须全套上,其实 create-t3-app 的设计思路是“渐进式采用”。不需要数据库可以不用 Prisma,不需要登录可以不用 NextAuth,甚至可以只用 TypeScript + Next.js。脚手架只是把最容易被拼错的胶水代码提前生成好,真正的业务代码还是你自己写。我见过不少项目从 WordPress 迁到 Next.js,再从手写 API 慢慢演进到 tRPC,这个过程并不需要推倒重来。

2. 初始化一个 t3code 项目:每个选项都在决定工程的形状

2.1 create-t3-app 交互式选择

初始化一个 t3code 项目其实只有一条命令:

pnpm create t3-app@latest my-todo-app cd my-todo-app pnpm install

接下来会有一连串交互式选项,很多人在这一步就开始懵,其实每个选项背后都有明确含义。以我搭 Todo 应用的经验,推荐这样选:

选项选择理由
TypeScript必选t3code 的灵魂,不需要犹豫
Tailwind CSS选写样式不用切换上下文,省心
tRPC选全栈类型安全的核心通道
Prisma选数据层的类型来源,还能一体生成数据库建模
NextAuth.js选演示场景需要登录,顺便展示会话类型的坑
App Router / Pages RouterApp Router新项目跟随 Next.js 官方演进方向
src 目录选业务代码和配置代码分层更清晰
导入别名@ 或 ~二选一,我喜欢用 ~,避免和 npm 包同名冲突

第一次尝试时我的建议是先全选,把 t3code 生成出来的东西跑通,再删掉不需要的模块。理由很简单:模板生成的代码都是互相咬合过的,你手动逐项配置反而更容易漏掉某个接口集成点。跑通全流程之后再裁剪,比你一开始就纠结“要不要这个依赖”高效得多。

2.2 目录结构里的约定俗成

创建完成后,最重要的目录结构长这样:

src/ app/ api/trpc/[trpc]/route.ts # Next.js API 路由,HTTP 请求转给 tRPC server/ api/root.ts # 所有业务 router 的汇总入口 api/routers/ # 具体业务模块,如 todo.ts、user.ts db.ts # Prisma Client 单例 auth.ts # NextAuth 配置 trpc/ server.ts # 服务端 router 创建器,包含 context 类型 client.ts # 独立的 fetch 封装,供服务端组件调用 react.tsx # 前端 React Hooks 绑定,真正导出的 api 对象

这套目录结构是 t3code 约定俗成的“回答”:router 统一放在 server/api/routers 下,服务端 context 的类型在 trpc/server.ts 中定义,前端 hooks 统一走 trpc/react.tsx。你不需要反复考虑“这个接口该放哪个文件夹”,模板已经把最佳实践固化下来了。对于开发者来说,这种约定比配置文件更省脑力,团队协作时也不需要开会讨论目录规范。

2.3 从 DB Model 到页面拿到数据:第一个最小闭环

搭好工程后,第一个闭环建议做一个极小的 Todo 模型。先在 prisma/schema.prisma 里定义数据模型:

model Todo { id String @id @default(cuid()) title String done Boolean @default(false) ownerId String owner User @relation(fields: [ownerId], references: [id]) createdAt DateTime @default(now()) }

然后创建对应的 tRPC router:

// src/server/api/routers/todo.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const todoRouter = createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) => { return ctx.db.todo.findMany({ where: { ownerId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1, "标题不能为空") })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.create({ data: { title: input.title, ownerId: ctx.session.user.id }, }); }), });

把 router 注册到 root.ts:

// src/server/api/root.ts import { createTRPCRouter } from "~/server/api/trpc"; import { todoRouter } from "~/server/api/routers/todo"; export const appRouter = createTRPCRouter({ todo: todoRouter, });

前端组件里直接消费:

"use client"; import { api } from "~/trpc/react"; export function TodoList() { const utils = api.useUtils(); const { data: todos, isLoading } = api.todo.list.useQuery(); const create = api.todo.create.useMutation({ onSuccess: async () => { await utils.todo.list.invalidate(); }, }); if (isLoading) return <div>加载中...</div>; return ( <ul> {todos?.map((todo) => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ); }

看到这里你就能感受到 t3code 的妙处:前端组件里api.todo.list的类型完全由服务端 router 推导而来,不存在手写接口类型一说。初次实现这个闭环后,后续加新功能基本就是“复制一个 router → 注册进 root → 前端直接调”,整个流程非常固化。

3. 类型沿着管道流动:Prisma、tRPC、Zod 的职责划分

3.1 Prisma 是数据的“原厂类型”

Prisma Schema 在这里不只是建表语句,它是整个应用数据类型的源头。执行prisma generate后,Client 里会生成完整的 TypeScript 类型,tRPC router 直接引用这些类型作为返回值,前端再通过 tRPC 拿到派生类型。也就是说,一行 Prisma model 可以影响到从前端到后端的整个调用链,这就是“原厂类型”的意义。

这里要区分两条命令:开发时改了 schema,应该执行prisma migrate dev,它会同步数据库并自动重新生成 Client;如果只是拉了别人的代码、数据库已经存在,只需要跑prisma generate重新生成类型。我经常看到有人改完 schema 不跑 migrate,结果前端和后端都引用旧类型,排查半天才发现是 Client 没更新。

3.2 tRPC router 如何变成前端 Hook

tRPC 的本质是“远程过程调用”,但它没有像 REST 那样把接口拆成 URL + Method 的资源模型,而是直接在服务端定义 router,客户端通过代理对象调用。t3code 里src/trpc/react.tsx导出的api对象,类型是 RouterOutputs 一层层展开得来的,所以每当你往root.ts里新增一个 router,前端api对象就会自动“多一个属性”,不需要额外写任何映射代码。

这个设计最大的好处是:接口的入参类型、出参类型、错误信息全都在编译期绑定,再也不存在“后端返回 data,前端猜字段”的情况。代价是它更适合前后端同仓库的全栈项目,如果 API 要开放给第三方,或者前后端仓库物理隔离,tRPC 的价值会打折扣。

3.3 Zod 在输入边界的双重防线

Zod 在 t3code 中主要负责 procedure 的 input 校验。后端用z.object({ title: z.string().min(1) })定义校验规则后,前端调用create.mutate({ title: "" })时,TypeScript 会直接提示参数错误,同时服务端在运行时也会拦一道。这种“编译期 + 运行期”的双层防护,能挡住绝大部分真实世界的脏数据。

不过要提醒一点:Zod 校验的是输入边界,不代表业务层就可以不做判断。比如删除一条不属于当前用户的记录,你不能只靠 Zod 限制 ID 格式,还得在 procedure 内部做所有权查询。类型安全只是工程实践的一部分,不是安全性的替代品。

4. 认证会话的类型旅程:NextAuth 接入的关键细节

4.1 session.user.id 为什么默认不存在

用 NextAuth 做登录时,很多人在 protectedProcedure 里写ctx.session.user.id,TypeScript 直接报错。原因是 NextAuth 默认的 Session 类型只包含 name、email、image 这三个基础字段,user.id 不在里面。解决思路分两步:先在 auth 配置里通过 session callback 把 user.id 塞进 session,再通过模块扩展告诉 TypeScript“Session 上现在有 id 了”。

// src/server/auth.ts import { type NextAuthOptions } from "next-auth"; import GitHubProvider from "next-auth/providers/github"; export const authOptions: NextAuthOptions = { providers: [ GitHubProvider({ clientId: process.env.GITHUB_ID ?? "", clientSecret: process.env.GITHUB_SECRET ?? "", }), ], callbacks: { session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, };
// types/next-auth.d.ts import "next-auth"; declare module "next-auth" { interface Session { user: { id: string; name?: string | null; email?: string | null; }; } }

这块是 t3code 项目里最容易让新人卡壳的地方,但也是类型扩展最典型的例子:当你需要往会话里补充业务字段时,不要直接改 node_modules,而是通过 declaration merging 做类型补充,这样后端和前端共享的是同一份扩展后的类型定义。

4.2 未登录状态下的统一处理

protectedProcedure 默认会把未认证请求当作错误抛出,前端拿到的是 tRPC 的 error 状态。实际项目里你往往希望未登录用户直接跳转登录页,而不是看到一个错误提示框。我习惯在布局层用 useSession 做一次统一判断:

"use client"; import { useSession, signIn } from "next-auth/react"; import { useEffect } from "react"; export function AuthGuard({ children }: { children: React.ReactNode }) { const { data: session, status } = useSession(); useEffect(() => { if (status === "unauthenticated") { void signIn(); } }, [status]); if (status === "loading") return <div>正在检查登录状态...</div>; if (!session) return null; return <>{children}</>; }

这样比在每个页面手动判断 session 省事得多,同时 protectedProcedure 依然保留服务端的安全兜底。两层都不可少:前端管体验,后端管安全。

5. 从开发到上线:t3code 项目实战踩坑记录

5.1 Server Component 里捏着 tRPC Hook 不松手

按 Next.js App Router 的约定,页面组件默认是 Server Component,但api.todo.list.useQuery()必须在客户端组件里运行。我第一次把查询逻辑直接写在 page.tsx 里,浏览器直接报 “Hooks can only be called inside the body of a function component” 的错。解决办法很简单:页面保持 Server Component,把需要数据交互的部分拆成一个带"use client"的子组件,把 session 信息或 id 作为 props 传下去。

5.2 Prisma 在 Serverless 环境下的连接数风暴

部署到 Vercel 这类 Serverless 平台后,每次请求都可能创建一个新的 Prisma Client 实例,短时间内大量请求会把数据库连接数打满。t3code 模板默认已经处理了这个问题,用 globalThis 缓存单例:

// src/server/db.ts 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; }

生产环境我建议直接用带连接池的数据库服务,比如 Neon 的连接池模式,或者 PostgreSQL + PgBouncer。如果你用的是云数据库自带的连接串,最好确认它是否支持面向 Serverless 的池化连接,否则流量一上来还是可能触发too many connections。

5.3 invalidate 粒度不对:列表死活不刷新

新增一条 Todo 后,列表数据不刷新是最常遇到的问题。这往往是因为 mutation 成功回调里的 invalidate 粒度写错了。比如某个查询带了输入参数api.todo.getByDate.useQuery({ date }),invalidate 时就要带上相同的参数,否则 tRPC 匹配不到对应的缓存 key,列表自然不动。

我习惯在 mutation 的onSuccess里统一用await utils.todo.list.invalidate()这种“按 router 路径批量失效”的方式,配合 React Query 的缓存机制,粗粒度失效虽然会多一些请求,但在中小型项目里完全够用,而且几乎不会出现数据不更新的问题。

5.4 本地 SQLite 和生产 PostgreSQL 的部署差异

create-t3-app 默认数据库可以选 SQLite,本地开发确实轻量,但部署到线上服务后 SQLite 文件写不进去,很多人才开始踩坑。我的建议是第一天就直接在环境变量里配置 PostgreSQL 连接,本地用 Docker 起一个 Postgres 实例,生产用托管数据库,通过DATABASE_URL环境变量切换。这样本地和生产环境行为一致,不会出现“本地好好的,线上全崩了”的经典场面。

5.5 部署前的检查清单

我每次上线前都会按这份清单过一遍,都是实打实花时间踩出来的经验:

检查项说明
数据库连接串确认生产 DATABASE_URL 使用连接池配置,不要用本地 SQLite
环境变量GITHUB_ID、NEXTAUTH_SECRET、NEXTAUTH_URL 是否齐全
schema 同步prisma migrate deploy是否已执行
路由缓存需要实时数据的页面是否被 Route Cache 缓存住
静态资源图片、字体等是否走 CDN 或 Next.js 优化

6. 选型边界:t3code 到底适合什么样的项目

6.1 最适合发挥价值的场景

t3code 最强的地方是把“全栈类型安全”的工程成本降到了最低,所以它特别适合三类项目:一是内部工具和管理后台,这类系统对开发速度要求高、对复杂分布式要求低;二是个人产品 MVP,你一个人同时写前端和后端时,少掉类型断层就少掉一大半联调时间;三是中小规模的全栈应用,项目里所有代码都在同一个仓库,前后端由同一团队维护。

我做的内部项目管理工具就是一个典型例子,几十个数据模型、十几个 tRPC router,迭代了半年,至今没出过因为字段类型不一致引起的线上 bug。这在以前手写 API 的时期几乎不可想象。

6.2 明确边界:什么场景不建议硬上

t3code 不适合的场景也很清晰。如果你的组织已经有独立的后端服务,API 需要同时面向多个前端甚至第三方开放,tRPC 的收益会大幅缩水;如果你的项目需要复杂的事件驱动、消息队列、微服务拆分,tRPC 的类型绑定反而会变成束缚;如果你做的是强 SEO 的大流量内容站,页面数据大量依赖客户端 query 拉取,首屏渲染时间扛不住。

比较理性的态度是:把 t3code 当成一套“默认推荐但可替换”的组合,而不是万能的银弹。选型时先想清楚项目真正的约束是什么,再决定要不要把这一整套接到你的工程里。

我自己用 t3code 做完两个内部系统和一个小型开源应用之后,最明显的变化是“模板的魔法感越来越淡”。root.ts 汇总 router、protectedProcedure 挂中间件、前端自动推导类型——这些当初觉得神奇的技术,现在都变成了日常肌肉记忆。如果要给刚上手的人一个建议,我会说:不要一上来就追求把所有抽象都做满,先老老实实把 Todo 这个闭环跑通,然后一个模块一个模块往里加,模板会推着你往正确的方向走。最后分享一个节省时间的小习惯:每次跑完prisma migrate dev之后,顺手看一眼 IDE 里 router 返回的类型有没有发生变化,如果没变,多半是忘了prisma generate——这个小检查在排查“数据对不上”的问题时,能帮你省下不止十分钟。

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

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

立即咨询