资讯详情 T3 Stack实战:t3code项目带你打通全栈类型安全
📅 2026/10/26 17:19:20
最近一直在编辑器里捣鼓一个叫 t3code 的项目起因很简单我需要一个能快速起新全栈项目的骨架同时又不想在“前后端类型各写一套、联调时对字段对到怀疑人生”这种事上继续浪费时间。如果你关注过 Next.js 生态应该听说过 T3 Stack——把 TypeScript、Next.js、tRPC、Prisma、Tailwind CSS 组合在一起的开发范式。t3code 就是我把这套范式真正落地的一个开源示例项目内置了一个代码片段管理工具的核心功能可以当作脚手架直接抄也可以当成一个完整的教学案例来读。适合那些已经会写 React、但还没系统接触过全栈类型安全的中级开发者也适合想看看“T3 全家桶到底怎么配合”的资深玩家。1. 内容整体设计与思路拆解1.1 t3code 到底解决什么问题先说痛点。传统的全栈项目一般是前端一个项目、后端一个项目两边各自定义接口文档、各自维护类型。前端的User和后端的User看起来是同一个概念实际上各写各的。一旦接口字段变更后端改了前端拿到的数据结构和类型对不上轻则页面白屏重则生产环境炸锅。t3code 想解决的问题就是这一个让类型从数据库一路通到浏览器中间不经过任何手工定义的 DTO 层。我最初是抱着“做个玩具”的心态开始的但越做越发现真正的价值不在功能本身而在于这套组合拳打下来项目结构会自然变得非常清晰。t3code 的定位不是一个能跑就行的小 demo而是一个“可以直接作为下一个项目起点”的工程化模板。它内置了完整的用户体系接入方案、数据模型、鉴权中间件、前后端调用链甚至连部署注意事项都整理进去了。1.2 为什么是 T3 Stack而不是 Vite Express 或 Django很多人问我选型的时候为什么不走 Vite Express 这种更“主流”的路线或者干脆用 Django 这种大而全的框架。我的判断角度比较实际对于以 React 为前端的团队来说语言统一带来的收益远大于框架多样性带来的灵活性。如果前端用 TypeScript后端用 Python 或 Java那么前后端之间就永远隔着一层“协议翻译”的硬伤。即使是 OpenAPI 自动生成代码也只能解决字段定义一致的问题解决不了运行时类型判断和端到端重构的体验问题。t3code 选择 TypeScript 一统前后端配合 tRPC 让 API 调用就像调用本地函数一样连类型推断都是自动的。一个字段改了前端马上能看到报错这就是我想要的开发体验。另一个原因是部署心智负担。Vite Express 这种组合虽然灵活但部署得分别处理静态资源和 Node 服务域名、跨域、证书一堆事。Next.js 一个应用搞定 BFF 和前端Vercel 上直接一个仓库走完部署流程省掉的运维成本非常可观。对于中小型项目和独立开发者这是实打实的效率提升。1.3 项目结构的组织方式t3code 没有用 monorepo而是标准的单包 Next.js 应用。这里我不是反对 monorepo而是想说如果你的项目规模没到需要多个独立服务协同部署的程度monorepo 的收益是负的。单包结构下src目录天然分成两块逻辑server是 Node 侧跑的东西trpc路由、数据库访问、鉴权逻辑都在这里app是 Next.js 应用目录服务端组件和客户端组件共存。tRPC 的createTRPCContext负责把 Next.js 的请求对象转成 tRPC 上下文router 定义好之后前端通过trpc-react的 hooks 直接调用。结构拆好了之后开发时的“心智模型”非常舒服不用想“这个接口是哪个服务的”只需要想“这个数据属于哪个 router”。数据模型归 Prisma 管路由归 tRPC 管页面归 Next.js 管各司其职没有谁越界。2. 核心细节解析与实操要点2.1 TypeScript 配置严格模式不是用来摆设的t3code 的 TS 配置直接拉满strict: true、noUncheckedIndexedAccess: true、exactOptionalPropertyTypes: true。很多人嫌严格模式烦人但我实测下来T3 这套组合恰恰需要在最严格的类型约束下才能发挥最大价值。noUncheckedIndexedAccess是最容易被忽视的一个选项。它的作用是让你访问数组元素或对象属性时TypeScript 会认为结果有可能是undefined强制你去做判断。比如const tags await prisma.tag.findMany(); const firstTag tags[0]; // 类型是 Tag | undefined不判空直接用会报错这个约束在写代码的时候确实烦但它在运行时保护了你。很多线上 bug 都是“数据比预期少一条”导致的这个配置能直接在编译阶段拦住一大半。exactOptionalPropertyTypes也很关键。它让?: string和string | undefined区分开来你不能随手把一个显式的undefined赋给一个可选属性。这个约束会逼着你更精确地表达数据状态尤其在处理表单提交数据时特别有用。2.2 tRPC 的工作原理与请求链路tRPC 是 t3code 里最核心的一块理解它就理解了整个前后端通信方式。它有五个基本概念procedure、router、context、middleware、caller。Procedure 是 tRPC 的执行单元分 query查询、mutation修改、subscription订阅三种。Router 是 procedure 的集合相当于传统后端里的 Controller。Context 是每次请求的初始数据通常包含数据库客户端和当前登录用户。Middleware 在 procedure 执行前后做拦截鉴权逻辑几乎都在这里写。一个典型的调用链是这样的用户在前端点击按钮trpc.snippet.create.useMutation()被触发请求通过 HTTP 发到 Next.js 的 API 路由tRPC 的适配器把请求转换成 context 对象中间件检查用户是否登录然后执行create这个 procedure 里的逻辑最后把结果返回给前端。全程不需要手工写任何 API 路由定义不需要为每个接口写参数校验类型这些都是由 tRPC 从一个 router 定义中自动推断出来的。实际操作里我最常用的写法是import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const snippetRouter createTRPCRouter({ create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default(plaintext), })) .mutation(async ({ ctx, input }) { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, createdBy: ctx.session.user.id, }, }); }), });注意到protectedProcedure已经帮你做了登录校验input用了 zod 在运行时校验参数格式。一次定义前后端类型全通这就是 tRPC 的核心体验。2.3 Prisma 数据建模与迁移策略Prisma 在 t3code 里承担数据访问层的职责。它的模式文件schema.prisma是唯一的数据真相来源表结构、关系、索引都在这里定义。以代码片段管理为例设计了三张核心表User、Snippet、Tag。用户和片段是一对多片段和标签是多对多。多对多关系通过隐式关联表实现Prisma 会自动维护不需要手工建表。关键设计细节是json字段的使用。有时候片段需要保存编译结果或附加元数据这种结构不固定的数据用Json类型比拆成多张表更务实。Prisma 的Json类型默认会在 TypeScript 里被推断成精确的结构你仍然能拿到类型提示。迁移策略上开发环境我推荐直接用prisma db push它会直接同步 schema 到数据库快不用生成迁移记录。一旦要上生产才改用prisma migrate dev --name init生成正式迁移文件。t3code 初始化时我预置了一个种子脚本跑完prisma db seed后会有几个测试用户和一批示例片段方便大家直接体验前端效果。2.4 Tailwind CSS 的样式组织Tailwind 在 t3code 里主要管样式看起来和业务没太大关系但实际操作中很多细节值得说。默认配置里我只做了两件定制加了深色模式的支持以及自定义了一组颜色变量。深色模式用next-themes实现样式层通过 Tailwind 的darkMode: class开启。需要注意的一个坑是服务端渲染出来的 HTML 和客户端首帧渲染的 HTML 必须一致否则会有样式闪烁问题。next-themes 提供的script标签会在页面加载早期读取本地存储避免这个闪烁。这个点很多人忽略了结果出现深色模式页面白闪一下才变黑观感非常糟糕。颜色变量方面我把主题色定义为 CSS 变量Tailwind 配置里通过rgb(var(--color-primary) / alpha-value)引用。这样项目后面要换主题、做白名单皮肤时只需要改 CSS 变量不需要动组件代码。3. 实操过程与核心环节实现3.1 初始化项目骨架我用的是 create-t3-app 官方脚手架命令很简单npm create t3-applatest t3code交互式选项里我勾了 Next.js、TypeScript、tRPC、Prisma、Tailwind CSS、NextAuth.js数据库选了 SQLite。注意这里选 SQLite 只是为了开发方便部署到生产时把DATABASE_URL换成 PostgreSQL 的即可Prisma 的代码不用改一行。初始化完之后的目录结构自带一层上下文封装src/server/db.ts里是 Prisma 客户端的单例src/server/auth.ts里是 NextAuth 配置src/trpc/下面是 Router 的根定义。这套脚手架最省事的地方在于你已经有了一个能跑通的“最小闭环”页面能渲染、数据库能连接、API 能调用。3.2 设计数据模型t3code 里最核心的 Prisma 模型长这样model User { id String id default(cuid()) name String? email String unique snippets Snippet[] } model Snippet { id String id default(cuid()) title String content String language String default(plaintext) createdAt DateTime default(now()) updatedAt DateTime updatedAt createdBy String author User relation(fields: [createdBy], references: [id]) stars Int default(0) tags Tag[] } model Tag { id String id default(cuid()) name String unique snippets Snippet[] }这里踩过一个小坑updatedAt字段如果不加updatedAt装饰器Prisma 不会自动更新它每次修改片段后时间戳还停留在创建时。这个字段在做“最近更新排序”时特别有用缺失的话功能就废了。3.3 核心 API 实现tRPC 路由数据模型定好之后接下来是写 API。t3code 里我拆成snippetRouter、tagRouter和authRouter三个子路由最后聚合成一个根路由。tRPC 的子路由聚合非常直观export const appRouter createTRPCRouter({ snippet: snippetRouter, tag: tagRouter, auth: authRouter, }); export type AppRouter typeof appRouter;前端调用时路径就是trpc.snippet.getAll.useQuery()、trpc.tag.list.useQuery()完全和路由结构对应不需要记忆接口字符串。snippetRouter里我重点实现了三个操作列表查询支持多条件筛选语言、标签、关键词都能传。我用了 zod 的optional()实现可选参数再用 Prisma 的where组合list: protectedProcedure .input(z.object({ language: z.string().optional(), tag: z.string().optional(), keyword: z.string().optional(), })) .query(async ({ ctx, input }) { return ctx.db.snippet.findMany({ where: { ...(input.language ? { language: input.language } : {}), ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), ...(input.keyword ? { OR: [ { title: { contains: input.keyword } }, { content: { contains: input.keyword } }, ], } : {}), }, include: { author: true, tags: true }, orderBy: { updatedAt: desc }, }); });这个写法看起来笨但它准确地表达了“条件可选”的语义。一开始我用where: { language: input.language }直接传发现undefined会把该条件忽略但显式传null会查不出数据这个区别很微妙。详情查询用getById这里我给每个片段额外的“上一条/下一条”导航数据。做法是查详情时同时查相邻两条记录用游标方式避免 offset 的深翻页问题getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { const snippet await ctx.db.snippet.findUnique({ where: { id: input.id }, include: { author: true, tags: true }, }); if (!snippet) return null; const [prev, next] await Promise.all([ ctx.db.snippet.findFirst({ where: { createdAt: { lt: snippet.createdAt } }, orderBy: { createdAt: desc }, }), ctx.db.snippet.findFirst({ where: { createdAt: { gt: snippet.createdAt } }, orderBy: { createdAt: asc }, }), ]); return { snippet, prev, next }; });Promise.all在这里很重要串行查询的两次网络往返变成了并行速度肉眼可见地快。这是 Prisma 和 tRPC 配合时非常实用的优化手段。3.4 前端页面与交互集成前端我用的是 Next.js 的 App Router服务端组件负责初始数据读取客户端组件负责交互。t3code 的首页是一个片段列表核心组件叫SnippetList它通过 tRPC 的 React hooks 拿数据use client; import { api } from ~/trpc/react; export function SnippetList() { const { data, isLoading, error } api.snippet.list.useQuery({ language: typescript, }); if (isLoading) return div classNamep-8 text-center text-neutral-500加载中.../div; if (error) return div classNamep-8 text-center text-red-600{error.message}/div; return ( ul classNamedivide-y divide-neutral-200 dark:divide-neutral-800 {data?.map((snippet) ( li key{snippet.id} classNamepy-4 h3 classNamefont-medium{snippet.title}/h3 p classNamemt-1 line-clamp-2 text-sm text-neutral-500{snippet.content}/p div classNamemt-2 flex gap-2 {snippet.tags.map((tag) ( span key{tag.id} classNamerounded-full bg-neutral-100 px-2 py-1 text-xs dark:bg-neutral-800 {tag.name} /span ))} /div /li ))} /ul ); }这里最值得强调的是api.snippet.list这个代理对象上的类型是完整的。如果后端 router 里改了参数名或者返回结构前端这里会直接标红编译错误不需要自己跑一遍接口文档做比对。列表页交互方面我加入了“刷新但保持滚动位置”的逻辑筛选条件变更时用useQuery的keepPreviousData属性保持旧数据渲染等新数据到了再替换。这样切换标签时不会出现列表闪空、滚动跳回顶部的问题这个体验细节很影响用户观感。3.5 关键配置与参数选择运行时依赖里有几个配置我单独拿出来说环境变量和 NextAuth 的 session 策略。.env文件里最关键的几个变量DATABASE_URLfile:./db.sqlite NEXTAUTH_SECRET生成一个足够长的随机字符串 NEXTAUTH_URLhttp://localhost:3000NEXTAUTH_SECRET这个值必须重视生产环境如果不设置NextAuth 每次重启都会生成一个新的临时密钥导致所有用户的 session 失效。我踩过这个坑上线后用户反馈“登录保持不了”排查半天发现是环境变量没配置到位。NextAuth 的配置里我特别把session.strategy设成了database。JWT 策略简单但它把 session 数据存在浏览器 cookie 里无法在服务端做“强制下线”或“踢人”操作。database 策略把 session 记录存在数据库虽然每次请求多一次查询但换来的是可控性和安全性。对于需要后台管理用户的项目这个选择很值。adapter用的是 PrismaAdapter它在 schema 里新增了几个表。如果之前没跑过迁移登录时会直接报表缺失解决办法是重新执行prisma migrate dev或者db push同步 schema。4. 常见问题与排查技巧实录4.1 类型报错tRPC 的两端类型不同步t3code 开发过程中最常撞的问题是改了 router 的 return 类型前端却一直提示旧类型。这个问题的根源在于 TypeScript 的增量编译缓存或者热更新没有正确触发。排查思路分三步看是不是 TS Server 缓存问题。VSCode 里执行TypeScript: Restart TS Server多半能解决。检查appRouter的类型是否更新。根路由文件里导出AppRouter的位置如果有改动需要让编辑器重新解析。终极方案删除.next目录和node_modules/.cache重新npm run dev。这个方法看着粗暴但对脏缓存状态几乎是 100% 有效。还有一个容易忽略的坑router 文件里的protectedProcedure和publicProcedure不能混着改。你在同一个 router 里一会用 public 一会用 protected会导致 ctx 类型联合起来前端拿到的手类型就是User | null逼你到处判空。我的习惯是一个 router 内所有 procedure 的鉴权策略保持一致要么全保护要么全公开。4.2 Prisma 客户端未生成或版本不一致prisma generate在依赖安装时会自动执行但有时候版本冲突会静默失败。现象是你明明改了 schema代码里却拿不到新字段。解决办法是手动执行npx prisma generate如果项目里有多个包管理器锁文件比如同时存在package-lock.json和pnpm-lock.yamlPrisma 会找不到正确的依赖位置这种情况删除多余的锁文件统一用一个包管理器重装依赖。部署到 Vercel 时更大的坑是prisma generate依赖系统架构比如本地是 macOS线上是 Linux生成的原生查询引擎不通用。解决方式是在构建命令里强制指定{ build: prisma generate next build }这样每次部署都会重新生成匹配当前架构的客户端避免运行时PrismaClientInitializationError。4.3 登录状态不稳定session 丢失t3code 里用户在写代码片段时需要登录但测试时经常出现 session 一会儿有效一会儿无效。先排查jwt和database两种策略是否切换过。如果你从 JWT 策略切到 database 策略旧 cookie 里的jwt字段已经过期要手动清一下浏览器的 cookie 再测试。这个很多人没意识到以为是 bug实际上只是新旧策略的残留干扰。另一个坑是 NextAuth 的secret在多个环境间不统一。本地开发、预览环境、生产环境各有一套密钥用户从生产跳到预览环境测试时session 直接失效。正确做法是不同环境各用一套专属密钥不要复制同一个值到所有地方。4.4 列表性能与 n1 查询问题数据量小的时候 n1 问题不明显但数据一多页面响应能慢好几倍。t3code 里我处理查询时用include或select做预加载避免在循环里查数据库。比如渲染 snippet 列表时如果还想去查每个作者的信息不能在 map 循环里prisma.user.findUnique那样会产生 n1 次查询。正确做法是列表查询时加include: { author: true }一次查出关联数据。这个优化是 Prisma 新手最容易忽略的地方也是我把“列表页是否有循环查询”列为 code review 必查项的原因。还有一个性能细节是列表分页。不要用skip加take翻页数据量大时 offset 越翻越慢。t3code 列表页用的是游标分页通过where限定比最后一条记录更新时间更早的数据稳定性和速度都更好。4.5 常见问题速查表问题现象可能原因排查命令 / 操作前端类型提示滞后TS Server 缓存重启 TS Server数据库表缺失schema 未同步npx prisma db push生产环境 Prisma 报错引擎架构不匹配build 命令加prisma generatesession 时灵时不灵策略切换残留清 cookie统一 secret列表加载慢n1 查询改用 include 预加载深色模式闪烁next-themes 未注入确保 ThemeProvider 前有 script热更新后页面白屏.next缓存脏删除.next重跑 dev5. 围绕 t3code 的扩展思考与实践心得5.1 从代码片段管理到通用脚手架t3code 虽然以代码片段管理为例但它的核心价值是提供了一条完整的开发路径怎么组织 tRPC 路由、怎么设计 Prisma 模型、怎么处理鉴权、怎么在 Next.js 里做数据获取。这个路径完全可以平移到其他业务场景。我后来用这套骨架做了一个团队内部的知识库工具表和代码片段管理几乎一样只是字段名换了一下开发周期压缩到了原来的一半。这才是 t3code 最实在的价值——它不是教你写某一个功能而是给你一套能复用的解题框架。如果你也想基于 t3code 扩展我强烈建议从这两个方向动手一是接入全文搜索可以用 Postgres 的全文检索功能不额外引入搜索引擎组件二是做数据导出给用户提供 Markdown 格式的片段导出。这两项功能能覆盖大部分真实使用场景。5.2 测试策略为什么不只写单元测试t3code 里我加了基础测试但没有追求覆盖率 100%。测试策略上我优先保三类数据库 CRUD 的集成测试、tRPC 路由的调用测试、关键页面的端到端测试。数据库层测试用一个独立的 SQLite 文件每个测试用例跑完清空数据避免测试之间互相污染。tRPC 层的测试可以用createCaller直接调用 router不需要起 HTTP 服务非常轻量const caller appRouter.createCaller({ session: mockUserSession }); const result await caller.snippet.list({ language: typescript }); expect(result).toHaveLength(3);端到端测试用 Playwright只覆盖核心路径登录、创建片段、列表展示。剩下的 UI 细节不做自动化因为投入产出比太低。测试不是越多越好关键是覆盖那些“坏了没人知道”的链路。5.3 部署与生产环境的注意事项部署这块踩的坑最实在。第一次部署 t3code 到 Vercel 时数据库用的还是本地 SQLite 文件结果发现线上每次重启数据就丢。后来切到 Vercel Postgres用DATABASE_URL指向线上实例才算真正可用。部署清单我按顺序列一下数据库选型和迁移生产环境必须用 PostgreSQL千万不能用文件型数据库。环境变量注入在平台后台填DATABASE_URL、NEXTAUTH_SECRET注意密钥长度至少 32 位。构建命令调整加上prisma migrate deploy或者prisma db push确保线上表结构最新。域名与回调地址NextAuth 的NEXTAUTH_URL必须和实际访问域名一致否则登录回调会失效。这里要提一个最常见的部署事故schema 有变更但你只next build没跑 migration结果线上表结构还是旧的新代码跑起来疯狂报错。我的做法是把数据库迁移放在构建前执行而不是放在运行时——运行时迁移一旦多实例并发执行会产生竞态条件。5.4 后续扩展方向权限、缓存与实时更新t3code 目前是单用户权限模型扩展方向我排了优先级。第一是高阶权限控制某人创建片段后只有本人和特定协作者能编辑。这个可以扩展 NextAuth 的 JWT 或 database session 数据在 context 里加角色字段。第二是查询缓存目前每次列表查询都直接打数据库。数据量上去之后可以在 tRPC 外层套一层缓存策略或者在 Prisma 层直接设置连接池参数。最简单有效的优化是 Redis 缓存列表接口缓存键按“语言 标签 页码”拼接失效策略设 60 秒。第三是实时更新如果有协作文档类的需求tRPC 支持 subscription配合 WebSocket 传输器可以做实时编辑提示。我在一个实验分支上试过用 subscription 推“其他人正在查看这个片段”的提示体验相当流畅但要注意连接数和鉴权的处理复杂度会上升。5.5 个人体验这套组合到底值不值得搬进你的项目做了几个月的 t3code我的总体感受是T3 Stack 不适合所有项目但对“全栈 TypeScript 兴趣者 Next.js 重度用户”来说它是目前体验最顺畅的一组方案。它的上限取决于你对 TypeScript 的掌握程度。类型系统越强tRPC 的自动推导越能发挥威力。如果你对 TS 的泛型、条件类型、satisfies这些特性不熟前期的开发速度可能还不如 Django DRF 来得快。但一旦跨过门槛后期的重构效率是碾压级别的。它的下限取决于你对业务边界的设计能力。t3code 的目录结构干净是因为我把边界切得很明确Prisma 只管数据、tRPC 只管接口编排、Next.js 只管渲染。你如果不做分层把所有逻辑都堆在组件里再好的架构也救不了你。另外还有一个建议数据库选 SQLite 起步不是不行但如果预见到多人协作和多实例部署我一律建议项目伊始就上 Postgres。开发时可以用 Docker 起一个本地 Postgres成本比想象中低。熟练之后你会觉得这套组合就是“少写很多胶水代码”的快乐机器。最后分享一个日常开发中让我效率提升特别多的习惯t3code 的.env里故意不放任何真实密钥所有环境变量一律用.env.example维护默认值新设备 clone 下来先 copy 一份再填自己的值。配合 Prisma 的 seed 脚本一条命令就能把开发环境从零跑到全绿省掉的沟通和排错时间非常可观。如果你想深入这套方案建议直接拿 t3code 的代码跑一遍把增删改查、登录、部署从头到尾走通一次。亲手把链路理顺之后你对 T3 Stack 的理解会从“听说过”变成“拿得出手”以后无论做个人项目、接外包还是带团队起服务这套骨架都能帮你省下大量起步时间。