
t3code把T3全栈栈做成一套工程模板的实践记录最近在整理自己手头一个代号为t3code的全栈项目模板起因很简单——团队里接了不少中小型Web应用的需求每次都从零搭架子REST接口、类型重复、数据库层重构折腾了两周才见到能跑的CRUD。后来我把整套基于T3 Stack的工程化思路固化成了一个可以快速复用的代码模板取名t3code跑完几个真实项目后发现这套组合确实能省掉大量重复劳动尤其适合那些重度依赖数据增删改查、又希望前后端类型完全贯通的应用。这篇文章就把t3code的完整设计思路、搭建步骤、核心链路和踩坑记录写下来供想用T3 Stack这套打法落地项目的朋友参考。1. t3code这个项目代号背后T3 Stack的来龙去脉与定位1.1 T3不是框架是一套全栈类型安全工程思路T3 Stack最早由Theo在社区推动名字里的三个T是TypeScript、Tailwind、tRPC但真正让它出圈的不是这三个工具的拼装而是端到端类型安全这个核心主张。传统全栈项目里前端调后端接口时类型往往靠手写接口一旦变动前端要同步改一遍后端要维护一遍写DTO又是一遍三层下来极其消耗精力。T3的思路是把前后端放进同一个TypeScript工程里让编译器替你保证API边界上的类型一致性。t3code这个名字本质上就是我给这套工程化思路起的代号既是T3的代码化落地也暗含了一整套可以拿来就跑的代码底座的意思。实际体验下来最有杀伤力的不是某个单一框架的能力而是类型推导在一条完整链路上自动流动。这个观点我在做t3code之前并没有多深的感触直到把tRPC、Prisma、Next.js串在一起后才发现数据库返回的内容类型可以直接推到你前端页面的props里中间几乎不需要手写任何重复的接口类型。对一个10人左右的小团队来说这种编译器帮你盯着的体验直接拉高了开发效率。1.2 t3code要解决的真实痛点我梳理过团队过去一年接过的项目大概七成属于典型的管理后台业务表单数据列表形态。这类项目的共同痛点非常集中接口文档更新不及时前端经常对着过期文档调接口后端改字段名前端编译期完全无感知上线前才发现数据库模型和接口返回结构之间存在大量手写映射每次新项目的初始化成本都花在搭建脚手架而不是业务逻辑上t3code的定位就是直面这些痛点。它不是一个全新的框架而是一个把T3 Stack理念固化下来的工程模板内部集成了Next.js App Router、TypeScript严格模式、Tailwind样式系统、tRPC接口层、Prisma数据库层、NextAuth认证体系并且把这些组件之间的接线方式固定为规范。你可以把它理解为一份长满肌肉记忆的起点仓库新人照着这份模板走不需要再纠结接口应该放哪个目录数据库client应该在哪里初始化这类问题。1.3 从t3到t3code模板的命名逻辑与结构起名的时候其实纠结过叫t3-template太普通叫t3-fullstack太宽泛。最后定成t3code表达的是一套可以被引用、可以被扩展、可以被拆解的代码实体。项目内部的大目录分为app层页面与路由、server层服务端逻辑、数据模型、认证、trpc层API过程封装、styles层全局样式加上prisma目录存放数据库schema与迁移脚本。这个分层的逻辑是顺着T3官方推荐的目录结构做的但我在细节上做了一些调整后面第三节会详细拆解每一层的职责。2. 技术栈选型这套组合是怎么定下来的2.1 全栈TypeScript类型不是负担是廉价的前置测试t3code的第一条铁律是所有代码必须是TypeScript且开启严格模式。很多人对严格的tsconfig有抵触觉得多写类型占了开发时间。我的看法恰恰相反——类型标注不是在给编译器交代工作而是在给未来的自己和接手的人写一份永不腐烂的文档。尤其是全栈项目里前端和后端共用一套类型定义其收益会随着项目规模增长而急剧放大。在t3code里严格模式不只是strict: true还把noUncheckedIndexedAccess也开了。这个配置一开始会让人很不舒服比如访问数组元素时拿到的类型会带上undefined逼着你处理边界情况。但跑过几个迭代后发现它真的能在编译期逮住不少运行时才会爆的空值错误尤其是从数据库取数、处理FormData这类场景。建议新上手的人先别急着关掉这个选项忍两周适应期之后你会感谢它。2.2 Next.js与TailwindUI层的快速迭代基础应用层选择Next.js 14的App Router是因为它同时承担了服务端组件、API路由、客户端组件三种能力。tRPC的路由挂在app/api/trpc/[trpc]/route.ts下实际是一个标准的Route Handler页面组件里既能用服务端组件直接查询数据也可以让客户端组件通过useQuery拉取数据这种混合形态非常贴近真实业务场景。UI层用Tailwind CSS原因也很直白在模板场景下Tailwind的原子类能让你不必频繁切换HTML和CSS文件对于快速搭建后台表格、表单、卡片这类通用界面效率很高。t3code里还预设了一套颜色语义变量primary、danger、muted等这样业务页面引用颜色时不用写死色值换主题时只改动全局配置文件即可。这一层没什么玄学就是怎么快怎么来。2.3 tRPC、Prisma与NextAuth数据层的取舍理由先看一张我在内部文档里常用的对比表它解释了为什么数据层最终选了这三件关注点REST风格tRPC Prisma NextAuth接口类型一致性需要Swagger/OA后手动同步编译期自动保证数据库字段映射手写Repository DTOPrisma自动生成类型认证集成需自行对接JWT/SessionNextAuth脚手架化新接口开发成本写路由写校验写文档写一个procedure即可多客户端场景天然支持偏向Web单应用跨端需额外封装Prisma的选择更多是因为开发体验。它提供schema即数据库定义迁移文件可读可审生成的client自带完整类型tRPC的procedure里直接ctx.db.post.findMany()返回的对象其类型就会一路推导到前端组件的data字段。这种数据库字段改名前端编译立刻报错的体验用惯了之后很难退回手写ORM映射的日子。NextAuth则承担了会话与会话类型注入。t3code里把ctx.session挂进了每个tRPC procedure的上下文受保护接口通过protectedProcedure统一处理开发者不需要在业务代码里反复写当前用户是否存在的判断。这个设计我后文会在第四节的代码示例里完整展示。3. 从零拉起一个t3codecreate-t3-app与工程目录精讲3.1 初始化命令与交互选项搭建t3code的第一步是使用官方脚手架create-t3-app这是T3社区维护的生成器它会询问你想启用哪些模块。我用的是pnpm初始化命令如下pnpm dlx create-t3-applatest my-t3code-app交互界面会让你勾选TypeScript强制、Next.js App Router推荐、Tailwind CSS、tRPC、Prisma、NextAuthAuth.js。实操时建议第一次全部勾选先把整条链路跑通再逐步移除不需要的部分。我见过不少朋友勾掉了Prisma或NextAuth等到后面要加数据表、做登录时又回来补反而多花时间理清这堆模块之间的依赖关系。有一点要特别提醒脚手架默认依赖是pnpm如果你习惯npm/yarn生成后建议删除pnpm-lock.yaml再重新安装不要混用锁文件否则node_modules里的依赖树容易出幺蛾子。3.2 src目录结构与每层职责t3code生成后的核心目录如下src/ ├── app/ │ ├── api/trpc/[trpc]/route.ts # tRPC的HTTP入口App Router路由转发 │ ├── _components/ # 通用UI组件 │ ├── layout.tsx # 根布局注入全局样式和SessionProvider │ └── page.tsx # 首页示例 ├── server/ │ ├── api/ │ │ ├── root.ts # 汇聚所有子路由的根Router │ │ ├── trpc.ts # tRPC初始化器、公共/受保护procedure定义 │ │ └── routers/ # 各业务领域的Router │ │ ├── post.ts │ │ └── user.ts │ ├── auth.ts # NextAuth配置 │ └── db.ts # Prisma客户端单例 ├── trpc/ │ ├── client.ts # tRPC客户端封装 │ ├── react.tsx # React Hooks封装useQuery/useMutation │ └── server.ts # 服务端组件调用的tRPC调用器 └── styles/ └── globals.css这套结构的核心思想是关注点分离但类型合一。业务页面写在app层业务逻辑收敛在server/api/routers下的各个Router里数据库访问统一经过server/db.ts导出的Prisma单例认证配置集中在server/auth.ts。tRPC作为粘合层把这三个部分安全地连接起来。如果你是从传统ExpressMVC转过来的刚开始可能会不习惯没有controller文件的感觉但跑通一两个流程后你会明白procedure本身已经承担了controller的职责只是大幅压缩了样板代码。3.3 环境变量与基础配置细节t3code要求提供DATABASE_URL、AUTH_SECRET等环境变量脚手架会生成一个.env.example第一次初始化后你需要复制为.env并填入真实值。这里的细节坑很多我单独拉出来说。数据库连接串要注意如果本地用的是PostgreSQL默认配置标准URL是postgresql://postgres:密码localhost:5432/t3code_db?schemapublic。如果连的是云数据库比如Neon、Supabase之类的托管PostgreSQL连接串里通常会带sslmoderequire之类的参数一定要保留否则连接会被服务器拒绝。AUTH_SECRET在开发环境可以用命令生成一个随机值npx auth secret生产环境还得确保它是长字符串且不泄露到代码仓库。t3code内部默认Git忽略.env文件你推仓库时一定要检查一下别把真实密钥带上。tsconfig方面脚手架默认已经开了strict我还会额外加上noUncheckedIndexedAccess: true和exactOptionalPropertyTypes: true。后者会让可选的props更加严格比如{ foo?: string }不能接受{ foo: undefined }这逼迫你把这个字段不存在和这个字段存在但值是undefined区分开虽然写代码时烦一点但能明显减少上线后的隐蔽逻辑错误。4. t3code的核心链路tRPC跑通一个完整的CRUD4.1 tRPC为什么能免掉REST的样板代码tRPC的工作方式不是发HTTP请求时按URL路由而是按函数调用路由。前端写api.post.create.useMutation()背后其实发了一个POST请求到/api/trpc/post.create但这一切对开发者透明。你不需要定义URL、不需要写请求体类型、不需要在服务端手动解析JSON因为调用者和实现者共享同一份TypeScript类型。打个比方传统REST像是你打电话前先要查一遍黄页tRPC则是直接拨快捷号码对方是谁由编译器保证不出错。这套方式的代价是它不是分布式接口规范跨语言客户端无法直接消费。如果你未来要把能力开放给Python/Go等其他语言的服务tRPC不是首选。但在Web全栈应用范围内它是开发速度最猛、类型最安全的方案之一。4.2 服务端路由设计root.ts、routers与contextt3code的根Router很简单核心是汇总各业务子Router// src/server/api/root.ts import { postRouter } from ./routers/post; import { userRouter } from ./routers/user; import { createTRPCRouter } from ./trpc; export const appRouter createTRPCRouter({ post: postRouter, user: userRouter, }); export type AppRouter typeof appRouter;trpc.ts里定义了两个层级的procedurepublicProcedure和protectedProcedure。后者先检查session里有没有用户没有就直接抛错// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import { createTRPCContext } from ./trpc-context; const t initTRPC.contexttypeof createTRPCContext().create(); export const createTRPCRouter t.router; export const publicProcedure t.procedure; const enforceUserIsAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); }); export const protectedProcedure t.procedure.use(enforceUserIsAuthed);这里的ctx是关键。t3code里每次tRPC请求都会先执行createTRPCContext把Prisma client、当前session拼装到上下文对象里业务procedure直接通过ctx.db、ctx.session拿数据。把session检查收敛到middleware层比在每个业务方法里重复判断优雅太多。一个完整的CRUD Router大致长这样// src/server/api/routers/post.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const postRouter createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, include: { author: { select: { name: true } } }, }); }), detail: publicProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx, input }) { const post await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post) throw new Error(Post not found); return post; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(120), 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, }, }); }), delete: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) { return ctx.db.post.delete({ where: { id: input.id } }); }), });注意里面用了zod做输入校验。tRPC的惯性做法就是用zod定义输入schema它会在运行时自动完成校验同时类型又在编译期映射到参数上。写title: z.string().min(1)前端传空字符串时根本不会执行到业务逻辑。4.3 客户端对接Query与Mutation的完整代码示例t3code在trpc/react.tsx中导出了带类型推导的hooks。页面里使用起来非常直白以下是一个专栏列表和创建功能的组合use client; import { api } from ~/trpc/react; export default function PostManager() { const utils api.useUtils(); const { data: posts, isLoading } api.post.list.useQuery(); const createMutation api.post.create.useMutation({ onSuccess: () { utils.post.list.invalidate(); }, }); if (isLoading) return div加载中.../div; return ( div button onClick{() createMutation.mutate({ title: 示例标题, content: 示例内容 })} disabled{createMutation.isPending} 发布新文章 /button ul {posts?.map((post) ( li key{post.id} h3{post.title}/h3 p{post.content}/p /li ))} /ul /div ); }最让人上头的细节在这里api.post.list.useQuery()拿到的data类型是服务端listprocedure返回值去掉undefined后的完整类型连author.name有没有被select进去都一清二楚。你永远不会因为调了post.author.nickname而编译不通过因为编译器明确知道Prisma里没有这个字段。创建成功后调utils.post.list.invalidate()tRPC会自动重新拉取列表省去了手动管理缓存的繁琐。4.4 类型推导的实际效果一次真实情况复盘之前t3code里有一个用户表刚开始User模型的昵称字段叫nickname后来数据库设计开会觉得改名成displayName更合适。我改了Prisma schema执行db push后代码里凡是引用了user.nickname的地方编译直接报错。整个项目里大概十几处引用编译器一次性全部揪出来全程花了不到十分钟。放在传统REST项目里你可能需要在几十个接口返回里做文本搜索然后祈祷前端不要遗漏。这种对比比任何广告文案都有说服力。5. 跑通后最容易翻车的三座大山数据库、认证、部署5.1 Prisma连接数据库DATABASE_URL与连接池的坑t3code默认配的是Prisma但连接数据库这一步翻车率极高。最典型的几个问题第一本地PostgreSQL版本和Prisma不兼容。建议直接用Docker跑一个和云环境同版本的PostgreSQLdocker run --name t3code-postgres -e POSTGRES_USERt3code -e POSTGRES_PASSWORDt3code -e POSTGRES_DBt3code_db -p 5432:5432 -d postgres:16第二云数据库的连接池地址和直连地址要区分。像Neon这种托管服务会给你两个连接串一个是直连的一个是带连接池的通常端口是5433带pgbouncertrue参数。部署到Serverless环境时如果直接用直连串数据库连接会被打爆本地开发反而不能用连接池地址因为本地没有配套的PgBouncer。之前我在这上面卡了一个小时网页一刷新就报2038 connection handshake failed换成直连串后才恢复正常。Prisma官方也建议本地用直连、服务器端用连接池t3code的.env.example里我把这两个地址都做了注释示例避免下次再踩。第三执行数据库迁移的命令要对齐环境。本地跑prisma db push或prisma migrate dev没问题但一旦涉及生产环境migrate deploy必须在CI流程里显式执行不要指望部署平台自动帮你跑。5.2 NextAuth登录会话密钥与运行时配置陷阱NextAuth和tRPC的集成是t3code最丝滑也最容易翻车的地方。丝滑在于只要配置正确ctx.session自动就有值翻车则大多出在运行时环境上。典型问题一本地一切正常部署后登录总是报错或者session丢失。排查方向先是AUTH_SECRET。本地开发时NextAuth会接受一个默认的开发密钥但生产环境必须显式设置而且要求长度至少32个字符。如果部署平台没有配好环境变量直接表现为登录态无法持久化刷新就掉登录。典型问题二中间件和Edge Runtime的兼容性。NextAuth v5的auth中间件默认跑在Edge运行时但Prisma和很多Node原生依赖跑不了Edge。t3code里如果直接把中间件和服务端逻辑耦合在一起部署时很容易报PrismaClient is not defined之类让人摸不着头脑的错误。我的实践是中间件只做路由级登录检查不碰数据库数据库相关的调用全放在Node运行时的API层和Server Component里。// middleware.ts export { auth as middleware } from /auth; export const config { matcher: [/((?!api|_next/static|_next/image|favicon.ico).*)], };如果项目还需要在边缘做更细的权限验证把Drizzle换成支持边缘的数据库或者把检查逻辑挪到tRPC的middleware里处理都比硬在中间件里开数据库连接靠谱。5.3 部署到Vercel与自托管的差异对照t3code同时支持Vercel和自托管但两边的环境差异会直接影响配置。可以参考这张我在部署阶段整理的对照表配置项VercelDocker/自托管数据库连接建议用连接池地址直连或内网地址AUTH_SECRET环境变量面板配置docker-compose env构建命令自动识别pnpm/npm手动指定prisma generate next build迁移时机推荐CI预执行启动脚本里执行日志排查用Vercel Function日志用容器stdout最需要留意的一点某些数据库托管商如Neon、Supabase的免费套餐在Serverless场景下冷启动时第一个连接可能非常慢页面首屏容易转圈。t3code里的Prisma客户端我用了一个全局单例同时开启连接池模式// 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;开发时热重载会反复创建Prisma实例这个单例写法可以避免连接数爆炸。生产环境则确保只有一个实例被多请求复用。5.4 Node.js与Edge运行时中间件、数据库连接的冲突这个坑我单独拿出来说是因为网上资料分散当初排查花了不少时间。Next.js 14里一个文件只要标注了export const runtime edge或者被Edge中间件引用整个模块链上的代码就不能依赖Node内置模块。Prisma默认依赖Node的net模块所以在Edge环境一调用就崩。t3code里所有用Prisma的Router都走Node运行时而middleware.ts只做会话解析和路径匹配不import任何服务端数据库模块。另一个容易忽略的细节是NextAuth v5的提供商配置。我用的是OAuth登录AUTH_GOOGLE_ID和AUTH_GOOGLE_SECRET必须在环境变量里跟上否则登录页点了没反应。调试这类问题最快的方式是看NextAuth的官方日志把AUTH_DEBUG1加进环境变量它会输出当前session回调的完整上下文。6. t3code不是银弹适合场景与经验沉淀6.1 适合的项目类型特征t3code的强项非常明显但它只适合特定的项目形态别拿着它硬冲所有场景。按实际体验以下类型的项目用t3code开发效率最高内部管理后台表单多、列表多、权限边界清晰tRPC的procedure天然契合中小型SaaS应用团队规模不大全栈TypeScript能减少前后端协作成本原型验证类的MVP两周内要出一版能演示的产品t3code的脚手架能省掉大量起步时间组件库与业务模组高度标准化的项目数据模型稳定Prisma的迁移流程非常顺6.2 不适合的场景别硬上反向场景同样明确。如果你的接口需要提供给外部第三方比如开放平台、合作伙伴系统那就别用tRPC改成REST或者GraphQL会更合适因为外部调用方不一定用TypeScript。如果项目需要处理极高并发的大流量读写场景Prisma加Serverless的冷启动特性可能达不到性能预期这时候要么引入独立的缓存层要么把高频读接口单独拆出来做优化。如果团队里全是新手且从未接触过TypeScript泛型t3code的抽象会让他们一开始摸不着头脑建议先用传统REST把类型基础打牢再切换。另外提醒一句服务端组件里直接调用tRPC procedure时不要把它当作前端的useQuery来滥用。t3code提供了api服务端调用器但我只在页面初始化时用它做首屏数据获取交互产生的事件型数据请求都交给客户端hook处理否则会失去流式渲染的部分收益。6.3 在t3code基础上继续扩展的可能性模板做到现在我自己已经积累了一套基于t3code的组件包包含通用数据表格、表单校验方案、权限指令和导出Excel工具。下一步打算把这套组件包抽成独立npm包让新项目可以直接引用连复制粘贴都省了。最后说一点体会t3code这套方案的价值不在于它里面的某个框架有多炫而在于它把类型安全从一个口号落实到了数据库、接口、页面、组件之间的每一个接缝处。我实际用下来最大的感触不是写代码变快了而是改代码胆量变大了——知道编译器会在类型层面拦住一大半错误后重构时的心态完全不一样。如果你也准备上手建议先拿一个真实的小需求完整跑一遍建表、写Procedure、前端页面调通、加登录、部署。这一条链路走完你对t3code的体感会比看十篇文档都深。老项目里我已经沉淀了不少踩坑记录和新组件之后会陆续整理出来欢迎交流。