资讯动态

全栈TypeScript脚手架实践:基于T3技术栈的t3code选型与踩坑

发布时间:2026/10/26 8:38:20 来源:尧图企业网站定制
前阵子团队内部讨论新项目骨架时又有人问起 t3code 这个名字——说真的这个项目一开始只是我熬夜拼出来的个人脚手架名字起得也随意T3 是当时圈子里热门的 T3 技术栈Next.js、tRPC、Prisma、Tailwind CSS的简称code 则是希望它在代码层面足够直白、足够类型安全。后来几个内部项目都拿它当底座从概念验证一路跑到了生产环境。经常有朋友问 t3code 到底是什么、能解决什么问题、适不适合他们的项目所以我把这段时间的完整思路和踩坑过程整理成文。如果你也在搭建新的全栈 TypeScript 项目无论是个人项目还是团队业务系统正纠结于脚手架怎么选、这套技术栈怎么组合这篇文章应该能帮你省不少时间。1. 从没有趁手的脚手架到 t3code一个反复折腾后的产物1.1 为什么现有方案总是差一点明明生态里已经有 create-t3-app 这样成熟的项目脚手架为什么还要再折腾一个 t3code这是我被问过最多的问题。create-t3-app 的定位是最小可用它把 Next.js、tRPC、Prisma、Tailwind 组合起来跑起来很快但真的把它当生产项目的底座时还有很多坑要自己填登录注册没有、文件上传没有、部署配置没有、环境变量示例没有、权限模型没有。你当然可以一个个去集成但这恰恰是每个新项目都要重复一遍的机械劳动。另一个极端是公司内部脚手架。不少中大型团队都维护着自己的工程模板里面绑定了内部组件库、埋点规范、权限审批流程、甚至 CI 流水线的特定命名规则。这种脚手架在团队内部效率很高但搬到个人项目或者一个新的独立业务线时往往要删掉一大半公司特色才能用改起来比从零搭还痛苦。所以我的真实诉求其实是一个介于官方最小样板和公司重定制模板之间的基线版本它自带登录、上传、部署、数据建模这些最通用的东西但每一块都可以独立拆卸。t3code 就是从这种反复折腾里长出来的。1.2 t3code 打算解决的核心问题清单我当时给自己列了一个非常具体的问题清单分享出来供大家参考一个新项目从 clone 到本地能跑时间应该控制在十分钟左右而不是半天。Git 仓库里应该有一套完整的示例页面而不是只留一个 hello world。类型安全要贯穿从数据库到组件渲染的整条链路不能只在某个孤立的接口里体现。鉴权、文件上传、部署等高频需求应该以能用、可改、易拆的方式预置而不是把业务绑死。配置文件要少而清晰每个配置文件都有注释说明它为什么存在。这个清单听起来不复杂但真正落地时会牵扯到非常多细节。比如类型安全贯穿链路这一条意味着 Prisma 生成的类型、tRPC 的输入输出类型、zod 的校验类型、React 组件的 props 类型必须形成一条闭环任何一环脱节最后的体验都会打折。再比如易拆这一条要求模块之间不能深度耦合用了 Auth.js 就不会把 Next.js 的中间件、Prisma 的 User 模型、前端 Session 判断全部写死。所以 t3code 与其说是一个脚手架不如说是一套被验证过的技术栈组合方式代码只是这套组合方式的可执行表达。2. 技术选型复盘T3 栈每个成员都在扮演什么角色2.1 Next.js App Router 作为应用层骨架的取舍选择 Next.js 其实是一个既要又要的结果。我希望项目同时具备前后端能力、服务端渲染、良好的 SEO、以及足够的生态扩展空间。Next.js 的 App Router 把这些能力统一到了一个目录结构里app目录下既可以放页面也可以放 API 路由服务端组件在默认情况下把数据请求放在服务器完成首屏的性能和页面的安全性都更好。取舍上需要提醒的是App Router 的学习曲线比 Pages Router 陡峭一些。刚接触async function Page()这种写法时很容易在我到底应该在服务端拿数据还是应该在客户端用 fetch之间纠结。t3code 的做法是明确给出规则页面初始化需要的数据走服务端调用用户交互后的变更走 tRPC mutation这样团队成员就不需要每次做架构决策模式统一之后代码也好 review。如果你问为什么不用 Vite 或者 RemixVite 的 SPA 模式做纯前端体验很棒但全栈项目还需要 SSR、路由规范、服务端数据获取的统一方案Remix 本身也优秀但在周边生态、招聘、社区资料的丰富程度上Next.js 仍然是我更稳妥的选择。这套模板的核心目标是让团队新成员快速上手那么生态成熟度就必须排在决策因素前列。2.2 Prisma 与 PostgreSQL数据层的类型闭环数据库选了 PostgreSQL 而非 MySQL主要看重它的扩展性JSONB 字段、数组类型、全文检索、行级安全这些能力在业务复杂起来之后非常有用而 Prisma 对 PostgreSQL 的支持也最完善。Prisma 的核心价值在于 schema 文件作为单一事实来源。你可以把它理解成数据库建模的 TypeScript定义一个Post模型npx prisma migrate后数据库表结构有了npx prisma generate后类型也有了。这几件事是同源的所以不会出现数据库字段改了代码类型忘了升级的错位感。这个体验在传统 ORM 里很难找到替代也是 t3code 敢把类型安全当成卖点的底气。这里也给一个建议不要把 Prisma 当成传统 ORM 来用。它没有暴露完整的底层查询能力一些复杂查询比如深层递归、窗口函数还是需要手写 SQL但这对于绝大多数业务系统来说是值得的因为换来的是类型安全和开发效率。如果你实在需要更轻的查询体感Drizzle 也是一个方向但它在 schema 迁移和类型生成的自动化程度上还差点火候所以我仍然把 Prisma 作为默认选项。2.3 tRPC 取代 REST 后前后端边界长什么样tRPC 是 t3code 里最让前后端协作舒服的一环。传统 REST 接口调试时前后端各自维护一份接口文档字段改了要手动同步漏改就出 bug。tRPC 的思路是后端导出一个 router 对象前端直接导入它的类型于是 client 调用的参数、返回值、甚至抛出的错误类型都是编译期强约束的。字段名拼错了、参数类型不对根本编译不过去而不是跑起来之后才被接口文档坑。相比 GraphQLtRPC 不需要引入额外的 schema 语言和学习成本也就没有 codegen、缓存策略这套复杂体系。它的代价是它对全栈 TypeScript场景要求比较高如果前端不是 TypeScript或者前后端完全分离成两个独立仓库tRPC 的优势就会减弱。t3code 的使用场景里前后端在同一个仓库中这个选项就非常合适。可以看一下项目目录结构基本能表达出类型从 schema 流出router 在中间页面消费的链路t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ │ │ └── db.ts │ └── trpc/ │ ├── client.ts │ └── server.ts这个结构在物理层面约束了每个模块的位置数据库访问归server/db.ts路由归server/api/routers页面组件只负责调用。谁越界了code review 时一眼就能看出来。3. 类型安全下的数据链路从数据库到组件一次走通3.1 一个最小业务模块的完整落地理论讲再多不如落地跑一遍。用一个最常见的帖子列表 创建帖子模块来说明 t3code 里数据是怎么流动的。先定义 Prisma schemamodel Post { id String id default(cuid()) title String content String author User relation(fields: [authorId], references: [id]) authorId String createdAt DateTime default(now()) updatedAt DateTime updatedAt }然后定义 tRPC router注意 zod 同时承担了输入校验和类型推断import { z } from zod; import { publicProcedure, router } from ../trpc; export const postRouter router({ list: publicProcedure.query(({ ctx }) ctx.db.post.findMany({ orderBy: { createdAt: desc }, take: 20, }) ), create: publicProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.user.id, }, }); }), });在服务端组件里读取初始列表export default async function HomePage() { const posts await api.post.list(); return ( ul {posts.map((post) ( li key{post.id} h2{post.title}/h2 p{post.content}/p /li ))} /ul ); }在客户端组件里提交创建表单const utils api.useUtils(); const createPost api.post.create.useMutation({ onSuccess: () utils.post.list.invalidate(), });从数据库记录到页面列表每一层拿到的类型都来自同一个源。这里最重要的体验是当你把post.title改成post.titl时IDE 会立刻画红线而不是等编译或者等线上报错。类型的闭环不是一个高级特性它是日常开发里每天都在省时间的隐形助手。3.2 输入校验与错误处理zod 如何成为 tRPC 的一部分输入校验很容易被忽略。很多项目字符串长度、枚举值、分页参数都在前端验一遍后端再手写一遍两套逻辑迟早不一致。t3code 里用的 zod tRPC 组合zod schema 既是运行时校验规则又是 TypeScript 类型来源。也就是说你写了z.string().min(1).max(100)之后后面的input.title在类型上就已经是string长度约束也在运行时生效。错误处理也会因为类型对齐而变得简单。tRPC 允许自定义 errorFormatter把 zod 校验错误映射成结构化字段错误const errorFormatter ({ shape, error }) { if (error.code BAD_REQUEST error.cause instanceof z.ZodError) { const fieldErrors error.cause.flatten().fieldErrors; return { ...shape, data: { fieldErrors } }; } return shape; };这样前端拿到的错误信息就是精确到字段的title 长度不能超过 100、content 不能为空而不是一条请求失败的废话。表单组件可以直接把这些错误渲染到对应输入框下面不需要前端再猜后端的校验规则。3.3 服务端组件与 tRPC 的协作边界App Router 下最容易混乱的问题服务端组件能不能用 useQuery不能。服务端组件没有客户端运行时自然不能使用 React Query 的缓存和订阅能力而客户端组件直接用服务端数据又会削弱 SEO 和首屏性能。t3code 给出的边界规则很简单首次渲染需要的数据在 Server Component 里通过api.router.procedure()直接取。用户点击、提交表单后的动态变更走客户端组件里的useMutation。某个组件既需要初始数据又需要动态刷新就拆成服务端取初值 客户端接管更新两层。这个边界不是拍脑袋定的它和 Next.js 的渲染模型强相关。我见过不少团队在 App Router 下写出了客户端组件里 fetch 接口再 setState的老写法等于主动放弃了服务端渲染的所有优势。按上面的规则来每个页面组件都能明确自己该做什么代码 review 时少很多争论。4. 鉴权、文件上传与部署模板里最容易打脸的三个环节4.1 Auth.js 集成session 与数据库会话的同步问题t3code 预置了 Auth.jsNextAuth但集成时踩了一个很现实的坑只配置 JWT session 是不需要数据库额外模型的一旦切到 database sessionPrisma schema 里少了 Account、Session、VerificationToken 三个模型登录直接报错。解决方案是提前在 schema 中加上 Auth.js 官方要求的这几个模型并做好外键关系。这个坑之所以高发是因为官方 starter 为了最小可用默认用 JWT但真实业务想做多设备登录管理、会话撤销时数据库 session 几乎是必须的而这时文档里的可选配置变成必选schema 却没跟上。另一个容易忽略的点是NEXTAUTH_SECRET。开发环境下不配置也能跑但部署到线上少这行环境变量session 就会诡异失效表现为登录成功但刷新后立即退出。这类问题排查起来非常隐蔽因为本地完全正常只有线上复现。后来我在.env.example里把NEXTAUTH_SECRET、NEXTAUTH_URL都列出来并加了注释才彻底避免团队新人在这个环节浪费半天。4.2 文件上传后的静态资源路径与权限控制文件上传是模板中另一个容易看起来能用、实际很危险的部分。如果直接把上传文件存到public/uploads用户上传了什么搜索引擎就能直接公开访问到什么这个坑在真实项目里出过事。t3code 的建议是开发环境把上传文件放在项目私有目录通过受控 API 返回文件流上生产环境时直接切换到对象存储数据库只存对象 key前端拿到预签名 URL 再展示。这样权限控制的逻辑不在应用代码里散落而是在文件存储层统一完成。这里要特别说明一个取舍本地目录存文件虽然简单但实例一多、文件一多就不好扩展对象存储则有成本和学习成本。t3code 的处理方式是抽象一个storage接口本地实现和对象存储实现都实现了同一组方法业务代码只依赖接口。这样开发环境跑得舒服生产环境也能平滑切换不会被存储方案绑架。4.3 从本地到容器的环境变量管理环境变量的问题永远不在本地而在环境切换。t3code 提供了一个.env.example把每个变量都写了注释同时给出 Docker Compose 示例services: db: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: t3code ports: - 5432:5432 volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:CI 部署时再用平台自带的 secrets 注入真实变量。这里有一个最容易被忽略的纪律.env绝对不要提交到 Git 仓库.env.example每次修改环境变量时要同步更新。我见过太多项目因为.env.example和实际代码不一致导致新成员本地跑起来反复报错最后才发现是示例文件里写着旧变量名。模板类项目最不该犯的错就是文档里的东西跑不起来。5. 踩坑实录我在这套模板里栽过的跟头和完整排查链路5.1 Prisma 连接池在 Serverless 环境下的崩溃排查这个坑发生在上线第二天。现象是线上时不时报PostgresError: sorry, too many clients already重启后又能撑一阵然后再次爆发。第一反应是数据库 max_connections 不够但把连接数调大后问题依旧。后来看 Prisma 文档才意识到Serverless 环境每个实例都有独立的 Node.js 运行时如果每次创建 PrismaClient 时都新建连接实例数量一多连接池的上限马上被打穿。排查的完整链路是查数据库连接数 → 看监控确认连接来自多个实例 IP → 顺着这个现象去找 PrismaClient 的初始化位置 → 命中根因。解决方法是两条路一是对连接进行池化比如通过 PgBouncer 代理二是在 Prisma 的 datasource url 上配置适合连接池的参数并确保全局只复用同一个 PrismaClient 实例。这个过程花了大概一个下午事后看起来路径清晰但当时每一步都像在猜因为现象和原因的关联并不直观。5.2 tRPC query 缓存不更新的根因定位过程另一个高频问题是mutation 成功了但列表页面不刷新。开发模式可能偶尔正常生产环境稳定复现。排查链路先确认 mutation 确实返回了成功 → 再看 React Query 的 staleTime 和 gcTime 配置 → 最后发现问题是 mutation 成功后根本没有调用 invalidate。修复方案很简单在 mutation 的 onSuccess 里加上一行const onSuccess () { utils.post.list.invalidate(); };这里要提醒一点invalidate 的 key 和 query 的 key 必须完全一致。如果 query 里用了带input的对象比如api.post.list.useQuery({ page: 1 })invalidate 时也要带同样的{ page: 1 }否则看起来调用了但实际上没有清除对应缓存。这个细节在文档里只提了一句话但实际踩到时你会对着 React Query DevTools 怀疑半天人生。5.3 Tailwind 类名覆盖的权重陷阱这个坑不算严重但遇到时很困惑自定义一个 Button 组件默认bg-blue-600外部传入classNamebg-red-500结果颜色还是蓝色。原因是 Tailwind 生成的 CSS 顺序和 HTML 中类名顺序无关同名工具类的优先级取决于它们出现在 stylesheet 里的先后位置而不是 className 字符串里的顺序。解决方法是引入tailwind-merge它能把冲突的类名合并成最终结果import { clsx } from clsx; import { twMerge } from tailwind-merge; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }然后用cn(bg-blue-600, className)来拼接。这件事看起来只是一个小函数但如果没有它组件库的可复用性会大打折扣。尤其是团队里多人同时写样式时没有cn统一入口迟早会出现传了类名却改不动样式的灵异事件。6. 给想直接上手的团队的三条建议6.1 先跑 demo 再取舍别迷信全家桶t3code 无法避免所有团队的偏好差异。有人喜欢用 Drizzle 替换 Prisma有人希望把 Auth.js 换成自己的内部登录这都合理。我的建议是先按默认技术栈完整跑通一个 demo 页面再决定保留哪几层。模板最忌讳的是别人说好就全部收下技术栈是要为自己的团队用的不是用来展示的。跑 demo 的过程会让你真正感受到 tRPC 的类型安全值多少钱、Tailwind 的样式覆盖有多痛然后你再做裁剪会理性得多。6.2 模板是起点不是终点我把 t3code 当作团队技术基线的第一个版本而不是永远照抄的答案。每次在真实项目里踩到新的坑我会把解决方案回写到模板里比如加上新的错误处理示例、补上某个部署配置、优化一下路由组织方式。这样模板会随着真实需求演进而不是变成一个落满灰尘的静态仓库。模板的真正价值不在于开箱即用而在于它能沉淀团队对技术栈的理解成为后续项目的活文档。6.3 文档与示例代码要跟上最后也是我认为最容易被忽略的一点模板的成本主要不在开发而在维护。如果模板里没有 README、没有.env.example、没有每个关键文件的功能注释三个月的团队新人看到它只会有一种感觉恐惧。所以我每次更新 t3code都会顺手把文档一起更新。因为脚手架本身解决的是怎么起步的问题而文档解决的是怎么理解的问题。一个只有代码没有文档的模板和一份只有结论没有推导过程的笔记一样保鲜期都很短。如果你问我现在让我从零再搭一次我大概率还是会从头写一个类似 t3code 的东西——不是为了折腾而是这个过程逼着我想清楚每一个技术选型背后的理由数据库为什么选它、鉴权放在哪一层、类型为什么要贯穿到底。这些决策能力比最终那套代码本身值钱多了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑