t3code 这个代号我本来是想随手起个练习项目的名字结果越写越觉得它值得单独拿出来聊一聊。它本质上是一个基于 T3 Stack 的全栈代码片段管理工具技术选型落在 Next.js TypeScript Tailwind CSS tRPC Prisma 这一套组合上核心诉求只有一个让前端、后端、数据库三层的类型从头到尾是一条线改一处字段其他层立刻在编辑器里告诉你哪里会出问题。如果你和我一样写过那种“前端一份接口定义、后端一份类型定义、数据库再一份 schema”的项目应该能体会这种痛字段名拼错、类型对不上、API 文档过期这些破事在联调时反复出现。t3code 这套方案能把这些麻烦消掉一大半。文章不教你怎么用它而是拆开看它是怎么做到的以及我从创建项目到跑通功能踩过的所有坑。适用人群我按经验分一下如果你已经会一点 React 和 TypeScript想来体验一下现在写全栈可以多省心这篇可以直接跟如果你完全没接触过 Next.js也不影响阅读但建议先把 Next.js 的基本路由概念过一遍再动手实操。1. 先想清楚 t3code 要解决什么问题很多人第一次看到 T3 Stack 的反应是这不就是把几个热门库拼在一起吗还真不是。它是一套有明确设计哲学的选型组合理解这个背景比理解每个库本身更重要。1.1 为什么叫“T3”而不是“三件套”T3 这个名字来自 T3 Stack 的提出者 Theo一开始指的是 TypeScript、Tailwind CSS、tRPC 这三个都以 T 开头的核心工具。后来社区把它扩展成一套完整的全家桶通常是 Next.js 做框架、Prisma 做 ORM、NextAuth.js 做认证但核心三个 T 没变。T3 这个名字还有个隐藏含义很多人会忽略它同时代表了“第三层”的意思。什么意思第一层是 JavaScript第二层是 TypeScript 带来的类型系统第三层就是让类型系统穿透到数据库和 API 层形成端到端的类型安全。t3code 这个项目里这一层“穿透”是它和普通全栈项目最大的区别。你可能觉得这有点抽象我用大白话解释一遍。传统项目里数据库表结构、后端返回的数据结构、前端接收的数据结构是三个独立的“世界”。数据库里有个language字段后端 TypeScript 类型里可能写成lang前端接的时候又变成languageName这三个名字靠人肉去对齐不出错才怪。T3 Stack 的思路是从数据库 schema 开始把字段定义作为唯一事实来源Prisma 生成对应的 TypeScript 类型tRPC 的接口输入输出直接从这些类型推导前端调用时又用同一个类型定义。字段从数据库一路流到 React 组件全程只有一份定义改一处其它地方全都在编译期报错。1.2 类型安全带来的连锁反应刚开始我以为类型安全只是少写几个 interface真正写完 t3code 才发现好处是连锁的。第一联调时间几乎消失。传统模式下前端写页面后端出接口两边在 Postman 和浏览器里来回调试。t3code 的模式下后端定义好 procedure前端 import 进来就能直接调用参数类型不对编辑器立刻红波浪线根本不存在“等后端修接口”这个过程。第二重构成本大幅度降低。我中途把代码片段模型从“只有标题和代码”改成“支持标签分类”动了 Prisma schema 之后tRPC 的 router 和前端组件一路报错哪个组件用了哪个字段全部暴露出来。没有类型提示的情况下这种改动只能靠全局搜索title碰运气漏改一个字段就是线上事故。第三心理安全感完全不同。写过大型项目的人都有这种时刻前端改了数据结构后端还不确定有没有同步页面一刷几百个红错只能硬着头皮找原因。t3code 这套组合让编译器拦截了绝大多数低级错误剩下的问题就只有“逻辑写没写对”而不是“类型对不对得上”。说实话T3 这名字起得神神叨叨的但背后的工程理念非常朴素能交给编译器的就不要消耗工程师的脑力。2. 核心技术点拆解每一层如何落地这一章我把 t3code 用到的每个核心库单独拆出来讲清楚它在整个链路里扮演的角色以及为什么不用别的东西替代它。2.1 Next.js 只做它该做的事Next.js 在 T3 Stack 里的角色很明确提供一个 React 应用框架处理路由、渲染模式、打包这些基础设施。t3code 用的是 App Router 模式也就是 Next.js 13 之后默认的目录结构。App Router 带来的一个关键能力是 server component服务端组件。页面在服务端直接读取数据渲染成 HTML 返回给浏览器而不是像传统 SPA 那样先返回一堆 JavaScript再在前端发请求拿数据。这对性能是有实际意义的尤其像代码片段列表这种读多写少的页面服务端渲染能让首屏几乎瞬间出内容。但在 t3code 里我发现App Router 也带来了一些理解成本。你可以同时用服务端组件和客户端组件而组件默认是服务端的只有标记use client的才是客户端组件。这一层在传统 React 里没有新人刚接触容易懵明明我已经用了 tRPC怎么在服务端组件里调不了这个坑我放在第四章细说这里先记住一个原则凡是需要交互、需要浏览器 API 的组件都显式加上use client。2.2 tRPC 让 API 层“隐身”tRPC 是整个 T3 组合里最容易被误解的工具。第一次听说的人多半会问又是一个 RPC 框架和 gRPC 什么关系其实 tRPC 不定义任何网络协议也没有代码生成它就是 TypeScript 里的一层函数调用封装。核心思想很简单前端代码可以直接调用后端的函数而且类型完全一致。逻辑上这就像你平时在自己的模块里 import 一个函数然后调用它只不过 tRPC 把这个调用从同一进程内延伸到了浏览器到服务器之间底层自动走 HTTP 请求。我举个例子t3code 里有个查询代码片段列表的接口。传统写法是前端先定义接口路径/api/snippets还要约定返回的 JSON 结构然后在后端写处理器。tRPC 的写法是这样先定义一个 procedure也就是一个可调用的后端函数import { z } from zod; const createSnippet protectedProcedure .input( z.object({ title: z.string().min(1), code: z.string(), language: z.string(), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.snippet.create({ data: { title: input.title, code: input.code, language: input.language, tags: input.tags ? { connectOrCreate: input.tags.map((name) ({ where: { name }, create: { name }, })), } : undefined, }, }); });前端调用的时候几乎感觉不到这是个远程调用const utils api.useUtils(); const createSnippetMutation api.snippet.create.useMutation({ onSuccess: () { utils.snippet.getAll.invalidate(); }, }); createSnippetMutation.mutate({ title: 防抖函数, code: function debounce(fn, delay) { ... }, language: typescript, tags: [javascript, 工具函数], });注意几个值得说道的细节。输入校验用 zod 做这不是可选项而是 tRPC 设计里的一环。因为类型只在编译期存在运行时的输入如果你不校验那类型安全就是纸糊的。zod 的 schema 同时提供了运行时校验和静态类型推导两者完美对齐。前端传一个title: 123接口层直接拦截根本进不了数据库。另外前端那行api.snippet.getAll.invalidate()是 React Query 的操作。tRPC 在内部集成了 TanStack Query自动帮你处理请求缓存、loading 状态、重新拉取这些逻辑。写好onSuccess里的 invalidate数据变更后列表自动刷新不需要手写fetch().then(() setList())这套样板代码。可以说用了 tRPC 之后fetch和 axios 这套熟悉的工具基本就从项目里消失了写接口的感觉更像在写一个本地函数库。2.3 Prisma 把数据库字段变成 TypeScript 类型数据库这块t3code 选 Prisma 而不是直接写 SQL原因主要是它可以生成完整类型定义。Prisma schema 文件的地位相当于数据库的“宪法”模型长什么样、字段什么类型、之间什么关系全部在这一个文件里声明。t3code 的核心模型设计我放在第三章细讲这里先看一个简化版。prisma model Snippet { id String id default(cuid()) title String code String language String createdAt DateTime default(now())tags Tag[] relation(SnippetTags) author User relation(fields: [authorId], references: [id]) authorId String }执行两个命令Prisma 先创建数据库表结构再生成 client bash npx prisma migrate dev --name init npx prisma generate之后在你的代码里prisma.snippet.findMany()返回的对象是强类型的snippet.title、snippet.language、snippet.tags全部有正确类型字符串字段不会出现number类型关联查询里该有.tags就有.tags。更重要的是t3code 的 tRPC router 里拿到的ctx.db.snippet也是这个强类型 client等于说数据库层面的字段约束一直传递到前端组件。这里要给个实用建议用 Prisma 就别绕过它去写原生 SQL。我知道有时候复杂查询写 SQL 更痛快但一旦绕过类型安全链就断了一层。T3 工程的舒服之处恰恰在于链路完整你只要守住 Prisma 这一条路后面的类型推导全自动。2.4 Tailwind 和 NextAuth 补上最后两块拼图Tailwind CSS 的功能是样式属于看起来不介入架构、实际影响开发体验的部分。t3code 这种工具类页面的开发体验非常美妙写一个按钮不需要新建 CSS 文件也不需要起类名直接在 JSX 里classNamerounded-md bg-blue-600 px-4 py-2 text-white一把梭样式和结构放在一起改起来心智负担极低。有人担心 Tailwind 会让 HTML 变得又臭又长这个顾虑我理解实际操作时可以用apply或者组件封装来消化。但 t3code 这种规模的页面我建议先别管代码量只管用等发现重复多次的样式组合再抽成组件也不迟。NextAuth.js 负责认证。它和 tRPC 的集成点很有意思登录后用户信息放进 sessionsession 注入 tRPC 的 context也就是每个 procedure 都能拿到的“上下文”然后就可以在业务代码里做权限判断。比如创建 snippet 时authorId直接从 session 取不允许前端传既能防止伪造也省了一次查询export const createSnippet protectedProcedure .input(/* zod schema */) .mutation(async ({ ctx, input }) { const userId ctx.session.user.id; // 直接使用 userId前端传什么都覆盖不了 });至于配置 OAuth 还是邮箱登录NextAuth 的 provider 机制已经封装好了专注写业务就行。3. 实操从 create-t3-app 到能跑的 t3code理论说太多容易飘本小节是完整的实操记录。我尽量把每一步的命令和代码都贴出来你可以直接照着敲。3.1 初始化项目选对脚手架create-t3-app 是官方脚手架帮你把上述一堆库装好、串好避免手动配置的痛苦。执行这一行npx create-t3-applatest t3code交互式选项里它问你要不要开启 TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth、ESLint 等。我的选择是全都要一个不留。这里解释几个容易犹豫的点Prisma 和 tRPC 是核心肯定开NextAuth 如果你短期不想做登录也可以先不开但开了之后权限模型才完整建议开Tailwind 已经成熟稳定是 T3 的官方标配没什么好纠结的。关于包管理器我用的是 pnpm速度和磁盘占用都比 npm 理想。如果你没装 pnpm可以用npm install -g pnpm安装。之后所有命令都用 pnpm 执行。脚手架生成完目录先看一眼文件结构重点有两个目录src/server/api/routers是后端 tRPC 路由的定义位置src/app是前端页面。create-t3-app 已经帮你初始化好一个示例 router 和首页可以直接启动验证环境pnpm dev浏览器打开http://localhost:3000能看到一个默认的欢迎页说明全家桶串联正常。这一步如果报错多半是环境变量问题直接看第四章的排查记录。3.2 数据模型设计代码片段怎么存t3code 的业务模型需要三张基础表用户User、代码片段Snippet、标签Tag。User 由 NextAuth 自动关联Prisma schema 里已经存在我们只需要在此基础上扩展。打开prisma/schema.prisma添加 Snippet 和 Tag 模型。这里有个多对多关系一个片段可以有多个标签一个标签可以挂在多个片段下。Prisma 的表达方式是在两边都声明数组字段让它自动生成中间表。完整的 schema 加上 User 的默认模型大概是这样的model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] snippets Snippet[] } model Snippet { id String id default(cuid()) title String code String language String 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[] }写完之后执行迁移。这里要注意create-t3-app 默认生成的 Prisma 配置是针对 PostgreSQL 的。PostgreSQL 功能强、生产环境首选但本地测试还得先装数据库对新手不友好。我实测下来开发阶段直接切到 SQLite 最快先把schema.prisma里的 datasource 改成 sqlitedatasource db { provider sqlite url env(DATABASE_URL) }然后在.env里设置连接字符串DATABASE_URLfile:./dev.db这两个地方改完继续执行pnpm prisma migrate dev --name init看到Your database is now in sync with your schema就成功了。注意如果用 SQLite生产部署时要记得把 provider 切回 PostgreSQL连接串换成对应地址这个切换成本很低提前知道就行。3.3 第一个 tRPC 接口从查询到页面脚手架已经生成了一套示例 router打开src/server/api/routers/post.ts能看个大概。我们要做的是新增一个喊作snippet的 router然后把它挂到根 router 上。我的做法是在routers目录新建snippet.ts写入查询和创建两个接口。查询接口注意要包含标签和作者信息import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure, } from ~/server/api/trpc; export const snippetRouter createTRPCRouter({ getAll: publicProcedure.query(({ ctx }) { return ctx.db.snippet.findMany({ include: { tags: true, author: { select: { name: true, image: true } }, }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), code: z.string().min(1), language: z.string().min(1), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { const userId ctx.session.user.id; return ctx.db.snippet.create({ data: { title: input.title, code: input.code, language: input.language, authorId: userId, tags: input.tags ? { connectOrCreate: input.tags.map((name) ({ where: { name }, create: { name }, })), } : undefined, }, }); }), });然后打开src/server/api/root.ts把 snippetRouter 挂进 appRouterexport const appRouter createTRPCRouter({ snippet: snippetRouter, });这一步很多人会漏掉漏掉之后前端怎么调都找不到api.snippet因为根 router 里根本不存在这个路由。前端调用也很简单写一个use client组件直接用脚手架自动生成的api对象use client; import { api } from ~/trpc/react; export function SnippetList() { const { data: snippets, isLoading } api.snippet.getAll.useQuery(); if (isLoading) { return div加载中.../div; } return ( ul {snippets?.map((snippet) ( li key{snippet.id} h3{snippet.title}/h3 code{snippet.language}/code pre{snippet.code}/pre /li ))} /ul ); }这里你能直观看到前面的说法snippets这个变量已经自带全部字段类型写snippet.title有自动补全写错了有标红提示不需要在前后端之间维护任何手动定义的类型。3.4 认证与权限把用户绑定到片段t3code 的创建接口用了protectedProcedure来自src/server/api/trpc.ts。这个封装是 create-t3-app 自动生成的它会检查 session 是否存在不存在就抛错。这比在每个接口里手动判断登录状态优雅太多。如果你没有配置 NextAuth 的 provider页面打开会是白屏或者登录页缺失。最轻量的做法是先配置 GitHub OAuth或者直接用 NextAuth 的 Credentials Provider 做一个简单的账号密码登录。生产环境我更推荐 OAuth 或者托管服务商本地开发测试用 Credentials 就够了。配置 NextAuth 需要三个环境变量放在.env里NEXTAUTH_SECRET这里填一个足够长的随机字符串 NEXTAUTH_URLhttp://localhost:3000生成随机字符串一行命令搞定openssl rand -base64 32认证这块和 tRPC 的联动重点是看trpc.ts里的 context 逻辑。它把session注入到 context这样每个 procedure 都能拿到ctx.session。在 t3code 里我把 session 的 user 信息当成数据的归属者snippet 创建时直接写入authorId而不是让前端传——这个细节再次体现了类型安全和安全性的双重优势前端根本不存在修改归属者的可能性。3.5 把项目跑起来环境变量与常用命令开发过程中的高频命令我整理在这张表里按使用频率从上到下排命令作用备注pnpm dev启动开发服务器默认 3000 端口pnpm prisma migrate dev --name 变更名开发环境执行数据库迁移每次改 schema 后必须执行pnpm prisma generate重新生成 Prisma Client如果出现类型不对先跑这个pnpm prisma studio可视化查看数据库表调试数据非常有用pnpm build生产构建发布前必跑能提前发现类型错误我强烈建议养成一个习惯每次打开 t3code 项目先看.env里有没有缺少环境变量create-t3-app 默认不会替你写好完整的.env只有一个.env.example需要复制一份再改。4. 踩坑记录t3code 开发中绕不开的五个问题下面这些问题都是我在实际搭建 t3code 过程中真实遇到过的有些花了我半个晚上才排查清楚记录下来省得你重蹈覆辙。4.1 tRPC 类型对不上报错先自查 router表现前端写了api.snippet.createTypeScript 报Property create does not exist。这个坑的典型原因是把 router 写好了但忘了挂载到根 router。也就是我在 3.3 节强调的那个细节。另一个可能原因是改了后端 procedure 的 input 类型前端的useQuery或useMutation没重新拉取类型。重启 dev server或者跑到 t3code 目录下pnpm build一次看编译器怎么报。经验法则前端类型报错时先检查 router 文件有没有导出、有没有挂载再检查是不是忘了pnpm prisma generate。这三个理由占据了 tRPC 类型报错的 90%。4.2 Prisma 迁移失败先把 dev.db 清掉表现prisma migrate dev报错说数据库存在不一致常见于改了 schema 之后又重新调整字段。本地开发时不需要保守。SQLite 迁移失败的常规操作是直接删掉prisma/dev.db和prisma/migrations目录然后重新迁移。别担心数据丢失开发库本来就是拿来折腾的。如果是 PostgreSQL就别删数据库这么粗暴了用prisma migrate reset来重置这个命令会把数据清空。还有一个小tip每次改完 schema 跑迁移之前先在pnpm prisma validate校验一遍 schema 语法这个命令 5 秒不到能过滤掉一半的迁移错误。4.3 页面刷新出现 hydration error表现在 Windows 上跑刷新页面时浏览器控制台出现Hydration failed because the server rendered HTML didnt match the client。常见原因是服务端渲染和客户端渲染结果不一致。比如你用new Date()渲染当前时间服务端拿到的和客户端拿到的不同。或者某个组件依赖了localStorage首次渲染时服务端没有这个 API。t3code 这种全栈项目里出现最多的 hydration 问题是数据在服务端拿到的顺序和客户端不同。排查方式很朴素先看报错里指出的是哪个组件然后检查这个组件里有没有非确定性的渲染内容。实在急的话可以用下策——在next.config.js里把出问题的页面强制dynamic force-dynamic但这只是绕路真正的解决办法是让组件内容在挂载后再渲染。4.4 环境变量缺失隐蔽且致命表现登录页打不开、Prisma 连不上数据库、build 时提示Missing NEXTAUTH_SECRET。create-t3-app 生成的项目带有.env.example但不会自动生成.env并把所有变量填好。我自己就经历过一次代码完全是好的就是忘了复制.env.example到.env结果跑了半天也不知道哪里有问题。这里的建议是把环境变量的检查前置项目一创建就复制.env.example马上填好DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL再去跑pnpm dev。别等到写着写着功能出问题再回头查环境变量排查成本高得多。4.5 Server Component 里别直接调 tRPC表现我在服务端组件里写了api.snippet.getAll.useQuery()然后报错说 hooks 只能在客户端组件里使用。这个坑理解起来不难但遇到时会愣一下。App Router 中服务端组件在服务器上执行不经过浏览器而 tRPC 的useQuery是 React Query 的 hook依赖客户端运行时状态。两者不能混用。服务端组件想获取数据有两条路一是用 tRPC server 端调用的方式在服务端直接执行 procedure二是把 tRPC 调用放在use client子组件里页面整体可以仍是服务端组件嵌套一个客户端组件就能搞定交互。我给 t3code 的处理是后者全页面保持 server component 的 SEO 优势只有需要交互的列表操作下沉到客户端组件。4.6 我的使用心得别急着引第三方组件库tdcode 之外单独说一句题外话Tailwind 上手后很多人会想找一个现成组件库比如 shadcn/ui 或者 Material UI。我的建议是如果才开始熟悉这套技术栈先用原生的 Tailwind 原生样式搭一两个页面亲手摸一摸整套链路。等跑通一个完整的从数据库到页面流程再引组件库你会更清楚它替你做了什么而不是一上来就被别人的抽象遮蔽了问题。5. t3code 后续怎么扩展t3code 目前的定位是代码片段管理但它的架构可以很方便地往外扩。如果你把它拿过去做别的工具这里有几个现成的扩展方向。第五加全文检索。目前查询只支持简单的列表没有搜索关键字的功能。Prisma 在 PostgreSQL 下支持全文检索加了它之后代码片段就能像搜索引擎一样被搜出来这个功能对“代码库管理”场景几乎是刚需。第五做分享链接。代码片段这类内容天然适合变成公开分享页登录用户生成一个公开链接未登录访客也能查看。实现起来就是加一个shareToken字段加一个公开查询 procedure页面路由用动态参数难度不大但实用性立刻提升一个档次。第三钉到浏览器扩展。tRPC 的模式适合做浏览器插件的后台插件端直接调同一套 tRPC 接口数据模型完全复用不用为插件单独写一套后端。这是我下一阶段想试的方向。这些都是在这个架构上做加法核心链路已经稳定后面加功能重点都在业务模型而不会在技术选型上返工。这也是 T3 Stack 给我的最大感受选型一次性搭好后面就真的可以专心写业务而不是天天在接缝处补漏。