资讯详情

资讯详情

T3栈实战:从零搭建类型安全的全栈应用

很多做全栈开发的朋友应该都听过“T3栈”这个说法但真正把这一整套东西玩明白、用顺手的人不算多。前两天我在梳理自己手头的项目模板时正好把t3code这套工程化方案重新整理了一遍踩了不少坑也攒下了一些实测可以用的经验。这篇文章不打算讲那种Hello World级别的东西而是从选型逻辑、完整落地、常见故障排查这几个层面把 T3 技术栈Next.js TypeScript tRPC Prisma TailwindCSS NextAuth从零拼装到一个生产可用状态的过程掰开揉碎地讲清楚。不管你是刚接触全栈开发、想给自己搭一套标准化起手式的独立开发者还是团队里负责技术基建、想把端到端类型安全和工程化做扎实的前端负责人这篇内容应该都能给你一些能直接抄作业的参考。整个t3code的核心价值用一句话概括就是用最少的技术选型博弈换一条从数据库到 React 组件全程类型安全的开发链路。1. 为什么我会盯上 t3code先拆解痛点再聊技术1.1 全栈开发的混乱根源类型断裂与领域割裂所有做过前后端分离项目的人都体验过一种阵痛后端把接口文档写好前端再照着文档定义类型表单提交、列表渲染、权限跳转各写各的联调时一改字段全链路崩塌。这种类型断裂带来的直接后果是类型安全只停留在单个项目内部跨端之后全靠自觉。我做过的几个中型项目里接口字段命名不统一、时间格式各搞一套、枚举值前后端对不齐简直成了常态。t3code这种方案的初衷就是从根本上解决这种领域割裂。它选的不是乱花渐欲迷人眼的微服务架构而是一个单体 Next.js 应用 数据库 类型安全 RPC 层的组合。在这个模型里你的数据库模型、服务端逻辑、前端调用链全部运行在同一个 TypeScript 类型系统内从 Prisma Schema 里删一个字段到前端组件里立刻出现红波浪线这不是魔术而是工程化本该有的样子。注意T3 栈并不是一个官方框架它是一套社区验证过的技术选型配方。t3code可以理解为基于这套配方的工程化落地样板。1.2 从 create-t3-app 到 t3code为什么还要再包一层如果你接触过社区应该知道有个create-t3-app脚手架它能快速生成一个带 tRPC、Prisma、NextAuth 的项目基础结构。那t3code和它有什么区别呢我的理解是create-t3-app解决的是5分钟内跑起来的问题而t3code解决的是跑起来之后怎么长久维护的问题。具体来说我把t3code定位成一套团队级别的项目规范层在脚手架基础之上补全了这么几块统一的目录约定、错误处理中间件、日志可观测性接入、环境变量校验、数据库迁移工作流、部署脚本模板。这些内容在一两个小项目里可能感受不到价值但当你的项目从几千行膨胀到几万行、多人协作时规范层的价值就会完全突显出来。这篇文章里的所有经验和代码都是在这套规范基础上打磨出来的。1.3 核心关键词速览六把钥匙开一把锁Next.jsApp Router负责页面渲染层同时承担 BFFBackend For Frontend角色。如果你用 Pages Router 也没关系核心逻辑一致只是文件约定不同。TypeScript全链路语言基础也是整个类型安全体系的基石。tRPC替代传统 REST 的端到端类型安全 RPC 方案。tRPC 不是 REST 的替代品它是让你不需要同时维护类型和接口的黏合剂。Prisma数据库 ORM负责模型定义与迁移是类型安全的终点起点。TailwindCSS原子化 CSS 方案降低样式层的心智负担。NextAuthAuth.js认证体系与 tRPC、Prisma 打通后快速实现登录态管理和鉴权保护。一句话记法t3code Next.js 当骨架Prisma 连数据tRPC 通前后端TypeScript 贯穿全身Tailwind 管样式NextAuth 守门禁。2. 核心细节解析tRPC、Prisma、NextAuth 这组铁三角怎么才能不打架2.1 tRPC 的设计哲学不是 没有了 HTTP而是 不需要关心 HTTP很多人第一次接触 tRPC 会很困惑这玩意儿到底是不是把我的接口藏起来了其实并不是。tRPC 底层依然走 HTTP 请求但它站在开发者的角度把接口定义这件事从心智负担里消掉了。你写的不是 URL 和 method而是一个个类型安全的函数调用。// server/api/routers/user.ts import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; import { z } from zod; export const userRouter createTRPCRouter({ getProfile: protectedProcedure .input(z.object({ userId: z.string() })) .query(async ({ ctx, input }) { const user await ctx.prisma.user.findUnique({ where: { id: input.userId }, include: { posts: true }, }); return user; }), updateName: protectedProcedure .input(z.object({ name: z.string().min(2).max(20) })) .mutation(async ({ ctx, input }) { const updated await ctx.prisma.user.update({ where: { id: ctx.session.user.id }, data: { name: input.name }, }); return updated; }), });这段代码写完之后前端如果想要调用getProfile不再需要记任何 URL也不再需要手写 fetch 封装、手动传参类型。直接在 React 组件里这样用// app/profile/page.tsx use client; import { api } from ~/trpc/react; export default function ProfilePage({ params }: { params: { userId: string } }) { const [user] api.user.getProfile.useSuspenseQuery({ userId: params.userId, }); return ( div h1{user.name}/h1 ul {user.posts.map((post) ( li key{post.id}{post.title}/li ))} /ul /div ); }useSuspenseQuery返回的user对象天然具备完整类型推导。你觉得前端同学会写错字段名吗不可能因为 TypeScript 在编译期就拦住了。这就是 tRPC 的核心价值类型不再是静态的参考而是运行时实打实的约束。2.2 t3code 里的输入校验Zod 不是可选项而是必选项在你输入一个 tRPC procedure 时它的input定义使用的是 Zod schema。这一步极其重要。很多半路出家做全栈的朋友会觉得反正前后端都是 TS还校验啥但你要明白一个事实你的 API 不止你的前端会调用。第三方脚本、爬虫、直接在控制台里发 fetch 的不怀好意者都可能触发你的服务端逻辑。没有输入校验任何一层安全防线都是裸奔。Zod 在t3code里的角色是双重的运行时校验 静态类型推导。运行时校验保证了进入数据库的每一个字段都符合预期静态推导保证了前端传参时就能感知字段约束。一个典型的场景import { z } from zod; export const createPostSchema z.object({ title: z.string().min(1, 标题不能为空).max(80, 标题最长80个字), content: z.string().min(1).max(20000), tags: z.array(z.string()).max(5).default([]), }); export type CreatePostInput z.infertypeof createPostSchema;z.infer这个操作符能直接从 Schema 反推出 TypeScript 类型意味着你的校验规则和类型声明只有一份来源不会出现校验归校验、类型归类型的两套标准。这个特性在多人协作时尤其重要因为你不需要再问队友这个字段的类型是什么类型就是校验规则本身。2.3 Prisma 与 tRPC 的配合context 才是连接两者的暗线t3code中有一个容易被忽略但极其关键的机制——创建 tRPC context。context 字面意思就是上下文它在每次请求发生时构建并能被所有 procedure 访问。在我的项目里context 里同时注入了 Prisma 客户端和当前会话信息// server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import { type CreateNextContextOptions } from trpc/server/adapters/next; import { getServerAuthSession } from ~/server/auth; import { prisma } from ~/server/db; import superjson from superjson; export const createTRPCContext async (opts: CreateNextContextOptions) { const session await getServerAuthSession(opts); return { prisma, session, }; }; const t initTRPC.contexttypeof createTRPCContext().create({ transformer: superjson, }); export const createTRPCRouter t.router; export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: { ...ctx.session } } }); });这段代码里有几个值得细说的点superjson是序列化方案选型。它支持Date、Map、Set这类 JSON 天生搞不定的类型能极大减少你处理时间格式、复杂结构的痛苦。protectedProcedure是一个中间件包装。任何受保护的接口只要用它定义就自动完成登录校验未登录直接抛出UNAUTHORIZED错误。你不必在每个接口里重复写if (!user) return 401。context 里挂 prisma 实例而不是在路由模块里new PrismaClient()。这是 Prisma 官方反复强调的开发规范能有效避免热重载时创建大量数据库连接、达到连接数上限的问题。2.4 NextAuth 与 tRPC 的会话打通一个容易踩坑的细节NextAuth 本身有自己的useSession、getServerSession等模式t3code中要处理的细节是如何把 NextAuth 的 session 塞进 tRPC 的 context并让 protectedProcedure 顺畅使用。这里我踩过一个比较隐蔽的坑在 App Router 模式下如果你在 Server Component 里直接调getServerAuthSession()可能拿不到 session因为 App Router 下某些场景没法直接访问 cookies 上下文。解决方案是在createTRPCContext的创建位置传入headers()或者在你的auth.ts配置里显式启用unstable_update相关的适配。更省心的做法是// server/auth.ts import { getServerSession, type NextAuthOptions } from next-auth; import { PrismaAdapter } from next-auth/prisma-adapter; import CredentialsProvider from next-auth/providers/credentials; import { prisma } from ~/server/db; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(prisma), session: { strategy: jwt }, providers: [ // 推荐直接接入第三方 OAuth省掉自建账号体系的运维成本 // GitHub Provider、Google Provider 等 ], callbacks: { jwt: async ({ token, user }) { if (user) { token.id user.id; } return token; }, session: async ({ session, token }) { if (token) { session.user.id token.id as string; } return session; }, }, };注意很多示例代码会用 jwt 策略而且不在session回调里把token.id写到session.user.id。结果就是前端session.user.id一直是undefined紧接着你在数据库查询里where: { id: session.user.id }就会查出空结果甚至报错。这个细节非常隐蔽排查时往往让人抓狂。3. 实操过程从空目录到跑通 t3code 的完整路线这一部分我直接给出一套经过多次验证的落地步骤尽量贴近你实际敲命令的顺序。我假设你已经具备 Node.js 18 环境和包管理器基础这里用 pnpm 为例。3.1 第一步初始化项目与依赖选择如果你要从零开始create-t3-app仍然是最高效的起点pnpm create t3-applatest t3code交互式命令行会让你选择包含哪些模块。我的配置习惯是Next.jsApp RouterTypeScript开启这是刚需tRPC开启Prisma开启数据库我建议直接用 Prisma 支持的 PostgreSQL 或 SQLite 起步TailwindCSS开启NextAuth开启如果你已经有一定基础不想用脚手架也可以手动拼装。但说实话手拼的初期成本高很多单纯为了学习原理可以为了效率建议直接脚手架初始化。create-t3-app初始化完成之后别急着写业务代码先把依赖装齐、环境变量配置文件补齐。默认生成的.env文件里DATABASE_URL和NEXTAUTH_SECRET是必须认真填的# .env DATABASE_URLpostgresql://postgres:passwordlocalhost:5432/t3code?schemapublic NEXTAUTH_SECRET自己生成一段足够随机的字符串 NEXTAUTH_URLhttp://localhost:3000NEXTAUTH_SECRET在本地开发可能没人在意但上生产环境后如果漏配NextAuth 每次重启都会重新生成 session 密钥已有的登录态全部失效。这个坑生产环境最容易遇到。3.2 第二步设计数据库模型和 Prisma 迁移流程t3code的典型模型设计里至少要有用户、帖子、标签这类基础模型。下面是一个比较能体现关系设计的 Schema// prisma/schema.prisma generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? posts Post[] accounts Account[] sessions Session[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String published Boolean default(false) authorId String author User relation(fields: [authorId], references: [id]) tags Tag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique posts Post[] }写完 Schema 后执行迁移pnpm prisma migrate dev --name init这一步会完成两件事生成 SQL 迁移文件 重新生成 Prisma Client。如果你改了 Schema 但没有重新prisma generate你的 TypeScript 代码里拿不到新模型的类型编辑器会报错。3.3 第三步将 Prisma 模型映射到 tRPC 路由有了模型后就可以绘制数据服务层了。在t3code里我的习惯是按业务模块拆 Router而不是把所有 query 堆在一个文件里。项目结构大概是这样src/ ├── server/ │ ├── api/ │ │ ├── root.ts │ │ └── routers/ │ │ ├── post.ts │ │ ├── user.ts │ │ └── tag.ts │ ├── auth.ts │ ├── db.ts │ └── api/trpc.ts ├── trpc/ │ ├── react.tsx │ └── server.ts └── app/post.ts路由注册这样一个查询// server/api/routers/post.ts import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; import { z } from zod; export const postRouter createTRPCRouter({ listLatest: 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.prisma.post.findMany({ take: input.limit 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, include: { author: true, tags: true }, where: { published: true }, }); let nextCursor: string | undefined; if (posts.length input.limit) { const nextItem posts.pop(); nextCursor nextItem?.id; } return { posts, nextCursor }; }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), tagNames: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { const post await ctx.prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tagNames.map((name) ({ where: { name }, create: { name }, })), }, }, }); return post; }), });这里有两个细节值得拿出来说游标分页take: input.limit 1是为了判断有没有下一页多拿一条如果多出来的那条存在就代表还有数据。这是 tRPC 文档推荐的写法能避免基于offset分页在深翻页场景下的性能问题。connectOrCreatetagNames传入的可能是新标签也可能是已有标签connectOrCreate能一次完成连接已有和创建缺失两种操作少写不少判断逻辑。3.4 第四步前端调用的接入与 Server Component 注意事项前端接入时t3code会在src/trpc/react.tsx里创建一个api对象。如果你在 Client Component 里调用流程如下// app/posts/page.tsx use client; import { api } from ~/trpc/react; export default function PostsPage() { const { data, isLoading, fetchNextPage, hasNextPage } api.post.listLatest.useInfiniteQuery( { limit: 10 }, { getNextPageParam: (lastPage) lastPage.nextCursor, } ); if (isLoading) return div加载中.../div; return ( div {data?.pages.flatMap((page) page.posts.map((post) ( article key{post.id} h2{post.title}/h2 p{post.content}/p span{post.author.name}/span /article )) )} {hasNextPage button onClick{() fetchNextPage()}加载更多/button} /div ); }useInfiniteQuery是 tRPC 基于 TanStack Query 封装的能力用于无限滚动列表非常顺手。你只需要定义好分页输入参数和getNextPageParamtRPC 会帮你把多次请求的数据自动拼接成pages数组。如果你是 Server Component方法更简单// app/posts/page.tsxServer Component 版本 import { api } from ~/trpc/server; import PostList from ./PostList; export default async function PostsPage() { const { posts } await api.post.listLatest({ limit: 10 }); return PostList initialPosts{posts} /; }注意在 Server Component 中调用 tRPC需要确保api实例是在请求期间创建的不能在模块顶层共享否则会出现状态污染。4. 常见问题与排查技巧实测踩坑实录4.1 Cannot read properties of undefined (reading user) 类错误这是我在接 NextAuth tRPC 时遇到的第一类高频报错。排查思路分三步确认session真的在 context 里存在在protectedProcedure中间件里console.log(ctx.session, ctx.session)。确认你的 NextAuth 配置回调里确实写入了session.user.id别只依赖默认的session.user.name和session.user.email。确认你的数据库User表里有对应记录。如果是第三方 OAuth 首次登录PrismaAdapter 应该已经帮你自动创建用户。如果以上都正常重点检查你的请求是不是走了一个未带 cookie 的跨端调用。用 Postman 或者 curl 调试 tRPC 接口时需要手动带上浏览器里的 Session Cookie否则服务端是感知不到登录态的。4.2 Prisma 连接数瞬间打满导致的延迟和报错开发模式下Next.js 的热更新会反复执行代码如果你在模块顶层直接new PrismaClient()每个热更新周期都会新增连接几分钟就能把 PostgreSQL 默认的连接池打满。t3code的标准做法是加一层全局单例// server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error, warn] : [error], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma prisma;这段代码的作用是在开发环境下把 PrismaClient 实例挂在globalThis上避免每次刷新都新建。它是一个约定俗成的优化但非常有效。4.3 superjson 与 Date 类型序列化问题如果你在 tRPC 返回的数据里直接包含Date对象前端拿到的可能是字符串或 undefined 之类的怪异值。这是因为 JSON 无法原生序列化Date。使用superjson能自动处理 Date、Map、Set 等特殊类型但前提是服务端创建 tRPC 实例时指定transformer: superjson。前端创建trpcClient时也要配置同名 transformer。两边不一致会导致序列化失败所以默认模板里已经帮你配好了自己改代码的时候注意别在某一侧手动覆盖掉了。// 前端入口 src/trpc/react.tsx import { createTRPCReact } from trpc/react-query; import superjson from superjson; export const api createTRPCReactAppRouter(); export function TRPCReactProvider(props: { children: React.ReactNode }) { const queryClient new QueryClient(); const trpcClient createTRpcClientAppRouter({ transformer: superjson, links: [...], }); // ... }4.4 部署到 Vercel 时 NextAuth 回调地址错误本地运行一切正常一部署到 Vercel 就出现 callback URL mismatch。原因通常只有一个NEXTAUTH_URL环境变量没有设置为你的生产域名或者忘了在 OAuth 服务商后台把https://your-domain.com/api/auth/callback/github这类回调地址加入白名单。另外Vercel 环境下数据库实例如果是自建的 PostgreSQL需要把DATABASE_URL里的连接字符串改成支持 SSL 的模式比如在参数末尾追加?sslmoderequire。这个我在云数据库上实测过不加的话连接会超时。4.5 生产构建时 TypeScript 类型报错但本地开发正常这种只在 build 阶段报错的现象本质原因是 Next.js 在生产构建时会把类型检查严格打开。而你本地跑pnpm dev时Hot Reload 不会对所有文件做完整检查。解决方案只有一个本地先跑pnpm build把报错提前暴露出来。常见的类型错误有两类tRPC 输入类型和前端传参不一致比如前端传了limit: 5而 Schema 要求number。Prisma 关联字段没include返回类型缺失。这类问题没有捷径只能靠 TypeScript 编译器帮我们把好关卡这也是 t3code 的价值所在。5. 工程化扩展日志、错误处理与代码生成5.1 tRPC 错误处理中间件很多项目在 tRPC procedure 里直接throw new Error()结果前端拿到的错误信息只有笼统的 Internal Server Error。我在t3code中维护了一个统一的错误处理中间件export const errorMiddleware t.middleware(async ({ ctx, next }) { try { return await next(); } catch (error) { if (error instanceof TRPCError) { throw error; } // 标准 Prisma 错误码映射 if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error.code P2002) { throw new TRPCError({ code: CONFLICT, message: 数据已存在字段冲突, }); } if (error.code P2025) { throw new TRPCError({ code: NOT_FOUND, message: 记录不存在或已被删除, }); } } // 未知错误统一记日志并转换成 500 console.error([tRPC Unhandled Error], error); throw new TRPCError({ code: INTERNAL_SERVER_ERROR, message: 服务异常请联系管理员, }); } });这样前端在调用接口时可以根据TRPCClientError的data.code做分支处理而不是盲目地把错误信息一屏一屏地刷给用户。比如UNAUTHORIZED跳转登录页、CONFLICT提示用户修改字段体验差别很大。5.2 统一日志接入别等到出了生产事故才想去查日志t3code里我建议接入一个结构化日志方案最简单的是在 tRPC 中间件里打印请求路径和耗时export const loggingMiddleware t.middleware(async ({ path, type, next }) { const start Date.now(); const result await next(); const durationMs Date.now() - start; console.log( JSON.stringify({ path, type, durationMs, at: new Date().toISOString(), }) ); return result; });在生产环境里这条 JSON 日志可以直接被托管日志服务解析和检索。如果前期没有引入日志平台直接用console.log也能在 Vercel 的日志面板里看到就是多维查询不方便。5.3 让 t3code 更顺手二次封装与代码生成当你跑了几个项目之后会发现 tRPC Router 里的代码模式非常固定。此时值得考虑引入一些代码模板。用plop或者手写脚本都行核心目标是新增一个业务模块时通过命令生成 Router、Schema、Zod 输入定义、前端 API 调用片段。一个简单脚本的思路pnpm plop router postPlop 模板里生成的东西包括src/server/api/routers/post.ts里面预先写好list、byId、create、update、delete五个标准 procedureZod 输入类型也预置好。这样团队新增模块时平均只需要 10 分钟就能把 CRUD 链路全部打通而不是从空文件开始敲。这也是t3code作为工程化方案最大的价值点之一。6. 个人在实际使用中的几点体会赶在文章末尾分享三条真实的使用心得。第一tRPC 的学习曲线没有想象中陡但不要把它当成万能药。如果你的项目本身是一个需要公开给第三方开发者调用的开放平台REST 或 GraphQL 反而更合适因为第三方不在乎你是否类型安全他们只认标准接口协议。而如果你是做内部系统、SaaS 应用或者前后端同一个团队维护tRPC 是效率极高的选择。第二Prisma 和数据库之间的模型设计仍然需要认真思考。类型安全只是约束了你怎么写代码但没约束你怎么设计数据。关系建模、代理键、必要字段、软删除策略这些老生常谈的概念在 t3code 里一样要认真琢磨。我见过不少项目因为一开始的 Prisma Schema 设计粗糙后期迁移成本高到想重构。第三NextAuth 搭第三方 OAuth 是最省心的登录方案但不要忽视账号注销和用户数据导出的处理。我在实际项目里补充了一条deleteAccount的 mutation在删除用户记录之前需要连带删除该用户创建的帖子、关联的 Session、Account 表记录这一步如果不做数据库会出现外键约束错误也会给用户留下删了账号但数据还在的困惑。t3code不是一个能让你一夜成为架构师的银弹但如果你愿意把它的工程化思路吃透至少能在未来几年内少写很多无效代码少吵很多接口又改了的架。如果你正准备开始一个全栈项目不妨试试按照这篇文章里的步骤走一遍跑通之后再回到自己的业务场景里看看它是不是比过去前后端分离 手写接口文档的老路顺畅得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →