☰
用T3技术栈打造类型安全的全栈应用:t3code实践指南
2026/10/14 5:22:04 网站建设 项目流程

世界上有两种前端开发:一种改接口文档改到麻木,另一种让编译器帮你改接口文档。我坚定地想成为第二种,于是有了 t3code 这个项目。

t3code 不是某个官方框架,是我给自己一套基于 T3 技术栈的全栈 Web 应用模板起的代号。T3 来自 TypeScript、Tailwind CSS、tRPC 三个名字的首字母,由知名开发者 Theo 带火的一套组合,通常配套 Next.js、Prisma 和 NextAuth。这套东西解决什么问题?一句话:用 TypeScript 把类型从数据库一路钉到 React 组件,REST 时代那种"前端等后端文档、后端等前端反馈"的循环,在这套体系里基本不存在。

这篇文章我不会复述官方文档,而是从我做 t3code 的前后决策讲起。容易跑偏的地方、上线前踩的坑、以及最终我把这套模板用在自己的小组件库里的实际感受,都会说完。如果你是中小规模 Next.js 全栈应用、前端后端都由同一拨人维护,这条路线值得你花半小时了解。

1. 为什么我把新项目代号定为 t3code

1.1 之前写接口时折磨我的三件事

在进入 t3code 之前,我一直用 Next.js 写全栈应用。API 层最常规的做法是:

  1. 在app/api下写 Route Handler。
  2. 手动定义请求和响应类型。
  3. 前端用 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 Handlers):tRPC 的 HTTP 出口挂在这里。
  • 基于 Node.js 的服务端部署能力:适合将整套应用发布到支持 Node 函数的平台。

我的建议是:如果你打算长期把前端静态导出(output: export),T3 这套组合可能不太适合,因为 tRPC 需要运行时的 HTTP 层。但如果你的目标运行环境是 Node.js,App Router 就是最自然的选择,它让"服务端取数、客户端交互"这个分工变得非常清晰。

2.2 tRPC:API 层的收缩器

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 className="flex min-h-screen items-center justify-center bg-gray-100 px-4"> <div className="w-full max-w-2xl rounded-xl bg-white p-8 shadow-lg"> <h1 className="text-2xl font-bold text-gray-900">t3code Blog</h1> </div> </div>

没有传统className="card"这种语义类名,取而代之的是大量原子类。刚上手会觉得是把 CSS 都塞进了 HTML,但写过一段时间后你会发现,它解决的恰恰是传统 CSS 最头疼的问题:选择器优先级冲突和复用时的样式互相污染。

t3code 里的 Tailwind 配合cn()工具(clsx+tailwind-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-app:

npm create t3-app@latest 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 type,Prisma + zod 联合推导出了所有类型。

把postRouter挂到root.ts:

import { 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 className="mx-auto max-w-3xl px-4 py-10"> <h1 className="text-3xl font-bold">t3code 博客</h1> <PostList initialPosts={posts.items} initialCursor={posts.nextCursor} /> </main> ); }

这里直接用服务端的 caller 在 Server Component 里同步取数。好处是首屏数据直接在服务端渲染完成,不用经历客户端发请求的闪烁。

对于需要交互的列表,我把它做成 Client Component,使用 React Query 的 useInfiniteQuery:

"use client"; import { api } from "@/trpc/react"; export function PostList({ initialPosts, initialCursor, }: { initialPosts: Awaited<ReturnType<typeof 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} className="mb-6 rounded-lg border p-4"> <h2 className="text-xl font-semibold">{post.title}</h2> <p className="text-gray-600">{post.excerpt}</p> {post.category && ( <span className="text-sm text-blue-600">{post.category.name}</span> )} </article> )) )} {query.hasNextPage && ( <button onClick={() => query.fetchNextPage()} className="mt-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。当时前端还没有任何组件引用这个新字段,所以整体的类型检查完全通过,页面看起来也正常——直到产品经理提出新文章详情页应该展示预计阅读时间,我才发现接口的返回结构里根本没有这个数据。

排查思路:

  1. 先看 Prisma Client 生成的类型:prisma.post.findMany的select里没readingTime,说明漏写了字段。
  2. 再看浏览器网络面板,接口返回 JSON 里确实没有readingTime字段。
  3. 类型系统在"前端还没有引用它时"不会报错;一旦前端开始引用,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 generate

migrate 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。

修复有两条路:

  1. 把完整的类名字符串写进源码,不用拼变量。
  2. 在tailwind.config.ts里用safelist显式声明动态类名。

如果你在团队里维护设计系统,建议从一开始就把按钮颜色变化用完整类名映射表管理,不要在业务代码里动态拼 Tailwind 类。这个坑在 T3 里不会报错,纯粹是"样式悄悄消失"型问题,排查起来非常耗时间。

四个问题其实可以概括成一张表:

坑根因修复方式
Prisma 新字段前端拿不到用了select没包含新字段在 select 列表补上字段并重编译
Server Component 查询重复执行服务端 caller 与客户端 hook 同一数据流叠加统一由 Server Component 取数,props 传给子组件
线上数据库报错缺字段只在本地跑了migrate dev部署前置migrate deploy+prisma generate
Tailwind 类名不生效运行时拼接类名躲开了 JIT 扫描用完整类名或safelist

5. 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 应用,日志天然分三处:

  1. 服务端 Server Component 的日志:平台控制台的标准输出。
  2. tRPC 内部抛出的 TRPCError:需要自己加 logger 中间件。
  3. 数据库慢查询: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 解析,这样即使将来哪一天后端返回结构变了,你的应用也不会把脏数据一路带进界面。

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

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

立即咨询