☰
t3code:T3 Stack 全栈开发代码片段库的构建与实战
2026/10/28 14:39:34 网站建设 项目流程

如果你最近在折腾 T3 Stack,大概率绕不开create-t3-app和社区里那堆模板。而t3code这个项目,本质上就是把这些模板再往下收一层:把 Next.js、TypeScript、Tailwind、tRPC 这四兄弟的高频写法和配置抽成一个个可以直接抄走的代码片段,整理成一个面向 T3 生态的“代码食谱”。它有别于一整包脚手架,也不强迫你用特定的目录约定,而是解决一个更现实的问题:当你需要在项目里加一个受保护的 API 路由、做一版游标分页、或者摸清 Next.js 15 升级后 tRPC 该配什么版本时,能有一个地方把答案一次给齐,而不是满屏搜到三年前的过期代码。

我做全栈开发这些年,踩过的坑一大半都集中在“配置对不上版本”和“代码片段残缺”这两类问题上。t3code就是我把日常踩坑、验证、沉淀的过程固化成可复现资产的一次尝试。这篇文章会从项目背景、内容架构、核心链路实现到排错记录,完整讲清楚这个项目的内部设计和落地经验。如果你是 T3 生态的用户,不管新手还是老手,这里面的模块划分思路和代码实现细节,都能直接搬到自己的项目里用。

1. 起因:为什么在脚手架之外还要攒一个代码库

1.1 先搞明白 T3 Stack 到底解决了什么问题

T3 Stack 这个名字来自 Theo 发起的create-t3-app项目,它把四个技术拼在一起作为起点:Next.js 负责页面和路由,TypeScript 负责类型安全,Tailwind 负责样式,tRPC 负责 API 层。这套组合的核心理念是“约定优于配置”,开局就是一个规范化、类型安全的全栈工程,开发体验确实比从零拼装顺手得多。

但脚手架解决的是“项目怎么建”,解决不了“业务怎么写”。create-t3-app生成的是骨架:登录有没有、鉴权怎么串、分页怎么做、错误处理怎么统一,这些都得在业务代码里一点一点补。很多时候你需要的不是一个完整的项目,而是某个模块的标准写法。团队协作的时候尤其明显,一个人写的 Prisma 查询风格和另一个人完全不同,代码 review 起来非常痛苦。

t3code的出发点就是把这些高频写法标准化。它不是要替代create-t3-app,而是它的补集:脚手架给你地基,t3code给你一摞经过验证的“预制构件”。论坛、邮件、社区模板里散落的代码,经常缺上下文、缺错误处理、缺版本说明,直接抄十次有八次要改半天。把验证过的版本集中管理,至少能保证下来就能跑、跑起来行为符合预期。

1.2 哪些人最需要这样一份代码资产

我整理这个过程的时候,心里大概有三类用户:

第一类是独立接单的全栈开发者。这类人时间最紧,经常要在一个晚上从前端写到数据库层。手头有一份可靠的片段库,等于把“查文档、搜报错、试版本”的时间直接砍掉一大半。

第二类是刚起步的小团队。团队初期最怕代码风格失控。用同一套片段作为基线,至少大家在路由、鉴权、数据层写法上是一致的,后面维护成本会低很多。

第三类是正在学 T3 Stack 的开发者。与其在各类博客里看零散截图,不如直接从真实项目里拆出的一线代码开始读。代码有没有跑通、为什么这么写,这比看任何教程都有说服力。

需要说明的是,t3code作为一个社区项目,定位一直在演化。我现在维护它的方式,就是把所有片段按场景分类,每段标注适用的版本和前置依赖,形成一份类似“速查手册 + 代码库”的组合。下文的内容架构,就是基于这套做法总结出来的。

2. 核心设计:目录怎么分、每个模块收什么

2.1 目录结构按场景而非按技术栈划分

设计目录的时候我纠结过很久。第一版按技术栈分,next/、trpc/、prisma/各一个文件夹,结果用起来很别扭。做鉴权要同时翻三个目录,效率极低。后来我干脆换了个思路:按业务场景分。一个完整功能涉及的所有文件放在同一块,复制过去就是一套闭环。

t3code/ ├── README.md ├── auth/ │ ├── nextauth-options.ts │ ├── protected-procedure.ts │ └── middleware.ts ├── trpc/ │ ├── context.ts │ ├── router-root.ts │ └── protected-query.ts ├── db/ │ ├── prisma-schema.prisma │ ├── cursor-pagination.ts │ └── soft-delete.ts ├── next/ │ ├── route-handler.ts │ ├── server-component.ts │ └── middleware.ts ├── config/ │ ├── tsconfig.strict.json │ ├── tailwind.config.ts │ └── .env.example └── components/ ├── form.tsx └── infinite-list.tsx

这个结构的核心原则是内聚。比如auth/下不是只放一个 NextAuth 配置,而是把authOptions、tRPC 保护中间件、Next.js Middleware 放一起。你要做“登录才能访问的接口”,直接把这三件套复制走就够了。类似的,db/下面既有 Prisma schema 示例,也有基于游标的翻页工具函数,做数据层功能时不需要四处找。

目录树层级不建议太深,三级以内是底线。太深了索引成本高,维护者自己都想不起来某段代码放在哪。我见过有人按“日期 + 功能名”命名的,看上去有历史感,但真要检索时完全没法用。

2.2 每个模块里值得收录的“必抄清单”

auth(鉴权模块):这是所有业务系统绕不开的一层。必收的是 NextAuth 的完整authOptions配置(包含 Credentials Provider 和 Session 回调节点)、把 session 注入 tRPC context 的标准写法、以及一个支持角色判断的protectedProcedure。注意,光有代码不够,还要在注释里写明 NextAuth 版本,因为 v4 和 v5 beta 的 API 差异很大。

trpc(API 层):tRPC 的威力在类型安全,但类型断掉的时候报错往往看不懂。这段要收 4 样东西:统一的initTRPC封装、根路由合的写法、错误格式化函数、以及怎么用inferRouterInputs在前端推导输入类型。错误格式化这块最容易被忽略,默认的错误信息只有 code 和 message,加上zodError路径解析之后,前端能直接把校验错误渲染到表单字段上。

db(数据层):Prisma schema 的模板是重点,但其实更重要的是分页、软删除、多租户这类“查询模式”。游标分页适合 feed 流场景,页码分页适合管理后台,两者边界要写清楚。软删除建议用deletedAt字段配合全局中间件过滤,保持查询代码干净。

next(框架层):Route Handler 的标准写法、Server Component 读取 session 的正确姿势、Middleware 里做访问控制的白名单方案。Next.js 15 之后异步请求 API 的变化一定要单列一个片段,后面我会详细展开。

config(配置层):tsconfig开启strict和noUncheckedIndexedAccess后的完整配置、Tailwind 自定义主题配色、.env.example的注释放到同一份文件里。配置层的东西看起来琐碎,但团队新成员入职第一天就会碰到。

每个片段都建议附一个“适用场景”和“依赖版本”的元信息,记录在 README 的索引表里。这样维护成本不算高,但能让整个库真正可搜索、可依赖。

3. 实操重现:从 clone 到跑通一条完整链路

3.1 环境准备与初始化

我建议的本地开发环境是 Node.js 20+、pnpm 10+、PostgreSQL 15+。Node 版本太老会出现 Next.js 15 直接报不兼容的问题,这种问题几乎浪费了我半天时间。数据库用 PostgreSQL 是因为 Prisma 对它的支持最完善,而且生产环境大概率也是用它。

初始化时先建目录,然后用create-t3-app打底:

pnpm create t3-app@latest my-app cd my-app pnpm install pnpm add next-auth@beta @trpc/server@11 @trpc/client@11 @trpc/react-query@11 zod

这里要特别注意next-auth我加的是@beta标签。因为 Next.js 15 的异步 API 变更,NextAuth v4 在 Route Handler 里读取 session 会有兼容问题,v5 beta 对 App Router 的支持更顺。tRPC 锁 11.x 是因为 v11 的 fetch adapter 和 Next.js 15 的配合更成熟,v10 在老页面上没问题,但在 App Router 下稍显别扭。

3.2 复现“受保护的 API 路由”完整链路

这一节是t3code里最常被复制的一段,它把 NextAuth、tRPC、Prisma 串成一条完整链路。我先把三个关键文件写出来,再讲为什么每一步要这么做。

第一步,服务端鉴权配置:

// auth.ts import { type NextAuthOptions } from "next-auth"; import Credentials from "next-auth/providers/credentials"; export const authOptions: NextAuthOptions = { session: { strategy: "jwt" }, providers: [ Credentials({ name: "credentials", credentials: { email: { label: "邮箱", type: "email" }, password: { label: "密码", type: "password" } }, async authorize(credentials) { // 这里应该走 Prisma 查询用户并对比密码哈希 const user = await findUserByEmail(credentials?.email ?? ""); if (!user || !verifyPassword(credentials?.password ?? "", user.passwordHash)) { return null; } return { id: user.id, email: user.email, name: user.name }; } }) ], callbacks: { session({ session, token }) { if (session.user) { session.user.id = token.sub ?? ""; } return session; } } };

第二步,tRPC context 和受保护过程:

// server/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; import { authOptions } from "./auth"; import { prisma } from "./prisma"; export const createContext = async () => { const session = await getServerSession(authOptions); return { session, prisma }; }; const t = initTRPC.context<typeof createContext>().create(); const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { user: ctx.session.user } }); }); export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(isAuthed); export const router = t.router;

第三步,在 App Router 下挂载 tRPC handler:

// app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "@/server/root"; import { createContext } from "@/server/trpc"; const handler = (req: Request) => fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, createContext }); export { handler as GET, handler as POST };

这几个文件的逻辑关系是这样的:用户请求进来后,tRPC 的 fetch adapter 会把请求交给createContext,在 context 里用getServerSession读取当前登录态。之后每次调protectedProcedure,中间件首先检查 session 是否存在,没有就直接抛UNAUTHORIZED,存在则把user注入到后续 context。这样业务代码里不需要再到处判断“用户是否登录”,类型上也能保证访问到ctx.user时一定存在。

我最初做这套东西时犯过一个错:把 Prisma 实例也整个塞进createContext,每个请求都新建连接。后来发现压力一大连接数直接就爆了。正确做法是 Prisma 实例作为模块级单例,context 只是引用它,而不是创建它。

3.3 配置文件的标准化与关键参数

.env.example这个文件很多项目不重视,但t3code把它当作一等公民。原因是数据库连接串、鉴权密钥这类信息一旦缺失,新成员 clone 项目后第一步就跑不起来,还没开始写业务就先卡十分钟。

# .env.example DATABASE_URL="postgresql://user:password@localhost:5432/t3code?pgbouncer=true&connection_limit=5" NEXTAUTH_SECRET="请使用 openssl rand -base64 32 生成" NEXTAUTH_URL="http://localhost:3000"

注意DATABASE_URL我专门加了pgbouncer=true和connection_limit=5。pgbouncer=true是给连接池代理用的。connection_limit=5控制 Prisma 在服务端维持的连接数上限,配合数据库最大连接数来测算。如果数据库 max_connections 是 100,一台服务器上跑 10 个实例,每个实例 5 条连接正好占满,留有余量。这个值要根据实际部署规模调整,不是越大越好。

tsconfig里最值得强调的是noUncheckedIndexedAccess。开启之后,数组和对象索引取值时类型会变成T | undefined,逼着你处理边界情况。写业务代码时确实会变啰嗦,但对代码质量的提升非常明显。tRPC 的input解析本身够安全,可一旦到了前端数组索引读值就没人管,很多线上 bug 都来自这里。t3code的模板默认开启它,这也是我用到现在最不后悔的一个配置。

4. 常见问题与排错实录

4.1 Next.js 15 升级后 tRPC 和 NextAuth 的兼容性

这是我汇总的故障速查表,每一条都来自真实环境:

现象根因解决方案
headers()报错“同步访问不被支持”Next.js 15 把headers()、cookies()改成异步 API使用await headers()获取;传参时保持 async 链路
tRPC handler 在 App Router 下 404路由文件路径不对,或handler没有同时导出 GET/POST确保路径为app/api/trpc/[trpc]/route.ts,导出{ handler as GET, handler as POST }
NextAuth 回调不触发用了getServerSession()但把authOptions写成了内联对象把authOptions定义为模块级常量,避免每次渲染重建引用
受保护路由能访问,但ctx.session为 nullMiddleware 和 tRPC context 之间没有同步 session确认 tRPC handler 里createContext调用getServerSession,且 JWT 回调正确挂载

Next.js 15 这个变化影响面很广。之前可以直接const headers = headers(),现在必须const h = await headers(),而且任何依赖它的函数都要变成 async。如果你用的库不是在主进程里做这件事,很容易漏掉异步链。排查时优先看控制台有没有出现sync关键字警告。

4.2 Prisma 连接池与 N+1 查询

用t3code里的模板跑起项目后,第二个常见问题是数据库连接数飙升。现象是 PostgreSQL 日志里出现大量connection limit exceeded。根因有两个:一是 Prisma 实例在热更新时被反复创建,二是生产环境没走连接池。开发模式可以设置PrismaClient为 global 单例来规避热更新重建。生产环境则需要把数据库连接串里的pgbouncer=true配上,并合理设置连接数上限。

N+1 查询也是数据层老问题。tRPC 里include联表时容易写出嵌套查询,比如先查文章列表,再每篇文章查作者。Prisma 的include会自动生成 JOIN,但如果你在循环里逐条findUnique,那必然触发 N+1。t3code 里的 Feed 流示例强制要求用include一次查完,并且分页查询只查必要的字段。

4.3 类型推导失败的处理思路

tRPC 的一大卖点是前后端类型共享。但类型一旦断掉,报错信息对新手来说基本是天书。最常见的是修改了后端router后,前端调用处类型没有同步。别急着怀疑代码,先确认有没有让 tRPC 的createTRPCReact类型指向最新后端类型:

// utils/api.ts import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "@/server/root"; export const trpc = createTRPCReact<AppRouter>();

这里的import type不会产生运行时依赖,前端包体不会被打进服务端代码。如果修改后端后前端类型没更新,重启 dev server 或者重新跑一次tsc --noEmit通常能解决。偶尔遇到类型推断太深导致编辑器卡顿,可以给输入输出类型建立显式 interface,避免类型层层推导。

4.4 数据库迁移和种子数据脚本

还有一个容易被忽略的问题:Prisma 迁移没有纳入t3code的标准流程。很多项目把prisma db push当生产迁移工具用,这在开发环境没问题,但生产环境一旦表结构变更,没有参数化的迁移文件,风险很大。我的做法是把每一步变更都记录成migration.sql,提交到代码库,部署时执行prisma migrate deploy。种子数据单独写在prisma/seed.ts,通过tsx prisma/seed.ts执行。片段库里的版本说明要跟着迁移文档走,否则新成员很容易把旧 schema 误当成当前状态。

5. 我用 t3code 沉淀出的实战心得

5.1 正确的使用姿势:片段库不是脚手架,别整套照搬

create-t3-app给你一个骨架,你可以在上面自由生长。t3code不是让你把这套目录直接克隆到业务项目里,它的价值在于给你可选的标准化实现。我见过有人把整个片段库拷贝进项目,结果目录层级和业务代码纠缠在一起,维护成本反而上去了。

正确的做法是:按需取用,每个片段都经过理解再落进项目。拿鉴权模块为例,你复制了authOptions和protectedProcedure,但要结合自己的用户表结构去改 Prisma schema;复制了游标分页,但要根据业务排序字段调整orderBy。t3code的 README 里专门列了“接入步骤”,每个接入点都提示了要修改的位置,就是为了避免无脑粘贴。

还有一个经验是:每次接入新功能后,应该给片段库回补一个“生产环境实测”标记。哪些代码在低流量场景没问题、高并发下会暴露问题,这种信息只有真实环境能给出。片段库维护得越久,这个标记越有价值。

5.2 接下来想扩展的方向

t3code目前还是我一个人在维护,下一步我打算做两件事:第一是把片段和自动化测试绑在一起,每个模块至少配一个集成测试,确保新版本依赖升级时能立刻发现兼容性问题。第二是把整个片段库转成可编程的使用方式,做一个简单的 CLI,执行t3code add auth就能自动把鉴权模块的代码复制到当前项目并给出改动提示。

说实话,做这个项目的过程中我自己受益最大。以前新起一个项目,同样的代码要重新查文档写一遍;现在直接翻开片段库,改一改字段名就完事。脑子里的“肌肉记忆”从代码变成了目录索引,效率提升非常明显。

最后分享一个不算复杂但很值钱的小技巧:所有t3code片段文件首部都保留一段注释,注明它在哪个真实项目里跑过、并发量大约多少。这比空泛的“示例代码”注释有用得多。你在另一个项目里要用它时,能立刻判断这段代码的“可信等级”。替代码写上出身和时间,是维护任何代码资产都值得养成的好习惯。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询