
前阵子我在梳理全栈项目模板时发现一个很有意思的现象很多人把 T3 Stack 挂在嘴边但真正能把 Next.js、TypeScript、tRPC、Prisma、Tailwind 这套组合落成一个可复用、可扩展的代码体系的人其实并不多。t3code 这个名字乍一看只是个简单的“代码”集合但往深了挖它更像是一套以 TypeScript 为骨架、以类型安全为灵魂的全栈项目落地模板。它能解决的是开发团队在启动新项目时反复做重复选型、重复配环境、重复写胶水代码的问题也能帮助个人开发者从零快速搭出一个结构清晰、前后端类型打通的应用骨架。这篇文章不适合只想抄一段代码就跑的人更适合真正想把全栈 TypeScript 项目做出“标准姿势”的人。无论你是刚开始接触 T3 相关技术栈还是已经在用 Next.js 但总感觉前后端类型没有打通都可以从这套代码实践中找到可以照搬的结构和思路。我会直接把 t3code 在架构选型、目录组织、数据模型、接口封装、前端接入、部署细节这几个层面的实际做法拆开来讲。1. t3code 在解决什么问题一套全栈 TypeScript 的“标准姿势”1.1 从技术选型说起为什么是 Next.js tRPC Prisma很多人在搭新项目时第一个纠结的地方就是技术选型。前端用 React 没问题但 Next.js 是不是过度设计后端接口用 REST 还是 GraphQL数据库 ORM 用 Prisma 还是 Drizzle这些问题一旦在团队里展开基本就是一场没完没了的讨论。t3code 的选择逻辑非常直白如果整个应用都用 TypeScript 写那就让类型从前端一路穿透到数据库。Next.js 负责应用框架和应用路由既有 SSR 能力也可以纯静态导出团队可以根据场景分别使用 Server Component 和 Client Component。TypeScript 负责语言层面的类型约束从组件 props 到接口参数所有边界都有类型定义。tRPC 负责前后端接口通信它的特点是不需要额外定义 REST 路由也不需要写 GraphQL Schema直接利用 TypeScript 的类型推断能力让前端调用后端方法时获得完整的自动补全和类型校验。Prisma 负责数据模型定义和数据库访问schema 文件本身就是一份数据库设计文档同时生成的客户端自带类型。这四样东西放在一起等于把“前端组件、后端逻辑、数据模型”三层的类型全部串成一整条链。说白了你在前端写const data await trpc.user.list.query()如果后端list方法没写编辑器直接就报红了根本不用等运行时接口报 404。1.2 T3 理念的核心类型安全不是口号是流程本身很多人会觉得类型安全就是“用了 TypeScript 而已”但 t3code 做的事情不止于此。它把类型安全延伸到了三个层面第一层是数据模型的类型安全。Prisma Schema 里定义了User、Post这些模型字段类型、关联关系、索引都在这里确认生成的 TypeScript 类型直接成为整个应用的唯一事实来源。第二层是接口调用的类型安全。tRPC Router 的每个 procedure 都带有输入输出类型。你在前端写调用时传的参数类型不对会立刻报错返回的数据结构也是自动推断出来的不需要手动维护一份接口文档。第三层是业务状态的类型安全。React Query 配合 tRPC请求状态、缓存 key、数据更新都能类型化。重新取数、失效缓存、乐观更新这些操作不再是靠字符串约定而是靠类型系统约束。所以 t3code 的核心价值不是它用了多新的技术而是它把类型安全变成了整个开发流程的默认行为。这就像你写后端代码时函数参数不写类型会浑身难受一样t3code 让你在写全栈代码时体会一次“类型不到处传心里就不踏实”的状态。2. t3code 的结构设计与代码组织2.1 项目目录与职责划分t3code 的目录结构看起来并不复杂但每个目录的职责边界非常清晰。我基于常见实践把它整理成下面这套划分方式新项目直接照抄都可以├── prisma │ ├── schema.prisma # 数据模型的唯一事实来源 │ └── migrations # 数据库迁移记录 ├── src │ ├── server │ │ ├── api │ │ │ ├── routers # 按业务域拆分的 tRPC Router │ │ │ │ ├── user.ts │ │ │ │ └── post.ts │ │ │ └── root.ts # 所有子 Router 汇总到根 Router │ │ ├── auth.ts # 认证相关逻辑如果启用了鉴权 │ │ └── db.ts # Prisma Client 单例 │ ├── pages # Next.js 页面路由 │ │ ├── _app.tsx # 全局 Provider 挂载点 │ │ └── index.tsx │ ├── components # 通用 UI 组件 │ ├── styles # Tailwind 入口文件 │ ├── utils # 工具函数 │ └── trpc │ ├── client.ts # 前端 tRPC 客户端配置 │ └── server.ts # SSR 时的 tRPC 调用辅助 └── .env # 环境变量这个结构的核心思想是按职责分层不按文件类型乱堆。server 目录只放后端相关代码pages 目录只负责路由入口components 目录只放可复用 UI。真正业务相关的代码比如查询逻辑、数据转换都收在 routers 和对应的业务模块里。2.2 最容易被忽略的配置细节目录结构谁都会列真正拉开差距的是配置细节。我见过不少项目从表面看结构一样但用起来就是各种不舒服问题基本都出在下面这几个地方。路径别名的统一。tsconfig.json 里配置server/*、/*这些路径别名本身不难难的是让 ESLint、Prettier、Jest 这些周边工具都认同一套路径。t3code 的做法是在 tsconfig 和 tailwind.config 里都显式声明同样的别名确保编辑器提示、构建解析、测试环境三者行为一致。严格模式必须开满。TypeScript 的strict: true只是底线。t3code 还会额外开启noUncheckedIndexedAccess这意味着arr[0]不会自动推断为元素类型而是T | undefined。这个配置一开始会觉得烦但实际上会逼你把所有数组访问的边界都想清楚在写业务代码时特别能预防线上才出现的取不到值问题。环境变量的类型化。项目启动时如果DATABASE_URL没设置Prisma 会报错但报错信息有时候不够直观。t3code 会做一个统一的环境变量校验模块启动前把所有必需变量一次性检查完缺哪个就明确提示缺哪个而不是等数据库连接超时之后才猜原因。依赖安装顺序。先装运行时依赖再装开发依赖。因为 t3code 涉及 Next.js 的编译、Prisma 的 generate、tRPC 的类型生成这几个工具的安装顺序如果不一致在某些 Node 版本下会出现原生模块编译失败或者 Prisma Engine 下载不完整。3. 从零搭建一次 t3code 全流程实操3.1 环境准备与脚手架初始化如果你要复现 t3code 这套代码实践建议直接从干净的目录开始。我假设你已经装好了 Node.js 18 LTS 以上版本的运行环境并且本机有可用的包管理器。创建一个新项目时可以直接基于 create-t3-app 起步。执行初始化命令之后交互式问答里重点确认几个选项是否启用 Next.js App Router、是否启用 tRPC、是否启用 Prisma、是否启用 Tailwind CSS。我的建议是全选启用因为 t3code 的完整价值就是让这四样东西协同工作去掉任何一样类型安全链路都会出现断点。完成初始化后第一件事不是写业务代码而是先跑一遍npm run dev确认基础页面能正常打开。然后再改schema.prisma让 Prisma 客户端和后端类型生成到node_modules/.prisma与src/server/db.ts能正常联动。这里有一个非常关键的细节不要一上来就用最新版 Prisma除非你已经确认它与 Next.js 当前版本兼容。我在实操中遇到的多数疑难问题都源于 Prisma 升级带来的生成路径变化。稳妥做法是锁定一个经过验证的版本组合比如 Next.js 14 Prisma 5.x跑通整条链路后再考虑升级。3.2 数据模型设计用 Prisma 定义业务实体并生成客户端数据模型设计是 t3code 里最需要提前想清楚的部分因为 Prisma 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()) email String unique name String? posts Post[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String published Boolean default(false) author User relation(fields: [authorId], references: [id]) authorId String createdAt DateTime default(now()) updatedAt DateTime updatedAt }一切模型设计都围绕“关系”展开。author和posts这两个字段在数据库层面对应外键关系在 Prisma 层面对应关联查询能力。当你写好这条 schema 后执行npx prisma migrate dev --name initPrisma 会帮你生成迁移文件并同步数据库结构同时自动重新生成客户端代码。我使用 Prisma 客户端时习惯在同一文件里导出单例避免在开发环境下因为模块热更新创建多个连接实例import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const prisma globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma prisma; }这个单例模式虽然不是 Prisma 官方文档开篇就讲的内容但在 Next.js 开发模式下几乎属于标配不这样做的话每次文件改动热更新都可能产生数据库连接堆积。3.3 接口层封装tRPC Router 的写法与调用链数据模型就绪后接下来就是最容易写出“样板代码”的部分接口层。tRPC 的 Router 并不等同于传统 REST 路由它更像是一个“可远程调用的函数集合”。每个 procedure 就是一个可被前端调用的函数由输入解析、权限检查、业务逻辑三部分组成。我先建立一个基础 Router 工厂import { initTRPC } from trpc/server; import { z } from zod; const t initTRPC.context{ userId: string | null }().create(); export const router t.router; export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.userId) { throw new Error(Unauthorized); } return next({ ctx: { ...ctx, userId: ctx.userId } }); });这里有个关键点输入校验用 zod而且是必选项。很多从 REST 转过来的开发者会觉得 zod 是多余的因为 TypeScript 类型已经定义了参数结构。但运行时输入是不可信的TypeScript 类型在编译后会消失zod 的作用就是接管运行时校验。tRPC 和 zod 是官配procedure 的输入一旦定义好前端调用时不仅自动获得类型推导错误信息也会非常直观。具体到业务 Router我会这样写import { z } from zod; import { router, publicProcedure, protectedProcedure } from ../trpc; import { prisma } from ../db; export const postRouter router({ list: publicProcedure .input(z.object({ take: z.number().min(1).max(50).default(10) })) .query(async ({ input }) { return prisma.post.findMany({ take: input.take, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) { return prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.userId, }, }); }), });接着在 root Router 里合并所有子 Routerimport { router } from ./trpc; import { postRouter } from ./routers/post; import { userRouter } from ./routers/user; export const appRouter router({ post: postRouter, user: userRouter, }); export type AppRouter typeof appRouter;上面这一步最重要的东西是export type AppRouter。这个类型导出之后前端可以基于它生成完整的类型安全的 tRPC 客户端。整个调用链的衔接靠的就是这个类型定义。类型的穿透力有多强前端代码里手写trpc.post.create.mutate({ title: x, content: y })时title和content都是必填的参数类型错误在保存文件的那一刻就会显示出来。整个链路里只要有一层类型对不上编辑器就是一面红色错误墙这比任何接口文档都来得可靠。3.4 前端页面接入与样式处理t3code 的前端接入核心工作就是把 tRPC 客户端挂到 React 应用上然后在组件里直接调用查询方法。页面入口_app.tsx要包一层 Providerimport { httpBatchLink } from trpc/client; import { QueryClient, QueryClientProvider } from tanstack/react-query; import { trpc } from ../utils/trpc; import type { AppProps } from next/app; const queryClient new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, }, }, }); const trpcClient trpc.createClient({ links: [ httpBatchLink({ url: /api/trpc, }), ], }); export default function App({ Component, pageProps }: AppProps) { return ( trpc.Provider client{trpcClient} queryClient{queryClient} QueryClientProvider client{queryClient} Component {...pageProps} / /QueryClientProvider /trpc.Provider ); }实际业务组件里的写法是import { trpc } from ../utils/trpc; export default function PostList() { const { data, isLoading } trpc.post.list.useQuery({ take: 10 }); if (isLoading) return div加载中.../div; return ( ul {data?.map((post) ( li key{post.id} h2{post.title}/h2 p{post.content}/p /li ))} /ul ); }注意看调用useQuery时传入的({ take: 10 })是有类型检查的data的每一项直接就是 Prisma 返回的Post类型。这里不需要手写任何接口类型定义也不需要在前后端之间复制 DTO。状态管理这部分用 React Query 处理缓存、重试、加载态都不需要自己造轮子。样式方面Tailwind 主要负责原子类但 t3code 并不强制你用 Tailwind。实际用下来Tailwind 在快速搭建页面时确实高效可一旦设计系统复杂到一定程度单独抽离组件样式反而更明智。所以我的建议是项目前期用 Tailwind 打通所有基础样式等出现重复性极高的样式组合时再抽成组件类不用把每个页面都写成纯原子类堆叠。4. 实际踩过的坑t3code 常见问题与排查记录这一部分是我最想写的因为 t3code 这类全栈模板真正的门槛不是学会某个框架而是各种配置在真实开发环境里互相作用时的那些隐藏问题。我整理了一份速查表然后挑几个最典型的展开讲。问题现象根本原因处理方式启动时提示PrismaClientInitializationErrorDATABASE_URL未设置或格式不正确检查.env文件和.env.example是否同步前端调用接口时类型不匹配tRPC 客户端基于的AppRouter类型是旧版本重新跑一遍prisma generate和类型检查确认根组件导出类型已更新页面显示旧数据刷新后恢复客户端 Query 缓存未失效在 mutation 成功回调里调用trpc.useUtils().post.list.invalidate()热更新之后数据库连接数暴涨Next.js dev 模式下创建了多个 Prisma 实例使用全局单例模式复用 PrismaClientTailwind 类名没有生效内容路径配置不全新目录未被扫描在tailwind.config.ts中显式添加./src/**/*.{ts,tsx}tRPC procedure 调用报 500但日志不详细异常被 tRPC 默认处理拦截开发环境开启onError日志生产环境使用错误格式化中间件4.1 Prisma 版本升级导致的生成异常这个问题我会放第一个说因为几乎每个跑了一段时间的 t3code 项目都会遇到。某天执行npm update后突然发现prisma generate生成的结果和旧版本不兼容数据库访问层报出一堆类型错误或者 Prisma Client 报“The generated client has been modified”的提示。排查思路很简单先看项目锁定文件里 Prisma 和prisma/client的版本是否完全一致。这两个包必须同步升级不能一个升一个不升。其次看 schema 文件是否有旧的生成器配置残留。最后如果升级后实在找不到原因直接删除node_modules、删除生成的prisma/migrations中未提交的迁移记录重新安装并执行prisma migrate dev基本都能恢复。4.2 tRPC 类型在跨包引用时丢失tRPC 在单一代码库里效果最好但如果你的项目一开始就设计成 monorepo或者把业务模块抽成了多个 npm 包类型穿透的链路就可能断掉。我遇到过的典型情况是后端新增了一个 procedure前端包里的AppRouter类型始终没有同步更新不管怎么重新安装依赖编辑器里都看不到新接口。这个问题在 t3code 里的标准解法是确保类型导出的路径是纯类型导出并且业务包之间的依赖方式使用 workspace 协议。如果前端包直接依赖后端包的构建产物那么每次后端代码变更后都要重新构建一次否则类型就是旧的。使用 workspace 协议配合 TypeScript Project References可以让类型在源文件层面直接关联省掉构建这一步。4.3 HMR 与路由缓存带来的“看得到旧数据”Next.js 开发模式下热更新有时会触发页面路由缓存导致你明明改了后端返回逻辑刷新页面看到的还是旧数据。这种行为不一定是 tRPC 或 Prisma 的问题而是 React Query 的缓存策略在作祟。解决方案是在 mutation 成功后主动失效相关查询缓存。t3code 习惯在封装的创建、更新、删除操作里统一调用失效方法const utils trpc.useUtils(); const createPost trpc.post.create.useMutation({ onSuccess: () { utils.post.list.invalidate(); }, });这样每次数据变更后列表都会自动重新请求页面展示的数据和数据库状态保持一致不需要手动刷新。这个细节如果没处理用户会反复反馈“我提交了但看不到新内容”特别影响体验。4.4 环境变量缺失引发的隐蔽报错环境变量相关的问题总是最隐蔽的因为本地开发时.env存在一切正常一到部署或者新成员拉代码时缺少必要变量报错五花八门。我见过最心累的一种是DATABASE_URL写错但不是完全缺失导致 Prisma 能初始化但每次查询都超时。t3code 的做法值得借鉴在服务端入口处统一校验环境变量而不是用到哪就在哪读取。我会写一个简单的校验函数在应用启动时检查DATABASE_URL、NEXTAUTH_URL如果启用了认证等核心变量缺失时直接抛出带有具体变量名的错误让排查时间缩短到一分钟以内。5. t3code 的扩展方向与我的个人体会5.1 从模板到业务脚手架还能往哪些方向长t3code 作为一套代码实践它的价值不止于“创造一个项目”更在于后续可以沿着多个方向延伸成完整的业务脚手架。第一个方向是接入文件上传与对象存储。目前大多数 T3 项目在涉及文件上传时都会临时找方案有的用第三方存储 SDK有的用云函数直传。如果把这部分抽象成统一的上传模块配合 Prisma 保存文件元数据就会省去很多重复劳动。第二个方向是把权限模型做进 Router 中间件。tRPC 的中间件机制非常适合做 RBAC。你可以根据业务角色封装出一套adminProcedure、editorProcedure把权限检查从业务代码里完全剥离出去。到这一步t3code 就从一个技术模板变成了贴合业务的开发框架。第三个方向是服务端渲染与 tRPC 的深度配合。t3code 的基础演示大多集中在客户端调用但在实际项目中很多页面需要服务端直接取数首屏渲染或者利用 Next.js 的 Server Component 来做数据预取。tRPC 的服务端调用器和客户端调用器可以在同一套类型体系下工作这意味着你在服务端可以直接调用喂给组件的预取数据不需要额外维护一套数据获取方案。这块做深之后整个应用的响应速度和首屏体验都会明显提升。5.2 关于 t3code 的几句实在话根据我个人的使用经验t3code 这套技术组合并不适合所有场景。如果你的项目非常简单只是一个展示页加上一个联系表单那完全没有必要上 Prisma 和 tRPC直接用 Next.js 的 API Route 就够了引入整条类型安全链路反而是负担。如果你的项目业务逻辑复杂、接口数量多、团队协作频繁那 t3code 带来的类型安全收益会非常可观。前后端类型统一之后接口联调的时间会大幅压缩很多运行时的“字段不存在”“类型是 string 但实际是 number”这类问题会大幅减少。它不保证没有 bug但确实能把一类特别难排查的集成 bug 在编译期挡在门外。另外我想特别提一点t3code 这种模板的价值会随着项目演进逐渐上升。刚开始你可能不觉得类型穿透有多重要但当项目到两万行代码、几十个 Router、十几个页面模型时你会庆幸每一次数据结构的改动都有 TypeScript 替你兜底。那种“改一个字段所有引用点全部提示更新”的体验值得你为它多花点时间把基础架构搭稳。最后再说一个小技巧如果你打算把 t3code 长期用在团队项目里一定要维护一份自定义的初始化文档记录你安装依赖的版本、遇到过的问题、团队约定的目录职责甚至可以把初始化脚本固化下来。模板的意义在于缩短启动时间而不是成为团队里人人都不敢碰的“黑盒”。把这些过程沉淀下来新成员进入项目的成本会低很多整个团队的开发效率也会真正受益。