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与Tailwind:UI层的快速迭代基础
应用层选择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 + DTO | Prisma自动生成类型 |
| 认证集成 | 需自行对接JWT/Session | NextAuth脚手架化 |
| 新接口开发成本 | 写路由+写校验+写文档 | 写一个procedure即可 |
| 多客户端场景 | 天然支持 | 偏向Web单应用,跨端需额外封装 |
Prisma的选择更多是因为开发体验。它提供schema即数据库定义,迁移文件可读可审,生成的client自带完整类型,tRPC的procedure里直接ctx.db.post.findMany()返回的对象,其类型就会一路推导到前端组件的data字段。这种"数据库字段改名,前端编译立刻报错"的体验,用惯了之后很难退回手写ORM映射的日子。
NextAuth则承担了会话与会话类型注入。t3code里把ctx.session挂进了每个tRPC procedure的上下文,受保护接口通过protectedProcedure统一处理,开发者不需要在业务代码里反复写"当前用户是否存在"的判断。这个设计我后文会在第四节的代码示例里完整展示。
3. 从零拉起一个t3code:create-t3-app与工程目录精讲
3.1 初始化命令与交互选项
搭建t3code的第一步是使用官方脚手架create-t3-app,这是T3社区维护的生成器,它会询问你想启用哪些模块。我用的是pnpm,初始化命令如下:
pnpm dlx create-t3-app@latest my-t3code-app交互界面会让你勾选:TypeScript(强制)、Next.js App Router(推荐)、Tailwind CSS、tRPC、Prisma、NextAuth(Auth.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作为粘合层,把这三个部分安全地连接起来。如果你是从传统Express+MVC转过来的,刚开始可能会不习惯没有controller文件的感觉,但跑通一两个流程后你会明白,procedure本身已经承担了controller的职责,只是大幅压缩了样板代码。
3.3 环境变量与基础配置细节
t3code要求提供DATABASE_URL、AUTH_SECRET等环境变量,脚手架会生成一个.env.example,第一次初始化后你需要复制为.env并填入真实值。这里的细节坑很多,我单独拉出来说。
数据库连接串要注意:如果本地用的是PostgreSQL默认配置,标准URL是postgresql://postgres:密码@localhost:5432/t3code_db?schema=public。如果连的是云数据库(比如Neon、Supabase之类的托管PostgreSQL),连接串里通常会带sslmode=require之类的参数,一定要保留,否则连接会被服务器拒绝。AUTH_SECRET在开发环境可以用命令生成一个随机值:
npx auth secret生产环境还得确保它是长字符串且不泄露到代码仓库。t3code内部默认Git忽略.env文件,你推仓库时一定要检查一下,别把真实密钥带上。
tsconfig方面,脚手架默认已经开了strict,我还会额外加上noUncheckedIndexedAccess: true和exactOptionalPropertyTypes: true。后者会让可选的props更加严格,比如{ foo?: string }不能接受{ foo: undefined },这逼迫你把"这个字段不存在"和"这个字段存在但值是undefined"区分开,虽然写代码时烦一点,但能明显减少上线后的隐蔽逻辑错误。
4. t3code的核心链路:tRPC跑通一个完整的CRUD
4.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与context
t3code的根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里定义了两个层级的procedure:publicProcedure和protectedProcedure。后者先检查session里有没有用户,没有就直接抛错:
// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { createTRPCContext } from "./trpc-context"; const t = initTRPC.context<typeof 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跑一个和云环境同版本的PostgreSQL:
docker run --name t3code-postgres -e POSTGRES_USER=t3code -e POSTGRES_PASSWORD=t3code -e POSTGRES_DB=t3code_db -p 5432:5432 -d postgres:16第二,云数据库的连接池地址和直连地址要区分。像Neon这种托管服务会给你两个连接串,一个是直连的,一个是带连接池的(通常端口是5433,带pgbouncer=true参数)。部署到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和自托管,但两边的环境差异会直接影响配置。可以参考这张我在部署阶段整理的对照表:
| 配置项 | Vercel | Docker/自托管 |
|---|---|---|
| 数据库连接 | 建议用连接池地址 | 直连或内网地址 |
| 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_DEBUG=1加进环境变量,它会输出当前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的体感会比看十篇文档都深。老项目里我已经沉淀了不少踩坑记录和新组件,之后会陆续整理出来,欢迎交流。