1. 为什么给t3code选T3 Stack,而不是自己拼一套
1.1 "类型安全贯通"这个卖点到底值多少钱
t3code这个名字其实没什么高深的含义,它就是一个内部项目在GitHub上注册时的顺手命名,结果一路做下来,反而成了团队里讨论最频繁的技术话题。这个项目是面向开发者的代码片段管理与协作平台,需求很具体:团队里积累了几百条代码片段和文档,散落在群聊、语雀、GitLab仓库里,检索成本越来越高,想做一个内部平台把它们收拢起来,带标签、带搜索、带关联需求单,最好还能把高频片段一键复制进IDE。
需求本身不复杂,但有一个绕不开的痛——我们组前后端都写TypeScript,可API层一直靠手写interface和Swagger生成,两端经常因为字段名不一致扯皮。T3 Stack的卖点恰好打在这个痛点上:从数据库到浏览器,类型定义全程由编译器推导,API边界不再需要单独维护契约文件。这句话听起来像口号,但实际用下来确实省掉了大量沟通成本。以前改一个接口要前后端两个人确认字段,现在只要改Prisma模型和tRPC router,前端调用的地方会直接报错,类型即文档。
我也认真对比过自己拼一套方案:Next.js、TypeScript、Tailwind是固定的,数据层选Prisma还是Drizzle,通信层选tRPC还是GraphQL,认证用NextAuth还是Clerk,每个选型都有理由,但没有一个理由是"它们天生能配合"。T3 Stack省掉的就是这块组合成本,而且社区维护者本身就是这批工具的活跃作者,版本兼容性追得比较紧。对t3code这种纯内部工具来说,这套组合是眼下性价比最高的选择。
1.2 六个成员的角色边界
t3code最终选用的组合如下:
| 成员 | 在t3code里的定位 |
|---|---|
| Next.js | 应用框架,负责页面渲染、路由、API路由兜底 |
| TypeScript | 全链路的类型语言基础 |
| Tailwind CSS | UI样式方案,快速搭内部工具的界面 |
| tRPC | 前后端过程调用,替代REST的通信层 |
| Prisma | ORM与数据库迁移,管理PostgreSQL表结构 |
| NextAuth.js | 认证体系,对接GitHub OAuth |
六个组件各管一摊,边界很清晰。Next.js管的是"页面和请求如何进来",tRPC管的是"进来之后如何安全地调用后端函数",Prisma管的是"函数执行时怎么读写数据",NextAuth管的是"谁有资格调用"。Tailwind负责把最后一公里的视觉层快速搞定,TypeScript则是串联所有环节的胶水。
一个容易混淆的点是:T3 Stack并不是一个新的框架,它更像一套插件化组合。每个成员都保留着原生用法,你随时可以绕开tRPC写一个普通REST API,或跳过Prisma直接操作数据库。这种"可拆卸"设计很重要,意味着技术栈不会绑架业务需求,后续如果要替换某个成员,改动范围是可控的。
1.3 什么样的项目不适合这个栈
我也得承认,类型贯通在一些场景下没那么重要,甚至会成为负担。如果你要做的是面向公众的开放API平台,需要精确控制REST或GraphQL契约的版本和SDK生成,那就老老实实走OpenAPI或GraphQL Codegen路线,tRPC的客户端约束反而会限制你暴露给第三方。再比如你需要同时对接小程序、iOS、Android等多端客户端,tRPC的TypeScript客户端在非JS环境的使用成本会变高,多语言端依然要重新定义协议。
t3code是纯内部工具、纯浏览器使用,所以这些限制都不成立。判断标准很简单:当所有使用方都是你自己的Web前端时,tRPC是性价比最高的通信层;只要出现"外人要接你的API"这个需求,就得重新考虑边界。选技术栈最怕的不是选错,而是明明场景不匹配还硬上,最后把框架缺陷当成自己的问题。
2. 初始化t3code:从create-t3-app到可运行工程
2.1 环境准备与版本选型
初始化之前先把本地环境理清楚。我用的Node.js 18 LTS,包管理器推荐pnpm。T3 Stack官方文档也把pnpm作为第一推荐,原因在于它的硬链接机制能显著减少依赖占用,多个实验项目共存时优势更明显。数据库选了PostgreSQL 15,本地用Docker跑了一个实例,生产环境用云数据库。
版本选型上,create-t3-app会拉取当前最新的稳定模板。以我落笔时的版本演进来讲,脚手架默认选项已经切到了Next.js 14的App Router模式,tRPC升级到v11,Prisma保持在6.x。建议首次上手直接用默认生成版本,千万不要为了"熟悉"老写法而手动降级,新版模板的目录结构和API都经过了大量真实项目的考验。
2.2 脚手架交互选项逐个拆解
执行命令:
pnpm create t3-app@latest交互式选项如下,每个我都按t3code的实际情况选了:
? Project name: t3code ? Would you like to use TypeScript? Yes ? Would you like to use Tailwind CSS? Yes ? Would you like to use tRPC? Yes ? Would you like to use Prisma? Yes ? Would you like to use NextAuth.js? Yes ? Would you like to use App Router? Yes ? Would you like to use ESLint? Yes ? Would you like to use Prettier? Yes这里有一句值得展开的提醒:脚手架里的每个选项不是独立开关,它们之间存在依赖关系。比如你选择tRPC,脚手架会自动装好@tanstack/react-query和@trpc/react-query;选择NextAuth,它会额外配置next-auth的Provider;选择Prisma,它会生成.env文件并预留DATABASE_URL字段。所以不要随意勾掉某个选项——如果你不需要认证,建议先全选再手动移除,而不是一开始就砍掉,否则模板的很多联动配置会缺失。我自己就是先全选,去掉ESLint之外的任何选项都可能让后续排查多绕弯路。
2.3 生成的目录结构怎么读
脚手架跑完后,t3code的src目录长这样:
src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx pages/api/auth/[...nextauth]/route.ts server/ api/ root.ts routers/ snippet.ts trpc.ts auth.ts db.ts trpc/ server.ts react.ts env.mjs第一眼看会有点乱,但核心逻辑很清楚:所有后端代码集中在src/server下,trpc.ts导出定义router和procedure的基础类型,root.ts汇总所有子router,auth.ts和db.ts分别暴露认证与数据库实例。src/trpc下的server.ts和react.ts是暴露给前端用的封装入口。App Router模式下,tRPC通过src/app/api/trpc/[trpc]/route.ts这个路由文件作为HTTP入口,NextAuth则通过src/pages/api/auth的API路由处理回调。
2.4 首跑前必改的三处配置
生成后目录里有一个.env,里面只有占位符。在跑第一步之前,必须改三个东西:
# 数据库连接(本地PostgreSQL) DATABASE_URL="postgresql://postgres:postgres@localhost:5432/t3code" # NextAuth的加密密钥,用openssl命令生成一个足够长的随机串 NEXTAUTH_SECRET="" # 登录回调地址,本地开发就是localhost:3000 NEXTAUTH_URL="http://localhost:3000"NEXTAUTH_SECRET我一般这么生成:
openssl rand -base64 32然后把输出粘贴进去。很多人喜欢把密钥写死在.env里图省事,但我现在的习惯是动态生成一个长随机串,方便后续轮换。另外,NextAuth在生产环境里NEXTAUTH_URL可以由平台自动推断,开发环境最好还是显式写清楚,否则回调地址会指向诡异路径,登录后跳转经常莫名其妙丢session。
改完这三处,先执行数据库迁移:
pnpm prisma migrate dev再启动开发服务器:
pnpm dev浏览器打开localhost:3000,能看到脚手架自带的一个示例页面,包含导航和tRPC健康检查的展示,这一步跑通了,说明全链路基本没问题。
3. 打通tRPC链路:类型安全在这里真正落地
3.1 tRPC是怎么做到不用手写API契约的
正常情况下,前端调用后端接口的流程是:后端定义路由和返回结构,写成文档;前端看文档,手动定义类型;两边类型一旦不同步,运行期才暴露问题。tRPC绕开了这个过程,它把后端函数当作可调用的过程(procedure),调用方直接引用函数的类型信息,编译器在构建期就能校验参数和返回类型。
可以这样理解:普通REST是"你给一个URL,我给你JSON",tRPC是"你直接引用我的一个方法,参数和返回值类型都在编译期就绑死"。URL只是运行期的寻址方式,类型则是编译期的契约。对t3code这种内部工具,收益最明显——我给搜索页加一个filter参数,前端代码里如果少传一个字段,编译直接红标,完全不需要等联调阶段才发现。
3.2 从定义router到页面调用的一手代码
t3code里最典型的例子是snippet查询。后端在src/server/api/routers/snippet.ts里这样定义:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ search: protectedProcedure .input( z.object({ keyword: z.string().optional(), language: z.string().optional(), page: z.number().min(1).default(1), pageSize: z.number().min(1).max(50).default(10), }), ) .query(async ({ ctx, input }) => { const where = { AND: [ input.keyword ? { content: { contains: input.keyword } } : undefined, input.language ? { language: input.language } : undefined, ].filter(Boolean), }; const snippets = await ctx.db.snippet.findMany({ where, skip: (input.page - 1) * input.pageSize, take: input.pageSize, orderBy: { updatedAt: "desc" }, }); const total = await ctx.db.snippet.count({ where }); return { snippets, total, page: input.page, pageSize: input.pageSize }; }), });前端页面里调用:
"use client"; import { api } from "~/trpc/react"; export default function SnippetList() { const { data, isLoading } = api.snippet.search.useQuery({ keyword: "prisma", page: 1, pageSize: 10, }); if (isLoading) return <Loading />; return ( <ul> {data?.snippets.map((snippet) => ( <li key={snippet.id}>{snippet.title}</li> ))} </ul> ); }注意,api.snippet.search这个调用链是类型推导出来的,不是手写的客户端方法。输入对象的每个字段、返回数据的每个字段,全都由后端router的schema和Prisma返回类型决定。我写错一个字段名,编译器立刻报错。这块是T3 Stack最核心的体验,也是它能吸引人持续用下去的根本原因。
3.3 鉴权中间件与错误处理
t3code里大部分查询需要登录,这里用protectedProcedure而不是publicProcedure就能实现:
export const protectedProcedure = createTRPCProcedure() .use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, userId: ctx.session.user.id }, }); });这个中间件的核心在于把userId注入到后续的context里。之后任何一个handler都可以直接用ctx.userId而不用再反复查会话,既省一次数据库请求,也降低出错概率。错误处理上,tRPC推荐用TRPCError而不是直接throw普通Error字面量——TRPCError会序列化code字段,前端React Query的error对象能精准判断错误类型,再决定是跳登录还是弹提示。t3code里我统一封装了一个Toast组件,捕获到UNAUTHORIZED就直接跳转登录页,其他错误才做用户可见处理。
3.4 一个值得做的性能配置
tRPC默认会走React Query的SSR预取,好处是首屏数据直接出HTML,但坏处是部分低频查询会在每次页面加载时阻塞首屏。t3code里我给搜索类查询关了SSR:
api.snippet.search.useQuery(payload, { trpc: { ssr: false }, staleTime: 60_000, });配合staleTime缓存一分钟,用户翻页时数据不会频繁重新拉取,体验顺畅不少。这里要强调:ssr:false只影响当前查询,不会影响其他查询的SSR,可以按需混用。比如详情页的snippet内容查询我保留了SSR,因为首屏直接展示正文对SEO和分享都友好;搜索列表则完全走客户端渲染。
4. Prisma与NextAuth联动:数据层和认证层不能各管各
4.1 数据模型设计
t3code的核心表有两张:snippet和tag,关联关系是多对多。Prisma schema的核心部分如下:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? snippets Snippet[] accounts Account[] sessions Session[] tags Tag[] } model Snippet { id String @id @default(cuid()) title String content String language String @default("plaintext") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] }设计时的取舍在于:snippet的content用String(PostgreSQL里对应text),没有用JSON结构,因为代码片段本质就是纯文本,保留language字段用于高亮即可。author与snippet做成强关联,删除用户时由应用层处理级联,不用数据库级联,避免误删。多对多关系通过Prisma隐式关联表实现,不需要手动声明中间模型,这种写法最省事,且查询API足够灵活。
另外注意User模型里的accounts、sessions字段,这是NextAuth的Prisma adapter要求的,不能随便删,否则登录链路会报错。脚手架生成时如果没有补全,需要手动加上,建议直接参照adapter的标准schema写。
4.2 NextAuth接入细节
认证配置放在src/server/auth.ts,示例片段:
import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), providers: [GitHub], session: { strategy: "jwt" }, callbacks: { session: ({ session, token }) => ({ ...session, user: { ...session.user, id: token.sub }, }), }, });这里有两个关键决定。第一,session策略选jwt而不是database:t3code是纯前端内部工具,JWT省去每次请求查数据库的负担,在Serverless环境也避免冷启动时连库。第二,回调里手动把token.sub塞进session.user.id,否则前端拿到的user对象没有id字段,跟后端Prisma的userId对接不上。这个坑我在后面会详细展开。
GitHub Provider需要在GitHub OAuth Apps里新建应用,回调地址填http://localhost:3000/api/auth/callback/github,环境变量里加GITHUB_ID和GITHUB_SECRET。
4.3 迁移与种子数据
数据库表结构改完,执行:
pnpm prisma migrate dev --name init_snippet_tag迁移文件会生成到prisma/migrations目录。开发阶段我建议每次改schema都带一个描述性名字,方便回滚时定位。种子数据我用一个独立脚本:scripts/seed.ts,里面用PrismaClient批量插入10条示例snippet和5个常用标签,跑完直接登录就能看到数据,省得手工造数。
种子脚本里有个细节:批量插入时用createMany比循环create快一个数量级,但createMany不支持嵌套创建关联,所以涉及tags关联时得先创建tag,再用connect关联。顺序上稍微注意一下就行,没啥难的,但能帮你省下几分钟等待。
5. t3code排错实录:三个绕不开的坑
5.1 坑一:tRPC context类型没同步,前端死活拿不到userId
现象:前端session.user.id能正常打印,但tRPC handler里ctx.userId是undefined,而且TypeScript不报错。
排查过程:先检查protectedProcedure中间件的注入逻辑,发现next({ ctx: ... })返回的上下文类型和trpc.ts里CreateContext的声明不匹配。脚手架生成时,createTRPCProcedure的泛型会从context类型推导,但如果你在trpc.ts里修改过CreateContext的返回结构,而中间件里返回的ctx没有同步更新类型,TS会因为上下文类型推断的宽松而不报错,运行期却拿不到值。
最终修复:把CreateContext的返回类型改成显式接口,中间件返回时用类型断言确保结构一致。排查教训是:类型不报错不等于运行时正确,尤其涉及context这种隐式数据流,一定要在登录后的接口里打印一次ctx的关键字段做验证。这个坑我至少害团队排查了半个下午。
5.2 坑二:Prisma连接池在Serverless环境被耗尽
现象:部署到Vercel后,冷启动频繁时日志里出现"Too many connections",页面随机502。
原因分析:Next.js在Serverless平台每个请求可能对应独立实例,而Prisma默认给每个实例创建独立连接池。如果数据库最大连接数是100,而平台上同时活跃的Lambda实例超过这个数,连接就被打满。
修复方案分两层。第一层,在src/server/db.ts里把PrismaClient声明为全局单例,利用Node.js的globalThis缓存连接实例,避免每次请求重建连接。脚手架本身就带了这段代码,但不少人会误删。
const globalForDb = globalThis as unknown as { prisma: PrismaClient | undefined }; export const db = globalForDb.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForDb.prisma = db;第二层,如果数据库支持连接池模式,DATABASE_URL改用连接池地址,比如PostgreSQL云厂商的pgbouncer入口,把连接收敛到池子里复用。这段代码的价值在于:开发环境热更新时不会反复创建新连接,生产环境则尽可能复用单例。
5.3 坑三:Server Component里不能用useQuery,页面白屏
现象:我在一个RSC页面里尝试用api.snippet.search.useQuery,结果报错说hooks只能在Client Component使用。
解决:tRPC v11同时提供了server caller,在RSC里可以直接调用后端函数,不需要经过HTTP层:
import { createCaller } from "~/server/api/root"; export default async function Page() { const caller = createCaller(); const { snippets } = await caller.snippet.search({ page: 1, pageSize: 10 }); return <SnippetList initialData={snippets} />; }拿到数据后传给Client Component作为initialData,避免重复请求。这里有个架构原则:RSC负责"尽量多的静态渲染",Client Component负责"需要交互的部分",数据获取路径要分清楚,不要在一个组件里混用两套机制。开始觉得麻烦,习惯之后反而觉得这种拆分让页面职责更清晰了。
5.4 排错方法论小结
三次排错有个共同点:问题的根源都不是"代码写错"这么简单,而是框架层级的机制理解偏差。我的建议是遇到诡异问题先别急着改代码,按下述顺序排查:
- 看框架版本的迁移日志——很多坑是版本升级留下的破坏性变更。
- 打印context和session的关键字段,确认数据流真的存在,而不是"理论上存在"。
- 最小复现:把问题代码抽到一个独立路由里,删除无关依赖,往往几分钟就能定位。
- 去官方Discord或GitHub Issues搜关键词,工具类项目社区活跃度很高,很多坑已经有人踩过,别重复发明轮子。
这套方法在t3code后续的开发里反复救了我的命,现在遇到问题我第一反应不是改代码,而是先补日志。
6. 部署上线与下一步:从能跑到跑稳
6.1 环境变量管理
t3code是内部工具,但环境变量管理不能随便。我的做法是分三层:开发环境用.env(不入库),预览环境用平台的Environment Variables面板,生产环境用密钥管理服务,部署流水线在构建阶段动态注入。最关键的原则是:密钥永远不进代码仓库,.env.example只留变量名和占位说明,真实值由团队内部分配。
在.env.example里我会把每个变量的用途写清楚,比如NEXTAUTH_SECRET注明"openssl rand -base64 32生成",DATABASE_URL注明"区分开发和生产连接串"。这是团队协作的基础,否则新人接手第一件事就是猜变量含义。
6.2 Vercel部署要点
Vercel部署T3 Stack相对省心,但有几件事要提前做好。第一,构建命令保持脚手架默认的pnpm build即可,别改成next build单独跑,因为prisma generate需要先执行。第二,数据库迁移不要在部署平台手动运行,建议纳入CI阶段,在部署前执行prisma migrate deploy。第三,Vercel的Serverless环境对NextAuth的JWT无状态策略友好——如果你选了database session,每次请求都要访问数据库,冷启动延迟会明显更高,JWT则没有这个问题。
部署完成后记得验证两个链路:登录回调是否正常跳转、tRPC的POST请求是否能拿到200。这两个都是最容易在平台层出问题的地方,尤其回调地址,很多失败案例都是忘了在GitHub OAuth设置里更新生产域名。
6.3 后续演进方向
t3code跑稳定之后,下一步有几个明显方向:一是把snippet的tag改成层级标签,支撑更细的检索维度;二是增加AI辅助的代码解释功能,把snippet内容发到大模型接口做摘要;三是考虑把搜索模块独立出来,未来接入内部更多工具。这些演进都可以在现有T3 Stack架构上渐进实现,不需要推翻重来。这也印证了当初选这套栈的判断——它不是玩具,支撑内部工具这个体量的业务绰绰有余。
最后说一个我实际操作中的体会:T3 Stack真正省下的不是写代码的时间,而是"前后端对齐"的时间。t3code从立项到第一版可用的整个过程,最顺畅的部分恰恰是之前最担心的数据链路——类型推导把大部分低级错误挡在了编译期,联调期缩短到几乎可以忽略。当然,没有银弹,选这套栈的前提是你认可TypeScript作为全栈协作语言的价值。如果你也在琢磨内部工具或中后台产品的技术选型,把它作为候选之一认真评估一次,应该不会后悔。