t3code是我最近在整理的一个全栈模板项目代号准确说它不是某个第三方框架而是我把过去两年用 T3 Stack 搭建、迭代、上线过的几套实际业务系统里沉淀出来的代码组织方式集中到了一个可复现的模板里。这个模板的核心是端到端类型安全从数据库到后端路由再到前端 React 组件类型定义一口气贯穿到底中间不需要手动维护接口文档也不存在前后端字段对不上这种让人头疼的问题。如果你是一个独立开发者、小团队的技术负责人或者正打算从零搭建一套内部工具、内容管理后台、数据看板这类中后台应用这篇内容应该能帮你少走不少弯路。接下来我会从 T3 这套组合的核心哲学讲起逐步展开技术选型逻辑、关键实现细节、生产部署的坑以及我在长期维护过程中总结的一些经验教训。1. 为什么是 T3它不是又一套“全家桶”吗T3 Stack 最早由 t3.gg 的 Theo 提出组合是 Next.js、TypeScript、tRPC、Tailwind CSS数据库和鉴权通常配套 Prisma 与 NextAuth.js。我第一次看到这个组合时也和你一样疑惑这年头框架本来就多为什么还要专门搞一套“全家桶”但用过一段时间之后我才意识到它和传统全家桶有本质区别——它不强迫你使用某个特定状态管理库也不绑定特定的 UI 组件库它真正绑定的是类型安全这件事。1.1 传统前后端协作中最隐蔽的痛点做过前后端分离项目的人都经历过这种场景后端写好了接口前端拿着接口文档联调调通之后接口又改了字段前端忘了同步更新类型定义运行到某个页面才报错。排查这类错误的成本往往比写业务代码本身还高。现在很多人会用 OpenAPI 自动生成类型但这一步又涉及工具链维护、代码生成时机、多环境差异等一堆问题。T3 的做法完全不同它通过 tRPC 让前端像调用本地函数一样调用后端方法TypeScript 的类型信息天然从后端函数签名传到前端调用处根本没有“接口文档”这个中间层。1.2 tRPC 解决了什么问题tRPC 的全称是 TypeScript Remote Procedure Call核心思路是在服务端定义路由router每个路由节点就是一个 procedure对应一个后端方法在客户端直接引用这个 router 的类型就能获得完全类型安全的调用。比如你在后端定义了一个getUserById的 procedure前端引入后调用trpc.user.getUserById.useQuery({ id })如果参数写错、返回字段用错IDE 会直接标红。这在编译期就能拦截一大类字段拼写错误、数据类型错误。你可能会担心性能问题tRPC 底层请求本质还是 HTTP序列化走 JSON没有引入沉重的协议包袱。对于中小规模应用它的性能完全够用。真正值钱的不是“远程调用”这个动作而是类型信息零损耗地在前后端之间传递这一点。提示tRPC 并不是 WebSocket 专用协议它默认基于 HTTP/JSON 传输。只有你需要双向实时通信时才需要额外引入 WebSocket 订阅能力。1.3 这套组合适合谁我个人的判断是t3code 这套模式最适合两类场景。一类是内部工具/管理后台这类项目要求快速开发、字段复杂、迭代频繁类型安全能省下大量联调和回归成本另一类是小型团队的全栈产品一个项目从立项到上线周期短前端工程师也要写后端逻辑tRPC 能显著降低“前后端交接”的沟通成本。如果你所在团队是几百人的大厂前后端严格分离有专门的 API 治理规范那这套组合未必适合强行引入反而会和现有流程冲突。2. 核心链路拆解从数据库到 UI 组件的类型闭环理解了“为什么用”之后接下来要弄清楚 t3code 里最核心的链路是怎么跑通的。这一节我会从底层往上拆Prisma 生成的类型如何进入 tRPCtRPC 的上下文如何注入数据库实例前端调用又如何继承这些类型。2.1 Prisma 是类型链路的起点Prisma 会在运行prisma generate时根据你的 schema 自动生成完整的 TypeScript 类型。这条链路的第一环就在这里你的数据模型定义了User、Post、Tag这些表Prisma 生成的类型里就自动带上了对应的字段、可选性、关系类型。这些类型可以非常自然地流入业务代码import { db } from ~/server/db; export function getPostById(id: string) { // 返回值会自动推导为 Post { author: User; tags: Tag[] } return db.post.findUnique({ where: { id }, include: { author: true, tags: true, }, }); }注意最后这行的返回值。当你写了include之后TypeScript 会自动推导出返回值带了author和tags字段不需要手写任何 interface。这一步是整个类型闭环的基础——如果这里用手写类型后面全部白搭。2.2 tRPC 的 Context 设计tRPC 之所以能拿到数据库实例是因为它的 context 创建函数在每次请求时会被调用你可以在里面把数据库实例、当前会话信息放进去// src/server/api/trpc.ts import { initTRPC } from trpc/server; import { type CreateNextContextOptions } from trpc/server/adapters/next; import { getServerAuthSession } from ~/server/auth; import { db } from ~/server/db; export const createTRPCContext async (opts: CreateNextContextOptions) { const session await getServerAuthSession(opts); return { db, session, }; }; const t initTRPC.contexttypeof createTRPCContext().create(); export const router t.router; export const publicProcedure t.procedure;context 里放什么很讲究。数据库实例db、登录会话session是最常见的两项。如果你依赖某个第三方服务的 client比如对象存储 SDK 的实例也可以放进来方便 procedure 内直接使用。但要注意 context 是在每个请求时创建的如果放一些重量级连接记得走单例模式复用否则每次请求都新建连接性能很快会出问题。2.3 一个从 schema 到前端调用的完整例子我拿一个“博客文章列表”的功能来说明这条链路的实际路径。先定义一篇带标签的文章的 Prisma schemamodel Post { id String id default(cuid()) title String content String published Boolean default(false) createdAt DateTime default(now()) map(created_at) updatedAt DateTime updatedAt map(updated_at) authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) tags Tag[] } model Tag { id String id default(cuid()) name String unique posts Post[] } model User { id String id default(cuid()) name String? posts Post[] }然后在 tRPC router 里定义查询逻辑// src/server/api/routers/post.ts import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ getPublishedPosts: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ where: { published: true }, include: { author: true, tags: true }, orderBy: { createdAt: desc }, }); }), });注意两个关键点getPublishedPosts的类型信息会一路传递到前端而query后面的 async 函数中ctx.db是被严格推断出来的 PrismaClient 实例我写代码时压根不需要去查 Prisma 文档IDE 会自动提示有哪些模型和方法。前端调用更直观在 App Router 的客户端组件里use client; import { api } from ~/trpc/react; export function PostList() { const { data, isLoading, error } api.post.getPublishedPosts.useQuery(); if (isLoading) return div加载中.../div; if (error) return div出错了{error.message}/div; return ( ul {data?.map((post) ( li key{post.id} h2{post.title}/h2 p{post.author.name ?? 匿名用户}/p span{post.tags.map((tag) tag.name).join(, )}/span /li ))} /ul ); }如果你在map里访问post.author.emailIDE 立刻报错因为author是User类型而User并没有email字段。如果服务端字段名改了一个字母前端组件里的标红会倒逼着你同步修改。这就是“类型闭环”的日常体验——类型不再是文档而是代码里强制执行的契约。2.4 React Query 在链路中的位置t3code 的前端默认集成了 TanStack QueryReact QuerytRPC 只是把它包了一层构建出api.post.getPublishedPosts.useQuery()这个 hook。有人会问为什么不直接用 fetch 调用 tRPC 的 HTTP 端点原因有两点。第一React Query 帮你处理了缓存、重新验证、请求去重、loading/error 状态管理这些在真实业务里迟早要自己写一套没必要重复造轮子。第二useQuery 的返回类型同样是严格推导的data字段的类型就是后端 procedure 的返回类型任何时候都不可能出现“前端以为返回数组、后端实际返回 null”这种失配问题。需要提醒的是如果你团队的页面以增量静态生成ISR为主大部分数据在服务端渲染时就能拿到tRPC 的用武之地会缩小。这时应该在服务端组件里直接调用 tRPC 的 caller而不是把数据请求都丢给客户端。tRPC 本身提供了createCaller能力可以在服务端只执行逻辑不发起 HTTP 请求类型仍然完整。3. 数据模型与鉴权状态最容易埋雷的两个角落很多照着 T3 模板做出来的项目一开始跑得很顺上线之后突然开始出怪问题比如用户登录状态丢了、某些查询特别慢、字段被莫名截断。这类问题往往集中在数据模型设计和 NextAuth 会话处理上。这一节我把两个最容易埋雷的角落单独拎出来说。3.1 Prisma 模型设计别急着建表先设计关系写 Prisma schema 的很多新手习惯把表结构先全部列出来然后靠外键搜索关系。这种做法的后果是字段命名混乱、关系语义不清、迁移时反复改表。我个人的做法是先画清楚业务实体关系图再落到 schema。比如博客系统作者拥有文章文章包含标签用户收藏文章。这些关系在 schema 里就应该表达得非常直白。还有一个细节特别值得注意隐式多对多关系与显式多对多关系。比如文章和标签的关系可以用隐式关系实现Prisma 会自动生成一张中间表model Post { tags Tag[] } model Tag { posts Post[] }不需要手动建PostTag表Prisma 在数据库层面自动管理。但如果你需要记录“用户收藏文章的时间”那就要显式建一张中间表把时间戳字段放进去。判断标准就一句话中间关系本身是否需要携带业务属性。需要就用显式关系不需要就用隐式关系省心且不容易出错。3.2 NextAuth 与 Prisma 的会话联动在 t3code 模板中我默认配置了 NextAuth 的 Session 策略。这里有一个非常经典的坑数据库会话database session strategy需要额外的 Session 表。如果你没有运行prisma db push或迁移Session 表不存在登录时会直接报错。另一个更隐蔽的坑是 JWT 会话策略下数据库里的用户改名后session 里还存着旧名字需要强制重新登录才能刷新。考虑到中后台项目内部使用的场景我建议内部工具使用 JWT 策略部署简单不依赖数据库表刷新 token 逻辑好控制。多端产品型应用使用 Database 策略会话状态可以服务端随时吊销更安全。NextAuth 的secret也经常被人忽略。开发环境下它可以从环境变量里读但生产环境一旦漏配或者生成方式不对登录状态会反复横跳。我自己写了一个脚本把它集成到部署流程里openssl rand -base64 32然后把输出配置到AUTH_SECRET环境变量中。这个值必须是固定的如果每次部署重新生成所有已登录用户的会话会全部失效。3.3 把上下文里的 session 用起来登录信息怎么在 tRPC procedure 里拿可以直接通过 context 里的session读取但更严谨的做法是在 router 层做访问控制。我的模板里会封装一个protectedProcedureimport { TRPCError } from trpc/server; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: ctx.session, }, }); });之后所有需要登录的接口都写成protectedProcedure.query(...)。这样写的好处是访问控制逻辑集中在一处新增的 procedure 默认不会忘了鉴权。我在真实项目里见过无数个“接口忘鉴权”的例子源头往往是散落在每个 procedure 里手写判断。4. 从空目录到能跑的项目T3 模板的完整落地路径如果你是第一次接触这套组合光看概念还是不够最好亲手从零把一个项目跑起来。这一节我给出一个我在实践中验证过无数次的完整路径不是官方的 README 复读而是包含我自己踩过的坑和调整过的细节。4.1 初始化与依赖安装创建项目时可以直接用官方脚手架npm create t3-applatest交互式命令行会让你选择使用的模块。我的建议是如果是做中后台应用路由模式选 App Router数据库选 Prisma鉴权选 NextAuthCSS 选 Tailwind最后的 tRPC 默认选中不用动。官方脚手架还会问你是否要开 ESLint 和 Prettier我建议全部都开后面省大量格式化时间。注意脚手架初始化之后别忘了检查package.json里的依赖版本。Next.js 14 与 15 在 route handler 写法上有差异tRPC 的 adapter 也分 v10 和 v11 两版 API。网上很多教程默认版本都比较老照抄之前先看你自己项目的版本。4.2 环境变量的规划初始化完成后第一步不是写代码而是配环境变量。模板里默认有一个.env.example你需要创建一份真实的.env。数据库连接串、NextAuth 的 secret、GitHub 或 Google 的 OAuth 客户端密钥都在这里DATABASE_URLpostgresql://user:passwordlocalhost:5432/t3code AUTH_SECRET这里是生成的随机字符串 AUTH_GITHUB_ID AUTH_GITHUB_SECRET这里最重要的经验是环境变量文件不要提交到 Git 仓库至少在.gitignore里确认.env被忽略。我在一次培训时见过有人把数据库密码推到开源仓库几分钟内数据库就被扫描工具刷爆了。本地开发和线上环境要各建一份.env文件命名可以区分成.env.local和.env.production但不要共用一份。4.3 数据库初始化如果是新项目直接执行npx prisma db push这个命令会把 schema 里的模型同步到数据库不需要生成迁移文件适合快速原型阶段。如果项目已经上线、模型需要版本化管理就要用npx prisma migrate dev来创建迁移文件。初始化之后记得运行npx prisma generate这一步会生成最新的类型定义。很多人改了 schema 之后忘了重新生成IDE 里一直报错其实只是类型定义过期了。我已经把prisma generate挂到了postinstall脚本中每次npm install后自动执行省掉不少低级问题。4.4 核心文件的组织逻辑初始化后的目录结构看起来有点多但真正核心的文件就几个src/server/db.tsPrismaClient 单例全局复用同一个连接。src/server/auth.tsNextAuth 配置负责 session 解析。src/server/api/trpc.tstRPC 初始化定义 context 和基础 procedure。src/server/api/routers/业务 router 目录每个业务模块一个文件。src/trpc/server.ts与src/trpc/react.ts服务端调用器和客户端 hooks 的封装。其中src/trpc/server.ts里有一个容易被忽略的细节它使用的是 App Router 的 cache 机制把调用结果缓存在请求生命周期内。如果你在同一个服务端组件的渲染过程中多次调用同一个 caller不会重复打数据库性能更稳。4.5 一个完整 CRUD 的实现节奏以“发表文章”为例。先定义输入校验层t3code 统一用 Zodimport { z } from zod; export const createPostSchema z.object({ title: z.string().min(1).max(200), content: z.string().min(1), tags: z.array(z.string()).default([]), });然后在 router 里写 mutationimport { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ create: protectedProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((name) ({ where: { name }, create: { name }, })), }, }, }); }), });前端组件调用就三两行const utils api.useUtils(); const createPost api.post.create.useMutation({ onSuccess: async () { await utils.post.getPublishedPosts.invalidate(); }, });invalidate这步很容易被新手忽略。你创建成功之后文章列表应该自动刷新React Query 的缓存并不会自动感知新数据需要手动把相关查询标记为失效。很多项目出现“提交之后列表没刷新”的问题九成都是这里漏了。5. 生产部署本地没问题不代表线上没问题本地跑得飞起部署到线上就崩这是全栈开发的常态。t3code 这类基于 Next.js 的应用如果部署到 Serverless 平台比如 Vercel会遇到几类特定的坑。这一节的内容来自我多次线上故障的复盘。5.1 服务器函数超时与长任务处理Serverless 函数通常有执行时间上限。如果你在 tRPC 的 mutation 里做了大文件处理、复杂报表生成这类耗时操作极容易触发平台超时。我踩过一次一个导入 Excel 的接口在本地跑 8 秒完成部署到线上直接超时原因就是 Serverless 的执行时长限制。解决方案有两种一是把任务拆小分批写入数据库二是引入异步任务队列把任务参数存库由独立 worker 消费处理。对大多数中后台项目来说先把长任务拆掉往往比引入消息队列更实际。比如导入 Excel可以先解析文件并把每行数据存入待处理表再逐批校验和落库客户端轮询进度。5.2 数据库连接池的配置Serverless 环境下每个请求可能对应一个新的函数实例如果每个实例都创建一个新的数据库连接连接数会迅速打满。Prisma 官方推荐在 Serverless 环境使用连接池比如 PG Bouncer 或者 Supabase 的连接池代理。社区里还有一种做法是把 PrismaClient 提升到全局单例通过globalThis保存import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error], }); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }这个模式能缓解“每次热更新/每次请求新建实例”的问题但不能替代真正的连接池。在 Vercel 这种平台上数据库连接串里加?pgbouncertrue并指向池化端口是常见的标准做法。5.3 环境变量、密钥与多环境管理前面提到的AUTH_SECRET是生产环境最常出错的地方。很多框架自带默认值开发环境照样跑于是大家就忘了配它的生产值。结果一上线登录页面能打开但一回调就报错。那次故障排查时我在日志里反复找最后才发现是 secret 不一致。现在我的模板里加了一个启动时的校验逻辑缺少关键环境变量就直接提示缺失项而不是让应用在运行中炸function assertEnv() { const required [DATABASE_URL, AUTH_SECRET]; for (const key of required) { if (!process.env[key]) { throw new Error(Missing environment variable: ${key}); } } } assertEnv();5.4 构建阶段的类型检查本地开发因为热更新很快类型错误往往感知不到。但生产构建会执行tsc --noEmit一旦有类型错误整个构建失败。这不是坏事我反而建议保留这个行为。它强制每个人在合并代码前做一次完整的类型检查防止带着错误进入线上。代价是构建时间会变长但这点时间换来的稳定性完全值得。6. 测试、校验与长期维护让 t3code 持续演进一个项目真正开始产生价值不是上线那天而是之后持续迭代的过程。类型安全在开发期帮你挡了一部分错误但运行时校验、测试策略和维护流程同样重要。这一节说说我在长期维护 t3code 类项目时坚持的一些习惯。6.1 Zod 校验是最后一道防火墙tRPC 本身不负责输入校验你需要用 Zod 在 procedure 的input定义里声明约束。这个动作是防火墙级别的任何请求进来先按 schema 校验不合法就直接返回错误业务代码里不需要再写一堆手写判断。一个常见的误区是只做存在性校验不做格式和边界校验const updateProfileSchema z.object({ nickname: z.string().min(2).max(20), bio: z.string().max(200).optional(), age: z.number().int().min(0).max(150).optional(), });max(20)这样的约束一定要有。否则数据库某个字段长度限制是 20用户传了个 200 字符的昵称数据库直接报错虽然也是错误但不如在校验层就拦截来得干净。6.2 用 Vitest 给核心链路打底全栈项目的测试策略不该追求高覆盖率而应该聚焦在核心业务链路。我的习惯是给三类逻辑写测试输入校验规则、数据库操作函数、权限控制逻辑。Vitest 可以把 tRPC 的 caller 直接拉起来测试不需要启动 HTTP 服务速度很快。一个典型的测试长这样import { describe, expect, it } from vitest; import { appRouter } from ~/server/api/root; import { createTestContext } from ./test-utils; describe(postRouter.create, () { it(未登录用户不能创建文章, async () { const caller appRouter.createCaller({ ...createTestContext(), session: null, }); await expect( caller.post.create({ title: 测试, content: 内容, tags: [] }), ).rejects.toThrow(UNAUTHORIZED); }); });测试环境里最麻烦的是数据库隔离。我通常直接用 Docker 起一个临时的 Postgres 实例每次跑测试前执行prisma db push --force-reset保证数据干净。6.3 Server Actions 与 tRPC 的取舍Next.js 团队这几年在推 Server Actions很多新项目会纠结到底用 Server Actions 还是 tRPC。我个人的经验是两者不冲突但适用场景不同。Server Actions 适合页面内表单提交、服务端跳转、与数据库简单交互的场景代码量更少但一旦业务链路复杂需要多处复用、需要精细的 loading/error 状态管理、需要在多个组件间共享数据请求状态tRPC React Query 的组合更顺手。我的建议是新项目以 tRPC 为主干表单提交这种强交互且功能单一的局部场景可以尝试 Server Actions不要把它当作必选项。6.4 长期维护中的类型卫生项目跑了一年多之后最容易出现的问题不是框架本身而是类型卫生失控。具体表现是某个字段本应是必填因为历史原因改成了可选某个 procedure 返回了超大对象包含一堆前端不需要的字段。这些都会导致类型链条变“脏”。我的维护准则是前端用不到的字段不在 select/include 里查出来。zod schema 变更必须同步更新前端测试用例。核心模型尽量不做物理删除使用软删除但类型上做好区分。升级依赖时先看 Next.js 和 tRPC 的 breaking change再升级 Prisma 和 React Query。还有一个小习惯很管用每次重构后运行一次完整的类型检查加测试套件并且把它挂到 CI 上。只要这个流水线是常态类型安全这条链路就能一直稳下去。在我自己使用 t3code 的过程中最大的体感变化是联调会议的减少和线上低级 bug 的大幅下降。类型系统把错误提前到写代码时Zod 把脏数据挡在入口外React Query 接管了数据的缓存与刷新。三者的配合并不复杂但它确实让一个中小型全栈项目可以仅靠一两个人就平稳地跑起来。如果你正在搭建新的中后台或内部工具不妨从这套组合开始试试。