t3code 是我最近整理全栈项目时随手敲下的代号。它不是什么官方框架,更不是某家公司的产品,就是我用 T3 Stack(Next.js + TypeScript + tRPC + Prisma + Tailwind)搭的一套业务代码底座。起这个名字有两个原因:一是提醒自己代码是第一等公民,二是这个项目里所有类型从数据库一直延伸到按钮,构成一条完全连通的类型链路。这篇文章不打算讲框架宣传话术,我只想复盘 t3code 在落地过程中的选型、拆解、实操和踩过坑,把能直接参考的东西平铺出来。
如果你已经听说过 T3 Stack 但不知道从哪下手,或者已经在用但被类型推断、缓存、日期序列化这些破事折腾过,那这篇应该能给你省点时间。文章按做项目的顺序走:先讲为什么这么搭,再给完整代码,最后是排障清单。
1. t3code是什么:它不是脚手架,是一套代码秩序
1.1 我对t3code的定位
先说明白,t3code 不是一个开箱即用的生成器。create-t3-app帮你初始化一个 T3 工程,但真正让它能在业务里跑起来的东西,是你对代码组织、类型边界、数据流和缓存策略的约定。把这些约定沉淀下来,才是 t3code。
T3 这个名字来自三件以 T 开头的东西:TypeScript、Tailwind、tRPC,而 Next.js 是承载它们的底座。很多人把这四件套当成"新技术四件套"直接用,结果发现类型安全并不自动降临,反而是 Zod 校验、Prisma 模型、tRPC 路由、React Query 缓存各种概念交织在一起,代码特别容易乱。我做 t3code 的目标很简单:给这些技术一个稳定的、可复用的业务结构,让团队的新人不看架构文档也能在十分钟内知道一个新功能该写在哪个文件里。
1.2 t3code能解决什么实际问题
传统全栈开发里,最浪费时间的事情不是写代码,而是"确认接口"。前后端各写各的,字段名靠文档对齐,类型靠肉眼核对,一旦数据库加了个字段,前端要等接口联调才发现。t3code 把这种"运行时才确认"变成了"编译时强制确认"。
举一个真实场景:REST 接口返回的id是 number,前端定义的类型却是 string,这在运行时才会炸,而且炸的时候你往往已经写了很多关联逻辑。换成 tRPC 后,数据库字段经 Prisma 读出来,类型直接传给前端,如果你把id的类型写错,编译器当场就喊停。联调成本至少砍掉一半,而且前端不再需要维护一份手写 API 类型。
对中小团队来说,这意味着一个后端同事也不必为每个页面维护 OpenAPI 文档;对独立开发者来说,这意味着从起项目到上线整个链路里少了一道最烦人的心智负担。
1.3 谁适合用t3code这套方案
我先泼一盆冷水:t3code 不是万能模板。它最适合的是 Next.js 全栈项目、内部后台系统、需要快速迭代的产品 MVP,以及团队本来就全员 TypeScript 的场景。如果你们团队是多种语言混合,一个服务用 Go、一个服务用 Python,那 tRPC 端到端类型安全的优势会直接消失,你更需要 REST 或 gRPC。如果你做的是高并发、低延迟的基础设施层,JSON over HTTP 的 tRPC 也不是最优选择。
所以这篇内容更适合:正在用 Next.js + Prisma 搭全栈应用的开发者、想把 tRPC 引入业务但是怕踩坑的人,以及纯粹想看看别人怎么组织 T3 工程的"考古"型读者。
2. 技术选型与架构拆解:类型安全不是玄学
2.1 四件套的分工其实是分层
我把 t3code 的技术栈拆成四层看,这样理解起来非常顺:
- Next.js 管页面与渲染,也就是 UI 和路由入口。
- tRPC 管 API 调用,替代手写 REST 路由和 DTO。
- Prisma 管数据库访问,替代手写 SQL 和模型层。
- Tailwind 管样式,替代写大型 CSS 文件。
这个分层的核心在于:每一层都尽量用 TypeScript 的类型把边界焊住。Prisma 根据 schema 生成模型类型,tRPC 在 router 里定义输入输出类型,Next.js 的 React 组件消费这些类型,Tailwind 不参与类型系统,但它不破坏类型链路。整条链路是单向的:数据库类型 → Prisma 类型 → tRPC 类型 → React Props/Hook 类型。你改一个 Prisma 字段,TypeScript 会通过这条链把所有用到它的地方全标红,这种"改一处、全联动"的体验是传统开发给不了的。
2.2 tRPC端到端类型安全的原理
很多人听到"端到端类型安全"第一反应是玄学,其实原理不复杂。tRPC 的 router 在服务端定义时,TypeScript 会推导出一个类型,叫作AppRouter。然后客户端用一个泛型把整个 router 的类型传过去:
// src/trpc/react.ts import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "~/server/api/root"; export const api = createTRPCReact<AppRouter>();这一行是关键。AppRouter在编译期是真实类型,前端调用api.post.listLatest.useQuery时,TypeScript 能顺着 router 的类型签名检查参数、返回值。如果服务端改了输入参数,前端代码里所有调用点全部编译报错,根本不需要查接口文档。
你可以在代码里这样理解它:服务端 router 像一份"函数签名表",client 拿着它做静态检查。tRPC 的方案与 REST + OpenAPI 的区别在于,OpenAPI 是文档生成的,两者还需要生成工具同步;tRPC 是类型直接生成的,不存在同步问题。
2.3 目录结构:按业务模块拆,而不是按技术层堆
create-t3-app 默认目录结构其实很简单:
src/ app/ server/ api/ root.ts routers/ trpc.ts auth.ts db.ts trpc/ react.ts server.ts在 t3code 里我做了重新梳理。当 router 数量超过五六个时,按"技术层"堆文件会变得非常痛苦——你要同时打开 routers 目录、schemas 目录、services 目录,来回跳转。t3code 改成按业务模块聚合:
src/ app/ (marketing)/ (dashboard)/ posts/ page.tsx server/ api/ root.ts trpc.ts auth.ts db.ts modules/ post/ post.schema.ts post.router.ts post.service.ts post.types.ts user/ user.schema.ts user.router.ts user.service.ts user.types.ts trpc/ react.ts server.tsmodules目录里一个业务域一个文件夹,路由、校验、业务逻辑放在一起。这样做有实际好处:当你需要开发"帖子"功能时,所有相关文件都在modules/post/下面,context 切换成本极低;后续把这个模块抽成独立服务时也很方便,直接文件夹拷走。
不过这里我不建议一上来就把所有代码塞进modules。小项目直接用 create-t3-app 默认结构完全够用,等业务复杂度上来之后再迁移。我实测下来,判断标准很简单:当routers目录下文件超过 8 个,跨模块复用大量出现时,就该做模块化了。
2.4 环境变量管理:用Zod把配置也纳入类型体系
这点很多人忽略,但 t3code 里我用env.mjs统一管理环境变量,而不是散落在代码各处访问process.env。create-t3-app 默认自带这个文件,核心逻辑是用 Zod 对每个环境变量做运行时校验,然后导出类型安全的env对象:
import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; export const env = createEnv({ server: { DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), }, client: { NEXT_PUBLIC_APP_URL: z.string().url(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, }, });这样做的好处很实际:项目部署到新环境时,如果遗漏了某个变量,应用启动不会给你一个难以追踪的undefined,而是直接抛校验错误,告诉你缺了哪个 key。有一次我部署到测试环境,忘配DATABASE_URL,提示信息直接写清楚了,而不是让我在报错栈里翻半天。
3. 实操记录:从零把t3code跑起来,并跑通一个完整功能
3.1 初始化项目
初始化我用 create-t3-app 的交互式命令,注意当前版本是 v11 以上,项目名用t3code:
npx create-t3-app@latest t3code交互选项我按需要选:TypeScript、Tailwind、tRPC、Prisma、NextAuth(如果需要登录)、ESLint 全开;Docker、PWA 这类按需选。初始化完进入目录:
cd t3code npm install npx prisma db push npm run devprisma db push会把 schema 里的模型直接同步到数据库,适合开发早期快速迭代。如果要上生产环境,我会改用prisma migrate dev生成迁移文件。
3.2 Prisma数据模型:以帖子功能为例
我们做一个最简单的帖子发布功能,先定义 Prisma 模型。如果初始化时选了 NextAuth,User 模型已经存在,我只需要加一个 Post:
model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? image String? posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String? published Boolean @default(true) author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId]) }我特意用cuid()而不是自增int。自增 ID 在业务中容易暴露数据量,且分布式生成时还要额外处理。CUID 是乱序字符串,客户端生成也安全,配合数据库索引完全够用。@@index([authorId])是为了加速按作者查帖子的查询。
做完后跑:
npx prisma db push npx prisma generateprisma generate这一步非常关键,后面排查部分我会专门讲。
3.3 tRPC路由实现:查询与创建
接着写 router。我习惯在 router 文件里直接定义 Zod schema,因为它们和 procedure 强相关。先看src/server/api/routers/post.ts:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "~/server/api/trpc"; const postCreateSchema = z.object({ title: z.string().min(1, "标题不能为空").max(200), content: z.string().max(20000).optional(), }); export const postRouter = createTRPCRouter({ listLatest: publicProcedure .input( z.object({ limit: z.number().int().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) => { return ctx.db.post.findMany({ where: { published: true }, take: input.limit, orderBy: { createdAt: "desc" }, include: { author: { select: { id: true, name: true, image: true } }, }, }); }), create: protectedProcedure .input(postCreateSchema) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });注意protectedProcedure依赖登录态,它的ctx.session由 NextAuth 填充。这样"必须登录才能发帖"是直接在 API 层实现的,前端不需要依赖路由守卫来做安全防护。
然后在根 router 里合并:
// src/server/api/root.ts import { createTRPCRouter } from "~/server/api/trpc"; import { postRouter } from "~/modules/post/post.router"; import { userRouter } from "~/modules/user/user.router"; export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, }); export type AppRouter = typeof appRouter;这里把 router 按业务域合并,前端调用时命名空间就是api.post.listLatest,语义清晰且不会冲突。
3.4 前端调用:React Query封装好的Hooks
tRPC 的前端不用手动封装 fetch,它基于 React Query 生成了 hook。以"最新帖子列表"组件为例:
"use client"; import { api } from "~/trpc/react"; export function LatestPosts() { const [posts] = api.post.listLatest.useSuspenseQuery({ limit: 10 }); return ( <ul> {posts.map((post) => ( <li key={post.id}> <h3>{post.title}</h3> <p>{post.author?.name ?? "匿名用户"}</p> </li> ))} </ul> ); }useSuspenseQuery需要包在<Suspense>里,它能让数据加载状态直接由 React 的 Suspense 机制接管,页面代码会少很多isLoading判断。如果你的项目不喜欢 Suspense,也可以用useQuery拿isLoading和isError。
发帖的 mutation 这样写:
const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: async () => { await utils.post.listLatest.invalidate(); }, }); createPost.mutate({ title: "Hello", content: "World" });关键是utils.post.listLatest.invalidate():发布成功后让帖子列表的缓存失效并重新拉取,这样列表会自动更新,不用手动 setState 刷新。
3.5 权限与登录态如何接入
t3code 里的认证用的是 NextAuth(Auth.js)。核心机制是:中间件里把 session 挂到 tRPC 的 context 上,然后在trpc.ts里分别暴露publicProcedure和protectedProcedure:
// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; const t = initTRPC.context<{ db: PrismaClient; session: Session | null }>().create({ transformer: superjson, errorFormatter(opts) { const { shape, error } = opts; return { ...shape, data: { ...shape.data, zodError: error.code === "BAD_REQUEST" && error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }); });这个protectedProcedure是全局的权限拦截器,业务 router 只要用它,就天然要求登录。它比在每个路由里手动判断 session 更不容易漏。
4. 开发中常踩的坑和排查技巧实录
4.1 Prisma generate时机不对,类型永远迟一步
这是 t3code 开发里最常见的坑:你在schema.prisma里加了一个字段,前端立刻引用它,但 TypeScript 还在报错说该属性不存在。原因很简单——Prisma Client 的类型是生成出来的,不是自动感知 schema 的。改完 schema 之后必须执行:
npx prisma generate如果你用的是prisma db push,它默认也会触发 generate。但如果你在 CI 里只跑了 migrate 没跑 generate,构建就会挂。我的习惯是每次 pull 代码后先跑npx prisma generate,再启动 dev server。
4.2 日期序列化:类型显示是Date,运行时却是字符串
这件事我第一次遇到时真的挠头。Prisma 返回的createdAt是Date对象,TypeScript 类型也写着Date,但是通过 tRPC 传回前端后,打印出来却是一串 ISO 字符串。这是因为 tRPC 默认的序列化会把复杂对象转成可传输的 JSON 结构。
create-t3-app 默认启用了superjson作为 transformer,它能在序列化时保留Date、Map、Set这些类型。但如果你初始化时没选 superjson,或者改动了 transformer,就要注意运行时类型和编译期类型不一致。
解决办法有两条路。一是在初始化时保留 superjson,在initTRPC的配置里写transformer: superjson;二是干脆别依赖 Date 对象做展示字段,接口统一返回createdAt.toISOString(),前端用字符串格式化。我建议小项目直接用后者,少一个跨网络的类型状态,心智负担低很多。
4.3 Next.js缓存让tRPC数据看起来像坏了
还有一次我把 t3code 的页面从客户端组件改成服务端组件,结果列表数据怎么刷新都不变。排查半天发现是 App Router 的静态优化缓存:页面在构建时被静态渲染,tRPC 的数据也被"固化"了。
如果你在服务端组件里调用 tRPC(用 server caller),务必明确页面的动态性:
export const dynamic = "force-dynamic";放在页面文件的顶部,强制每次请求都重新渲染。如果你用的是客户端 hook,一般不会遇到这个缓存问题,因为这走的是 React Query 自身的缓存。
4.4 Prisma连接池被跑爆
部署到 serverless 环境后,每个函数实例都可能创建自己的数据库连接,连接数很快就满。这个问题的征兆是应用间歇性报Can't reach database server,但本地开发完全正常。
常见解法是给 Prisma 配置连接上限:
datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") connectionLimit = 5 }这里directUrl用于执行管理命令(如 prisma migrate),普通查询走连接池。如果你的数据库支持 PgBouncer,则在连接字符串里配pgbouncer=true。这些配置没法一刀切,需要根据你的并发量和数据库实例数来调。
4.5 NextAuth的session里取不到user.id
这是 Auth.js v4 的经典坑。默认情况下session.user上并没有id字段,因为你自己的 User 模型扩展了字段,但类型定义没跟上。你在protectedProcedure里写ctx.session.user.id,TS 会报错。
需要扩展类型声明:
// src/types/next-auth.d.ts import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }同时在auth.cts(或[...nextauth].ts)里配置 session callback,把 id 塞进去。这样 tRPC context 里的ctx.session.user.id才真正可用。这个坑隐蔽在类型和运行时之间,一旦踩到很容易怀疑人生。
4.6 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 新加的 Prisma 字段在代码里报类型错误 | 没重新prisma generate | 执行npx prisma generate |
| 前端拿到的日期是字符串 | transformer 未配置 superjson | 启用 superjson 或后端返回字符串 |
| 页面数据不刷新 | App Router 静态缓存 | 配置export const dynamic = "force-dynamic" |
| 部署后连不上数据库 | serverless 连接数爆掉 | 设置connectionLimit或接 PgBouncer |
| session.user.id 不存在 | Auth.js 类型未扩展 | 添加 d.ts 声明并配置 session callback |
| tRPC 调用报 403 | protectedProcedure 且未登录 | 先检查 NextAuth 登录态 |
| mutation 成功后列表不更新 | React Query 缓存未失效 | 调用utils.post.listLatest.invalidate() |
5. 后续扩展与个人体会
t3code 现在还在持续往里加东西。我最想做的有三个方向:一是给帖子功能加图片上传,走 S3 兼容存储的预签名 URL,Pre-signed URL 从 tRPC mutation 里返回,前端直传对象存储,不经过自己的服务器;二是做更细的权限模型,现在只区分登录和未登录,后面要加角色和资源所有权的校验,会把protectedProcedure扩展成requireRole(["admin"])这种形式;三是在 mutation 里统一写审计日志,记录谁在什么时候改了哪条数据,这对后台类项目是刚需。
说真的,用了 t3code 跑了几个月之后,我最大的感受不是"全栈开发好快",而是终于敢改代码了。以前改接口字段,要前后端同时改,改完还要来回联调。如今一个字段从 Prisma 改到页面按钮,编译器在保存那一刻告诉你哪里断了。这种安全感是会上瘾的。
如果你也想试,我给一条实在建议:先把目录结构和命名约定定死,别让类型安全代替你做事。类型系统只能保证数据形状匹配,不能保证业务模块之间不纠缠。t3code 的价值不在于技术栈本身有多新,而在于它迫使你在写第一行业务代码之前就想清楚数据的来龙去脉。祝你在 TypeScript 的红色波浪线里找到自己的秩序。