真正的全栈开发最怕的不是不会写代码而是你花大量时间搭完架子结果发现前后端的数据流逻辑各自为政——前端定义一套类型后端又是另一套接口文档跟不上代码变更等到联调阶段整个人都是裂开的。我用t3code这个项目实践了一轮T3技术栈Next.js TypeScript Tailwind CSS tRPC从零搭建到部署上线的完整流程把过去散落在各个项目里的经验整合了一遍。这篇文章就把我在t3code里做的事情、踩过的坑、以及为什么最后选择了这套方案完完整整摊开来聊一聊希望对正在做全栈选型或者打算入手T3 Stack的朋友有参考价值。t3code本质上是一个基于T3 Stack构建的全栈Web应用示例项目核心解决的是“如何用一套端到端的类型安全体系把前端、后端、数据库串起来”的问题。它适合三类人第一类是熟悉React但没怎么碰过后端的前端工程师想快速上手全栈开发第二类是受够了“前后端接口文档对不上”的小团队开发者想体验整条链路类型畅通的快感第三类是准备用Next.js做生产项目的朋友需要一个规范化的项目骨架做参考。如果你属于其中任何一种这篇文章的内容应该能帮到你。1. 先说清楚 t3code 到底在解决什么问题1.1 为什么在2024年还要纠结“技术栈选型”很多开发者在开始一个新项目时总习惯直接打开命令行create-vite-app或者干脆从网上找一套现成的后台管理模板。这种做法本身没有问题但问题出在项目一旦进入业务快速迭代期各种隐患就会集中爆发React组件里手写的接口类型和后端返回的数据不一致验证逻辑在前端写了一套、后端又写了一套数据库字段改动后前端根本感知不到。这些问题每一个单独看都能解决但合在一起就是一遍遍的重复劳动和低级bug。我在做t3code之前也经历过那种“前端代码和后端代码都在同一个仓库但感觉完全是两个世界”的项目——前端同事改了一个请求参数后端不知道后端改了字段名前端也不清楚最后线上报错全靠用户帮忙发现。这种协作成本即便在三五个人的小团队里也很致命。所以当T3 Stack这套方案出现的时候我几乎是一拍大腿这不就是我一直想要的“一条龙类型安全”吗1.2 T3技术栈的核心设计哲学T3 Stack并不是一个单一框架而是一个“可组合的、以TypeScript为骨架”的全栈开发方案。它的核心成员是Next.js、TypeScript、Tailwind CSS和tRPC按需还可以加Prisma、NextAuth.js和Zod。这套组合的设计哲学非常明确能静态类型化的地方绝不留到运行时。意思是说你在数据库定义了一个字段这个类型应该自动流到API层再流到前端组件中间不需要任何手写接口定义也不需要通过Swagger之类的外部文档中转。打个比方传统开发模式像两个人隔着墙打电话——你说“我这边要传一个username字段”对面说“好的收到”但墙那边的实现可能跟说的完全不一样而T3 Stack这种方式像两个人共用一本字典你写什么都算数对面收到的一定是你写的那个东西。这不是什么花哨的魔法就是TypeScript的类型推导能力被应用到了后端和数据库层而已但就是这层“看似简单”的一致性能帮你省掉整个联调环节中至少一半的沟通成本。t3code这个项目在设计之初就定了一个原则不引入复杂状态管理库不使用独立的后端服务所有数据请求都通过tRPC完成所有页面渲染都要能支持SSR或SSG。这么做的目的就是逼着自己想清楚——在T3技术栈的约束下哪些事做起来是顺手的哪些事其实很别扭。只有亲自跑一遍才知道这套方案的边界在哪。2. 从0搭建 t3code 全栈项目关键决策解析2.1 create-t3-app 脚手架到底替你做了什么如果你不想手动集成每一个包官方提供的脚手架create-t3-app可以直接帮你把项目骨架拉起来。这个过程做过很多次实测下来不同版本之间还是有一些差异的但整体的交互逻辑保持一致。命令如下npm create t3-applatest执行之后它会问你几个问题项目名称、是否使用NextAuth、是否使用Prisma、是否使用Tailwind CSS、是否使用tRPC、要不要用App Router、Drizzle还是Prisma二选一等等。这里我多说一句我知道很多人看到“脚手架”三个字就觉得是傻瓜式操作但实际上t3-code脚手架的每一个选项背后都对应着不同的数据流模式和部署策略你如果只是闭着眼睛一路回车后边八成要重来。我在创建t3code时选的组合是Next.js App Router TypeScript Tailwind CSS tRPC Prisma NextAuth Zod。为什么这么选先说NextAuth认证这件事自己用sessioncookie去撸看起来很酷但安全细节非常容易被忽略比如CSRF防护、session固定攻击、token过期策略NextAuth帮你把这些标准做法都处理好了再说Zod它是tRPC生态里做输入验证的默认选择因为它和TypeScript类型可以做到天然联动后面我会具体讲它是怎么做到“验证即类型”的。至于Prisma选它是因为它在数据库迁移和类型生成两个环节都非常成熟对你搭建一个注重类型贯通的项目来说是最高效的选择。初始化完成后项目会自带一套标准的目录结构。我在这儿放一个我实际用到的精简版目录说明t3code/ ├── prisma/ │ └── schema.prisma # 数据库模型定义 ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ # tRPC 路由定义 │ │ ├── auth.ts # NextAuth 配置 │ │ ├── db.ts # Prisma Client 实例 │ │ └── api/ │ │ └── trpc.ts # tRPC 上下文与初始化 │ ├── trpc/ │ │ ├── server.ts # 服务端调用 tRPC 的封装 │ │ └── client.ts # 客户端 hooksReact Query 封装 ├── src/app/ │ ├── layout.tsx # 根布局 │ ├── page.tsx # 首页习惯上作为 Dashboard │ └── api/trpc/[trpc]/route.ts # tRPC HTTP 路由入口 └── src/styles/ └── globals.css # Tailwind 指令2.2 目录结构与数据流设计思路这里有一个细节想单独拎出来说很多人拿到T3项目的目录结构后第一反应是“怎么这么多server和trpc文件夹前缀还都叫server”其实这个结构是有明确分工的src/server是物理上落在Node.js环境服务端的代码负责真正访问数据库、验证token、跑业务逻辑src/trpc是给客户端用的类型引用入口它不执行实际代码只是把服务端的类型信息“导出”给前端使用。理解了这层关系你才能明白t3code项目里数据流的方向浏览器发起请求→Next.js接收→tRPC识别调用方是客户端还是服务端→执行对应的Procedure→Prisma操作数据库→返回结果→类型完整的响应回到前端组件。整条链路里你实际上只需要写数据库模型、tRPC路由和React组件三块东西剩下的类型传递都是自动完成的。这也就是为什么T3技术栈用起来会觉得“很顺手”——它把全栈项目里最让人分心的“胶水代码”消灭掉了。我在t3code里设计数据流时特别注意了一个点要避免把数据请求散落在各个组件内。我自己的做法是页面组件如DashboardPage负责调用tRPC的query然后在服务端把这些数据通过props传给子组件。这样做的原因是子组件理论上只关心“怎么展示数据”不应该关心“数据从哪来”一旦你允许每个子组件都自己发请求页面请求次数就会失控而且调试时的数据流会非常凌乱。3. 类型安全的全链路实现从数据库到UI3.1 Prisma模型设计与数据层约束t3code项目的核心业务是“文章列表 标签系统 用户收藏”三个模块。既然主要目标是演示类型安全的架构那么模型的复杂度不需要堆得太高但一定要能覆盖“一对一、一对多、多对多”三种常见关系。Prisma的schema文件定义得非常直观我挑用户和文章模型来举例model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] likedPosts Post[] relation(PostLikes) } model Post { id String id default(cuid()) title String content String published Boolean default(false) createdAt DateTime default(now()) author User relation(fields: [authorId], references: [id]) authorId String tags Tag[] likedBy User[] relation(PostLikes) } model Tag { id String id default(cuid()) name String unique posts Post[] }在这个schema里relation(PostLikes)表示User和Post之间除了“作者”关系之外还有第二层关系用户收藏文章这种命名关系的方式在Prisma里属于进阶技巧。很多新手写多对多关系时会直接在两侧都加User[]结果Prisma根本不知道这两边是同一组关系还是两组就会报错。只要用命名关系刻意区分就可以在同一个模型里维护多条关系链路并且生成的类型依然完全正确。模型定义好之后执行一次npx prisma migrate dev --name init npx prisma generate前一个命令会在数据库里建表并生成迁移文件后一个命令会根据你最新的schema重新生成Prisma Client的类型定义。注意只要你的schema发生了字段改动必须重新执行prisma generate否则你在代码里引用新字段时会发现TypeScript明明报错了但运行时却一切正常——这种情况是最容易迷惑人的。3.2 tRPC Router消灭“手写API类型不一致”的痛tRPC全称是TypeScript Remote Procedure Call原理并不复杂你把函数像RPC一样暴露出去客户端调用时就跟调用本地函数一样。t3code项目里我把文章相关的操作全部收敛到一个叫做postRouter的路由里并用Zod定义输入输出的边界import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const postRouter createTRPCRouter({ list: publicProcedure .input( z.object({ cursor: z.string().nullish(), limit: z.number().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) { const items await ctx.db.post.findMany({ take: input.limit 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, include: { author: true, tags: true }, }); let nextCursor: string | null null; if (items.length input.limit) { const nextItem items.pop(); nextCursor nextItem!.id; } return { items, nextCursor }; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(10), tagIds: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { connect: input.tagIds?.map((id) ({ id })), }, }, }); }), });这块是t3code项目里我认为含金量最高的一段代码值得逐点解释。首先publicProcedure表示这个接口不需要登录即可访问例如文章公开列表protectedProcedure则强制校验session未登录用户会被直接拒绝。其次.input()里用了Zod这一层不仅仅做验证还承担了“类型源头”的角色——Zod的推断类型会自动绑定到tRPC的Procedure输入参数上客户端拿到的类型和这里的Zod schema完全一致。最后我在list里实现了一个cursor分页虽然代码看起来多了一点条件分支但这避免了使用page/pageSize做深分页时在大数据量下的性能问题属于直接可以抄到生产环境的写法。而另一端React组件里调用的方式是这样的import { api } from ~/trpc/client; export function PostList() { const { data, fetchNextPage, hasNextPage } api.post.list.useInfiniteQuery({ limit: 10 }); return ( div {data?.pages.flatMap((page) page.items).map((post) ( PostCard key{post.id} post{post} / ))} {hasNextPage ( button onClick{() fetchNextPage()}加载更多/button )} /div ); }useInfiniteQuery是tRPC基于React Query封装好的hook你只需要告诉它查询参数limit它会自动帮你管理分页游标的状态。可能有人会问“这不就是普通的React Query吗”确实底层就是React Query但区别在于这个hook是根据服务端Router自动生成类型且有对应API的一旦你服务端的返回结构改了前端组件里的post对象字段立刻就会高亮报错这就是t3code类型安全的直接体现。4. 核心功能模块实操认证、列表渲染、文件上传4.1 NextAuth集成与权限控制t3code的认证用的是NextAuth.js并且接了GitHub OAuth Provider。我选择GitHub而非邮箱密码方式因为对于演示项目而言OAuth方式可以省去邮箱验证、找回密码、密码加密存储等一系列完整且繁琐的链路而且大多数开发者都有GitHub账号测试起来方便。NextAuth在App Router里的配置相比Pages Router有了一些变化t3code里我在src/server/auth.ts统一暴露了认证配置import { NextAuthOptions } from next-auth; import GitHubProvider from next-auth/providers/github; import { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ./db; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(db), providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], session: { strategy: database }, callbacks: { session: async ({ session, user }) { if (session.user) { session.user.id user.id; } return session; }, }, };这里最重要的不是Provider配置而是session.strategy: database这个选项。如果你用的是PrismaAdapter可以把session策略设为database意味着session记录存在数据库的Session表里而不是JWT里。这两种方式各有优劣但我在t3code里选择database策略的原因是方便以后在后台管理系统里查看“当前在线用户”这种功能数据都在自己库里查询非常直接。权限控制方面刚才提到的protectedProcedure就是tRPC层面的入口保护。此外在页面层面还可以做更细的控制例如在服务端组件里判断用户是否登录来决定渲染什么内容。我建议所有涉及敏感操作的接口写入、删除、修改都必须走protectedProcedure并且不要在客户端路由守卫上过度依赖因为Next.js的页面在SSR阶段就会执行数据请求如果服务端不加校验客户端再遮遮掩掩也没用。4.2 服务端渲染下的数据请求模式T3 Stack的全栈开发里最让人容易混淆的一个点是“我到底应该在哪里请求数据”在Pages Router时代大家习惯在getServerSideProps里调用tRPC的createCaller但在App Router时代你可以直接在服务端组件里调用Router。我在t3code里做了一个对比实验同一份文章数据一个页面走服务端获取一个页面走客户端useQuery获取观察两者的表现差异。服务端调用方式是这样import { createCaller } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; export default async function DashboardPage() { const caller createCaller( await createTRPCContext({ headers: await headers() }) ); const posts await caller.post.list({ limit: 10 }); return PostTable initialPosts{posts.items} /; }这种模式的好处是首屏数据直接嵌入HTML页面渲染前已经拿到数据不会出现“先加载骨架屏再请求再渲染”的三段式动作。但这个方案也有一个明显的缺陷如果你在多个服务端组件里分别调用caller可能会产生多次数据库查询。实际上App Router服务端组件本身支持cache()函数来缓存数据但在t3code项目里因为数据是动态更新的我没有盲用缓存而是只在需要静态化的页面上使用了缓存策略。更好的方案其实是让大部分页面走客户端请求也就是用useQuery和useInfiniteQuery让React Query帮你在客户端统一做缓存和状态管理。服务端获取只服务于两个场景SEO敏感的公开页、登录后Dashboard首屏。这是我在t3code里总结的一条经验——不要神话SSR也不要全盘放弃SSR按页面角色来决定数据获取方式才是正解。4.3 文件上传的三种取舍方案文章功能里如果只有纯文本内容肯定不够完整所以我还加了一个“封面图上传”的需求做试验。文件上传是T3社区里讨论特别多的一个话题因为Next.js本身并不直接帮你处理文件存储你需要自己选方案。我在t3code里对比了三种做法最后选了其中一种落地。方案一是直接上传到Vercel的/public目录这种方式在本地开发时完全可行但一旦部署到Serverless环境文件系统是临时的重启实例后文件就会丢失肯定不能用于生产。方案二是用S3兼容的对象存储例如AWS S3、Cloudflare R2前端直接把文件POST到对象存储的临时上传URL服务端只负责生成URL和校验权限。方案三是用Base64编码塞进MySQL的字段里这种方式实现起来最快但数据库会快速膨胀而且请求体太大后会卡死网关。t3code最终选了方案二使用的是预签名URL的方式。流程是客户端先调用post.getUploadUrl这个tRPC Procedure服务端用AWS SDK生成一个临时有效的PUT对象URL客户端拿到URL后直接把文件PUT上去上传完成后再调用post.updateCover将这个对象的路径写入文章记录。这里的核心细节是临时URL一定要设置过期时间我设的是5分钟避免有人拿着这个URL长期享受你的服务端上传带宽。5. 部署与性能优化实录5.1 Vercel部署与Edge Runtimet3code的部署我首选了Vercel因为Next.js就是Vercel家的产品两者之间兼容性天然最好。在Vercel上部署时它的默认构建命令和输出目录都已经自动适配好了Next.js你会发现几乎不需要在控制台做额外配置就能直接跑起来。但我实际部署之后还是踩了一个坑数据库连接用的是PostgreSQL如果部署环境的Region和数据库所在Region离得太远每次查询的延迟会非常明显页面接口响应可能超过1秒。解决方式也很简单在Vercel项目的Settings里把Function Region调整到数据库同Region。我用的数据库是Neon的PostgreSQL东京Region所以我把Vercel函数区域也设置成了Tokyo。这一项调整看起来微不足道但实际首屏加载时间从2.3s降到了800ms左右差距非常夸张提醒各位部署时一定要留意函数区域与数据库的区域对齐问题。另外如果你的Next.js页面大量使用服务端组件和动态数据建议把涉及较重数据库查询的接口单独抽出来放进route.ts或者tRPC里避免整个页面都被迫运行在Edge Runtime上。Edge Runtime的优势是冷启动时间短、分布在全球边缘节点但它的限制也非常多比如不能直接使用Node.js的原生模块意味着Prisma在Edge上运行会有很多坑需要额外配置driver adapter。t3code的做法是所有涉及Prisma的接口都走Node.js Runtime纯静态展示页才考虑Edge渲染。5.2 3个必须知道的性能瓶颈T3 Stack项目上线后我特意用Lighthouse和Vercel Analytics跑了一轮检查总结出了三个最容易被忽略的性能瓶颈每个都直接影响用户体验。第一个是tRPC响应体积太大。tRPC本身返回的是JSON如果你在数据库查询里无脑加了一堆include比如每篇文章都把作者信息和评论列表一股脑带出来那么一次列表请求可能就有上百KB。这个问题很好排查Network面板看一眼响应大小就清楚了。解决的思路是列表接口只返回列表需要的最小字段详情内容单独开一个Procedure去查询不要让列表查询背上详情页的重担。第二个是React Query缓存策略没配好。tRPC的useQuery默认staleTime是零意味着首次加载后组件一挂载就会重新请求。对于数据新鲜度要求不高的页面比如文章列表我把staleTime设为60秒让用户在60秒内的重复访问直接走缓存避免每次切页都打数据库。这个参数在tRPC里的配置方式很简单api.post.list.useInfiniteQuery( { limit: 10 }, { staleTime: 60 * 1000 } );第三个是图片优化没做好。如果你直接在页面里引用了Next.js的next/image组件而远程图片的地址没有放在remotePatterns白名单里开发阶段可能发现不了问题但生产环境会看到图片一直加载不出来。而且我强烈建议给所有图片都指定宽高或者使用fill属性否则CLS页面布局偏移分数会很难看。6. 常见问题与排查技巧6.1 经典报错与解决做t3code这个项目的时候我故意在里面留了几个容易出错的场景就是为了检验排查流程是否顺畅。这里整理几个我在开发过程中实际碰到的问题和对应的解决逻辑。第一个是“Hydration failed because the server rendered HTML didn’t match the client”。这个报错非常常见十次有八次是你在服务端组件里读取了Date.now()或者Math.random()这类非确定性值导致服务端渲染出来的HTML和客户端首次渲染的HTML不一致。排查方法就是报错后打开浏览器控制台按提示找到出错组件的具体位置把非确定性代码挪到useEffect里执行或者让组件延迟挂载到客户端。第二个是“PrismaClient is not configured to run on Edge Runtime”。如果你用了Vercel Edge Functions而你的tRPC Router里的某个Procedure涉及到数据库读写就会碰到这个报错。解决办法是不要让Prisma代码跑在Edge上把涉及数据库的路由标记为Node.js Runtime或者在项目配置里调整函数的运行时环境。第三个是“Type string | undefined is not assignable to type string。这个报错经常出现在你从NextAuth的session里取用户id时。原因很简单session的user.id在类型定义里是可选的。这不是框架设计问题是因为很多OAuth Provider可能不返回id所以类型上做了宽松处理。解决办法是在回调里把user.id显式赋给session.user.id就像上面auth.ts里写的那样让类型变窄。6.2 新手最容易犯的3个T3Stack错误除了具体的报错我还想集中说三个我认为新手在T3 Stack项目里最容易犯的“认知型”错误。这些错误不是语法层面的而是架构层面的一旦走偏后面整个项目都会跟着别扭。第一个错误是把所有的业务逻辑全部塞在组件里tRPC Router反而成了摆设。有人会觉得“反正客户端可以直接调用tRPC那我直接在组件里写处理逻辑岂不是更简单”但在组件里写业务逻辑意味着逻辑没法被其他调用方复用也增加了组件层的复杂度还很难做单元测试。正确姿势是业务逻辑应该放在Router或者独立的service模块里组件只做数据和用户交互的转译。第二个错误是试图在tRPC里手动管理鉴权。t3code里我用的是protectedProcedure统一拦截但有人会写类似“if (!ctx.session) throw new Error(401)”的判断代码。这种方式虽然能跑但每个Router里都重复写一遍鉴权判断冗余且容易遗漏。tRPC提供了procedure级别的中间件机制你把鉴权抽象成protectedProcedure后续所有敏感接口复用它就够了。第三个错误是忽略Zod的复用。Zod的schema不仅可以用在tRPC的输入验证上还可以用在表单提交验证、外部API调用的参数校验等场景。但很多人在初始配置后就把它搁置了后续写新的数据校验时又手写if判断。zod类型校验的核心价值在于你可以通过z.infertypeof schema直接拿到验证规则对应的TypeScript类型这一招用好了你真的可以不用再手写任何接口类型。6.3 关于t3code后续的一些建议如果你看完了上面的内容也想自己搭一个类似的项目练手我个人的建议是从一个极小的真实需求开始比如“带标签的文章发布系统”或者“极简记账本”。不要在一开始就想着把文件上传、消息队列、权限管理、多租户这些全都塞进去因为T3技术栈的真正价值在于让你体会“类型安全带来的快感”如果你一开始就陷入复杂业务反而会忽略掉框架本身的精髓。另外可以持续关注t3code的后续演变方向。我下一步的打算是测试一下把整个项目迁移到Drizzle ORM的效果对比它和Prisma在类型体验和迁移流程上的差异另一个方向是尝试把部分页面移到Server Components里做深度SSR看看在动态数据场景下能带来多少性能收益。这些探索本质上还都围绕着一个核心问题在不断提升开发效率的同时如何最大程度地保证全栈类型安全。在真正跑完t3code之后我个人的体会是技术栈本身没有绝对的优劣之分适合自己团队协作模式的就是好方案。如果你现在正深陷“前后端定义了两套TypeScript类型却总是对不上”的泥潭里花一天时间用T3 Stack重写一个核心模块你会立刻感受到那种“全链路类型推理”带来的安全感。那种感觉很难用语言描述亲身体验一次之后就回不去了。
阅读完成 · 觉得有帮助?