前段时间帮团队做技术预研把一堆老项目往 TypeScript 全栈迁移折腾一圈下来我发现最耗时的不是业务逻辑本身而是反复配置路由、鉴权、数据库模型、接口定义这些底层基建。每个项目都要复制粘贴一遍风格还不统一后面对接和交接全是隐性成本。直到我把 t3code 这套实践串起来才算真正把重复劳动压下去。如果你也在搜 t3code大概率正在考虑 T3 这条技术路线——用 Next.js 做页面和 APItRPC 保证端到端类型安全Prisma 管数据库Tailwind 处理样式。这篇文章不堆理论我按自己从初始化到部署的完整过程来拆把关键环节、选型理由和容易翻车的细节一次讲清楚。适合已经在用 TypeScript、但还没跑通全栈闭环的同学也适合想快速搭一个可扩展 MVP 的独立开发者。1. t3code 要解决的真实痛点全栈项目里的重复造轮子1.1 先从 t3code 和 T3 生态的关系说起很多人第一次看到 t3code 会有点懵以为它又是一个新框架。实际我理解它就是围绕 T3 技术栈的一整套代码工程实践把 Next.js、tRPC、Prisma、Tailwind 这四样东西按一套约定好的方式组合起来形成可以直接开工的项目底座。它的重点不在某个新特性而在“组合方式”和“约定内容”。T3 这套组合为什么流行核心原因是它把类型安全的链路从前端一路打通到数据库。传统开发里前端调接口要手写类型声明后端要维护接口文档数据库字段变了要同步改多端代码任何一环漏了线上就会冒出一个运行时错误。T3 的思路是从数据库 Schema 开始生成类型再通过 tRPC 把接口类型直接导入前端组件调用处让 TypeScript 在编译器就拦住大部分不一致。我用 t3code 做过一个实际项目后最大的感受是团队的沟通成本降低了。以前前后端联调要反复对字段、对返回结构现在只要类型能过编译接口基本不会出错。不需要额外生成 OpenAPI 文档不用维护重复的类型定义改一个字段编译器会告诉你要改哪里。1.2 全栈项目里最容易被忽略的四类重复劳动我梳理过自己过去一年的项目发现真正拖慢进度的不是业务复杂度而是下面这四类事情项目初始化创建 Next.js 应用、接入 Tailwind、配 ESLint/Prettier、装 tRPC 和 Prisma每个新项目都要重来一遍步骤虽不复杂但极其琐碎。接口层胶水代码定义 API 路由、处理请求参数校验、拼接返回结构很多代码跟业务无关纯粹是“翻译层”。鉴权与会话逻辑登录态怎么存、Session 怎么刷新、受保护接口怎么校验换一个项目就要重新写一版还经常写得不一致。数据模型迁移字段新增、删除、重命名开发环境和生产环境的同步多人协作时经常因为迁移脚本不统一出问题。t3code 这类工具的价值就是把其中一部分沉淀成模板和约定让你不用每次从零开始想。尤其对独立开发者能省下至少一两个工作日这部分时间拿去做业务验证显然更值。1.3 为什么“先定骨架”比“先写业务”更划算我见过不少项目是业务跑得差不多了再补工程化这也没问题但如果一开始就能用一套成熟的骨架后面避免的返工更多。骨架解决的不只是初始创建更重要的是目录结构、数据流、类型边界这些“长期契约”。以 tRPC 为例它要求你把 API 的输入输出类型在前后端共享一旦项目跑起来再想导入类型系统改动面会非常大。数据库也一样Prisma Schema 如果在后期再引入那些手写的 SQL 和查询代码都要重写。先定骨架的意思不是说把所有基础设施都配到完美而是把关键链路先打通让后续业务代码能直接往这条链路上叠加。我用 t3code 搭项目时会把数据模型、鉴权、基础路由作为第一优先级页面和交互放在第二步。这样验证核心链路时能尽早发现架构层面的问题而不是在写了一堆页面之后才被迫改结构。2. 环境准备与目录规划动手前先把地基打牢2.1 选对 Node 版本和包管理器这个坑我踩过多次。t3code 涉及 Next.js 14、Prisma、tRPC 这些依赖对 Node 版本有要求。太老的 Node 版本会导致 Prisma 引擎启动失败或者 Next.js 构建报错。我现在的统一标准是 Node 18 LTS 或 20 LTS核心是保证 pnpm 能正常安装依赖、Prisma CLI 能跑 migration。包管理器我推荐直接用 pnpm。原因很简单t3code 这类项目依赖数量多pnpm 的硬链接机制能大幅减少磁盘占用而且安装速度快。npm 和 yarn 不是不能用但在 monorepo 或者项目依赖较多时pnpm 的依赖隔离做得更干净能避免很多“我本地没问题CI 上就报错”的灵异事件。# 建议先全局安装 pnpm npm install -g pnpm # 验证版本 node -v pnpm -v另外要提前把.nvmrc文件加到项目根目录内容写20这样团队成员和 CI 都能自动切到统一版本。我见过太多因为本地 Node 版本偏高、生产环境版本偏低导致的构建问题这个文件值得花十秒钟加上。2.2 一个能保住“类型边界”的目录结构t3code 的工程实践里我最看重的是目录结构是否能把类型边界维护住。下面是我现在比较常用的结构├── prisma │ └── schema.prisma ├── src │ ├── pages │ │ └── api │ │ └── trpc │ │ └── [trpc].ts │ ├── server │ │ ├── api │ │ │ └── routers │ │ ├── db.ts │ │ └── auth.ts │ ├── trpc │ │ ├── client.ts │ │ ├── server.ts │ │ └── context.ts │ ├── styles │ └── utils └── package.json分层的核心逻辑是prisma目录只放数据模型src/server放所有后端逻辑数据库、鉴权、路由src/trpc放 tRPC 的上下文和客户端连接src/pages放页面和 API 入口。这样前端页面代码不会直接接触 PrismaClient后端路由不会直接操作数据库查询每一层都通过类型明确的函数边界交流。2.3 环境变量、tsconfig 与代码规范t3code 对环境变量管理有一个很实用的做法用zod写一个环境变量校验模块。因为 Next.js 在编译时会把环境变量嵌入代码如果某个必填变量缺失运行时才报错会非常难排查。用校验模块把问题提前到启动阶段这是性价比极高的习惯。import { z } from zod; const envSchema z.object({ DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), NEXTAUTH_URL: z.string().url().optional(), }); export const env envSchema.parse(process.env);我把这个模块放在src/server/env.ts在 server 入口处引入一次。启动时如果环境变量配错控制台会直接打印缺失字段不用去翻日志。tsconfig 方面t3code 通常默认开启strict: true这个尽量不要关。关闭严格模式等于自废 TypeScript 能力短期内写起来快长期会让错误越积越多。项目如果允许建议再加上noUncheckedIndexedAccess: true它会让数组和对象访问返回T | undefined逼着你处理边界情况运行时错误会少很多。代码规范我直接用 ESLint Prettier 的固定组合关键是不准关闭typescript-eslint/no-unused-vars这类基础规则。项目大了以后死代码和未使用的参数是最常见的维护障碍规则从一开始就立住后面不会那么痛苦。3. 核心模块拆解从 API 到数据库再到鉴权3.1 tRPC 路由一条链路贯穿前后端tRPC 的原理不复杂它不走 REST 风格的路由约定而是把后端函数直接暴露给前端调用类型信息跟着函数走。前端引入一个类型化的 hook调用后端方法就像调用本地函数一样。我在实践中的做法是把路由按业务模块拆分。先在src/server/api/routers下建todo.ts、user.ts这类文件然后在根路由文件里聚合// src/server/api/root.ts import { createTRPCRouter } from ../trpc; import { todoRouter } from ./routers/todo; import { userRouter } from ./routers/user; export const appRouter createTRPCRouter({ todo: todoRouter, user: userRouter, }); export type AppRouter typeof appRouter;类型AppRouter导出后前端就能通过它获取所有路由的类型。每次新增 router只要在聚合处挂载前端调用处的类型提示立即更新。开发体验上这比 REST 手写类型方案顺畅得多。3.2 Prisma 建模先想清楚关联关系Prisma 的价值不仅在于类型安全更在于数据库迁移的一体化管理。我之前用 SQL 脚本管理表结构多人协作时经常出现“本地改了表线上忘了执行”的问题。Prisma 的 migration 机制把每次 Schema 变更固化为迁移文件可以追溯、可以回滚部署时也能一键执行。建模时我有几个固定原则。第一所有表必须有主键第二关联关系用relation显式声明不给隐式外键留机会第三时间字段统一用createdAt和updatedAt配合default(now())与updatedAt自动维护。model Todo { id String id default(cuid()) title String completed Boolean default(false) userId String user User relation(fields: [userId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }这里我把 id 设计成cuid()而不是自增整数理由是避免暴露业务量也便于将来做数据拆分或合并时减少冲突可能。3.3 NextAuth 接入会话与鉴权的联动t3code 项目里NextAuth 的作用不只是“登录”更关键的是和 tRPC 的 context 联动。每次请求进来tRPC context 会读取 NextAuth 的 session把当前用户信息放到请求上下文里后端路由只需要从ctx.session.user.id读取用户 ID就能做数据隔离。这里最容易踩坑的是 Session 类型覆盖。NextAuth 的默认类型里session.user只有name、email、image几个可选字段没有id。不扩展类型的话你在前端拿session.user.id会直接类型报错。我的做法是在项目里声明一个types/next-auth.d.tsimport { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; } DefaultSession[user]; } }配好这一步后续在 tRPC 路由里拿用户信息就很顺手前端也能安全地读取session.user.id。3.4 React Query数据请求状态管理t3code 默认会集成 React Querytanstack/react-query它解决的是请求状态管理问题加载态、错误态、缓存、重试、失效刷新。tRPC 的客户端也是建立在 React Query 之上所以这两者配合得非常自然。使用上我比较依赖 query key 的失效机制。做完一个 mutation比如新增一条 Todo后不需要手动刷新页面只需要让相关 query 失效const utils api.useUtils(); const createTodo api.todo.create.useMutation({ onSuccess: () { utils.todo.getAll.invalidate(); }, });invalidate会让todo.getAll重新拉取最新数据界面上的列表自动更新。这个机制比“手动 setState 然后塞数据”要可靠得多尤其是在多人协作或多端修改的场景下。4. 从零跑通一个示例功能TODO 应用实战4.1 定义数据模型与迁移说了这么多原理不如直接跑通一个功能。我拿最经典的 TODO 应用举例从数据模型到页面组件完整走一遍。第一步是写prisma/schema.prisma包含用户和 Todo 两张表model User { id String id default(cuid()) name String? email String unique emailVerified DateTime? image String? accounts Account[] sessions Session[] todos Todo[] } model Todo { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id]) }Schema 定义好后执行迁移命令生成数据库表prisma migrate dev --name init4.2 编写 routercreate/update/delete 的拼装逻辑路由层的逻辑是校验输入、写入数据库、返回结果。tRPC 在定义 procedure 时可以用protectedProcedure保证只有登录用户可以调用。// src/server/api/routers/todo.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const todoRouter createTRPCRouter({ getAll: protectedProcedure.query(({ ctx }) { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1) })) .mutation(({ ctx, input }) { return ctx.db.todo.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), });注意每个 procedure 都从ctx.session.user.id取用户信息不信任前端传入的userId这是最基本的数据隔离原则。否则任何用户都能创建属于别人的数据这才是真正的安全漏洞。4.3 页面组件与缓存更新页面侧核心逻辑是调用api.todo的 hooks 获取数据提供输入框和按钮来新增 Todo。import { useState } from react; import { api } from ~/utils/api; export default function TodoPage() { const [title, setTitle] useState(); const utils api.useUtils(); const todos api.todo.getAll.useQuery(); const createTodo api.todo.create.useMutation({ onSuccess: () utils.todo.getAll.invalidate(), }); return ( div input value{title} onChange{(e) setTitle(e.target.value)} placeholder输入待办事项 / button onClick{() { createTodo.mutate({ title }); setTitle(); }} 添加 /button ul {todos.data?.map((todo) ( li key{todo.id} {todo.completed ? 已完成 : 未完成} - {todo.title} /li ))} /ul /div ); }这段代码跑通后整个数据流已经闭环页面调用 tRPC hook请求打到 Next.js API 路由tRPC server 端从 context 拿到 session再用 Prisma 查询数据库并返回。全程类型都是自动推导的不需要手写接口类型。4.4 端到端验证与类型检查功能写完后我会先跑prisma migrate dev确认数据库没问题再跑npm run typecheck。这里有个容易被忽略的细节Next.js 开发模式下代码会经过 SWC 编译很多类型错误不会在页面运行时暴露必须依赖独立的typecheck脚本。t3code 项目我建议在package.json里记住这个命令{ scripts: { typecheck: tsc --noEmit } }跑通类型检查后打开浏览器实际操作一遍登录、添加待办、刷新页面、确认数据持久化。实测下来整个过程在数据量小的场景下响应非常快几乎没有额外延迟。这是因为 tRPC 走的还是 HTTP 请求没有额外的套接字开销和普通 API 相比体感一致。4.5 关于数据验证的补充很多项目在路由层省掉输入校验等于把安全交给调用方自觉。t3code 里通常用 zod 做 schema 校验我强烈建议每个 mutation 的 input 都写清楚字段约束哪怕只是z.string().min(1)。这不只是防攻击更大的作用是让前端调用时类型提示更精确也约束了业务边界。5. 部署与性能细节把项目真正送上生产环境5.1 平台选择Vercel、自托管还是容器t3code 这类 Next.js 应用最顺的部署平台确实是 Vercel尤其是小项目和原型验证阶段连数据库用 Vercel Postgres 的话基本能做到“git push 就上线”。但 Vercel 也有它的限制免费额度有限、超出后可能被限流而且 Serverless 函数有执行时长限制长时间运行的定时任务不适合放这里。如果项目要长期跑业务我更倾向自托管或容器化部署。Docker 方案的好处是环境一致性避免“本地正常、服务器跑不起来”的问题。我一般用 Node 20 的 Alpine 镜像多阶段构建把依赖安装和运行分开镜像体积能压到两三百兆级别FROM node:20-alpine AS base WORKDIR /app FROM base AS deps COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile FROM base AS build COPY --fromdeps /app/node_modules ./node_modules COPY . . RUN pnpm build FROM base AS runner ENV NODE_ENVproduction COPY --frombuild /app/.next ./.next COPY --frombuild /app/node_modules ./node_modules COPY --frombuild /app/public ./public CMD [pnpm, start]5.2 两个必须提前定好的部署细节第一是执行数据库迁移的时机。最稳妥的方式是先跑prisma migrate deploy再启动应用否则新的查询代码访问旧表结构会直接报字段不存在。我在 CI/CD 流程里专门加了一步 migration 任务和应用启动解耦。第二是环境变量在构建时和运行时的区别。Next.js 会在next build时把NEXT_PUBLIC_*前缀的变量内联进 JS bundle所以这类变量必须在构建阶段注入而数据库地址、密钥这类服务端变量运行时不建议直接写进镜像用运行平台的 Secrets 管理更安全。5.3 包体积与静态页面缓存Next.js 的next build输出里会区分静态生成的页面和动态响应的 API 路由。对于纯展示型页面尽量让它被静态化这样响应速度会非常快服务器压力也小。t3code 实践中我会用revalidate或按需静态生成来平衡内容更新与性能。如果包体积太大先查node_modules里有没有不必要的依赖。t3code 默认带了 tRPC 和 React Query体积已经比较克制。真正会撑大体积的通常是图表库、日期处理库这些业务依赖可以考虑按需加载或动态导入。6. 避坑记录我在 t3code 实践里踩过的五个坑6.1 Session 顺序导致 tRPC 鉴权失效第一次接入 NextAuth 和 tRPC 时我在authOptions里还没配置session策略就急着在 context 里读ctx.session.user.id结果类型检查过了运行时报空指针。原因在于 NextAuth 默认的 session 策略是数据库模式配置里没声明的话ctx.session可能是null。解决方法是把 session 策略明确写出并确保NEXTAUTH_SECRET已配置export const authOptions: NextAuthOptions { session: { strategy: jwt }, secret: env.NEXTAUTH_SECRET, // ... };用 JWT 策略在 Serverless 环境中更省事不用每次请求都查数据库。但代价是无法即时吊销 session团队要提前权衡。6.2 Prisma 在 Serverless 下的连接池耗尽Serverless 环境里每个请求都可能拉起一个函数实例如果每次实例化PrismaClient连接数会快速增长直到把数据库连接池打满。我遇到过部署到 Vercel 后访问量稍大就直接报Too many connections。解决方法是在src/server/db.ts里做全局单例import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这个改动看起来小但能显著降低 Serverless 环境下的连接压力。6.3 Tailwind 类名冲突与样式失效Tailwind 用得多了会遇到自定义类名和样式层叠导致的表现异常。有一次我给按钮加了bg-blue-500但页面始终显示默认色排查半天发现是globals.css里有一条更晚加载的自定义样式把它覆盖了。建议所有自定义样式尽量用layer包裹并避免直接写死后缀的类名覆盖 Tailwind 工具类。如果你用的是 Tailwind v3/v4检查tailwind.config.ts里的content配置是否覆盖了所有用到类名的目录漏掉目录会导致生产构建时样式缺失。6.4 环境变量类型不匹配Next.js 的env文件只提供字符串值如果你使用process.env.PORT_NUMBER做算术运算很容易得到字符串拼接结果而不是数字。我用 zod 做环境变量校验时就遇到过PORT3000被当字符串传给函数导致逻辑错误。解决办法很简单校验 schema 时用z.coerce.number()做转换或者在解析后手动Number(process.env.X)。重点是让“环境变量是字符串”这个事实在入口处被处理掉不要在业务代码里到处做类型转换。6.5 部署时 migrate 与 build 的时序问题我做过一次部署构建时Prisma Client还没生成导致next build直接失败。原因是.env文件里没有DATABASE_URLPrisma 的postinstall阶段无法连接数据库生成 client。我的解法分两步构建阶段允许无数据库环境生成 client部署阶段再执行真正的 migration。在 CI 脚本里顺序一定是pnpm prisma generate pnpm build pnpm prisma migrate deploy pnpm start这里最怕的是把migrate deploy放在build之后但依赖build期间生成的 client 文件顺序错一步就会陷入“鸡生蛋”的问题。最后分享一个我养成的习惯每次调整完prisma/schema.prisma我会立刻跑一次prisma migrate dev --name xxx绝不攒多个改动一起执行。小的迁移文件容易定位问题回滚也简单。真等到上线前再来一次大迁移报错时你根本不知道是哪个字段引起的。t3code 这套实践本身并不神秘它好就好在把复杂的全栈链路收敛成了几条固定的路径你按照路径走遇到问题时知道去哪里查、从哪里改这就是它最大的价值。