
最近在搞一个内部工具平台代号叫t3code。名字听起来挺玄乎其实拆开就一句话一个基于 T3 Stack 的全栈 TypeScript 项目样板把 Next.js、tRPC、Prisma、Tailwind 四件套揉在一起让你从头到尾只用 TypeScript 一种语言写完整条业务链路。这个项目折腾下来我最大的感受是类型不是约束是最高效的沟通工具。以前写前后端接口文档、类型对拍、联调排错每一步都在消耗注意力。现在 tRPC 把 API 变成类型安全的函数调用前端调后端像调本地方法一样写错了编译期就炸给你看根本不会带到浏览器里。这篇文章我就用t3code这个项目作为例子把从技术选型到落地实施的完整过程拆开讲透包括目录怎么组织、数据库模型怎么设计、tRPC 路由怎么划分、认证怎么做、有哪些绕不开的坑。适合正在学习全栈 TypeScript、打算用 Next.js 搭中大型项目、或者想把自己那套开发模板体系化的人。1. 整体设计与技术选型思路1.1 项目到底要解决什么问题t3code说白了是我给自己团队用的一个代码片段管理平台后来加上了团队分享和在线运行的功能。其实这种轮子网上多得是为什么还要自己造一个因为市面上现成的方案都存在同一个问题要么数据存在别人服务器上要么类型不通透要么 UI 改起来跟做手术一样。我的需求很简单代码片段支持多语言语法高亮按标签和项目维度分类。支持创建人和团队成员权限区分成员可以看和用只有创建人能改。有一个极简的在线运行入口可以直接跑 JavaScript 和 Python 的短脚本。所有操作最好在同一个技术栈里搞定不要前端一套、后端一套、数据库又一套。这些需求拆解下来技术选型的空间其实很大但我最终选了 T3 Stack没有别的原因就是它把“类型安全”从 promise 变成了默认值。T3 Stack 是 Theo 提出的全栈 TypeScript 组合核心是组件职责为什么需要它Next.js前端框架 Server Side API 路由App Router 天然支持 RSC 和服务端逻辑tRPC前后端 API 调用层端到端类型安全不需要 codegenPrismaORM 和数据库迁移类型生成与 schema 同步写起来最省心Tailwind CSS样式方案原子类效率高配合组件封装不容易踩样式坑说句实在话这个组合的强不在于某个单独工具而在于每一层之间都有一层类型映射Prisma 生成的类型可以直接用进 tRPC 的输入输出tRPC 的返回类型可以直接被前端组件消费整个链路中间没有“手写的 DTO”这种额外负担。1.2 为什么选 tRPC 而不是 REST 或 GraphQL写这个项目之前团队里其实有过一波讨论有的人倾向 REST OpenAPI有的人想直接上 GraphQL。最后我拍板用 tRPC核心原因是这类项目 90% 的业务都是内部 API不需要对外暴露。如果你做的是开放平台、需要给第三方开发者调用那 REST 带上 OpenAPI 文档是合理的。但像t3code这种团队内部系统最大的痛点是前后端联调和类型同步而 tRPC 用最朴素的方式解决了它后端定义 router前端引入该 router 的 type直接调用。没有 codegen没有 schema 文件没有手写的请求体和响应体类型。拿createSnippet这个 mutation 举例。后端写import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const snippetRouter createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), language: z.enum([typescript, python, javascript, bash, sql]), code: z.string().min(1), tags: z.array(z.string()).default([]), projectId: z.string().optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.snippet.create({ data: { title: input.title, language: input.language, code: input.code, tags: input.tags, project: input.projectId ? { connect: { id: input.projectId } } : undefined, createdBy: { connect: { id: ctx.session.user.id } }, }, }); }), });前端调用import { api } from ~/trpc/react; const createSnippet api.snippet.create.useMutation(); await createSnippet.mutateAsync({ title: 防抖函数, language: typescript, code: export function debounce(fn, delay 300) { ... }, tags: [utils], });ctx.session.user.id是服务端中间件注入的前端根本碰不到但返回的 snippet 对象类型却精确到字段级别。这就是端到端类型安全。如果换成 REST这种场景下你需要手写 ApiClient、Response 类型、错误处理时间一长接口一多必然有类型不同步的时候。用 GraphQL 也行但为了一个内部系统引入 codegen 链路我觉得得不偿失。1.3 目录结构设计背后的思考t3code的目录结构沿用了 T3 Stack 的标准约定但我在团队项目基础上做了几个关键调整src/ app/ (auth)/ # 登录注册相关页面 (main)/ # 主界面布局 dashboard/ snippets/ projects/ api/ trpc/ [trpc]/route.ts # tRPC HTTP 入口 server/ api/ routers/ snippet.ts project.ts user.ts runner.ts root.ts # 汇总所有子路由 trpc.ts # createTRPCContext / createCallerFactory auth.ts # NextAuth 配置 db.ts # Prisma Client 实例 trpc/ react.tsx # React Query 封装 server.ts # Server Component 中调用 tRPC 时用 lib/ utils.ts # 通用工具函数这里有个很多人一开始会忽略的设计点src/trpc/react.tsx和src/trpc/server.ts必须分开。react.tsx里导出的是带 hooks 的 tRPC React Query 实例server.ts里导出的是服务端 caller两者都从同一个AppRouter类型推导但运行时行为完全不同。你在 Server Component 里用api.snippet.list()直接调用不用走 HTTP在客户端组件里用useQuery走 HTTP 且带缓存、重试、失效等能力。2. 核心开发流程与实操要点2.1 从 create-t3-app 起步创建项目是最没意思但也最容易埋坑的第一步。我用的是官方脚手架npx create-t3-applatest t3code交互式选择时我建议勾上NextAuth、Prisma、Tailwind先把 TypeScript 勾上这是默认的。ESLint和Prettier也建议直接要因为后文代码格式问题会让 review 很痛苦。Docker 可以不选但如果你不想在自己电脑上装 PostgreSQL那就带上一条docker compose up -d起来一个库特别省事。这个脚手架出来的默认结构非常规整但它有一个坑把 tRPC 放在了src/server/api里而 App Router 的入口放在src/app/api/trpc/[trpc]/route.ts。很多人第一次看会懵其实这个[trpc]是 tRPC 的请求路由所有/api/trpc/xxx.yyy的请求都会进到这个 handler 里。注意你不能随便改这个路径否则客户端调用地址会对不上。2.2 数据库模型设计t3code的数据模型一开始很简单我建了四张表model User { id String id default(cuid()) name String? email String unique image String? snippets Snippet[] projects Project[] } model Project { id String id default(cuid()) name String createdAt DateTime default(now()) members User[] snippets Snippet[] } model Snippet { id String id default(cuid()) title String language String code String createdAt DateTime default(now()) updatedAt DateTime updatedAt tags String[] createdBy User relation(fields: [createdById], references: [id]) createdById String project Project? relation(fields: [projectId], references: [id]) projectId String? } model RunRecord { id String id default(cuid()) snippetId String? language String code String output String? status String createdAt DateTime default(now()) userId String user User relation(fields: [userId], references: [id]) }这里我故意踩了一个 PostgreSQL 的坑tags String[]在 SQLite 上不支持所以必须用 PostgreSQL。如果你用 create-t3-app 时选了 SQLite后面再用数组字段会直接迁移报错。T3 Stack 官方默认配置的是 PostgreSQL这也是为什么我前面强调要用 Docker 起库。设计模型时我建议给所有关联字段加上索引尤其是createdById和projectId因为列表页几乎都会按这些字段做筛选。数据量不大时无所谓但几百条之后就明显感觉查询变慢。迁移命令很简单npx prisma migrate dev --name init npx prisma generate注意prisma generate必须在 schema 修改后执行否则前端拿不到新的类型定义。很多“类型凭空消失”的报错原因就是忘了跑这个命令。2.3 tRPC 路由划分与 API 规范路由划分这个事我和团队反复调整过几次最后形成了个人现在比较满意的规则每个业务域一个文件比如snippet.ts、project.ts、runner.ts。只暴露业务需要的操作不要无脑写 CRUD。比如 snippet 就不提供listAll只提供listMine和listByProject。所有输入用 zod 校验不要偷懒只写z.any()否则类型安全直接破功。返回类型尽量精简不要直接把 Prisma 的完整对象丢过去用select控制字段。举个例子列表页我只需要 title、language、tags、createdAt、authorName 几个字段如果直接findMany()返回整个对象前端会拿到一坨与渲染无关的数据类型一多也是负担。所以我一般这样写listMine: protectedProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) { const items await ctx.db.snippet.findMany({ where: { createdById: ctx.session.user.id }, select: { id: true, title: true, language: true, tags: true, createdAt: true, createdBy: { select: { name: true, image: true }, }, }, orderBy: { createdAt: desc }, take: input.limit 1, cursor: input.cursor ? { id: input.cursor } : undefined, }); let nextCursor: string | undefined; if (items.length input.limit) { const nextItem items.pop(); nextCursor nextItem?.id; } return { items, nextCursor }; }),cursor 分页是我个人比较喜欢的方案比 offset 分页稳定不会因为新插入数据导致重复或丢失数据。这里有个小细节take要比 limit 多取一条用来判断是否还有下一页然后pop掉再返回。这个逻辑第一次写容易漏漏了之后前端拿到多出来的数据会一脸懵。2.4 前后端类型共享的“魔力”t3code开发过程中最爽的时刻就是你改了一个字段整个前端类型检查马上告诉你哪里要改。这得益于 tRPC 的类型导出机制。在src/trpc/react.tsx里有一行export const api createTRPCReactAppRouter();这个AppRouter是从rootRouter的类型里推导出来的前端任何地方import { api } from ~/trpc/react都能拿到完整的路由类型和输入输出类型。改后端前端编译直接报错根本不需要“记得去同步类型”这种心智负担。但这里有个细节Server Component 里不要用api.snippet.listMine.useQuery()。在服务端组件里你只能用api.snippet.listMine()这种直接调用方式因为它不走 React Query而是通过createCaller直接执行。如果混用轻则拿到 undefined重则因为缺少QueryClient而直接白屏。这个坑我已经帮大家踩过了。3. 实操过程与核心环节实现3.1 认证模块与中间件接入认证我用的是 NextAuth配置在src/server/auth.ts。这里有一个很多人忽略的模板流程NextAuth 的配置选项在 create-t3-app 里默认是写在auth.ts里的而src/app/api/auth/[...nextauth]/route.ts会引入它。你想加 GitHub 登录也好、Credentials 登录也好都去改providers数组。我为了内网使用方便加了一个邮箱 GitHub 双登录的方式。注意 NextAuth 的session.strategy我选择的是database而不是默认的jwt。为什么呢因为t3code里有少量用户角色字段需要持久化放在 token 里每次读都很啰嗦直接查库反而更干净。代价是每次请求都多一次数据库查询但对这个体量完全无所谓。中间件这一层我不建议在 Next.js 中间件middleware.ts里做权限判断因为中间件跑在 Edge 环境拿不到 Prisma Client。更靠谱的做法是在 tRPC 的procedure层控制。protectedProcedure就是干这个的export const protectedProcedure createTRPCProcedure() .use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { // 这里可以注入额外字段 userId: ctx.session.user.id, }, }); });用protectedProcedure定义的路由天然携带ctx.userId不需要每个 handler 里再检查 session。权限封装到这一层之后业务代码非常干净。3.2 tRPC 上下文与数据库实例管理createTRPCContext的逻辑在src/server/api/trpc.ts里T3 模板默认写法是从 NextAuth 拿 session然后挂上dbexport const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db, session, ...opts, }; };这个db是src/server/db.ts里导出的单例 Prisma Client。为什么用单例而不是每次创建因为 Prisma Client 底层有连接池和查询引擎每次 new 会重复建立连接初始化效率低还容易把连接数打满。模板里默认的写法就非常规范你不用改。有个小经验如果你的服务跑在 serverless 环境比如 Vercel可能需要在db.ts里对 Prisma Client 做 global 缓存避免热重载时反复初始化。T3 模板已经处理了这个问题但如果你是自己手写的千万别漏。3.3 样式方案与页面骨架Tailwind 这块我不是来科普原子化好处的我要讲的是t3code里如何结合 shadcn/ui 高效搭建界面。shadcn 不是组件库它是一堆可以直接拷进项目的带样式组件源代码基于 Tailwind Radix UI。用它的一个好处是完全可控改样式直接改源码没有包管理的黑盒。以SnippetList组件为例核心逻辑是拿到 snippet 列表然后渲染卡片。这里用 Tailwind 写起来非常迅捷div classNamegrid gap-4 sm:grid-cols-2 lg:grid-cols-3 {snippets.map((s) ( div key{s.id} classNamerounded-lg border border-gray-200 p-4 shadow-sm transition hover:shadow-md div classNamemb-2 flex items-center justify-between span classNametext-sm font-semibold{s.title}/span span classNamerounded bg-gray-100 px-2 py-1 text-xs{s.language}/span /div div classNameflex flex-wrap gap-1 {s.tags.map((tag) ( span key{tag} classNamerounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600 #{tag} /span ))} /div /div ))} /div这里有个实操细节代码高亮我用的是react-syntax-highlighter对于这种轻量场景已经完全够用而且体积可控。如果你想更炫、支持更多语言可以换shiki但打包体积会明显涨。我的建议是内部工具不折腾react-syntax-highlighter足够。3.4 在线运行子系统的实现t3code里比较有意思的一个模块是“在线运行”支持 JavaScript 和 Python。这里我想吹一下 Next.js 的 Route Handlers Vercel 的云函数能力但我最终没有直接用无头浏览器在本地跑代码因为不可控又危险。运行任意用户提交的代码安全边界很重要所以我选择了一招通过服务端路由把代码和一个简单的运行器通信。其实这里的前端实现很简单调用一个 tRPC mutation把language和code传过去后端再去执行。执行方式我用的是 Python 环境里常见的沙箱命令不过这里面牵扯太多安全细节这块我改天单独写一篇。简单说t3code里的在线运行更多是一个演示功能真实生产环境要考虑资源限制和网络隔离不能在主应用进程里裸跑。对于 JavaScript可以用 Node 自带的vm模块做一个最小沙箱import { runInNewContext } from node:vm; import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const runnerRouter createTRPCRouter({ runJavaScript: protectedProcedure .input( z.object({ code: z.string(), }) ) .mutation(({ input }) { const sandbox { console: { log: (...args: unknown[]) (logs args.map(String).join( )) }, }; let logs ; const context Object.create(sandbox); runInNewContext(input.code, context, { timeout: 1000 }); return { output: logs }; }), });一段话总结在线执行尽量和主业务分离部署这是我在t3code后续演进中最可能的改造方向。先把代码收进来、记录下来再去异步执行才是正规做法。4. 常见问题与排查技巧实录4.1 类型错误“代码幽灵”t3code开发过程中我最常遇到的类型报错就是Property xxx does not exist on type ...。这种错误 80% 的情况是你改了 Prisma schema但忘了重新生成类型。另一个常见场景是 zod 的 input 类型和 Prisma 的输入类型不一致比如tags字段我从String[]改成了Json前端如果不同步修改类型检查和运行时都会出问题。排查技巧其实很朴素所有类型层面搞不定的问题先npx prisma generate再清一下 Next.js 的.next缓存然后重启 dev server。大多数“幽灵报错”都源自这三者。4.2 数据库迁移冲突与重置t3code迭代很快基本上每天都要改 schema。有一次我改了字段名但没写 down migration直接migrate dev报错说迁移历史不干净。解决方法就是npx prisma migrate reset --force这个命令会清空数据并重新执行所有 migration所以只适合本地开发环境。生产环境一定要用真正的 migration 文件来升级不要随便 reset。我这里有个血的教训某次测试环境被我 reset 后测试账号数据全没了客户的 demo 也变成白板十分狼狈。我的做法是本地开发随便 reset测试环境用prisma db push代替 migrate生产环境规规矩矩走migrate deploy。三个环境三条策略各得其所。4.3 tRPC 路由拉流报 404如果你发现前端调用api.snippet.listMine()时报 404或者提示No GET action大概率是路径匹配的问题。tRPC 的 HTTP 路由格式是/api/trpc/snippet.listMine如果你的route.ts文件名不对或者rootRouter里没有正确挂载子路由就会出现这种诡异情况。我这里有一个排查顺序确认src/app/api/trpc/[trpc]/route.ts存在且内容正确。确认root.ts里用snippet: snippetRouter注册了子路由。确认服务端代码没有任何运行时异常导致程序直接抛错。如果是自托管部署还有可能和反向代理配置有关注意不要把/api路径吞掉。4.4 React Query 缓存导致页面“不刷新”这是 tRPC React Query 最常见的“玄学”问题。用户创建了一个 snippet回到列表页发现列表没有更新。原理很简单React Query 的默认配置认为数据在staleTime内是新鲜的不会重新请求。解决方法有两种。一是创建成功后调用utils.snippet.listMine.invalidate()const utils api.useUtils(); const createSnippet api.snippet.create.useMutation({ onSuccess: () { utils.snippet.listMine.invalidate(); }, });二是全局调低staleTime或者干脆设置refetchOnWindowFocus: true。我建议第一种因为它更精确不会产生无谓的网络请求。4.5 Tailwind 类名不生效Tailwind 的类名不生效绝大多数时候不是 Tailwind 的问题而是动态拼接类名的方式不对。你如果写const color selected ? bg-blue-500 : bg-gray-500; div className{... ${color}}这是 OK 的。但如果你写div className{dynamicClassMap[status]}其中dynamicClassMap里都是完整字符串这也可以。怕的是你在类名中间用模板字符串拼“部分内容”比如bg-${color}-500Tailwind 的编译器根本扫描不到这种动态片段构建产物里就不会有这个类。排查时先全局搜一下有没有这种写法十有八九就是它。5. 部署实践与项目复盘体会5.1 部署到 Vercel 与自托管的选择t3code最后是部署在 Vercel 上的因为 Next.js 在 Vercel 上的体验最顺滑。但这里有一个隐藏问题Vercel 的 serverless 函数有冷启动延迟而且 Prisma Client 在 serverless 环境里需要额外的配置优化比如binaryTargets设置。如果你的 schema 里没加generator client { provider prisma-client-js binaryTargets [native, rhel-openssl-1.0.x] }那在 Vercel 上部署时大概率会遇到PrismaClientInitializationError因为 Prisma 引擎二进制和你服务器环境不匹配。另一种选择是自托管用 Docker 部署一条docker build加docker run解决问题。对于内网工具来说自托管可控性更强但你需要自己处理环境变量、SSL、日志等运维事务。我的建议是如果已经在用 Vercel 或者不介意冷启动直接上 Vercel如果这是公司内部系统而且有私有化需求自托管。5.2 性能优化实测记录t3code在数据量达到数千条之后列表页偶尔会出现明显卡顿。我用 React Query Devtools 看了一下网络请求发现每次返回的数据量高达几十 KB渲染时又要对 tags 做 map。优化手段有两步第一用 cursor 分页限制单次返回条数一次只取 20 条滚动到底部的距离超过阈值后再请求下一页。这个方案把单次请求数据量从几十 KB 降到了几 KB。第二列表项用 React.memo 包裹避免每次父组件状态变化时所有卡片全部重新渲染。t3code里有一个点赞功能点击一个卡片导致全部卡片重绘这个问题在列表变大后非常明显。加 memo 后重新渲染范围被限制到那一项。5.3 扩展方向与个人体会目前t3code已经稳定运行了一段时间我后续打算在这几个方向继续扩展。一是加一个“建议片段”的能力通过给每个 snippet 打上语言、用途等多个维度标签再配合简单的统计得分来推荐给团队成员。这个功能技术上不难但能直观提升工具的粘性。二是引入 AI 辅助生成代码片段。现在调用大模型接口的成本已经足够低完全可以在创建 snippet 时提供一个“帮你把旧代码整理成更好的写法”的按钮。这里注意控制好上下文长度和鉴权不要让普通用户直接触碰模型密钥所有调用统一走后端 procedure。三是把 tRPC 的出入口接入审计日志。团队内部系统用久了谁改了什么、删了什么最好都能留痕做合规审计。tRPC 的 middleware 天然适合做这件事在protectedProcedure里同步写一条日志记录不用侵入业务代码。踩过这轮坑之后我现在的看法是选技术栈不要盲目追新关键是每一层都能让你省心而不是费心。T3 Stack 最打动我的地方就是它让“全栈类型安全”从口头禅变成了日常体验。t3code这个项目体量不大但麻雀虽小五脏俱全从认证、数据库、API 到部署运维都走了一遍踩坑的经验也比较真实。如果你也在酝酿自己的全栈项目或者想把手头这套脚手架流程化不妨从 T3 Stack 开始按照我上面的路线先搭建第一版跑起来再一点点迭代。过程中遇到的具体问题欢迎带着报错信息来找我聊聊很多问题看起来吓人排查到最后都是些小细节。