最近我把 t3code 这个项目从零搭了起来,它是个基于 T3 Stack 的代码片段管理工具,用来整理我散落在各个笔记、聊天记录和临时文件里的代码片段。T3 Stack 这一整套东西,说白了就是 TypeScript 技术栈的全家桶组合:Next.js 负责前端页面和路由,tRPC 让前后端接口像调本地函数一样顺手,Prisma 管数据库模型和迁移,Tailwind 处理样式。我选这套方案,是因为它把类型安全贯彻到了数据库这一层——从表结构到前端组件,类型定义一以贯之,项目规模一大省的心就多了。
我见过太多团队在“前后端接口如何维护”这个问题上反复拉扯,要么手写 REST 文档,要么上 Swagger 然后文档过期,要么 GraphQL 引入一堆缓存复杂度。t3code 用 tRPC 直接从后端函数定义推导出前端可用的类型,编译器就是接口文档,跑不通就编译不过,这种确定性能让人上瘾。如果你也是独立开发者或者小团队,想在几天内把想法落地成能用的全栈产品,这篇文章值得你看完,因为里面不光有 t3code 怎么搭的,还有我踩过的坑和某些官方文档不会告诉你的细节。
1. 为什么 t3code 要死磕 T3 Stack:选型逻辑拆解
1.1 T3 Stack 到底解决了什么问题
T3 Stack 在社区里流传最广的英文描述是“构建最健壮、类型安全的 Web 应用”,核心逻辑其实是三个关键词:类型安全、全栈复用、极简心智。
先说类型安全。传统的前后端分离项目里,前端要请求后端接口,需要先看接口文档、定义 d.ts、写请求函数,后端改了字段名,前端很容易在运行时才发现崩了。t3code 里我写一个新的查询函数,前端调用它的那瞬间,IDE 就已经把参数类型、返回类型全部约束好了。这不是什么黑魔法,就是 tRPC 通过 TypeScript 的泛型把类型从服务端“传递”到了客户端。
再说全栈复用。Next.js 的 API Routes 其实就是 Node.js 服务端,tRPC 的 router 可以放在同一个仓库里,前端组件直接 import 后端定义的类型和调用函数。我不用在两个代码库之间来回切换,也不需要额外维护一套 OpenAPI 规范。整个 t3code 就是一个单体仓库,看起来是前后端一体的,实际上边界足够清晰。
第三点是心智负担。T3 Stack 的哲学是“少而精,约定大于配置”。它不像某些框架上来就生成一堆抽象层,而是只提供你真正会用到的几样东西:路由、数据库、样式、验证。我选它而不是别的方案,核心原因就在于这套组合能让我用一个下午就从零搭出可运行的全栈骨架。
1.2 与主流全栈方案的横向对比
我跟你说,选型这事最怕的就是盲目跟风。我在动工前特意把 T3 Stack 和其他几条常见路线做了对比:
| 方案 | 类型安全强度 | 学习成本 | serverless 友好度 | 适合场景 |
|---|---|---|---|---|
| T3 Stack | 全链路强 | 中 | 高(Next.js 天然适配 Vercel) | 中小型全栈应用、内部工具、创业 MVP |
| 传统 React + Express | 前后端割裂,接口类型靠手写 | 低 | 中(需额外部署服务端) | 已有企业习惯、需要自定义后端中间件 |
| Next.js 全栈 + REST API | 中等,靠生成器或手写类型 | 中 | 高 | 团队熟悉 REST、接口给第三方用 |
| Nuxt + Nitro | 强(前后端也是 TypeScript) | 中 | 高 | Vue 技术栈团队 |
这个表格不是想分个高低,而是想说清楚每个方案的取舍。t3code 选 T3 的核心理由有两个:一是所有代码用一套类型系统串起来,省掉的沟通成本肉眼可见;二是部署链路短,Vercel 上一挂就是全球边缘网络,不用自己买服务器、配 Nginx、处理 TLS,这些东西对个人项目来说都是纯负担。
1.3 t3code 的功能边界与数据建模思路
工具类应用最怕的是功能膨胀。我最初想在 t3code 里加 Markdown 编辑器、分享链接、协作文档、甚至代码高亮主题切换,后来被自己劝住了:所有功能都围绕一个主流程“记录-检索-复用”来设计。你保存代码片段,给它打语言标签和业务标签,需要的时候搜索或按标签筛选,点开复制,完事。多余的功能不加,保持代码库可维护性。
数据建模也顺着这个思路走。三张核心表:CodeSnippet(代码片段本体)、Tag(标签)、User(用户)。Snippet 和 Tag 是多对多关系,因为一个代码片段可以又属于“TypeScript”又属于“拦截器”;User 和 Snippet 是一对多,每个用户只能看到自己创建的片段。这个模型简单直接,但已经能覆盖核心场景了。
提示:做个人项目时,尽量用最少的表结构支撑核心功能。先跑通主闭环,再把表拆细、加索引都不迟,不要被“高大上”的表设计拽着走。
2. 从 create-t3-app 初始化到项目骨架搭建
2.1 一条命令生成初始代码
我搭建 t3code 的时候,没有手敲配置文件,而是用了官方脚手架。执行命令:
npm create t3-app@latest然后它会交互式问你要用哪些模块。我实际勾选的是 Next.js、tRPC、Prisma、Tailwind、NextAuth.js,其余像 Playwright、Drizzle 这类因为暂时用不到,全部留空。这里有个容易被忽略的选项:使用 npm 还是 pnpm。我个人建议独立项目用 pnpm,依赖安装速度快、磁盘占用小,后续跑 CI 也会舒服一些。如果公司环境有网络限制,再用 npm 不迟。
初始化完成后 README 会提示你补环境变量。我第一次跑npm run dev的时候,页面直接报错,因为.env里数据库连接串和 NextAuth 密钥是空的。这不是脚手架 bug,而是它故意让你在启动前先确认外部依赖。
2.2 目录结构与关键文件的作用
t3code 生成后的目录结构大概是这样的:
t3code/ ├── prisma/ │ └── schema.prisma # 数据模型定义 ├── src/ │ ├── pages/ │ │ ├── api/trpc/[trpc].ts # tRPC 的 HTTP 入口 │ │ ├── index.tsx │ │ └── ... │ ├── server/ │ │ ├── api/routers/ # 业务路由(tRPC) │ │ ├── db.ts # Prisma 客户端实例 │ │ └── auth.ts # NextAuth 配置 │ ├── styles/ │ └── utils/ │ └── api.ts # tRPC 客户端封装 ├── tailwind.config.ts ├── next.config.js ├── tsconfig.json └── package.json刚开始看到这个结构的人可能会困惑:页面组件和业务逻辑放一起,不会乱吗?实际跑起来会发现,这种“靠近功能的组织方式”非常符合中小型项目的直觉。接口路由集中在src/server,UI 层按页面分开,中间通过 tRPC 连接。如果以后某个页面的逻辑太多,再考虑加src/features之类的按功能模块拆。
要注意的细节是next.config.js,T3 脚手架默认加了transpilePackages和实验性选项,这些是在国内网络环境下也能正常工作的一套保守配置,不要随便删。
2.3 初跑流程与环境变量配置
有了目录结构后,先别急着写业务代码,把基础依赖跑通。我在本机配置了 PostgreSQL,然后改.env:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="本地开发先用随机字符串,上线前必须换" NEXTAUTH_URL="http://localhost:3000"之后执行:
npm run db:push这步会把 Prisma schema 推送到数据库,相当于免写 SQL 建表。接着npm run dev,打开http://localhost:3000,界面如果渲染出来且没有红屏报错,说明骨架已经通了。很多新手在这一步就开始纠结“要不要 docker 化、要不要加 CI”,我的建议是先别,本地跑通再谈部署,否则排查问题的维度会同时爆炸。
注意:
NEXTAUTH_SECRET在未来上线时务必要换成一个足够长的随机字符串,可以直接用openssl rand -base64 32生成。上线环境继续用开发密钥,等于把登录态的安全门敞开。
3. tRPC + Prisma 全栈链路:t3code 的核心代码实践
3.1 数据模型:从需求到 Prisma Schema
t3code 的核心表设计,我一开始就把它写成 Prisma schema:
model User { id String @id @default(cuid()) name String? email String? @unique snippets CodeSnippet[] createdAt DateTime @default(now()) } model CodeSnippet { id String @id @default(cuid()) title String code String @db.Text language String description String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id]) tags Tag[] } model Tag { id String @id @default(cuid()) name String snippets CodeSnippet[] createdAt DateTime @default(now()) }多对多关系在 Prisma 里是隐式的,它会在底层自动生成关联表,不需要我们手动创建第三张表。这一点非常省事,你只要在 schema 里两边都写上数组字段,Prisma 会自动处理外键关系。我选择 cuid 而不是自增 int,是因为 t3code 以后如果要迁移到分布式环境,cuid 可以避免主键冲突;虽然现在单机用 int 也行,但既然 Prisma 默认支持,没必要先图省事再返工。
3.2 编写 tRPC Router:输入校验与业务逻辑分层
tRPC 的 router 相当于传统后端里的 Controller。我在 t3code 里写了一个 snippet 路由,核心代码长这样:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const snippetRouter = createTRPCRouter({ // 查询当前用户的代码片段列表,支持按语言过滤 list: protectedProcedure .input( z.object({ language: z.string().optional(), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const snippets = await ctx.db.codeSnippet.findMany({ where: { userId: ctx.session.user.id, ...(input.language ? { language: input.language } : {}), }, include: { tags: true }, take: 20, ...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}), orderBy: { createdAt: "desc" }, }); return snippets; }), // 创建代码片段 create: protectedProcedure .input( z.object({ title: z.string().min(1), code: z.string().min(1), language: z.string(), description: z.string().optional(), tagNames: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.$transaction(async (tx) => { const snippet = await tx.codeSnippet.create({ data: { title: input.title, code: input.code, language: input.language, description: input.description, userId: ctx.session.user.id, }, }); if (input.tagNames.length > 0) { const tags = await Promise.all( input.tagNames.map((name) => tx.tag.upsert({ where: { name }, update: {}, create: { name }, }) ) ); await tx.codeSnippet.update({ where: { id: snippet.id }, data: { tags: { connect: tags.map((tag) => ({ id: tag.id })) }, }, }); } return snippet; }); }), });这段代码里有几个关键点。第一,protectedProcedure表明这个接口必须登录才能调用,tRPC 会在进入业务逻辑前自动检查 session,无需我们在每个函数里手动判断。第二,输入校验用 zod,它和 TypeScript 的契合度极高,前端传过来的数据如果不符合 schema,直接就抛错,不会脏数据落库。第三,创建片段时标签的处理用了 upsert,不存在就建,存在就复用,这是处理“标签”这种多对多关联常见的套路。
3.3 为什么用事务包裹多步写入
你可能注意到了,创建 snippet 的时候我包了一层$transaction。这一步不是炫技,而是真实需求:如果先创建代码片段、再处理标签,第二步失败时就会产生一个没有标签的半成品数据。加事务之后,任何一步出错,整个操作自动回滚,数据库始终处于逻辑完整的状态。
事务在单机数据库上几乎是免费的,但对数据一致性有立竿见影的效果。t3code 这个场景里多对多的写入尤其需要用事务兜底。官方文档里对$transaction的阵列式用法(即传入一个数组)有个限制:数组里的事务请求不允许超过特定数量,所以当我说按标签数可能较多时,用了回调式$transaction,这种交互式写法更灵活,能包住 upsert 加 connect 这类需要依赖前一个结果的操作。
3.4 前端调用:像调本地函数一样请求数据
tRPC 最爽的部分在前端调用。t3code 的代码列表页面,调用形式是这样的:
import { api } from "~/utils/api"; function SnippetList({ language }: { language?: string }) { const { data, isLoading, fetchNextPage, hasNextPage } = api.snippet.list.useInfiniteQuery( { language }, { getNextPageParam: (lastPage) => lastPage[lastPage.length - 1]?.id } ); if (isLoading) return <div>加载中...</div>; return ( <div> {data?.pages.map((page) => page.map((snippet) => <SnippetCard key={snippet.id} snippet={snippet} />) )} {hasNextPage && <button onClick={() => fetchNextPage()}>加载更多</button>} </div> ); }注意这个useInfiniteQuery,它就是 react-query 的无限滚动查询,配合 cursor 分页,可以实现“加载更多”的体验。tRPC 把 react-query 集成进了 React 绑定里,所以我们既能享受缓存、失效、重试这些数据请求能力,又能获得端到端的类型推导。snippet.list的函数签名,IDE 会直接提示输入参数的类型是{ language?: string; cursor?: string },返回类型是完整的 Prisma 对象类型,根本不需要额外定义接口类型。
这种前后端一体感,对开发效率的提升是实打实的。我在写完 snippet 路由后,几乎没花时间对接口联调,因为类型不一致在编译期就报错了。
4. 部署上线与常见问题排查实录
4.1 部署到 Vercel 的完整配置
t3code 上线我选了 Vercel,原因是 Next.js 和 Vercel 的兼容性最好,零配置就能部署。但要注意几个环境问题:
第一,数据库连接串。Vercel 的环境变量面板里需要重新配置DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL。其中NEXTAUTH_URL要填线上域名,而不是localhost:3000。
第二,Prisma 在 Serverless 环境下的客户端生成。直接在 Vercel 上跑npm run build时,如果遇到 Prisma 引擎二进制不匹配的报错,常见解法是在package.json的 build script 里加上prisma generate,并且把@prisma/client的postinstall钩子保持启用。我的 build 脚本最终是这样的:
{ "scripts": { "build": "prisma generate && next-build" } }第三,如果你用了db:push来建表,线上不能直接执行这个命令。正确做法是本地生成迁移文件:npx prisma migrate dev --name init,然后把迁移文件提交到仓库,线上 build 执行npx prisma migrate deploy。这个命令只应用迁移,不动业务数据,是生产环境的标准操作。
4.2 高频坑位速查表
说实话,t3code 从开发到上线,我踩的最多不是框架本身的坑,而是配置和习惯问题。整理一个速查表:
| 问题现象 | 根因 | 解决方式 |
|---|---|---|
| 登录态失效,接口全返回 401 | NEXTAUTH_SECRET在本地和线上不一致 | 统一环境变量,用同一把随机密钥 |
| Prisma 连接超时 | Serverless 冷启动 + 连接数耗尽 | 使用 Prisma 连接池,或调大连接池上限 |
| 中文搜索不出来 | PostgreSQL 默认文本搜索对中文分词支持差 | 改用pg_trgm,或加拼音索引/搜索引擎 |
| Tailwind 样式错乱 | Purge 配置把动态类名删了 | 避免用字符串拼类名,用完整类名字符串 |
tRPC 返回Date类型在 JSON 序列化后变字符串 | 这是正常的,Prisma 的 Date 会传给客户端字符串 | 前端统一按 ISO 字符串解析即可 |
| 冷启动页面很慢 | 首屏动态渲染 + 数据库查询 | 考虑增大内存,开启边缘缓存,或把公开数据定时静态化 |
其中冷启动慢这个问题最隐蔽。Vercel 的 Node 函数默认是懒加载的,一小时没人访问就会休眠,下一个访问者可能要等好几秒。对工具类应用来说,这个延迟很致命。我有两个缓解思路:一是把不需要登录的页面改为export const dynamic = "force-static",让 Vercel 提前生成静态页面;二是把 tRPC 的查询细化,减少首屏函数里的重计算。
4.3 我验证过的安全实践
安全这块,t3code 虽然只是个人工具,但用户数据还是要保护的。我最基本的三条实践:
- 所有操作只允许操作自己的数据。比如更新删除 snippet 时,where 条件里必须同时带上
userId,而不是只靠传进来的 id。否则接口一旦被遍历,别人就能改你的数据。 - NextAuth 的 Session 里只放必要的字段。Prisma adapter 默认会把整个 user 对象塞进 session,我做了映射,只暴露 id、name、email,避免把数据库内部字段泄漏给客户端。
- 上传代码片段时做基本清洗。虽然是个人使用,但代码片段会被 Postgres 存进 Text 字段,为了防止极端情况下的 XSS(比如未来做分享页面时渲染高亮),至少在展示侧对
<script>之类的内容做转义。
这些不是花哨技巧,是每一个 Web 应用都应该有的底线。类型安全解决的是“不该传错的传错”问题,安全实践解决的是“不该访问的别访问”问题,两者要同时做好。
4.4 性能优化日志:从 2 秒到 400ms
我要分享一个真实优化记录。t3code 的列表页一开始在冷启动后要接近 2 秒才出结果,后来我做了三个调整:
第一,Prisma 查询只返回需要的字段。以前findMany默认返回全字段,代码片段的code内容可能很长,列表页根本不需要。我改成select: { id: true, title: true, language: true, updatedAt: true },传输体积直接降了一个量级。
第二,列表加载改成了无限滚动。一次只取 20 条,首屏数据量可控,渲染压力也小。
第三,把 tRPC 的 query 缓存时间调长。对不太变动的标签列表,缓存设为 3 分钟,用户切换页面时不用每次重新请求。
改完以后,首屏加载稳定在 400ms 左右,Vercel 冷启动带来的额外延迟也因为是静态页面而被绕过了。
5. 从 t3code 里沉淀的一点个人心得
项目做到这里,我最大的体会是:选技术栈不是选一个“最强的”,而是选一个“最少能让你分心的”。T3 Stack 的价值不在于每个组件都是某个领域的性能冠军,而在于把 TypeScript 的类型系统作为粘合剂,让全栈开发变成一个连续的思考过程。t3code 本身是个很小的工具,但正因为小,我才能把所有注意力放在产品和数据模型上,而不是在接口联调和环境配置里耗掉八十个小时。
如果你也想拿这套技术栈练手或者做自己的项目,我建议不要先盯着“什么时候部署到哪个平台”,而是先闭门把核心闭环跑通:数据库表建好、tRPC 路由调通、前端页面渲染出来。等你意识到 typescript 编译器已经帮你挡住了 90% 的类型错误、tRPC 报错信息直接指到代码行号的时候,你会回来感谢这个组合的。对我而言,t3code 从一个念头变成一个能用的产品,这个过程本身就是这套技术栈最好的广告。