
最近把团队内部的工具站重构了一遍项目代号就叫t3code。很多人看到名字第一反应是这不就是 T3 Stack 嘛其实不完全是——T3 Stack 只是技术底座真正让我花心思的是整个项目从立项、数据建模、接口设计到生产部署那一整套链路的东西。断断续续做了六周中间踩了十几个坑也推翻过两次技术方案最后沉淀出一套可以直接复用的全栈工程模板。这篇文章就是 t3code 项目的完整复盘。如果你正准备用 Next.js 全家桶做全栈应用或者在犹豫要不要上 tRPC Prisma 这套组合读完之后你应该能少走不少弯路。我尽量按真实的时间线来写先讲为什么这么选型再讲每个核心环节怎么落地最后把生产环境里遇到过的经典问题一次性列清楚。1. 项目定位与整体设计思路1.1 t3code 到底是个什么项目t3code 的目标很简单做一个面向团队内部的代码片段管理工具。每个团队都有这种烦恼——攒了一堆常用脚本、配置片段、正则表达式、SQL 语句散落在微信收藏、本地便签、GitHub Gist 和个人博客里找的时候翻半天用的时候还得改半天。这个工具要解决的就是统一沉淀、快速检索、一键复制三件事。前端要够轻够快后端要能支持多人协作最核心的是要支持全文搜索和标签分类。听起来不复杂但真正做的时候够用和好用之间的差距就出来了。1.2 从零选型为什么是 Next.js 而不是纯前端或传统后端一开始我其实想用 Vue3 FastAPI 的方案。团队里有人对 Python 更熟而且 FastAPI 写 CRUD 确实快。但后来算了一笔账我们需要用户系统、数据库访问、API 层、管理后台如果前后端分离至少得维护两个服务还要解决跨域、接口文档同步、部署编排这些问题。换个思路用 Next.js 一个应用全包前后端代码在一个仓库里类型还能打通。对于 t3code 这种规模的项目边际成本明显更低。再加上 Next.js 的 App Router 成熟度已经够高服务端组件、路由缓存、流式渲染这些能力都是现成的没必要再自己拼一套 BFF 层。1.3 最终技术栈全貌定下来之后整个技术栈长这样层选型说明框架Next.js 14 (App Router)服务端渲染 API Route语言TypeScript 全栈前后端共享类型定义数据层Prisma PostgreSQLORM 关系型存储API 层tRPC v10端到端类型安全认证NextAuth.jsGitHub OAuth 邮箱登录样式TailwindCSS shadcn/ui原子化 CSS部署Docker 单机 VPS方便迁移和备份这个组合就是常说的 T3 Stack。我选它的核心理由只有一个端到端的类型安全。从数据库表结构到前端组件的 props全程由 TypeScript 类型链牵着走改一个字段名编译期就能把所有用到的地方报出来这在以前的前后端分离项目里想都不敢想。2. 核心细节解析与实操要点2.1 tRPC 为什么能替代 REST API很多没接触过 tRPC 的人会问不用 REST 不别扭吗我的答案是对于内部 CRUD 系统REST 的规范感其实是负担。写 REST 你要想资源怎么命名、动词怎么映射、嵌套层级怎么设计、错误码怎么统一每层都有大量约定要维护。tRPC 的思路完全不同你直接在服务端定义一个函数前端像调用本地函数一样调用它。没有 URL 拼接没有 DTO 序列化没有接口文档维护。所有输入输出的类型都是推断出来的前端写错参数名IDE 立刻给你标红。以 t3code 里的创建片段接口为例REST 写法要定义POST /api/snippets、SnippetCreateRequest、SnippetResponse三个东西还要写错误处理中间件。tRPC 里就一个函数create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().default(text), tags: z.array(z.string()).default([]) })) .mutation(async ({ ctx, input }) { return ctx.prisma.snippet.create({ data: { ...input, authorId: ctx.session.user.id } }) })前端调用长这样const createSnippet api.snippet.create.useMutation() await createSnippet.mutateAsync({ title: Golang time 格式化, content: 2006-01-02 15:04:05, language: go })看到了吧前后端共享的 Zod schema 就是双端的合同。后端改了校验规则前端类型立刻变永远不可能出现前后端对齐不了的接口问题。这是 t3code 整个项目里最值得的一部分。2.2 数据库建模Prisma Schema 的设计心法Prisma 最爽的地方是 Schema 即文档。t3code 的核心表就三张User、Snippet、Tag外加一个SnippetTag中间表做多对多关联。设计 Schema 时有几个点我特别想说第一所有外键关系都要显式命名。默认生成的名字虽然能用但可读性差。我习惯在字段上指名关联model Snippet { id String id default(cuid()) title String content String db.Text language String default(text) author User relation(fields: [authorId], references: [id]) authorId String tags SnippetTag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt }第二全文搜索别急着上 PostgreSQL 的 tsvector。我一开始用 Prisma 的contains做模糊查询数据量小的时候没问题等片段超过 3000 条之后明显变慢。后来给 PostgreSQL 加了全文索引用 raw query 跑搜索速度从几百毫秒降到十几毫秒。第三CUID 比自增 ID 更适合这种场景。自增 ID 会暴露数据量而且分布式迁移容易冲突。CUID 不需要额外依赖Prisma 内置支持直接用。2.3 认证系统NextAuth 集成需要留意的三个坑NextAuth 是 T3 Stack 的默认认证方案但集成它绝不是照着文档抄一遍那么简单。我在 t3code 里用的是 GitHub OAuth 邮箱魔法链接实际对接时遇到过三个比较深的坑坑一Session 策略必须显式声明。如果不写session: { strategy: database }默认是 JWT 模式。JWT 模式下 Prisma 的session表完全没用到查 session 永远是空自己排查起来很懵。坑二回调地址必须在 GitHub 后台正确配置。开发环境的回调是http://localhost:3000/api/auth/callback/github生产环境又是另一个域名这两个要分别配。我没注意第一次部署后所有登录都跳 500排查半天才发现是回调白名单问题。坑三中间件里一定要判断 session 是否为 null。NextAuth 的getServerSession在未登录时会正常返回 null不会抛异常。如果你在页面里直接用session.user.id线上会直接炸。我写了一个protectedProcedure封装const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }) } return next({ ctx: { user: ctx.session.user } }) })所有需要登录的接口都走这个 procedure未登录请求统一返回 401前端再配合跳转登录页。3. 实操过程与核心环节实现3.1 create-t3-app 初始化与目录结构规划说实话这个项目没有从零手写脚手架直接用create-t3-app生成再按需精简。生成命令很简单npx create-t3-applatest t3code交互式选项里我勾了 Next.js、TypeScript、Prisma、tRPC、NextAuth、Tailwind没勾 ESLint 之外的附加项。生成后的目录结构很清晰t3code/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ ├── app/ │ │ ├── api/ # API routes基本没用到 │ │ ├── snippets/ # 页面路由 │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ # tRPC router 定义 │ │ ├── auth.ts # NextAuth 配置 │ │ └── db.ts # Prisma client 实例 │ └── trpc/ │ ├── server.ts # tRPC 服务端初始化 │ └── client.ts # 前端调用端这个结构最大的好处是src/server和src/trpc分得很清楚服务端代码不会混在 UI 组件里。后面加新模块只需要在src/server/api/routers下建新文件然后在根 router 里挂载。3.2 从数据库到页面的完整链路拿 t3code 最核心的列表页来走一遍全流程新手朋友可以按这个链路理解整套架构数据库层Snippet表存片段数据Tag表存标签中间表关联。服务端逻辑在snippetRouter里写一个getAllquery关联 tags。服务端类型通过inferRouterOutputs自动导出类型。前端数据获取用api.snippet.getAll.useQuery()在服务端组件里 fetch 数据。getAll的完整实现是这样的getAll: protectedProcedure.query(async ({ ctx }) { const snippets await ctx.prisma.snippet.findMany({ include: { author: { select: { name: true, image: true } }, tags: { include: { tag: true } } }, orderBy: { updatedAt: desc } }) return snippets })前端页面直接const { data, isLoading } api.snippet.getAll.useQuery() if (isLoading) return SkeletonList / return ( div {data?.map(snippet SnippetCard key{snippet.id} snippet{snippet} /)} /div )你注意没有这里没有 Promise、没有 fetch、没有 JSON 序列化跟调用本地函数一样。数据从 PostgreSQL 出来后经过 Prisma 转成对象tRPC 序列化前端拿到的时候类型完整字段名完全一致ISnippet接口是自动推断的不需要手写 model。整个链路少了两类 bug字段名拼错的 bug以及类型不匹配的 bug。3.3 全文搜索的落地方案前面提到搜索是核心功能。我踩过的方案有两版第一版用 Prisma 的contains做 LIKE 查询where: { OR: [ { title: { contains: keyword } }, { content: { contains: keyword } } ] }数据量到 3000 条左右这个查询就要 400ms 以上。接近生产标准之前我把它换成了 PostgreSQL 全文检索-- 在片段表上加 generated column 存储 tsvector ALTER TABLE Snippet ADD COLUMN searchVector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector(simple, coalesce(title, )), A) || setweight(to_tsvector(simple, coalesce(content, )), B) ) STORED; CREATE INDEX snippet_search_idx ON Snippet USING GIN (searchVector);然后给 this 字段建了 GIN 索引tRPC 路由里查询时用 raw sqlsearch: protectedProcedure .input(z.object({ keyword: z.string().min(1) })) .query(async ({ ctx, input }) { const results await ctx.prisma.$queryRaw SELECT * FROM Snippet WHERE searchVector plainto_tsquery(simple, ${input.keyword}) ORDER BY ts_rank(searchVector, plainto_tsquery(simple, ${input.keyword})) DESC LIMIT 20 return results })改完之后搜索响应稳定在 30ms 以内完全够用。这个改动的经验是当 ORM 性能遇到瓶颈时不要硬扛普通 SQL 反而是更好的补充工具。3.4 部署流程与容器化改造部署这块我没有选 Serverless 平台因为团队代码库里有私有依赖而且要给客户提供交付包。最后的方案是一台 4C8G 的云服务器 Docker Compose三步搞定写一个多阶段构建的Dockerfile构建阶段用 Node 18 装依赖并 build运行阶段用 debian slim 只跑.next/standalone。写docker-compose.yml编排 app 容器 postgres 容器加上 volume 持久化数据。配 Nginx 反向代理把 80/443 流量转到 Next.js 容器的 3000 端口。其中有一个必须注意的坑Next.js 的 standalone 输出默认不会打包public目录和.next/static。我一开始没留意部署后发现页面样式全丢了。解决办法是在 Dockerfile 里显式拷贝COPY --frombuild /app/public ./public COPY --frombuild /app/.next/static ./.next/static这个坑让我折腾了一个晚上写出来给后来的朋友提个醒。4. 常见问题与排查技巧实录4.1 tRPC 类型报错server 和 client 的配置必须一致新手最容易遇到的就是trpc/server和trpc/client版本不一致或者AppRouter类型没有从根 router 导出。tRPC 的类型安全依赖 TypeScript 的声明合并router 类型没正确导出前端api对象就会变成 any写代码时完全不报错但运行时就是 404。我的经验是改 tRPC 配置时永远先检查src/trpc/server.ts和src/trpc/client.ts的createTRPCNext是否从同一个根 router 导入。最简单的方式是直接跑tsc --noEmittRPC 类型检查通常能把问题全列出来。4.2 Prisma 连接池耗尽开发环境常见但容易被忽略PostgreSQL 默认连接数上限约 100。Prisma 客户端默认连接池是connection_limit1这个值太小并发一上来就报P1001。我把连接池调到了 10同时加了一层超时控制DATABASE_URLpostgresql://user:passlocalhost:5432/t3code?connection_limit10connect_timeout5连接池大小不是越大越好。4核8G 的机器Prisma 官方推荐连接池是 CPU 核数乘以 2 左右也就是 8 左右。调太大反而容易把数据库打挂。4.3 NextAuth 在生产环境登录后白屏这个问题排查了很久。开发环境一切正常生产环境点了 GitHub 登录之后浏览器跳回来却是白屏console 里只有一句TypeError: Cannot read properties of undefined (reading split)。最后定位是NEXTAUTH_SECRET 未设置。NextAuth 在开发模式会给一个临时的 JWT 密钥生产环境必须自己制定。我直接在环境变量里加了一个固定值openssl rand -base64 32程序上可能还有其他原因但九成情况下登录白屏都是在 NEXTAUTH_SECRET 上出了问题。4.4 常见问题速查表把 t3code 开发过程中最有代表性的问题整理成表格方便大家直接对照排查问题出现时机根因解决方案前端 api 对象是 any配置 tRPC 后根 router 类型未正确导出检查 server/client 是否共用同一 AppRouter 类型登录回调 500生产环境首次登录GitHub OAuth 回调地址未加白名单在 GitHub App 设置里加完整回调路径登录后白屏生产环境NEXTAUTH_SECRET 未配置生成 32 位随机字符串写入环境变量Prisma 报 P1001并发量上升后连接池过小connection_limit 调整为 8~12加 connect_timeout样式全部丢失Docker 部署后standalone 未拷贝 public/staticDockerfile 中显式 COPY页面服务端渲染很慢列表页Prisma 查询无缓存重复请求打库对不常变的数据加unstable_cache或 React cache4.5 我最终沉淀的工程习惯项目收尾时我把一些散落的经验整理成了几条铁律现在放在团队的新项目规范里任何新接口都先写 Zod schema再写实现。输入输出的边界一开始就卡死比事后补校验省心得多。不要在页面组件里直接访问ctx.prisma永远走 tRPC。这样前端永远感知不到数据库的存在未来把服务端拆出去时页面不用改。凡是可能变动的配置一律环境变量化。包括数据库地址、OAuth 密钥、搜索开关都不要硬编码。每次 Schema 变更都写一个 migration并提交到仓库。团队其他人克隆项目后直接prisma migrate dev就能复现数据库比导出 SQL 再执行靠谱得多。5. 性能优化与后续演进思路5.1 响应速度优化从 800ms 到 120mst3code 的列表页加载完在 800ms 左右对内部工具来不能说不可用但我还是做了两轮优化。第一轮是 React cache。Next.js 14 自带React.cache()可以缓存服务端的数据请求同一个请求在同一个渲染周期内只会执行一次。对 t3code 这种大量重复请求的场景很有帮助代码改动也少import { cache } from react export const getSnippetsForUser cache(async (userId: string) { return prisma.snippet.findMany({ where: { authorId: userId } }) })第二轮是 tRPC query 的 staleTime。React Query 默认的staleTime是 0意味着每次切页面都会重新请求服务端。设为 60 秒后用户在 1 分钟之内的重复访问基本不打后端体验提升非常明显api.snippet.getAll.useQuery(undefined, { staleTime: 60_000 })两轮优化之后列表页的实际加载时间降到 120ms体感上是秒开。5.2 未来的扩展方向t3code 目前只覆盖了代码片段整个架构跑通之后扩展成本其实很低。比如可以把标签管理扩展成语义分类可以加评论和收藏功能甚至可以把某个片段一键导出成 Gist 格式。因为 tRPC 的 router 是模块化的新增一个业务模块基本就是复制粘贴 router 新增一个页面组件的事。不过这些都是后话了。现在最值得做的还是把基础打好。t3code 的技术栈在很长一段时间内都会是团队做全栈项目的第一选择。5.3 给新人的最后的建议如果你是我这样的经验值想用 t3code 或 T3 Stack 这套东西做自己的第一个全栈项目我的建议是第一别犹豫直接用 create-t3-app 生成。从空文件夹开始手写脚手架只会消耗热情脚手架的默认配置已经相当合理先把业务做出来再慢慢理解每一层。第二代码量少的时候多写注释。tRPC 的类型安全会掩盖很多学习成本等你回头翻代码的时候会发现当时这个函数能给前端调用这个状态就是最宝贵的理解节点用注释把它锁住。第三永远保留一条从 Prisma Studio 手动查数据的路径。t3code 开发期间我无数次要确认数据库里到底有什么npx prisma studio这个命令给我省了大量调试时间。我个人在这整个项目里最大的体会是所谓技术选型比速度更重要的是心智负担。T3 Stack 把类型安全做到底我在编码时可以更少地思考这里会不会出错更多地把脑力留给真正的业务逻辑。如果你也在做一个偏内部、重协作、需要快速迭代的全栈项目t3code 这套工程模板值得直接抄走。