
t3code 这个名字如果拆开看就是 T3 和 code。T3 指的全是开发者圈子里流传的那个 T3 Stack也就是 Next.js、TypeScript、tRPC、Prisma、Tailwind 再加上 NextAuth 这套组合。我当时拿着这套理论去搭自己的项目模板也就是后来一直维护的 t3code 全栈脚手架一路踩坑也一路受益。今天就把这套东西从零到部署的完整过程、设计思路、实操代码和踩过的坑全部摊开讲一遍适合正在做全栈项目、想统一前端后端类型安全、又不想被繁琐的接口联调拖累的开发者参考。1. 内容整体设计与思路拆解1.1 t3code 想解决什么问题我见过太多全栈项目前端一套 TypeScript 类型后端又写一套改了字段忘了同步接口文档和代码脱节运行的时候各种字段对不上。真正把时间花在接口联调上的时候往往比写业务逻辑还累。t3code 的核心目标就是用一套代码、一套类型贯穿前后端让类型安全不再是开发完之后的检查项而是从写第一行业务代码开始就天然成立。这套模板选的是 Next.js App Router 作为骨架搭配 tRPC 做端到端类型安全调用Prisma 管数据库模型Tailwind 管样式NextAuth 负责登录会话Zod 做输入校验。组合起来之后改动一个数据库字段从后端接口到前端页面的类型引用都会跟着报错这种编译器帮你兜底的感觉用习惯了真的回不去。它适合的人群很明确一是受够了手工维护接口文档的开发者二是想快速启动一个新全栈项目的团队三是想学习类型安全最佳实践的前端工程师。如果你只是写一个简单静态站这套组合确实偏重但只要是涉及用户体系、数据存储和复杂交互的应用收益远大于成本。1.2 为什么不用 REST 或者 GraphQL这个问题我经常被问到。REST 的好处是简单、生态成熟但要实现端到端类型安全通常需要 OpenAPI 规范加代码生成或者手动写类型定义。项目一复杂类型文件和接口路径越来越多维护成本直线上升。GraphQL 的 schema 优先模式确实好但学习曲线陡客户端缓存和复杂查询的调优对团队要求高小团队往往驾驭不住。tRPC 的做法完全不同它不需要额外的接口描述文件前端直接引用后端 router 中的函数调用时类型是通过 TypeScript 推断出来的。你写一个api.post.all.useQuery()返回的 data 类型自动就是你在后端定义的那个类型。没有生成步骤没有 schema 同步问题这恰恰是 T3 栈在中小型全栈项目里最有杀伤力的地方。当然如果项目对外开放大量 APIREST 依然是更稳妥的选择毕竟 tRPC 的设计初衷是内部服务调用公网 API 的可发现性和第三方兼容性都不如 REST。t3code 的核心场景是自己团队的端到端应用搞清楚这个边界技术选型就不会纠结。2. 工具选型解析与技术原理2.1 Next.js App Router 为什么当骨架我在最早的版本里用过 Pages Router后来切到 App Router体验差别还是挺大的。App Router 默认支持 Server Component这意味着很多数据获取可以在服务端完成不需要客户端一堆 useEffect。配合 tRPC 时我通常在页面服务端组件里直接调用后端查询把初始数据渲染出来再在客户端用 tRPC 的useQuery做交互后的增量更新这样首屏数据快后续操作也顺滑。还有一点App Router 的路由结构天然适合按功能模块组织代码。t3code 模板里app/(dashboard)这类路由组可以做到地址栏没有路径前缀但代码结构清晰区分了公共页面和登录后页面。中间件、布局嵌套、加载状态这些也都是开箱即用省掉了自己搭路由框架的麻烦。需要注意的地方是 App Router 的缓存策略。默认情况下静态渲染会缓存页面动态数据页面要手动标记force-dynamic或者用cookies()、headers()这类动态 API 触发动态渲染。我在模板里直接封装了一个dynamic配置避免新手部署之后发现数据不刷新。2.2 tRPC 的类型安全原理tRPC 的类型安全并不是什么黑魔法本质上就是 TypeScript 的类型推断加函数引用。后端定义了一个 router它内部的每个 procedure 都是一个函数TypeScript 会把函数的参数类型和返回类型完整保留下来。前端通过createTRPCReact生成的 hooks 拿到的是同一个 router 类型引用所以类型在编译期就对齐了。这里面有一个关键设计输入校验器。tRPC 的每个 procedure 都可以挂一个input()我用的是 Zod这样输入类型和运行时校验是同一份 schema。前端调用时如果多传了一个字段或者类型不匹配编译直接报错即使有人绕过前端手动请求Zod 也会在后端拦截非法输入。这种编译期 运行期双保险比普通的接口参数校验可靠很多。再说到中间件tRPC 中间件的执行顺序是洋葱模型类比快递分拣的过程先经过最外层中间件再到里层返回时再反向经过。我在模板里放了requireAuth和rateLimit两个中间件。requireAuth从 context 里读 session没有登录直接抛错rateLimit用简单的内存 Map 统计请求频率防止接口被刷。中间件和 procedure 之间是组合关系什么接口要登录、什么接口要限流写得很直白不需要搞 AOP 那套复杂框架。2.3 Prisma 和 NextAuth 的配合Prisma 作为 ORM最舒服的一点是 schema 文件即模型定义prisma migrate生成迁移文件prisma generate生成类型安全的客户端。t3code 里用户表、会话表、账号表这些和 NextAuth 直接相关的模型我是参考了 NextAuth 官方适配器推荐的 Schema 结构再加了自己的业务表。NextAuth 的接入其实没有想象中复杂但有一个容易翻车的点数据库 session 策略和 JWT 策略的选择。小站点用 JWT 策略省去查库压力但要修改用户信息得手动同步需要服务端读最新用户状态时务必要用数据库 session 配合 Prisma 适配器。我模板里默认用数据库 session因为 tRPC 的服务端调用依赖getServerSession获取当前用户数据库 session 能保证会话信息一直同步。3. 实操过程与核心环节实现3.1 初始化项目与配置裁剪初始化我用的是官方 CLI 工具执行如下命令pnpm create t3-applatest t3code交互式命令行里会询问选择哪些模块。我的建议是第一遍全部勾上先看看完整结构跑通了再删。如果是为了复用最终保留下 Next.js、TypeScript、tRPC、Prisma、Tailwind、NextAuth 这几个核心模块就够了。初始化完成后第一件事就是清理样板代码和重建目录结构。我习惯把src/server拆成api、auth、db三个子目录src/trpc专门放 tRPC 的 router 初始化和上下文创建。组件目录按页面维度分模块每个模块除了组件文件还带上自己的_types.ts这样模块边界清晰改动时影响面可控。环境变量在.env文件里管理模板默认给了.env.example作为参考。关键的几个变量如下表所示变量名用途示例值DATABASE_URLPrisma 连接串postgresql://user:passlocalhost:5432/t3codeNEXTAUTH_SECRET会话签名密钥随机生成的一长串字符NEXTAUTH_URL站点公网地址http://localhost:3000GITHUB_IDGitHub OAuth App 的 ID在 GitHub 开发者设置里创建GITHUB_SECRETGitHub OAuth App 的密钥同上生成NEXTAUTH_SECRET的通用做法是执行openssl rand -base64 32然后把输出粘贴进环境变量里。生产环境务必换成独立的强随机值绝不能复用开发环境的。3.2 数据建模和迁移流程数据模型是整个应用的根基我拿一个典型的博客系统举例。除了 NextAuth 需要的User、Session、Account、VerificationToken外我加了Post和Tag两张业务表。Schema 定义如下model Post { id String id default(cuid()) title String slug String unique content String db.Text published Boolean default(false) authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) tags Tag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique posts Post[] }这里有几个细节值得说。第一个是主键用cuid()而不是自增数字好处是分布式环境下不必担心冲突对外暴露 ID 也更安全不会让人通过递增 ID 猜到你总共有多少数据。第二个是slug加了unique约束文章地址用语义化路径搜索引擎友好代码里生成 slug 时要注意中文标题的转换和重名校验。写完之后执行npx prisma migrate dev --name init npx prisma generatemigrate dev会在数据库里创建表并生成迁移文件。generate会更新prisma/client的类型定义。这个顺序很重要很多类型报错都是因为改了 schema 忘记重新生成客户端导致的。3.3 tRPC Router 的完整链路搭建先看 tRPC 的上下文创建。tRPC 的每个请求都会先进入 context我在里面做了两件事查数据库实例和拿当前登录用户。代码在这个文件里// src/server/trpc.ts import { initTRPC, TRPCError } from trpc/server; import superjson from superjson; import { ZodError } from zod; import { getServerSession } from next-auth; import { authOptions } from ~/server/auth; import { db } from ~/server/db; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); return { db, session, headers: opts.headers, }; }; const t initTRPC.contexttypeof createTRPCContext().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, });阅读这段代码的关键在于理解 context 的职责。它相当于所有 procedure 共享的请求级数据容器在这里放 db 和 session业务 procedure 里就直接用ctx.db和ctx.session。getServerSession在服务端组件和 tRPC 上下文里都能拿到会话避免重复读取 cookie。接着定义需要登录的中间件const requireAuth t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, user: ctx.session.user } }); });注意return next()时传回的新 ctx后续 procedure 里ctx的类型就会自动带上了user字段。这种类型层面的增量推断用起来非常顺畅。业务 router 的一个示例// src/server/api/routers/post.ts import { z } from zod; import { createTRPCRouter, publicProcedure, protectedProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ all: publicProcedure.query(({ ctx }) { return ctx.db.post.findMany({ include: { tags: true, author: { select: { name: true } } }, orderBy: { createdAt: desc }, }); }), bySlug: publicProcedure .input(z.object({ slug: z.string() })) .query(({ ctx, input }) { return ctx.db.post.findUnique({ where: { slug: input.slug }, include: { tags: true }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(120), slug: z.string().regex(/^[a-z0-9-]$/), content: z.string().min(1), tagNames: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { const post await ctx.db.post.create({ data: { title: input.title, slug: input.slug, content: input.content, authorId: ctx.user.id, tags: { connectOrCreate: (input.tagNames ?? []).map((name) ({ where: { name }, create: { name }, })), }, }, }); return post; }), });publicProcedure和protectedProcedure是两个预先配置好的实例前者不校验登录后者在调用create这类写操作前强制要求登录。Zod 的校验规则直接写在 input 里title为空、slug格式不对都会在 API 层拦截掉不需要再手写一堆 if 判断。3.4 根 Router 合并与前端 API 封装根 Router 的作用是聚合所有子 router并在 App Router 下注册 HTTP 端点。代码结构如下// src/server/api/root.ts import { postRouter } from ./routers/post; import { createTRPCRouter } from ~/server/api/trpc; export const appRouter createTRPCRouter({ post: postRouter, user: userRouter, }); export type AppRouter typeof appRouter;appRouter的类型导出之后前端就能直接拿到完整的类型信息。紧接着在app/api/trpc/[trpc]/route.ts里创建 HTTP 处理器import { fetchRequestHandler } from trpc/server/adapters/fetch; import { appRouter } from ~/server/api/root; import { createTRPCContext } from ~/server/trpc; const handler (req: Request) fetchRequestHandler({ endpoint: /api/trpc, req, router: appRouter, createContext: () createTRPCContext({ headers: req.headers }), }); export { handler as GET, handler as POST };这样整个/api/trpc端点就建好了。前端调用侧src/utils/api.ts里通过createTRPCReact生成了 hooks在组件里直接调用use client; import { api } from ~/utils/api; export function PostList() { const { data, isLoading, error } api.post.all.useQuery(); if (isLoading) return p加载中.../p; if (error) return p加载失败{error.message}/p; return ( ul {data?.map((post) ( li key{post.id} h2{post.title}/h2 p{post.author.name}/p /li ))} /ul ); }这里面有个关键类型细节data的类型就是post.all这个 procedure 的返回类型不需要手动标注泛型。如果后端改了返回字段前端这里立即报错这就是 t3code 最核心的省心之处。写操作同理const utils api.useUtils(); const createPost api.post.create.useMutation({ onSuccess: async () { await utils.post.all.invalidate(); }, }); createPost.mutate({ title: 示例文章, slug: example-post, content: 正文内容, });onSuccess里调用invalidate让相关查询失效并重新拉取这是 tRPC 客户端缓存管理的关键操作比手动维护全局状态简单太多。3.5 页面搭建与服务端数据预取App Router 下有好几种取数思路我推荐的是服务端组件预取 客户端组件交互的组合。拿文章详情页举例// app/post/[slug]/page.tsx import { api } from ~/trpc/server; export default async function PostPage({ params }: { params: { slug: string } }) { const post await api.post.bySlug({ slug: params.slug }); if (!post) { return p文章不存在/p; } return ( article h1{post.title}/h1 div{post.content}/div /article ); }~/trpc/server是 t3 模板提供的一个服务端调用封装它和客户端 hooks 共用同一套类型。页面组件直接用返回的数据渲染没有额外的 loading 状态。需要交互的部分再抽成客户端组件调用 tRPC hooks这样首屏渲染快交互也不掉队。页面的动态性标记我记得加了这一句export const dynamic force-dynamic;因为文章数据随时可能更新如果 Next.js 在构建时把它当静态页面缓存了用户看到的就会是旧数据。个人博客无所谓但像后台管理、实时数据这种场景必须加上。3.6 生产部署的经验部署到 Vercel 是最省事的路径但是数据库不能也用 Vercel 的临时环境。生产库我推荐用 Neon 或者 Railway 托管 PostgreSQL。连接串在 Vercel 的环境变量里配置为DATABASE_URLpostgresql://user:passwordyour-neon-host:5432/neondb?sslmoderequiresslmoderequire这里一定要加很多连接失败都是因为数据库服务商要求 SSL 而连接串里没带。部署前还需要跑一遍数据库迁移。服务器上执行npx prisma migrate deployCI 流程里可以把这个命令放在构建之前。如果用的是 Docker 部署启动命令写成prisma migrate deploy next start。Prisma 在 Serverless 环境下的一个经典坑是引擎文件过大。Vercel 默认 CPU 不支持 Prisma 的二进制引擎会有报错。解决办法是在package.json里设置prisma: { schema: prisma/schema.prisma, client: { output: node_modules/.prisma/client, engineType: binary } }或者用PRISMA_CLIENT_ENGINE_TYPEbinary环境变量。这里有个取舍二进制引擎比 WASM 引擎启动更快但部署包体积会大一些。实测在 Vercel 上用二进制引擎是稳定的。3.7 安全加固与性能优化我在 t3code 里默认加了几个安全相关配置。第一个是 CSP 安全头在next.config.ts里配置Content-Security-Policy限制脚本来源防止 XSS。第二个是 NextAuth 的信任主机配置明确指定允许的回调域名防止登录回跳被劫持。性能层面Prisma 查询要重点关注 N1 问题。上面示例里post.all已经用include同时查了作者和标签如果写成循环里逐个查一次列表页可能触发几十条 SQL。还有大数据量分页skip/take在深分页时会变慢更好的方案是用游标分页也就是用cursor配合take文章里可以按createdAt做游标。tRPC 的响应默认是 JSON 格式但我开启了superjson转换器。这个库能序列化Date、Map、Set这些 JSON 原生不支持的类型。没有它的话Prisma 返回的Date字段会被转成字符串类型推断依然正确但序列化精度会有问题开启之后省心很多。4. 常见问题与排查技巧实录4.1 类型不同步和编译报错这是我碰到最多的一类问题。现象是改了schema.prisma之后代码里ctx.db的相关类型还是旧的甚至直接报错提示某个字段不存在。原因多半是忘了跑prisma generate。排查顺序是固定的先检查 Prisma schema 改动是否已保存再执行npx prisma generate然后重启开发服务器。如果prisma/client的输出目录被自定义过要确认路径一致。还有next dev的热更新偶尔对 Prisma 类型变化不敏感重启一次基本都能解决。另外一个容易踩的类型坑是 tRPC 中间件里改写了 ctx 之后procedure 里拿到的ctx.user类型不生效。这通常是因为中间件里next()传参时展开的字段名和 context 类型定义的字段不一致。解决方法是统一从ctx.session.user取数据并显式传给next不要依赖隐式属性合并。4.2 登录状态拿不到useQuery在页面上一直报UNAUTHORIZED或者ctx.session始终是 null大概率是 NextAuth 配置和实际请求 Context 对不上。常见的坑包括NEXTAUTH_SECRET没设置、NEXTAUTH_URL和当前访问域名不一致、在服务端组件里没有通过authOptions调用getServerSession。我自己的习惯是写一个统一封装把 NextAuth 配置抽成独立模块tRPC 上下文、路由处理器、服务端组件全部引用同一个实例。这样配置只维护一份不会出现接口能登但页面跳转失败这种诡异问题。NextAuth 还有一个回调地址的细节。GitHub OAuth 注册时回调地址要填http://localhost:3000/api/auth/callback/github写错端口或者写少了路径点击登录按钮就会跳回首页但没有任何错误提示。4.3 生产环境数据库连不上本地跑得非常好一上生产就开始报Cant reach database server。这个问题十有八九出在连接串和环境变量上。用 Vercel 的话本地.env不会自动同步需要在 Vercel 项目的 Settings 里手动配置 Environment Variables。DATABASE_URL必须是生产库的连接串别把本地 SQLite 路径传上去。如果数据库托管在 Neon 这类服务上要注意连接池地址和直连地址的区别。Prisma 在 Serverless 环境下推荐用连接池地址用直连地址会因连接数限制导致频繁断连。判断方法很简单连接串里有没有-pooler后缀需要和数据库服务商的文档对齐。迁移文件的执行也要谨慎。多人协作时本地prisma migrate dev会生成带时间戳的迁移文件提交代码时确保迁移文件一并提交。部署环境执行prisma migrate deploy而不是dev避免意外往生产库写入测试数据。4.4 分页和数据缓存踩坑列表页翻了十几页就越来越慢这是典型的深分页问题。skip/take在 offset 很大时数据库要扫描并丢弃前面的记录成本极高。我后来把列表接口改成了游标分页byCursor: publicProcedure .input(z.object({ cursor: z.string().optional(), take: z.number().min(1).max(50).default(10) })) .query(async ({ ctx, input }) { const posts await ctx.db.post.findMany({ take: input.take 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, include: { tags: true }, }); const hasMore posts.length input.take; const items hasMore ? posts.slice(0, -1) : posts; return { items, nextCursor: hasMore ? items[items.length - 1]?.id : null, }; });前端拿到nextCursor作为下一次请求的 cursor 参数。这个方案在数据量大的时候稳定得多而且记录新增时也不会出现重复或跳项。客户端缓存方面tRPC 默认是 0 秒 staleTime这会导致每次组件挂载都重新请求。我建议在utils/api.ts里设置全局defaultOptions比如const trpc createTRPCReactAppRouter({ defaultOptions: { queries: { staleTime: 10 * 1000, refetchOnWindowFocus: false, }, }, });staleTime设到 10 秒用户体验会顺滑不少也减少后端压力。4.5 中间件执行顺序的困惑tRPC 中间件是洋葱模型这个理解起来容易实际用起来很多人会搞混。比如在一个 procedure 上同时挂了rateLimit和requireAuth两个中间件的执行顺序决定校验优先级。如果限流放在最外层未登录用户也会消耗限流配额可能被恶意刷爆导致登录用户也登不上。所以我把requireAuth放在最外层先拦掉未登录请求再走限流。用代码表达就是export const protectedProcedure publicProcedure .use(requireAuth) .use(rateLimit);这样顺序清晰也不容易出现为什么我明明登录了还报限流的疑问。5. 扩展玩法与个性化改造方向5.1 加一个后台管理模块模板可以快速扩展一个简单的后台管理。思路是新增一个app/admin路由组里面页面都受requireAuth保护。我用 tRPC 的protectedProcedure定义了post.update、post.delete等 mutation前端管理页直接调用。关键点在于权限控制。如果只是登录用户就能管理显然不安全。所以我在角色字段上做文章在User模型里加一个role枚举然后在管理接口上挂一个requireAdmin中间件import { TRPCError } from trpc/server; const requireAdmin requireAuth.unstable_pipe(({ ctx, next }) { if (ctx.user.role ! ADMIN) { throw new TRPCError({ code: FORBIDDEN }); } return next(); });unstable_pipe是 tRPC 提供的一种中间件复合方式语义上比嵌套use更清晰。这样普通用户和管理员的权限边界由编译器加运行时双重保证。5.2 接入实时功能tRPC 本身不支持 WebSocket 推送但 T3 Stack 里可以搭配 Pusher 或者 SSR 事件。最简单的做法是客户端轮询。对实时性要求不高的场景比如通知数、在线状态用useQuery加一个refetchInterval就够了const { data } api.notification.unreadCount.useQuery(undefined, { refetchInterval: 30_000, });更高级的做法是接入 Pusher服务端在 mutation 完成之后触发一个事件客户端订阅频道收到新数据后调用invalidate。我在 t3code 的扩展分支里就放了一个基于 Pusher 的实时通知示例整体接入成本不算高。5.3 抽取业务模块为独立 npm 包如果团队里有多个项目要复用同一套用户体系和文章模型可以把 t3code 的src/server部分抽成一个 npm 包。实际做法是用 monorepo 工具比如 pnpm workspace 加 Turborepo把db、auth、trpc提成共享包。这样多个应用之间共享同一套 Prisma schema 和认证逻辑修改一次、处处生效。这个方案会把复杂度提升一个量级适合多产品线的团队。如果只是单项目不建议一上来就拆包先用好模板本身就能获得很高效率。6. 个人经验收尾把 t3code 从最初的一个小模板迭代到现在的状态我最大的体会是类型安全不是负担而是一支不会跑偏的导航团队。以前写接口总要在心里记着请求字段、响应字段、错误字段现在这些全部由 TypeScript 在编译期盯着。还有一个比较隐性的收益是新人上手快新来的同事看一遍 tRPC router 代码就知道各个接口的输入输出不用追着问这个接口返回什么格式。最后分享一个小技巧给 procedure 命名时尽量用动词开头的语义化名称比如create、update、bySlug、all。tRPC 的 client 端调用路径是逐层拼接的命名清晰之后前端代码读起来几乎就是一条业务链路后期维护省力不少。这套模板的后续我还会继续迭代方向是加入更多开箱即用的业务模块比如文件上传、订阅支付、后台权限管理。先把一个全栈模板打磨透比频繁换技术栈实在得多。