
t3code 这个项目我维护了挺长时间了从一开始只是自己折腾的一套脚手架到后来慢慢沉淀成一套比较完整的前后端一体方案中间踩过不少坑也砍掉过不少花里胡哨的想法。你要是正在纠结全栈 TypeScript 项目怎么组织、类型安全怎么贯穿到底、又不想在配置工程上花太多时间那这篇内容应该很适合你。我尽量把思路、选型、实操和踩坑记录都拆开讲清楚你可以直接照着抄也可以当成一份避坑清单来用。1. T3 核心项目思路拆解t3code 到底在解决什么问题1.1 T3 技术栈的底层逻辑先说 T3 栈本身。T3 不是一个新的框架而是一套技术组合的约定核心成员是 TypeScript、Tailwind CSS、tRPC。很多人第一次听说 T3 的时候会问为什么偏偏是这三样我觉得关键在于它们分别回答了三个问题语言基础用什么、样式怎么写、数据怎么流通。TypeScript 是地基。它让类型系统成为整个项目里唯一的事实来源。以前写前端接口文档是 Word 里的表格后端的 DTO 是 Java 类前端再手写一遍 interface两边对不对得上全靠自觉。T3 的思路是让类型从数据库一路流到 UI 组件中间不手工维护任何契约这就把一类特别容易出现的 bug 直接消灭在编译期。Tailwind CSS 解决的是样式组织问题。它不是必须的但在 T3 这套体系里它有个隐性优势组件库和页面模板各自独立样式不会互相污染而且写起来速度快非常适合快速迭代原型的场景。如果你不喜欢 Tailwind完全可以用 CSS Modules 替代T3 强调的是类型安全不是绑死某个 UI 方案。tRPC 是最关键的一环。它让前端调用后端接口像调用本地函数一样输入输出类型自动推导不需要生成 client SDK不需要维护 OpenAPI 规范。这一点在中小型全栈项目里非常舒服因为它把前后端联调这件事简化到了几乎不存在的程度。1.2 t3code 补齐了什么T3 栈本身是一个很好的起点但真实项目里光有这三个还不够。我在做 t3code 的时候主要是在 T3 的基础上补了几块东西。第一块是数据库层的类型化。T3 官方推荐 Prisma我也沿用了这个选择。Prisma 的强大之处在于 schema 文件是唯一数据源它根据 schema 自动生成完整的 TypeScript 类型模型关系、枚举、索引都覆盖。也就是说从数据库表设计到后端代码里的类型是完全同步的改表结构只要迁移一次类型立刻跟着变。第二块是认证体系。我做项目几乎都会碰到登录、注册、权限控制所以 t3code 直接集成了 NextAuth.js。它支持 Credentials 登录、OAuth 登录、JWT session 等几种常见模式和 tRPC 中间件配合起来可以非常干净地实现未登录不能调用某组接口这类需求。第三块是部署链路的规范化。很多脚手架只管开发环境跑得通一到部署就各种问题。t3code 里放了一套 Docker 多阶段构建的方案包括环境变量管理、数据库迁移的执行顺序、静态资源的处理这样不管是部署到 VPS 还是主流云平台都能用一个稳定的流程跑起来。所以 t3code 本质上是T3 栈 数据库类型化 认证 部署规范的完整封装。它不是要发明什么新东西而是把一套经过验证的全栈约定整理出来让你不用每次从零开始搭积木。2. 核心细节解析t3code 的工程结构与类型链路2.1 端到端类型安全的设计很多朋友看到端到端类型安全这个词会觉得这有点玄乎我拆开说一下它到底指什么。一条数据从数据库到界面要经过这么几个环节MySQL/PostgreSQL 的表结构、服务端 ORM 模型、API 层的输入输出、前端调用函数、UI 组件的 props。传统项目里这五层每一层之间都可能出现不一致比如数据库字段叫created_at后端返回createdAt前端又定义成createTime每个环节都在手动对齐早晚出问题。t3code 里这条链路是完全打通的。用 Prisma 定义 model 之后比如一个Post模型Prisma 会自动生成完整的类型定义。tRPC 的 router 里写查询逻辑时返回值的类型就是 Prisma 推断出来的不需要手写。前端通过trpc.post.list.useQuery()调用时拿到数据的类型也完全一致。这时候如果你在组件里写data.titelIDE 会直接标红因为类型系统知道这个字段根本不存在。这个设计最大的价值不只是少写了几个 interface而是让团队协作变得更稳。后端改了字段名前端编译直接报错这就逼着开发者同步修改而不是上线之后才发现页面白屏。类型安全不是锦上添花它是一种约束力让整个系统的修改成本下降了非常多。2.2 目录结构与模块划分t3code 的目录结构是我反复调整过很多次才定下来的。整体分三大块应用入口、服务端逻辑、共享类型。src/ ├── pages/ # Next.js 路由页面 ├── server/ │ ├── api/ # tRPC 路由注册和 HTTP 适配 │ ├── auth/ # NextAuth 配置与权限工具 │ ├── db/ # Prisma Client 实例 │ └── trpc/ │ ├── router/ # 业务路由按模块拆分 │ │ ├── post.ts │ │ ├── user.ts │ │ └── root.ts │ ├── context.ts │ └── trpc.ts # tRPC 初始化与中间件定义 ├── styles/ # 全局样式与 Tailwind 入口 ├── types/ # 共享类型与工具类型 └── utils/ ├── api.ts # 前端统一的 tRPC Client └── auth.ts # 前端权限判断辅助这个结构有个很重要的原则server目录下的东西不允许被前端页面直接 import前端只能通过utils/api.ts里的 tRPC Client 来调用接口。这样做的好处是把边界划清楚服务端代码不会意外把数据库连接打包到浏览器 bundle 里。权限中间件我放在server/trpc/trpc.ts里跟业务路由分开因为这样可以在不侵入业务代码的情况下统一做登录校验。router目录按模块划分每个文件对应一个业务域比如帖子、用户、评论。模块之间如果有交叉引用通过root.ts统一合并这样避免了循环依赖的问题。2.3 中间件与上下文设计tRPC 有个比较重要的概念叫 context它会在每次请求时创建用来放请求级别的数据。我在 t3code 里把 session、数据库实例、请求 headers 都塞到 context 里这样所有 router 都能直接访问。这里有一个很容易忽略的细节Context 是异步创建的而 Prisma Client 是全局单例。如果你把数据库连接放在 context 里每次请求都新建连接连接池会被打爆。所以我在 t3code 里把 Prisma Client 定义成全局单例context 里只放 session 和请求相关信息数据库实例直接 import 单例。这个细节实践里非常重要我见过不少项目上线后出现连接数飙升的问题根源就在这。3. 实操过程从零跑通一个 t3code 典型业务模块3.1 初始化与依赖安装如果你用的是 t3code 的模板初始化非常简单npx create-t3-applatest my-app这个命令会带出一个交互式问题让你选择需要的功能模块。我建议首次使用勾选Next.js、TailwindCSS、tRPC、Prisma、NextAuth.js这些是 t3code 的基础组合。如果你不需要认证也可以把 NextAuth 去掉但 Prisma 和 tRPC 建议保留它们直接关系到类型链路的完整性。安装完成后进入项目目录先跑一下cd my-app npm install npx prisma migrate dev --name initPrisma 迁移这一步会创建数据库表结构。它默认读.env里的DATABASE_URL这个值在模板里是localhost的 PostgreSQL你要是用 MySQL改一下连接串就行。迁移完Prisma 会自动生成最新的 Client 类型这一步很关键因为后面写的所有查询代码都依赖这层类型。3.2 用 Prisma tRPC 实现第一个 CRUD我先用一个待办事项的例子来走通完整的流程因为 todo 足够简单又能覆盖增删改查。第一步在prisma/schema.prisma里定义模型model Todo { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt }定义好之后跑迁移然后在server/trpc/router/todo.ts里写路由import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const todoRouter createTRPCRouter({ list: publicProcedure.query(({ ctx }) { return ctx.prisma.todo.findMany({ orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(({ ctx, input }) { return ctx.prisma.todo.create({ data: { title: input.title }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const todo await ctx.prisma.todo.findUnique({ where: { id: input.id }, }); if (!todo) { throw new Error(Todo not found); } return ctx.prisma.todo.update({ where: { id: input.id }, data: { completed: !todo.completed }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(({ ctx, input }) { return ctx.prisma.todo.delete({ where: { id: input.id }, }); }), });这里有两个地方值得说明。publicProcedure和protectedProcedure的区别在于是否校验登录状态我把创建、修改类操作都设为需要登录列表这种读取操作公开因为不需要身份信息。另一个是 zod 的 input 校验层它不只是验证数据格式还会根据 schema 自动推导出input的类型写起来几乎没有额外的类型标注成本。3.3 前端接入与状态管理路由写完前端调用非常简单。比如在组件里读取待办列表import { api } from ~/utils/api; export default function TodoList() { const { data: todos, isLoading, refetch } api.todo.list.useQuery(); const createTodo api.todo.create.useMutation({ onSuccess: () refetch(), }); const toggleTodo api.todo.toggle.useMutation({ onSuccess: () refetch(), }); const deleteTodo api.todo.delete.useMutation({ onSuccess: () refetch(), }); if (isLoading) return divLoading.../div; // 渲染 todos 列表 }useQuery和useMutation是 tRPC 基于 React Query 封装出来的 Hooks它可以自动管理缓存状态。onSuccess: () refetch()这种写法是 t3code 里非常典型的模式因为 mutations 完成之后需要刷新相关列表重新拉一次数据就能保持界面最新。如果你想要更细粒度的缓存更新还可以在onSuccess里调用utils.todo.list.setData直接更新本地缓存免去一次请求。但对于简单场景refetch 足够用了代码也更直白。3.4 部署到生产环境的注意点开发环境跑通之后部署是另一套逻辑。我建议用 Docker 来打镜像因为这样能保证生产环境和本地的一致性。t3code 里有一个多阶段构建的 DockerfileFROM node:20-alpine AS base FROM base AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci FROM base AS builder WORKDIR /app COPY --fromdeps /app/node_modules ./node_modules COPY . . RUN npx prisma generate RUN npm run build FROM base AS runner WORKDIR /app ENV NODE_ENVproduction COPY --frombuilder /app ./ EXPOSE 3000 CMD [sh, -c, npx prisma migrate deploy npm run start]这里有个关键细节prisma generate必须在 build 之前执行因为 tRPC router 里的类型使用了 Prisma 生成的类型没有提前生成会导致构建失败。另一个细节是CMD里的prisma migrate deploy和npm run start迁移一定要在应用启动前完成否则新版本的代码可能访问到旧的表结构。在真实的服务器环境里你可以把这步提出来放到 CI/CD 流程里做效果一样。环境变量方面NODE_ENVproduction、DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL是在运行时必须要有的。我之前踩过好几次坑启动容器时忘了传NEXTAUTH_URL导致登录跳转地址变成 localhost折腾了挺长时间。4. 常见问题与排查技巧实录4.1 类型报错与 schema 同步问题用 t3code 写项目最常见的编译错误就是类型对不上。比如你改了 Prisma schema加了个字段但没跑npx prisma generate然后代码里引用新字段的时候就报错。我第一次遇到时还以为是 IDE 的问题后来才反应过来Prisma Client 的生成类型是缓存的必须手动刷新。解决办法很简单每次修改schema.prisma之后记得跑npx prisma migrate dev或者至少npx prisma generate。我习惯在开发过程中加一个pretest脚本把生成步骤挂进去这样测试之前一定会重新生成避免漏掉。另一种容易出现的类型问题是 zod input 和数据库字段不完全匹配。比如前端传了completed: false但数据库默认值也是false双方逻辑看起来都正常但create的 input 并没有允许传completed字段前端就会报类型错误。解决办法是明确 input schema 里到底允许哪些字段宁少勿多保持输入和更新逻辑的边界清晰。4.2 tRPC 请求失败与错误处理tRPC 默认的错误信息比较简洁生产环境里可能只显示INTERNAL_SERVER_ERROR。排查的时候首先要确认请求是不是真的发到了正确路径接着看服务端日志Prisma 的报错通常会带着比较明确的 SQL 或约束信息。如果是不带认证信息的用户访问了protectedProcedure会直接抛UNAUTHORIZED。前端可以统一加一个错误拦截器在utils/api.ts的QueryClient里配置MutationCache和QueryCache的onError弹出提示或者跳转登录页。这样用户看到的就不是生硬的报错文字而是请先登录这类友好提示。还有一个小坑是数据序列化。tRPC 传输的数据会被 JSON 序列化所以Date类型会变成 ISO 字符串。如果你的代码里依赖todo.createdAt.getTime()这类调用前端拿到的其实已经是字符串了直接调用会报错。我建议在前端不要直接使用 Date 类型的方法要么在服务端就把格式化好的字段返回要么前端用一个工具函数做转换。4.3 环境变量与数据库迁移的问题生产环境里最容易让人头疼的就是环境变量没配齐造成应用启动失败或者运行异常。下面这个清单是我根据踩坑经验整理出来的每次部署前我都照着过一遍环境变量作用缺失后果DATABASE_URL数据库连接串应用完全无法连接数据库NEXTAUTH_SECRETJWT 加密密钥登录后 session 校验失败NEXTAUTH_URLNextAuth 基础 URLOAuth 回调地址错误NODE_ENV运行环境标记某些逻辑分支走错NEXT_PUBLIC_APP_URL前端页面基础地址绝对路径拼接错误数据库迁移又是一个容易出错的地方。本地开发用prisma migrate dev会做开发用的迁移和 seed但在生产环境中你需要在部署流水线里执行prisma migrate deploy这个命令只会应用尚未执行的迁移文件不会触发 reset。如果生产库的表结构和本地不一致deploy 会报错这时候千万不要跑prisma migrate reset那会清空数据。正确做法是把生产库的迁移记录对齐或者人工检查差异后手动补迁移。4.4 性能相关的高频问题t3code 这类全栈方案在小规模项目里非常流畅但数据量上来之后有一些性能点需要提前注意。最典型的坑是 N1 查询。Prisma 的findMany默认不会自动关联查询其他表。如果你的业务里有列表页展示用户头像这样的需求直接访问post.author.avatar就会触发逐条查库列表几十条数据就会发几十次请求。解决办法是在查询里显式加include或selectreturn ctx.prisma.post.findMany({ include: { author: { select: { name: true, image: true }, }, }, });另一个容易忽略的点是缓存策略。tRPC 的useQuery默认只在组件挂载时请求一次如果多个组件调用同一个 queryReact Query 会共享缓存。但如果你希望数据保持实时性比如某个列表会被人改动那就要配合 React Query 的 staleTime 和 refetchOnWindowFocus 等配置。我一般把基础 staleTime 设为 30 秒太频繁的刷新反而会拖慢体验。Prisma 查询本身还有一个建议就是能用select就不要用include全字段拉取特别是有的字段是长文本列表页根本用不到。select会让 SQL 只查需要的列对减小响应体挺有效的。4.5 关于 NextAuth 的一些适配细节最后聊聊 NextAuth 在 t3code 里的一些适配细节。NextAuth 的默认形态是 RESTful 认证端点但 t3code 里的思路是尽量让认证状态进入 tRPC context这样业务接口就不再依赖 cookie 解析和 session 库统一走 tRPC。具体做法是在context.ts里调用getServerSessionexport const createTRPCContext async ({ req, res }) { const session await getServerSession(req, res, authOptions); return { prisma: prisma, session, }; };然后在trpc.ts里定义受保护的中间件const protectedProcedure publicProcedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: ctx.session, }, }); });这里有个蛮重要的点NextAuth 的getServerSession内部会读取 cookie 并校验 session这个操作是异步的而且不要放在全局初始化阶段必须在每个请求的 context 里执行。很多人把它抽成一个公共函数然后在别处直接调用结果某些非请求上下文里拿到了 null排查起来非常诡异。如果你在项目里用到了 Credentials 登录要注意 NextAuth 默认不提供 Credentials Provider 的持久化 session需要配合 JWT 策略来做。也就是说session.strategy要设置成jwt否则登录后刷新页面会掉登录态。这个问题我在早期版本里碰到过当时花了不少时间才确认是 session 策略配置的问题。最后分享几个我一直保留的工程习惯这些小习惯不一定每个人都认可但对我长期维护 t3code 帮助很大分享给你参考。新增业务模块时我习惯先写 Prisma schema再写 tRPC router最后才写页面。这个顺序符合类型链路的自然流向数据源先定了类型才能传下去。反过来先写页面再倒推接口容易为了迁就 UI 而设计了不必要的字段后患比较多。还有是 seed 脚本。开发环境里没有数据很多时候测不出真实情况t3code 里放了一个prisma/seed.ts每次重置数据库后执行一遍会生成一批带关联关系的测试数据。这个投入很小但节省的时间相当可观。部署上线时我有一个三层检查清单的习惯第一层是本地 build 能否通过第二层是 Docker 镜像能否在空数据库上完成迁移并启动第三层是模拟线上环境变量跑一遍核心登录链路。这三层走完基本不会有上线翻车的问题。t3code 这套方案比较适合中小型项目、内部工具、以及个人项目的快速验证。它不像大型微服务系统那么复杂也不像纯前端站点那么简单它瞄准的是一个人或一个小团队可以驾驭整套全栈的场景。如果在用的过程中你有自己的坑和心得欢迎回来交流工程化这东西永远是在真实项目的摩擦中进步的。