1. 项目整体设计与技术选型剖析1.1 t3code到底在解决什么问题这段时间我一直在打磨一个项目代号叫t3code。它不是一个商业产品而是一套基于 T3 Stack 的参考工程和起步模板目的是解决一个特别实际的问题当你想从零启动一个 TypeScript 全栈项目时为什么总要花掉一整天去纠结技术选型、目录结构、API 约定和鉴权方案很多团队在搭建新项目时会陷入“脚手架地狱”——选了 Next.js 之后要纠结 API 怎么暴露是写 REST 还是 GraphQL数据库 ORM 选 TypeORM 还是 Prisma样式方案是 Tailwind、CSS Modules 还是 styled-components鉴权又该接哪家服务。每个决定单独看都有道理但组合在一起就容易形成一团乱麻。t3code 的核心立场是给出一个强约定的组合Next.js Tailwind CSS tRPC Prisma NextAuth用一套被社区反复验证过的默认值把做决定的时间省下来直接进入业务编码。我在做这个项目时给自己定了几条原则第一所有依赖必须能顺畅地协同工作不能出现“A 框架配 B 库需要一堆补丁”的情况第二默认配置要带生产可用的能力比如鉴权、数据库迁移、环境变量校验、严格的 TypeScript 覆盖第三文档和代码要一一对应看文档能跑通跑通之后能理解为什么要这么设计。如果你是刚接触全栈开发的人t3code 可以当学习地图沿着这个项目就能搞清楚现代 TypeScript 全栈的完整链路。如果你已经有一定经验它可以当做一个骨架把里面默认的表单、路由、API 调用改成自己的业务即可。这套东西最核心的价值是你不是在拼积木而是拿到了一套事先验证过的积木组合方案。1.2 技术栈的取舍逻辑先过我最终选定的技术栈清单然后逐个解释为什么选它们。Next.js 14 App Router作为前端框架和运行时负责服务端渲染、路由、API Route、中间件。tRPC负责前后端类型安全的数据调用替代 REST 或 GraphQL。Prisma作为数据库访问层负责模型定义、迁移、类型生成。Tailwind CSS负责样式方案配合 dark mode 和组件化设计。NextAuth.js负责认证会话对接 GitHub、Google 等 OAuth 提供商也支持邮箱登录。Zod负责运行时数据校验既校验客户端输入也校验服务端入参。这套组合最容易被质疑的是“为什么用 tRPC 而不是 REST”和“为什么用 Prisma 而不是直接写 SQL”。我会在后面用专门的小节说明这两个关键决策。选择 Next.js 是最没有争议的决定它同时能覆盖前端渲染和后端 API部署到 Vercel 或者自建服务器都方便。我在 t3code 里用了 App Router并且在路由组件层面明确了标注哪些是服务端组件、哪些是客户端组件。这一点很重要因为 tRPC 的调用通常发生客户端组件中而页面初始数据的预取可以由服务端组件完成两者必须清楚分离否则会在 Next.js 的 hydration 环节踩坑。Prisma 解决的是模型定义和数据库访问的繁琐问题。用它的 schema 文件声明 User、Post 这类数据模型跑一条迁移命令就能生成 SQL 表和对应的 TypeScript 类型。开发时我不需要单独维护一份数据库表结构文档单看 schema 文件就能理解整个数据模型。Prisma 的 client 在类型推断上做得非常好查出什么字段返回对象上就能拿到什么字段多查或少查的情况在编译期就暴露了。NextAuth.js 是 T3 Stack 默认推荐的认证方案因为它和 tRPC 的配合很顺把 session 数据注入到请求上下文中tRPC procedure 里可以直接判断当前用户是否已登录然后决定是否允许执行操作。我在 t3code 里还做了扩展支持 session token 更新这样可以处理用户改昵称或换头像后页面 header 能立即同步显示新信息。Zod 是被很多人忽略但绝对不能少的拼图。tRPC 的入参校验、Prisma 的输入净化、NextAuth 的 callback 参数校验都可以用 Zod 统一描述 schema。我在 t3code 里把 Zod schema 放在一个公共目录中前端做表单验证时用同一份 schema后端接受请求时又用同一份 schema 做服务端校验。这样做的好处是前后端验证规则永远不会漂移在多人协作时尤其重要。1.3 为什么不选 REST 或 GraphQL每次分享 t3code 的技术方案时总有人问我REST 已经够用了干嘛要折腾 tRPC我理解这种质疑。REST 本身不是错的但它在你没有明确 API 文档、没有接口规范、没有版本管理的情况下很容易变成一坨散落的 endpoint。我画过一张熟悉的对比图放在心里就足够了对比维度REST 传统方案GraphQLtRPC类型安全前后端类型需手动维护需要生成 codegen类型直接从函数推导学习成本低但规范随团队经验浮动中高需要理解 resolver 和数据加载低就是调用普通函数调试成本需要 Postman 或 Swagger需要 GraphQL playground浏览器直接看 network类型自带适用场景需要公开 API 给第三方复杂数据聚合、多端消费前后端都是 TypeScript 的内部服务请求体积可能出现过获取可精确控制可精确控制且天然按需暴露我实际体验下来tRPC 最爽的一点是改一个后端函数的参数或返回类型前端编译立刻报错。无需 codegen无需手动同步类型定义因为整个过程都是 TypeScript 的 function call。在 t3code 里新增一个功能时流程是写一个 procedure然后在页面里 import 那个调用钩子自动就有类型提示开发体验非常接近本地函数调用。GraphQL 也有它的位置比如你需要把一个 API 暴露给多个不同技术栈的客户端或者需要非常灵活的查询能力。但就 t3code 这种以内置业务为主的项目来说GraphQL 相当于杀鸡用了牛刀还额外带来 resolver 层级、缓存策略、codegen 维护这些负担。tRPC 更像是“TypeScript 全栈应用的本地函数代理”它不试图替代所有 API 方案而是解决内部前后端通信这类高频场景。如果未来某个接口确实要开放给第三方我仍然会在项目里补充一个 REST route两者完全可以共存。2. 核心实操从零初始化 t3code 工程2.1 环境准备与创建项目在动手之前先确认基础环境Node.js 18.17 以上版本npm、pnpm 或 yarn 任选其一。我自己长期用 pnpm因为它安装速度快磁盘占用低关键是锁文件能保持依赖版本的一致团队协作时不会出现“我这边跑得好好的你那边就是装不上”的问题。接下来执行pnpm dlx create-t3-applatest t3code命令执行后CLI 会询问需要哪些模块我建议全部勾选Next.js、Tailwind CSS、tRPC、Prisma、NextAuth.js以及 eslint 配置。这里要特别提醒一下不要觉得 prisma 和 next-auth 暂时用不到就不装t3code 的定位就是全功能骨架这些模块的默认集成是否完整直接影响后续业务开发的速度。初始化完毕后先运行一次开发服务器确认模板正常cd t3code pnpm install pnpm dev浏览器打开http://localhost:3000如果能看到默认 Starter 页面说明整套基础管线已经跑通。到这里只完成了骨架接下来要做的是理解目录结构然后改造它。2.2 目录结构解读与关键文件改造T3 App 默认生成的目录结构很清爽但很多人拿到手之后不知道该改哪里。“我该在哪写业务代码哪边是后端哪边是前端”这些问题实际上只要理解三个目录就能解决src/ ├── pages/ # 页面文件包含 API Routes 的额外定义 ├── server/ # 服务端代码db 客户端、auth 配置、tRPC 路由 └── styles/ # 全局样式新版 create-t3-app 已经迁移到 App Router所以pages目录换成了app。src/server是 t3code 的后端核心包含所有 tRPC router 和 prisma 实例src/app是前端页面和布局。这种结构天生就把“服务端逻辑”和“客户端展示”分开了写之前先想好这层数据要放进哪个目录如果是在服务端读数据库就放到server/api/routers里如果是用于页面布局展示就写到app下对应的布局组件里。我在 t3code 里做的第一件改造是调整 src/server 下的模块组织方式。默认的root.ts会聚合所有子路由我直接把业务相关路由拆分到routers目录例如postRouter、userRouter每个 router 独立维护自己的 procedure。这样当业务逐渐变大时路由文件不会膨胀成一个几千行的怪物文件。另一处关键改造是环境变量的校验。T3 默认引入了env.mjs配合 Zod 校验环境变量我非常喜欢这个设计因为环境变量在应用启动阶段就应该被强制验证而不是运行时才发现某个 key 缺失。我在env.mjs里新增了DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL这些变量的验证规则并为它们提供默认值或明确抛错信息。只要某个变量缺失应用启动时就会直接给出清晰提示连排查时间都省了。2.3 数据模型设计以实际业务场景为例为了让 t3code 有示范价值而不是空壳我给它塞了一个社区场景用户可以注册登录发帖子给帖子点赞关注其他用户。数据模型围绕这个场景来设计用 Prisma schema 定义大概是这样model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] likes Like[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String authorId String author User relation(fields: [authorId], references: [id]) likes Like[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Like { id String id default(cuid()) userId String postId String user User relation(fields: [userId], references: [id]) post Post relation(fields: [postId], references: [id]) unique([userId, postId]) }这是很常见的用户-帖子-点赞模型但我在设计时特别考虑了几点Like表用联合唯一索引unique([userId, postId])防止重复点赞User的email和accounts与 NextAuth 的默认表结构对应后续实现登录时可以直接用官方 adapter所有主键都用cuid()而不是自增 id这样分布式环境下生成 ID 更安全也不会暴露数据量。写完 schema 后执行迁移命令pnpm prisma migrate dev --name init这条命令会生成 SQL 迁移文件、创建数据库表并且重新生成 Prisma Client 的类型。如果之前已经在env.mjs里配置了DATABASE_URL这里直接用本地 SQLite 也能跑通。我用的是 PostgreSQL连接串形如postgresql://user:passwordlocalhost:5432/t3code。2.4 样式体系与 UI 配置T3 默认的 Tailwind 配置已经能直接用但我在 t3code 里做了一些定制。首先是暗色模式。Tailwind 的暗色模式有多种实现策略项目默认的方案是通过添加 class 来控制我建议维持这种设定因为它在切换主题时更可控。在tailwind.config.ts中把darkMode设为class然后在根布局里加一个主题切换按钮点击时切换html节点的 class 即可。其次是组件设计。我把 UI 组件按通用程度拆成两层通用基础组件放在src/components/ui下比如 Button、Input、Card业务组件则直接放在页面目录的相邻位置。我不喜欢把所有组件塞进一个components大杂烩因为项目一大会变得无法索引。通用组件必须做到语义化、可复用业务组件则可以更自由地依赖具体数据形态。Tailwind 本身是一个原子类名的集合但纯靠原子类名写复杂页面会导致 JSX 里 class 字样很长。我的解决策略是少量使用layer components抽取复用类但绝不全部抽。比如按钮会有btn-primary、btn-ghost这样的基础类而页面里具体的布局类还是留在组件内。这样的权衡让代码既有复用性又不至于把 Tailwind 的灵活性改成记忆负担。3. 端到端链路打通数据库、tRPC 与页面渲染3.1 如何用 tRPC router 暴露业务能力数据库模型有了界面样式有了现在到了 t3code 最有魅力的部分把数据库能力通过 tRPC 暴露给前端。我写业务逻辑的步骤是这样的先考虑前端需要什么能力再把这些能力映射成 tRPC 的 procedure最后在页面中调用。拿“发帖子”这个功能举例在src/server/api/routers/post.ts中定义import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const postRouter createTRPCRouter({ create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), getAll: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, include: { author: true, likes: true }, }); }), });这里有两个 procedure 类型publicProcedure允许任何人访问protectedProcedure要求登录后才能访问。t3code 里大量使用 protectedProcedure 来保证写操作必须基于有效 session。不要在公共 procedure 里直接操作需要登录的资源这是安全管理红线。每次写新 procedure 前先判断这个操作是否需要当前用户信息如果是就用 protectedProcedure 并把用户 id 从ctx.session.user.id里取出客户端传来的参数里根本不应该包含 user id。这样设计可以防止用户伪造请求去修改别人的数据。3.2 页面中调用 tRPC 并处理加载态前端页面调用 tRPC 的方式非常直接。比如帖子列表页在app/posts/page.tsx或任一客户端组件里引入use client; import { api } from ~/utils/api; export function PostList() { const { data: posts, isLoading, error } api.post.getAll.useQuery(); if (isLoading) return div加载中.../div; if (error) return div出错了{error.message}/div; return ( ul {posts?.map((post) ( li key{post.id} h2{post.title}/h2 p{post.content}/p span作者{post.author.name}/span /li ))} /ul ); }注意这里用了useQuery这个自动生成的 React Query 钩子。它自带缓存、重新获取、loading/error 状态不需要额外写 fetch 和 useEffect。这种做法的体验是“像在调本地函数”但底层已经在走网络请求并且自动处理了序列化和反序列化。还有一点需要强调post.author 是通过 include 直接查出来的。Prisma 因为类型推断的存在这个author对象在前端是被明确标注类型User | null的你要是直接渲染post.author.nameTypeScript 会提醒你可能为 null编译期就能规避空指针这类问题。这和传统 REST 手拼 JSON 的体验完全不一样。3.3 乐观更新与失效重取的实践帖子列表页写完之后我开始处理点赞功能。点赞是高频操作如果每次点击都要等网络回包再把新列表重新加载一遍体验会很差。t3code 里我采用了 React Query 的乐观更新模式。具体做法用api.post.toggleLike.useMutation()触发 mutation在onMutate阶段先取消相关查询的缓存然后手动更新缓存中的点赞数据这样 UI 立即响应用户操作。如果请求失败则在onError里回滚缓存并触发一次重新获取保证数据最终一致。这里的重点是理解 React Query 的缓存 key 机制。tRPC 钩子生成的 key 是过程路径加参数的组合比如[post.getAll, { page: 1 }]。在乐观更新时我需要用api.useUtils().post.getAll.setData去指定要更新哪一份缓存数据。如果不指定 key很可能更新错了页面范围。我在实际开发中因为缓存 key 没对上还出过 bug这个细节值得所有用 tRPC 的人留意。乐观更新不是所有场景都适用。涉及金额、库存等强一致性业务我强烈建议不要用乐观更新而是走“mutate 之后先显示 loading成功后自动失效重取”的模式const utils api.useUtils(); const likeMutation api.post.toggleLike.useMutation({ onSuccess: () utils.post.getAll.invalidate(), });这里invalidate()的作用是让所有跟post.getAll相关的查询标为过期然后页面会自动重新请求。React Query 对失效重取的处理非常成熟哪怕你只是简单地在 mutation 成功后调用 invalidate也能做到数据新鲜。3.4 鉴权边界与中间件保护再往后我把整套应用的鉴权逻辑梳理了一遍加了几道安全防线。第一道防线是 NextAuth 的 session 策略。在auth.ts里设置session: { strategy: jwt }并把callbacks里返回的用户信息包括 id。很多教程默认只返回 name 和 email这样后面写ctx.session.user.id时会拿不到 id数据关系就断掉了。记住JWT 的 session 本身只是拿凭据你需要把数据库里的用户 id 合并进 session 对象。第二道防线是 Next.js 中间件。t3code 里有一些整页都需要登录才能访问的比如/admin、/settings。我在src/middleware.ts里配置import { withAuth } from next-auth/middleware; export default withAuth({ pages: { signIn: /auth/signin }, }); export const config { matcher: [/admin/:path*, /settings/:path*], };这层保护发生在路由解析阶段比在页面组件里写useSession判断更早所以体验更顺滑未登录用户会被直接重定向到登录页。第三道防线才是 tRPC 的 protectedProcedure。前面两道防线保证页面级访问安全这第三道保证的是 API 级访问安全。三者叠加才能形成完整的防护链。我在日常开发中见过太多人只做了页面级判断结果用户可以直接调接口改数据库这种漏洞必须堵上。4. 部署、性能与常见问题实录4.1 自部署Docker 镜像构建与容器编排t3code 从一开始就要支持自部署因为在某些业务场景中你必须把应用跑在自己的服务器上而不是依赖某个托管平台。我把 Dockerfile 放在项目根目录构建流程分成两阶段编译阶段用 node 20 镜像安装依赖并执行next build运行阶段用 node 20 slim 镜像只保留.next、public、node_modules中必要内容。Dockerfile 的核心配置大概长这样FROM node:20-alpine AS builder WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm prisma generate RUN pnpm build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENVproduction COPY --frombuilder /app/.next/standalone ./ COPY --frombuilder /app/.next/static ./.next/static COPY --frombuilder /app/public ./public EXPOSE 3000 CMD [node, server.js]这里有几个关键点。第一next.config.js里必须开启output: standalone否则.next目录里不会生成可独立运行的server.jsDocker 容器里就没法用 node 直接启动。第二Prisma 的 client 需要在构建时执行 generate否则运行时找不到 client。第三COPY --frombuilder /app/public ./public是为了把静态资源复制进来如果项目里没有 public 目录可以删除这一行但 t3code 保留了 favicon 和默认占位图所以需要。docker-compose 里我用了一个 PostgreSQL 服务加一个应用服务。数据库和应用都是容器通过 compose 网络互通。首次启动时应用启动命令需要先执行迁移可以通过CMD里的npx prisma migrate deploy node server.js实现。4.2 实际性能数据与优化调整我把 t3code 部署到一台 2 核 4G 的云服务器上跑了一次压力摸底。在没有接入任何 CDN 和缓存时首页首屏 TTFP 大概在 1.2 秒左右主要耗时在数据库读取和页面服务端渲染。开发者模式下更慢但那是正常的生产构建会好些。之后我做了几个针对性优化第一减少客户端组件数量。t3code 的首页布局全部使用服务端组件只有需要交互的区块进入客户端组件。这样初始页面的大部分 HTML 可以直接从服务端生成JavaScript 体积明显变小。第二对帖子列表启用 React Query 的staleTime。因为帖子列表在一分钟之内的变化用户通常可以接受我在查询配置里设置了staleTime: 60 * 1000这样切回页面时不会频繁触发重新请求API 的压力小很多。第三静态资源走 CDN。Tailwind 产物、图片、字体这类内容几乎不变化我在 Docker 部署时把它们挂到 CDN实际效果是 TTFB 降低了约 30%。如果你用的是 Vercel 或 Cloudflare 这类平台这一步会更简单。第四数据库连接池。如果是自建 PostgreSQL我用 PgBouncer 做连接池避免每个 Next.js 实例都持有大量数据库连接。Prisma 官方支持directUrl和连接池配置在环境变量里把DATABASE_URL指向 PgBouncer 就能完成切换。优化完之后同一台 2C4G 服务器上首页动态内容接口的平均响应时间从 180ms 降到 100ms 左右并发 50 时没有出现明显排队。4.3 常见问题速查表在开发 t3code 和给朋友内测的过程中我收集了不少典型问题这里整理成速查表基本覆盖了 T3 Stack 新手会踩到的绝大多数坑。问题现象产生原因解决方案PrismaClientInitializationErrorschema 变更后 client 未重新生成执行pnpm prisma generateP1001: Cant reach database serverDATABASE_URL 指向错误或数据库未启动检查连接串、确认 PostgreSQL 容器健康tRPC 报UNAUTHORIZED使用 protectedProcedure 但当前无 session先确保完成了登录或改用 publicProcedureNextAuth 登录后 session 里没有 user.idcallbacks 里未合并数据库用户 id在 jwt/session callback 里返回 id 字段Tailwind 样式不生效内容目录未加入 content 配置检查tailwind.config.ts中content是否包含所有 src 路径页面出现 hydration 不匹配客户端和服务端渲染结果不一致避免强制使用随机数或当前时间直出用 useEffect 延后执行Docker 构建后容器启动即退出未开启 standalone 模式在 next.config 加入output: standalonePrisma 多行事务报错用错了交互式事务写法使用$transaction(async (tx) ...)或数组批处理4.4 踩坑清单与避坑建议最后分享一些不容易在网上看到但我实操中真实踩过的坑这些教训很有价值。先说 Prisma 的迁移问题。prisma migrate dev是开发环境用的部署环境一定要用prisma migrate deploy。dev 命令会尝试重置数据库、生成迁移文件、执行 seed这些操作在生产环境很危险deploy 只是按顺序把未应用的迁移执行一遍安全得多。我第一次部署时直接在服务器上跑了 migrate dev差点把线上数据清空这个教训非常深刻。再说 Next.js 中间件和 Edge Runtime 的兼容性问题。NextAuth 的 middleware 很好用但你要注意它运行在 Edge Runtime 上里面不能高兴写 Node.js 的 API比如fs、path。所以中间件里不要做数据库查询只做 session 判断和重定向即可。复杂的权限判断应该放在 tRPC 层。关于 tRPC 的错误处理我有一个原则不要把数据库原始错误直接抛给客户端。用户在注册时如果邮箱重复你拿到的是一个唯一的约束错误但前端显示这个英文错误很吓人。我习惯在 procedure 内部捕获已知错误转成业务错误返回给前端。在 t3code 里我为所有 mutation 写了一个统一错误处理 wrapper能对 Zod 验证错误、数据库唯一约束、权限校验这三种常见错误做归一处理前端拿到的是结构统一的错误对象。最后关于项目结构这是一条软性建议但非常重要如果 t3code 这类骨架项目的业务模块逐渐变多一定要在 app 层做功能模块目录拆分而不是无限地把所有页面平铺在 app 根目录下。比如app/dashboard、app/posts、app/settings各自拥有独立的布局和页面跨模块共享的组件再提升到公共位置。这样当项目的某个模块需要单独迁移到 monorepo 包时可以几乎无缝操作不会因为目录纠缠而痛苦。我在用 t3code 搭建下一个业务项目时最大的体会是选择一个强约定的全栈模板不是你放弃了思考而是把有限的心力聚焦在真正有业务价值的地方。与其每次新项目都重新纠结一遍技术选型和接口规范不如把 t3code 这种经过验证的骨架直接作为起点。等踩过几次坑再根据自己的团队习惯缓缓调整配置整个过程比从零搭建省下的时间非常明显。如果你想把 t3code 做成自己的长期脚手架还有两个地方可以继续投入一是把测试环境搭起来用 Vitest 写覆盖 tRPC router 的集成测试二是往里加 OpenTelemetry 可观测性埋点这样到生产环境排查问题时能快速定位到某个 procedure 的执行耗时。这些扩展都是我下一步打算做的也希望你能在动手实践后找到自己的节奏。
阅读完成 · 觉得有帮助?