真正的全栈开发,最怕的不是不会写代码,而是你花大量时间搭完架子,结果发现前后端的数据流逻辑各自为政——前端定义一套类型,后端又是另一套,接口文档跟不上代码变更,等到联调阶段整个人都是裂开的。我用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-app@latest执行之后它会问你几个问题:项目名称、是否使用NextAuth、是否使用Prisma、是否使用Tailwind CSS、是否使用tRPC、要不要用App Router、Drizzle还是Prisma二选一等等。这里我多说一句,我知道很多人看到“脚手架”三个字就觉得是傻瓜式操作,但实际上,t3-code脚手架的每一个选项背后都对应着不同的数据流模式和部署策略,你如果只是闭着眼睛一路回车,后边八成要重来。
我在创建t3code时选的组合是:Next.js App Router + TypeScript + Tailwind CSS + tRPC + Prisma + NextAuth + Zod。为什么这么选?先说NextAuth,认证这件事自己用session+cookie去撸看起来很酷,但安全细节非常容易被忽略(比如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 # 客户端 hooks(React 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. 类型安全的全链路实现:从数据库到UI
3.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 Runtime
t3code的部署我首选了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.infer<typeof schema>直接拿到验证规则对应的TypeScript类型,这一招用好了,你真的可以不用再手写任何接口类型。
6.3 关于t3code后续的一些建议
如果你看完了上面的内容,也想自己搭一个类似的项目练手,我个人的建议是从一个极小的真实需求开始,比如“带标签的文章发布系统”或者“极简记账本”。不要在一开始就想着把文件上传、消息队列、权限管理、多租户这些全都塞进去,因为T3技术栈的真正价值在于让你体会“类型安全带来的快感”,如果你一开始就陷入复杂业务,反而会忽略掉框架本身的精髓。
另外可以持续关注t3code的后续演变方向。我下一步的打算是测试一下把整个项目迁移到Drizzle ORM的效果,对比它和Prisma在类型体验和迁移流程上的差异;另一个方向是尝试把部分页面移到Server Components里做深度SSR,看看在动态数据场景下能带来多少性能收益。这些探索本质上还都围绕着一个核心问题:在不断提升开发效率的同时,如何最大程度地保证全栈类型安全。
在真正跑完t3code之后,我个人的体会是:技术栈本身没有绝对的优劣之分,适合自己团队协作模式的就是好方案。如果你现在正深陷“前后端定义了两套TypeScript类型却总是对不上”的泥潭里,花一天时间用T3 Stack重写一个核心模块,你会立刻感受到那种“全链路类型推理”带来的安全感。那种感觉很难用语言描述,亲身体验一次之后就回不去了。