T3 Stack 模板 t3code 全解析:类型安全全栈开发实战
第一次看到t3code这个项目名我第一反应是这又是一套 T3 Stack 模板吧。但真正把它跑起来之后我意识到它并不是换了个名字的老套路。T3 Stack 这几年讨论度一直不低核心就是拿 TypeScript 当底座用 Next.js 扛应用框架用 Tailwind CSS 处理界面样式再用 tRPC 把前后端调用串成一个类型闭环最后把 Prisma、NextAuth、Zod 一起塞进同一套工程体系里。这样组合下来全栈开发从“拼接技术栈”变成了“建立一套端到端类型安全的开发范式”。这篇文章我想把 t3code 这套体系从设计思路到实操落地完整讲一遍包括初始化选型、目录结构、tRPC 路由设计、Prisma 数据模型、NextAuth 集成、部署方案以及我实际踩过的一些坑。不管你是准备上全栈车的新手还是被类型推导折腾过多次的老手只要想了解“类型安全全栈”到底是什么体验这篇文章都值得往下看。1. 为什么把 T3 Stack 做成一站式代码模板1.1 t3code 要解决的全栈协作痛点传统前后端分离项目里最折磨人的不是写业务逻辑而是“接口对齐”。后端定义了一百个接口前端对着文档手写类型字段拼错了直到运行时才报错。前后端各自有一份类型定义时间一长文档过期类型漂移谁也不敢保证改一个字段名会不会弄崩上下游。t3code 的出发点其实就是解决这个痛点让一份类型定义从数据库一路贯穿到浏览器组件。T3 Stack 并不是什么新发明它本质上是对一套已被验证过的技术组合的收拢。Next.js 负责页面和数据请求TypeScript 负责在编译期卡住错误tRPC 负责让 API 调用像调用本地函数一样自然Prisma 负责把数据库表变成类型安全的查询客户端Zod 负责在边界上校验数据。这套组合的最大价值在于你不需要自己写接口文档不需要手工维护类型定义只要沿着 tRPC 的调用链写下去类型会自动跟着走。1.2 类型安全贯穿“数据库到组件”的完整链路用一个具体场景说明。传统的全栈项目里Todo这个概念至少存在三份数据库表字段一份、后端 DTO 一份、前端 interface 一份。任何一份更新了另外两份没跟上项目就跑得踉踉跄跄。t3code 的思路是只定义一次 Prisma 模型再通过 tRPC 的 router 把数据库查询、类型推断、前端调用全部绑定起来。前端拿到什么数据结构后端返回什么数据库存了什么三者在编译期就被校验了。我自己调试过不少全栈项目最深的体会是类型安全带来的不只是“少写代码”更是一种心理安全感。以前迭代一个需求改完后端接口还要去前端同步类型生怕哪里漏了。现在在 t3code 里改完 Prisma schema跑一次prisma generate业务代码里所有相关类型自动更新有冲突编译器会直接报出来。这种体验很像开车时多了一套车道偏离预警你不用时刻盯着线系统会在跑偏的时候提醒你。1.3 t3code 适合哪些场景不适合哪些场景t3code 适合的是需要快速搭建完整全栈应用的场景比如个人项目、内部工具、SaaS 产品原型以及教学演示。它对单服务端 多个客户端的模式很友好尤其适合你既写前端又管数据库的全栈开发者。一套模板初始化下来目录清晰路径别名配好环境变量校验配好ESLint 和 Prettier 已经就位省去了大量“搭工程”的时间。但它也不是万能的。如果你的业务主要是对外开放 API 给第三方或者是复杂的高并发微服务架构那 tRPC 就不是最优选择REST 或 GraphQL 可能有更成熟的生态。另外 t3code 对团队里所有成员的 TypeScript 水平有要求如果大多数人对泛型、类型推导还不熟这套体系的维护成本会明显上升。所以我在选型时对团队的判断是宁可前期花时间补 TypeScript 基础也不要带着一帮人陷入类型泥潭。2. 上手前的准备工作与初始化配置2.1 环境依赖与脚手架选型建议一套可复现的 t3code 环境软件版本不能太随意。我的建议是 Node.js 20 LTS 起步包管理器用pnpm。npm 虽然也能用但 install 速度慢磁盘占用大在大型全栈项目里差距还是很明显的。如果你的本机还没装 pnpm一条命令就能搞定npm install -g pnpm。另外数据库方面开发环境直接用 SQLite 最省心生产环境再用 PostgreSQL后面我会讲怎么切换。初始化项目直接用官方脚手架命令是pnpm create t3-applatest t3code交互式提示会让你勾选技术模块这里我的选择是Next.jsApp RouterTypeScript必选Tailwind CSS必选tRPC必选Prisma必选NextAuth.js按需需要登录就选ESLint Prettier必选脚手架跑完之后会提示安装依赖并初始化 Git然后让你校验环境变量。整个过程大概三两分钟就拿到了一个带有完整基础结构、路径别名、环境变量校验、基础样式、ESLint 规则和 tRPC 骨架的新项目。2.2 目录结构逐层解析别被花哨的写法吓到初始化完的目录乍一看挺密密麻麻但理清楚之后其实很简单。最上层有app和src两个入口app是 Next.js App Router 的页面目录src底下放了三块核心逻辑src/env.js是环境变量校验src/trpc是 tRPC 客户端封装src/server是数据库、鉴权、API Router 的服务端逻辑。具体到一个新克隆下来的 t3code 项目你会看到这些关键目录app/api/trpc/[trpc]/route.tsNext.js 把请求交给 tRPC 的桥接层。app/api/auth/[...nextauth]/route.tsNextAuth 的认证请求入口。src/server/db.tsPrisma Client 的实例创建与导出。src/server/auth.tsNextAuth 配置包括凭证、数据库适配器。src/server/api/trpc.tstRPC router 的初始化文件所有 procedure 的基础封装。src/server/api/routers/按业务拆分的 router 定义比如 post.ts、todo.ts。src/server/api/root.ts把所有子 router 合并成 appRouter。src/trpc/server.ts服务端调用 tRPC 的 caller 封装。src/trpc/react.tsx客户端 React Hooks 封装基于 tanstack/react-query。prisma/schema.prisma数据模型定义。这套结构的好处是约定大于配置。你知道哪一层该放什么文件找代码时不用翻半天。2.3 环境变量为什么 t3code 要用 Zod 卡校验新项目跑起来的第一关就是环境变量。t3code 默认用src/env.js里的 Zod schema 做校验启动时如果不满足就拒绝运行。这样会让很多新手抓狂明明只是少了个NEXTAUTH_SECRET项目直接起不来报错很刺眼。但这是故意这么设计的。环境变量是最容易出幺蛾子的地方拼写错了、类型错了、某个环境忘了配经常导致线上故障原因还特别难查。用 Zod 做启动校验本质是把问题卡在构建阶段而不是运行阶段。项目里需要读哪些环境变量schema 里写清楚少一个跑不了你就不用担心运行时出现“某个值为 undefined”的诡异 bug。如果你选用了 NextAuth.env里至少要有DATABASE_URLfile:./db.sqlite NEXTAUTH_SECRETyour-super-secret-key NEXTAUTH_URLhttp://localhost:3000这些变量在本地开发时还可以随便填部署到线上之后一定要换成真值。我见过不止一次线上数据库连接失败原因就是把生产环境的DATABASE_URL指向了本地 SQLite 文件。2.4 Tailwind 与全局样式模板里的默认设计够用吗t3code 的样式层直接用 Tailwind CSS模板里自带了一个基础布局和浅色/深色切换视觉上非常简洁。如果你不想从零调整全局样式直接用默认的配色和布局就够看了。但有一点要注意Tailwind 的类名建议在组件内部用语义化的方式组织不要为“省时间”把一长串className甩在一个 div 上。项目一大满屏的类名堆叠会严重影响可维护性。我习惯的做法是先抽一组const cardClass rounded-xl bg-white p-6 shadow-sm把常用的样式片段提取成变量再在 JSX 里引用。这样视觉调整只改一个地方不会满项目搜索某个类名。早期图省事直接在组件里手写类名后来要统一改圆角样式时大到想删库重建。别小看这类约定它们才是 t3code 真正帮你节省维护成本的地方。3. 动手实现做一个完整的待办管理模块3.1 定义 Prisma 数据模型把业务对象落到数据库这一步是所有业务的源头。假设我们要在 t3code 里做一个带账号体系的待办事项模块那就先定义 User 和 Todo 两个模型。打开prisma/schema.prisma调整数据模型model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? 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]) }定义完之后在终端执行pnpm prisma migrate dev --name init这会创建数据库表并且生成类型安全的 Prisma Client。这一步之后你的ctx.db.todo就会带有完整的 TypeScript 类型提示字段名拼错了编辑器直接红线提示。对新手来说我特别强调一点Prisma schema 是模型定义的唯一事实来源不要试图手改 Prisma Client 的生成代码也不要绕过 migrate 直接改数据库。任何改动先改 schema 再跑迁移才能保证类型和表结构长期同步。3.2 tRPC Router 与 Procedure定义接口的现代姿势在 t3code 中定义后端接口不需要写路由路径不需要声明请求方法只需要写一个 router然后导出这个 router。比如创建src/server/api/routers/todo.tsimport { z } from zod; import { createTRPCRouter, publicProcedure, protectedProcedure } from ~/server/api/trpc; export const todoRouter createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) { return ctx.db.todo.findMany({ orderBy: { createdAt: desc }, }); }), getById: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) { return ctx.db.todo.findUnique({ where: { id: input.id } }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1, 标题不能为空) })) .mutation(async ({ ctx, input }) { return ctx.db.todo.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const todo await ctx.db.todo.findUnique({ where: { id: input.id } }); if (!todo) { throw new Error(待办不存在); } return ctx.db.todo.update({ where: { id: input.id }, data: { completed: !todo.completed }, }); }), });这里有两个核心概念。query是普通数据查询mutation是写操作两者在调用链上共用同一个类型模型。每个 procedure 都可以用 Zod 对入参做校验前端传{ title: }时还没进业务逻辑就会被拦截。publicProcedure 是公开访问protectedProcedure 要求登录用户内部通过 session 获取 userId这种权限控制方式非常直观。然后需要在src/server/api/root.ts里注册这个 routerimport { todoRouter } from ~/server/api/routers/todo; export const appRouter createTRPCRouter({ todo: todoRouter, }); export type AppRouter typeof appRouter;AppRouter这个类型是关键中的关键。它把整个后端接口的类型结构暴露出去前端可以直接拿它推断所有可用调用。后面你在前端写api.todo.getAll.useQuery()时编辑器能自动提示路径和入参类型靠的就是这个类型导出。3.3 前端组件接入useQuery 与 useMutation 的正确用法定义好 router 之后前端调用出奇地简单。t3code 在src/trpc/react.tsx里已经封装好了 React Query 集成的 Hooks所以你在组件里不用自己写fetch也不用拼 URL。创建一个客户端组件app/todos/page.tsxuse client; import { api } from ~/trpc/react; export default function TodoPage() { const utils api.useUtils(); const { data: todos, isLoading } api.todo.getAll.useQuery(); const createTodo api.todo.create.useMutation({ onSuccess: async () { await utils.todo.getAll.invalidate(); }, }); const toggleTodo api.todo.toggle.useMutation({ onSuccess: async () { await utils.todo.getAll.invalidate(); }, }); if (isLoading) { return div classNamep-8 text-gray-500加载中.../div; } return ( div classNamemx-auto max-w-xl p-8 form onSubmit{(e) { e.preventDefault(); const form new FormData(e.currentTarget); const title String(form.get(title) ?? ); createTodo.mutate({ title }); e.currentTarget.reset(); }} classNamemb-6 flex gap-2 input nametitle placeholder输入待办标题... classNameflex-1 rounded-lg border border-gray-200 px-4 py-2 text-sm / button typesubmit classNamerounded-lg bg-black px-4 py-2 text-sm text-white 添加 /button /form ul classNamespace-y-2 {todos?.map((todo) ( li key{todo.id} classNameflex items-center gap-3 rounded-lg border p-3 input typecheckbox checked{todo.completed} onChange{() toggleTodo.mutate({ id: todo.id })} / span className{todo.completed ? text-gray-400 line-through : }{todo.title}/span /li ))} /ul /div ); }使用useQuery时返回的data类型就是后端 query 返回的类型mutate的入参类型也是后端定义好的。写完这两个组件前后端接口就已经“勾搭”上了哪里的字段对不上编译期就会提示。有个细节值得注意每次 mutation 成功后我调用了utils.todo.getAll.invalidate()。这是 React Query 缓存失效的标准做法它会让当前所有getAll的缓存失效并重新请求。如果你忘了这一步新增待办之后页面上可能看不到新数据因为 React Query 认为原缓存仍然有效。这是新手非常容易踩的坑。3.4 服务端调用 tRPCcreateCaller 在 Server Component 里的角色App Router 下页面默认都是 Server Components它们不能直接使用useQuery。那如果有的页面必须在服务端拉数据再渲染呢t3code 提供了一个干净的方案src/trpc/server.ts里的createCaller。import { createCaller } from ~/trpc/server; export default async function TodoListPage() { const caller createCaller(await createTRPCContext()); const todos await caller.todo.getAll(); return ( ul {todos.map((todo) ( li key{todo.id}{todo.title}/li ))} /ul ); }createCaller让你像调用后端函数一样直接在服务端发起查询它不需要前端网络请求也不用担心 hooks 规则。这个模式在写首屏、做 SEO、或是不需要即时交互的页面时特别实用。我的建议是尽量把纯展示的组件用 Server Component createCaller 来实现只有需要交互的部分表单提交、按钮点击、输入联动才标记为use client并用 useMutation/useQuery。这样既能保留类型安全也能减少客户端 JavaScript 的体积首屏加载更快。3.5 NextAuth 鉴权把登录用户串进业务逻辑一个有账号体系的模块必然离不开认证。t3code 默认的 NextAuth 配置已经写好了 GitHub 登录的基本套路但如果你想做更完整的用户名密码登录或者想快速测试可以用 Credentials Provider 临时顶一下。在src/server/auth.ts中注册 Credentials Providerimport Credentials from next-auth/providers/credentials; providers: [ Credentials({ credentials: { email: { label: 邮箱, type: email }, password: { label: 密码, type: password }, }, authorize: async (credentials) { const email credentials?.email ?? ; const password credentials?.password ?? ; // 这里写你的用户校验逻辑 const user await db.user.findUnique({ where: { email } }); if (!user) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ]在 tRPC 层protectedProcedure会自动读取ctx.session。任何没有 session 的请求都会直接被拒绝。这样你的 mutation 里就能安全使用ctx.session.user.id不会出现“用户身份为空还往库里插了一条数据”的情况。测试阶段最省事的做法是直接去app/api/auth/session拿 session或者写一个“模拟登录”按钮。但上生产之前一定要把 NextAuth 的 secret 换成强随机字符串否则服务重启一次登录状态就失效一次。3.6 部署从本地 SQLite 切换到 PostgreSQL 的关键操作本地开发用 SQLite 很舒服但部署到 Vercel 或其他 Serverless 平台时SQLite 是行不通的。Serverless 函数没有持久化文件系统SQLite 文件会随每次实例重启丢失或者被多个实例并发写坏。所以上生产前必须切换数据库。切换步骤修改prisma/schema.prisma的 datasource provider 为postgresql。找一个 PostgreSQL 实例比如 Neon、Supabase 或者云服务商的 RDS。把DATABASE_URL改为postgresql://...连接串。执行pnpm prisma migrate deploy重新执行pnpm prisma generate让 Prisma Client 匹配新的 datasource。如果是 Vercel 部署记得在后台把DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL都配成生产环境变量。很多线上问题都出在这里本地跑得欢push 上去之后一片红一查是环境变量没有设置。4. 高频踩坑记录与排查手册4.1 启动时报 NEXTAUTH_SECRET 缺失怎么办这是 t3code 项目最常见的启动错误之一。报错信息类似[next-auth][error][NO_SECRET]虽然有些场景下 NextAuth 会自动生成临时 secret但一旦使用数据库 adapter缺少 secret 会让你登录状态在服务重启后全部失效。解决办法是生成一个强随机 secret写入.env。我一般直接用 OpenSSL 生成openssl rand -base64 32然后粘到.env里的NEXTAUTH_SECRET。生成一次所有环境共用不要每次部署都换否则已登录用户全部被踢下线。4.2 Prisma 报 pending 或 Client 版本不一致开发环境跑prisma migrate dev后有时编辑器仍然报类型错误或者在运行时出现PrismaClientInitializationError。这类问题九成是因为 Prisma Client 生成文件过期。解决办法很简单重新生成pnpm prisma generate还有一个容易忽略的点项目新增依赖后特别是切换 Node 版本后node_modules/.prisma里的二进制文件可能和当前系统不匹配。如果你刚才从 Node 18 切到 Node 20且看到和二进制相关的报错删掉node_modules重新安装通常能解决。4.3 tRPC 请求失败且网络上没有对应请求记录如果你在浏览器 Network 面板里能看到请求发出但控制台报失败先确认 tRPC 的 API route 是否正常挂载。一个检查项是app/api/trpc/[trpc]/route.ts里是否导出了handlerimport { fetchRequestHandler } from trpc/server/adapters/fetch; import { appRouter } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; export const dynamic force-dynamic; export const GET (req: Request) fetchRequestHandler({ endpoint: /api/trpc, req, router: appRouter, createContext: createTRPCContext, }); export const POST GET;模板里一般已经写好了但如果你自己改造过目录很容易漏掉export const POST GET导致 POST 请求全部 404。4.4 React Query 缓存不更新invalidate 到底该怎么用当你用 mutation 更新了数据页面却没有变化原因基本是缓存没有失效。tRPC 的 React Query 集成中invalidate是在 query key 层面工作的。utils.todo.getAll.invalidate()对应的是getAll这个 query如果你同时有getAll和getById两个查询只 invalidate 前者后者不会自动更新。一个更稳妥的写法是前缀 invalidateawait utils.todo.invalidate();这样todo命名空间下所有 query 都会刷新。但要注意粒度如果数据刷新频繁且数据量大全量 invalidate 会带来额外请求。我的经验是先精确 invalidate观察请求数再决定是否需要放大范围。4.5 环境变量注入后仍报校验失败有时.env里已经写好了变量但 tRPC 或 NextAuth 还是说环境变量缺失。这时要区分几个层是否在根目录创建了.env而不是.env.local。是否有 Spaces 或引号夹在变量值两边。是否重启了 dev server。Next.js 的 env 读取是启动时加载的改完.env必须重启开发进程。是否部署平台没有同步环境变量尤其是在 Vercel 上本地.env不会自动上传。如果都检查了还不行就把src/env.js里的 schema 先临时放宽加上console.log看实际取值再逐步排查。这也是最快定位方式。4.6 常见问题速查表把上面这些经验整理成一张表方便以后对照排查。问题现象常见原因排查方向解决方法启动报 missing secret环境变量缺失检查.env文件与平台配置生成随机 secret 写入所有环境Prisma 类型错误或 Client 初始化失败client 生成文件过期重跑 generatepnpm prisma generatetRPC POST 404API route 未导出 POST查看app/api/trpc/[trpc]/route.ts补上export const POST GET页面数据不更新mutation 后无 invalidate查看组件中的缓存策略精确或前缀 invalidation本地正常部署后数据库连不上环境变量未配置检查部署平台设置配置DATABASE_URL与 secretNextAuth 登录后刷新失效secret 变更或未设置检查 secret 是否持久固定 secret重启服务这张表的价值在于很多问题一开始都表现得很玄学但对着表一看其实都是环境变量或缓存失效这种“低级问题”。遇到报错先别急着改代码从下往上的排查习惯能省很多时间。5. 关于 t3code 后续扩展的一点真实建议把 t3code 这套组合完整跑通之后我最直观的变化不是代码写得快而是改代码时敢下刀了。以前迭代一个接口前后端各改一遍出错还要猜是类型对不上还是逻辑写岔了。现在 tRPC 把类型和实现绑在一起改一端没同步另一端编译器会直接拦截。这种安心感是传统全栈项目很难给你的。如果你准备在这个基础上继续扩展我个人的建议是先把 Prisma 的 relation 用熟练尤其是嵌套查询和分页策略这是业务复杂度上来之后最大的挡路石。其次可以研究 Zod 和 tRPC input 的复合校验很多接口层参数设计其实可以做得非常细。最后不要迷信一份模板解决所有问题t3code 的价值在于帮你把正确的基础设施一次配好而真正“值钱”的代码永远是那些被类型安全保护着的业务逻辑。