资讯动态

T3 Stack 实战:用 Next.js、TypeScript、tRPC 构建全栈类型安全应用

发布时间:2026/10/26 17:02:21 来源:尧图企业网站定制
前几个月我把手头所有零散的小项目统一收敛成一个代号就叫 t3code。这个代号没什么玄学就是 T3 Stack 里三个 T 的组合TypeScript、Tailwind CSS、tRPC再加上 Next.js 当骨架。我用它做了一个个人代码片段管理工具把收藏、标签、搜索这些功能全塞进去顺带把前后端类型不一致的老毛病一次性解决掉。这篇文章把从选型到落地的完整过程记一遍重点写为什么这么选、具体参数怎么配、以及哪些坑是官方文档里看不到的。如果你刚想接触 T3 技术栈或者打算拿这套组合做内部工具和个人项目这篇可以直接当路线图看。1. 从项目代号重新认识 T3 Stack 的技术取向1.1 t3code 到底在解决什么问题t3code 不是某个新框架它是我对自己项目技术栈的一种收敛。底层核心是 Next.js负责页面路由、SSR、SSGTypeScript 定义整个项目的类型纪律Tailwind CSS 处理样式tRPC 解决客户端到服务端的 API 调用Prisma 管理数据库访问NextAuth 处理登录。这一套组合下来你会发现一个特点每个环节都在为类型安全服务。我过去做个人项目最头疼的不是功能写不出来而是写接口的时候前后端各维护一份类型定义前端改了一个字段后端没跟上接口直接崩。t3code 这个项目里这种问题从根上被消灭了。tRPC 可以在不写 REST 路由、不写 Swagger 文档的前提下让前端直接拿到后端的函数和类型改一处另一处马上编译报错。另一个隐藏价值是维护成本。个人项目经常写完就扔三个月后再回来改需求连自己都不记得接口长什么样。T3 Stack 把所有 API 集中到 router 里数据模型又跟 Prisma schema 绑定看一眼 schema 就能想起来全貌。这种代码即文档的特性对我这种习惯一个人维护多个小项目的人来说价值比团队协作场景还要大。1.2 为什么不用 REST API 或 GraphQL我在技术选型时不是没想过 REST。REST API 当然成熟但它的类型问题是结构性的你写一个GET /api/snippets后端返回的数据要和前端对齐只能靠手动维护 interface 或者生成 openapi-typescript 这类工具。分清 controller、service、router 这几个层每层都要过一遍类型一个接口前后端加模板代码轻松上百行。GraphQL 我也研究过它的类型系统是强但需要引入 schema 定义、解析器、codegen 流程对一个小项目来说工具链太重了。尤其是自己一个人开发的场景GraphQL 的灵活查询能力其实是过剩的而它带来的 schema 维护成本是实打实的。对比之下tRPC 的思路很像直接在 TS 里调用函数。后端定义一个 procedure前端通过 hooks 拿到同名函数调用所有入参出参类型都由编译器推断根本不需要定义第二份类型。拿一个常见的创建代码片段接口举例REST 要写 handler、DTO、校验、client 封装tRPC 只需要在 router 里写一个 mutation前端直接调用Zod 负责校验。能少写大概一半样板代码。1.3 create-t3-app 脚手架的取舍逻辑t3code 的初始化并没有我手动搭而是用了官方脚手架 create-t3-app。这个脚手架不是简单的代码生成器它更像一个决策收敛器你一上来就需要做一轮技术栈取舍选完它再帮你把目录、依赖、基础配置全部铺好。脚手架默认启用 TypeScript 的 strict 模式、ESLint、Prettier这一点看着不起眼实际作用很大。很多项目最后类型混乱就是因为没开 strict各种 implicit any 混过去。脚手架强制帮你把纪律立住后面维护少掉很多头发。不过它也替你做了一些我认为非常合理的默认选择比如默认使用 App Router默认把 tRPC 和 NextAuth 接好默认把 Prisma client 实例化并注入上下文。这些代码新手自己拼最容易拼错的是各种类型泛型、上下文注入、Cookie 传递。脚手架把这些拼接胶水提前做好你只需要往 router 里填业务逻辑。2. t3code 快速搭建流程与原理解读2.1 初始化命令与选项创建 t3code 项目只需要一条命令官方现在默认用 create-t3-app 的最新版本npm create t3-applatest命令跑起来之后脚手架会进入交互式选择流程。我当时的实际选项如下项目名称输入t3codeTypeScript / JavaScript选 TypeScriptTailwind CSS启用tRPC启用Prisma启用NextAuth.js启用App Router是导入别名~前缀保持默认这套组合选完脚手架会把 Next.js 项目目录、Tailwind 配置、tRPC 初始化代码、Prisma schema、NextAuth 基础配置全部生成好。我当时没选 Drizzle 而是选了 Prisma主要是个人经验里 Prisma migrate 流程更顺手而且它的类型生成跟 tRPC 配合得很自然。2.2 目录结构与数据流初始化完成后t3code 的目录结构里最需要看懂的是这几块src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx server/ api/ root.ts routers/ snippet.ts post.ts trpc.ts auth.ts db.ts trpc/ react.tsx server.ts utils/ api.ts prisma/ schema.prismasrc/server/api/trpc.ts是核心工厂它创建 tRPC 的 router 和 procedure同时把 Prisma client 和当前会话注入到 context 里。src/server/api/root.ts把所有子 router 合并成 appRouter。src/trpc/react.tsx负责创建 React Query 的 client 和 Providersrc/utils/api.ts是你前端调用 API 的唯一入口。数据流大致是这样前端页面 importapi调用api.snippet.getAll.useQuery()请求会通过 tRPC HTTP Handler 进入/api/trpc/[trpc]这条路由然后由 tRPC 解析到对应的 router 方法方法内部通过ctx.db访问 Prisma查完数据库把结果原路返回。整个过程没有手写 fetch 地址也没有定义 interface类型全程自动流动。2.3 数据模型设计与 Prisma 初始化t3code 的核心功能是管理代码片段所以我的 Prisma 模型分成三张表用户、片段、标签。用户和 NextAuth 直接关联片段属于某个用户标签跟片段是多对多关系。model User { id String id default(cuid()) name String? email String? unique snippets Snippet[] } model Snippet { id String id default(cuid()) title String content String db.Text language String createdAt DateTime default(now()) updatedAt DateTime updatedAt author User relation(fields: [authorId], references: [id]) authorId String tags Tag[] } model Tag { id String id default(cuid()) name String snippets Snippet[] }写下 schema 之后执行npx prisma migrate dev --name init这一步会创建数据库表并生成 Prisma Client。migrate 看似简单但它背后做的事很多diff 出 SQL 变更、创建迁移文件、应用迁移、重新生成 client。我的习惯是每改一次 schema 就迁一次尽量让迁移文件跟代码提交保持同步这样部署的时候可以直接prisma migrate deploy。3. t3code 里类型安全怎么贯穿三层3.1 tRPC Router 与 Zod 校验t3code 里的业务逻辑大部分写在 router 文件中。拿代码片段列表接口为例它做了两件事限制只能查自己的数据支持按标签过滤和关键词搜索。// src/server/api/routers/snippet.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const snippetRouter createTRPCRouter({ getAll: protectedProcedure .input( z.object({ tag: z.string().optional(), query: z.string().optional(), }) ) .query(async ({ ctx, input }) { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id, ...(input.tag ? { tags: { some: { name: input.tag, }, }, } : {}), ...(input.query ? { OR: [ { title: { contains: input.query, mode: insensitive } }, { content: { contains: input.query, mode: insensitive } }, ], } : {}), }, include: { tags: true }, orderBy: { createdAt: desc }, }); }), });这里protectedProcedure会先检查 session没登录直接抛错。如果用 REST 思路这个校验要在中间件或业务代码里重复写但 tRPC 把鉴权变成一个 procedure 的修饰器哪个接口需要登录就挂哪个不需要登录的用publicProcedure。Zod 负责运行时校验前端传一个不合法类型进来不用等业务逻辑跑起来入口直接拦截。3.2 前端 useQuery 的零成本类型调用前端调用时不需要知道后端路由地址直接 import 同一个 api// src/app/tags/[tag]/page.tsx use client; import { api } from ~/utils/api; import { useParams } from next/navigation; export default function TagPage() { const params useParams{ tag: string }(); const { data, isLoading, refetch } api.snippet.getAll.useQuery({ tag: params.tag, }); if (isLoading) return p加载中.../p; return ( div {data?.map((snippet) ( pre key{snippet.id}{snippet.content}/pre ))} /div ); }这段代码有个很有意思的点api.snippet.getAll这个链式调用是从后端 router 自动推导出来的。如果后端把返回值里的content字段改名了这边访问snippet.content会直接编译报错。不需要跑服务、不需要联调、不需要 mock 数据类型错误在开发阶段就被按住了。tRPC 还内置了 TanStack Query所以缓存、重试、刷新这些能力是开箱即用的。我写创建片段的 mutation 时就用到了它的失效刷新机制const createSnippet api.snippet.create.useMutation({ onSuccess: () { apiUtils.snippet.getAll.invalidate(); }, });invalidate 之后列表自动重新请求我不会手动去维护全局 store。这是 T3 组合拳里很舒服的一点类型出错了编译器先告诉你数据过期了 React Query 帮你处理而我只需要关心业务逻辑。3.3 Tailwind 在 t3code 里的实际配置样式部分t3code 用了 Tailwind CSS。脚手架默认生成 tailwind.config.ts 和全局 CSS 文件。我给代码片段卡片设计了很简单但实用的样式布局article classNamerounded-lg border border-neutral-200 bg-white p-4 shadow-sm hover:shadow-md transition-shadow h3 classNamefont-medium text-neutral-900{snippet.title}/h3 pre classNamemt-2 overflow-x-auto rounded-md bg-neutral-50 p-4 text-sm text-neutral-800 {snippet.content} /pre /article这里我刻意用 utility class 而不是写一堆 CSS 文件。个人项目里我最怕样式文件分散、命名冲突Tailwind 的原子类让我在改样式时不用跳文件维护成本低很多。如果你愿意还可以在此基础上用 shadcn/ui 之类的组件库但我的建议是先把原子类和响应式布局练熟再考虑上组件来兜底不然很容易陷入改样式必须翻组件源码的窘境。4. 实操中的常见问题与排查实录4.1 Prisma 连接池在开发环境的坑开发 t3code 时我遇到的第一个坑来自 Prisma 和 Next.js 的配合。Next.js 开发模式会频繁重新编译每次热更新都可能尝试创建新的 PrismaClient 实例积累到数据库连接数上限日志里会出现Too many connections。解决方法是官方推荐的全局实例缓存模式// src/server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: [query, error, warn], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这个模式把 PrismaClient 挂到 globalThis 上开发环境不会重复创建实例。第二个连接池相关的因素是数据库本身的连接数限制。我用的是 PostgreSQL默认 max connections 是 100serverless 环境里每个请求都可能占用连接所以我在部署环境里把连接池上限调低并且设置了较短的 connect_timeout。建议尽快把 pool 参数写进 DATABASE_URL 的连接串里。4.2 tRPC 与 NextAuth 会话的边界问题一开始我在 t3code 里给所有页面都加了 Next.js 中间件希望通过 middleware 统一拦截未登录请求。实测下来发现问题middleware 只在路由层判断并不了解数据库里的真实会话状态而 NextAuth 的 JWT 会话在中间件里能拿到但在某些边缘场景下会跟页面内 session 状态不一致。我的最终用法是分层处理middleware 只负责前置兜底跳转真正的数据级保护交给 tRPC 的protectedProcedure。比如删除片段的那个 mutation如果只依赖前端按钮显隐那通过 API 直接调还是能删数据。挂上保护 procedure 以后后端每个入口都会检查 session这才是最终防线。如果你的 t3code 需要在服务端组件里获取 session我建议手动读取 Cookie 然后传给服务端调用器。tRPC 的 server caller 支持传入包含 Cookie 的请求头不然服务端渲染时拿不到当前用户身份。4.3 部署到 Vercel 时容易踩的坑t3code 部署用的是 Vercel整体体验很顺但有几个点必须先处理。第一数据库不能用 SQLite。Vercel Serverless 函数的文件系统是临时的任何本地写入都会丢失必须换成托管 PostgreSQL。我选了 Neon 和 Supabase 这一类的云数据库连接串直接填到DATABASE_URL。第二迁移命令要用migrate deploy而不是migrate dev。在本地开发时migrate dev会生成新的迁移文件部署环境里直接执行prisma migrate deploy按已有迁移文件顺序建表不触发交互。第三环境变量容易漏配置。t3code 至少需要三个关键变量DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL。NEXTAUTH_SECRET如果忘了设生产环境登录会直接报错而且错误信息不容易一眼看出原因。部署前我建议先在本地 build 一次把类型检查、Prisma generate、环境变量引用问题全部暴露出来再推 Vercel。4.4 给新手的避坑清单这一节是我自己重构 t3code 过程中总结出的经验不一定适合所有团队但对单人或少量开发者特别适用。第一不要过早抽象数据访问层。很多从企业项目转过来的朋友一上来就在 Prisma 外面包 repository 和 service。T3 的官方风格是直接在 router 方法里写 Prisma 查询这对大多数业务场景已经够用了。等真出现多端复用或者复杂事务再考虑抽层不要提前给代码加杠杆。第二不要自己封装一个统一返回结构。习惯写{ code, data, message }的人会下意识想给 tRPC 套一层。这个做法会直接废掉 tRPC 的类型推导因为前端拿到的不再是精准的数据类型而是一个包装壳。tRPC 的错误处理原生支持 throwing你不需要再发明一种错误格式。第三Zod schema 尽量放在离 router 近的位置。有时候客户端也会用到同一个 schema直接导出后在页面里 import 即可不要专门维护一个巨大的 schema 目录那样反而增加跳转成本。第四注意 tRPC 的 query key 设计。当你传多个筛选条件时React Query 会根据 input 自动生成 key。我一开始担心它会缓存混串实测没有这问题只要服务端按条件查询前端invalidate 就会刷新。别动用手动 key 去干扰它天然机制已经够用。第五Tailwind 样式如果越写越乱先检查是不是组件拆得太碎。我在 t3code 里把代码片段卡片、标签、编辑器拆成独立组件之后样式冲突就基本消失了。原子类本身不难难的是决定每段 UI 的边界。这个项目跑起来大概两周后面我基本不用再花时间维护类型定义和接口文档。真正花时间的反而是数据模型设计和搜索条件梳理。T3 这套组合把我的问题从前后端类型不一致转移到了业务逻辑本身这是它最让我舒服的地方。如果你也打算拿它做自己的工具站点我的建议是先画几版最丑的草图把核心表和交互定下来然后让 tRPC 直接驱动页面。抽象层、接口文档、额外状态管理这些能晚一点加就晚一点加。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑