ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

t3code:基于 T3 Stack 的类型安全全栈脚手架实战与踩坑指南

t3code:基于 T3 Stack 的类型安全全栈脚手架实战与踩坑指南

t3code 这个项目,最初只是我在一个周末捣鼓出来的内部脚手架,后来用着用着发现它值得被单独拉出来讲清楚。它本质上是一套基于 T3 Stack 的全栈应用骨架,把 Next.js、TypeScript、Tailwind CSS、Prisma、tRPC 这些工具串在一条“类型安全链路”里,让我从定义数据库表、写 API 接口,到前端页面消费数据,全程都不需要手工维护 DTO 和类型定义。如果你正在找一个能快速起步、少踩配置坑的前后端一体化方案,或者单纯想搞明白 tRPC 和 Prisma 这套组合为什么用起来会上瘾,这篇文章应该能把整个链路从头到尾捋明白。

我先说结论:t3code 不是又一个“什么都要有”的低代码平台,它更像是一个可复制的工程底座。下面我会从项目设计、目录结构、核心实现、实际部署,到我在迭代过程中踩过的坑,一条条聊清楚。

1. t3code 项目概览:它到底解决什么问题

1.1 一句话讲清 t3code 的定位

t3code 的定位,就是“类型优先的全栈脚手架”。它默认使用 TypeScript 严格模式,把数据模型、API 层、前端调用分成三个区域,但三者的类型定义由一套代码自动推导出来。我新建一个业务模块时,只需要先写 Prisma 的数据模型,然后在 tRPC router 里定义输入输出,前端的useQuery和useMutation就会自动拿到完整的类型提示。整个过程中,我不会再去手写 interface 来对应接口返回值,也不会出现“后端字段改了、前端还拿着旧类型”这种慢性病。

很多人第一次看到 t3code 会问:这不就是 create-t3-app 换了个名字吗?我的回答是:create-t3-app 解决了“项目怎么初始化”的问题,t3code 解决的是“初始化之后,一个业务模块怎么快速落地”的问题。它把常见业务里最高频的 CRUD、鉴权、列表查询、分页这些场景,提前用一套统一的方式封装好,还附带了一个命令行的代码生成器。你可以把它理解成“装修好的样板间”和“只给你毛坯房和钥匙”的区别。

1.2 选型逻辑:为什么偏偏是 T3 这套组合

市面上的全栈方案其实不少,但每个方案都有让人纠结的地方。如果只用 Next.js 的 API Routes 加 fetch,短期看起来简单,时间一长,接口一多,前后端类型全靠人肉同步,光是一份接口变更通知就能让协作效率掉一半。用 GraphQL 当然类型也安全,但你要引入 schema 定义、解析器、codegen 那一大套工具链,小团队很难坚持把所有类型都维护好。

T3 Stack 的思路聪明在:它选择的全都是“把业务代码量压缩到最少”的工具。Prisma 负责数据库映射,schema 文件写出来同时就是数据库表结构;tRPC 负责 API 调用,它不生成独立 schema,而是直接复用 TypeScript 函数签名;Next.js 负责页面渲染和路由;Tailwind 负责样式。这四样东西的共同点是都用 TypeScript 表达契约,所以我从数据库到 UI 之间没有一层“翻译”。t3code 也继承了这套哲学:能少写一行类型,就绝不写第二行。

选 T3 还有个现实原因:上手成本低。我团队里的前端同学不需要专门学一套 schema 语言,只要会写 TypeScript,基本半天就能上手。面试招人的时候,候选人只要 TypeScript 基础扎实,T3 这套组合他不需要之前碰过,我给个小任务试一下就知道行不行。这种低门槛,在我看来比某个框架本身多酷更重要。

1.3 适合什么人使用

t3code 最适合三类人。第一类是独立开发者,你需要在尽量短的时间里把 idea 变成能交给用户使用的 Web 应用,少维护一层 API 文档就是实打实的时间。第二类是小型产品团队,两三个前端、一两个后端,大家不需要在“接口协议”上开太多会,直接看类型定义就能联调。第三类是教学场景,想理解现代 TypeScript 全栈开发怎么做,t3code 是一个足够简单、又没有把最佳实践牺牲掉的参考实现。

当然它也有不适合的场景。如果你的项目有大量复杂报表查询、异构数据源,或者整个团队只熟悉 Java/Go 技术栈,那硬套 t3code 反而不是最优解。技术选型永远是场景说了算,这点我在后面讲“什么时候不要用”的部分还会再提。

2. t3code 的整体结构:目录布局与类型共享链路

2.1 Monorepo 目录布局与依赖关系

t3code 的仓库采用 pnpm workspace 的 monorepo 结构。之所以不用单个 Next.js 应用把所有代码堆在一起,是因为项目里有两类代码经常要独立演进:一类是数据库相关逻辑,另一类是 API router 相关逻辑。把它们拆成独立 package,不但能避免循环依赖,还能在后续接入不同前端(比如管理后台和用户端)时,直接复用同一套 API 层。

t3code/ ├── apps/ │ └── web/ │ ├── app/ │ │ ├── (auth)/ │ │ ├── (main)/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ │ └── route.ts │ │ ├── _trpc/ │ │ │ ├── client.ts │ │ │ └── provider.tsx │ │ └── layout.tsx │ └── package.json ├── packages/ │ ├── db/ │ │ ├── prisma/ │ │ │ └── schema.prisma │ │ ├── src/ │ │ │ └── index.ts │ │ └── package.json │ └── api/ │ ├── src/ │ │ ├── router/ │ │ │ ├── index.ts │ │ │ ├── post.ts │ │ │ └── auth.ts │ │ ├── context.ts │ │ └── trpc.ts │ └── package.json ├── pnpm-workspace.yaml ├── tsconfig.base.json └── package.json

这里最关键的一点是依赖方向:apps/web依赖packages/api,packages/api依赖packages/db,而packages/db不依赖任何上层。这样每层都只知道自己下层的能力,类型从数据库向 UI 单向流动,改起来不会牵一发动全身。我在项目里还做了一个校验脚本,谁要是手滑在packages/db里 import 了上层内容,CI 会直接红灯,从机制上杜绝了反向依赖。

2.2 数据库模型怎么设计:Prisma Schema 是源头

既然类型安全要从源头开始,Prisma Schema 就是整个 t3code 的事实来源。我在设计示例业务时会先用一个非常经典的“用户-文章”模型,因为几乎所有业务都能从这里面衍生出来。

generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String @unique posts Post[] createdAt DateTime @default(now()) } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) }

这里的细节很多人会忽略:id 字段我统一用cuid()而不是自增数字。原因很简单,在分布式部署或数据库导入导出时,自增 id 极易冲突,而 cuid 由 Prisma 在客户端生成,不需要额外查一次数据库取号,性能上更稳。时间字段我加了@default(now()),这样创建记录时应用层不需要再手动塞时间。published这种业务字段也直接在 schema 里给出默认值,避免前端漏传导致报错。

写 schema 时有一个习惯我强烈建议保留:每张表都写清楚 relation 关系。不要图省事只写外键字段,因为 Prisma 会根据 relation 自动生成嵌套查询能力,比如prisma.user.findUnique({ include: { posts: true } }),这种能力在写 tRPC 接口时会节省大量组装代码。

2.3 tRPC Router 的拆分与鉴权上下文

tRPC 的 router 是一个树状结构,t3code 会按业务领域拆分 router 文件,而不是把所有 query 和 mutation 堆在一个文件里。根 router 只负责组合子路由,这样你的 API 路径会自然变成post.list、post.create,语义清楚,前端调用时也很好记。

// packages/api/src/router/index.ts import { router } from '../trpc'; import { authRouter } from './auth'; import { postRouter } from './post'; export const appRouter = router({ auth: authRouter, post: postRouter, }); export type AppRouter = typeof appRouter;

鉴权是几乎所有业务都绕不开的东西,t3code 在 tRPC 初始化时定义了两类 procedure:publicProcedure和protectedProcedure。前者允许匿名访问,后者会先从 session 里取用户信息,取不到直接抛UNAUTHORIZED错误,取到了就把用户信息注入到后续的 ctx 里。这个封装最大的价值是:你不需要在每个接口里写一遍“判断是否登录”,路由能不能匿名调用,一眼就能看出来。

// packages/api/src/trpc.ts import { initTRPC, TRPCError } from '@trpc/server'; import type { Context } from './context'; const t = initTRPC.context<Context>().create(); export const router = t.router; 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, }, }); });

这样做还有个附带好处:前端也能通过类型推断看到某个 mutation 是否需要登录。如果某个接口应该保护但忘了加 procedure,code review 时在 diff 里很容易发现“这里用的是 publicProcedure”,比在运行时才意识到要好得多。

2.4 CLI 工具怎么让模块生成自动化

t3code 为了方便复用,在脚手架之外提供了一个t3code命令行工具。它底层做的事情不复杂,本质上是从仓库里的模板目录复制文件,再根据用户输入的模型字段替换占位文本。但把它做成交互式 CLI 之后,整个体验完全不同了。

npx t3code@latest new my-app cd my-app pnpm install pnpm db:up pnpm dev

这几个命令做完,一个带 Prisma、tRPC、Tailwind、NextAuth 的完整项目就已经在本地跑起来了。相比于我早期手动复制 create-t3-app 再删默认代码的日子,省掉的不只是十分钟,而是每次都会漏改某个配置文件的心智负担。

在 CLI 里,我目前提供了三个最有用的命令:

t3code add trpc t3code add next-auth t3code generate crud Post

generate crud是最有想象空间的部分。它会读取当前的schema.prisma,找到匹配的 model,然后自动生成对应的 tRPC router、zod 输入校验、以及前端 list/detail/form 三个页面的骨架。它生成的代码不是完美的生产代码,但省掉的是“从零敲样板代码”的时间,剩下的业务逻辑只需要你在生成的骨架上改。

3. t3code 从零到一:跑通一个带鉴权的 CRUD 实操

3.1 环境准备与项目初始化

开始之前,我建议先确认你本机的环境:Node.js 版本最好在 18.17 以上,包管理器用 pnpm 8,数据库用 PostgreSQL。如果你本机没装 PostgreSQL,用 Docker 起一个临时实例就够了:

docker run --name t3code-postgres \ -e POSTGRES_USER=t3code \ -e POSTGRES_PASSWORD=t3code \ -e POSTGRES_DB=t3code \ -p 5432:5432 \ -d postgres:16

用 t3code CLI 初始化项目时,它会自动询问你要不要开启 Prisma、tRPC、NextAuth、Tailwind 这几个模块。我第一次使用时会全部勾上,因为只有完整跑一遍才知道各个模块之间是怎么配合的。项目初始化完成以后,需要手动创建一个.env文件,把数据库连接串和 NextAuth 的密钥填进去:

DATABASE_URL="postgresql://t3code:t3code@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="t3code-demo-secret" NEXTAUTH_URL="http://localhost:3000"

这里有一个小坑:NEXTAUTH_SECRET不要用明文写在仓库里,生产环境的 secret 至少要 32 个字符的随机字符串,在 CI 里用密钥管理服务注入。dev 环境用明文图省事可以,但提交代码前要记得把.env加进.gitignore。

3.2 定义数据模型并生成迁移

初始化完成之后,我会先把仓库里自带的示例模型改成自己业务的模型。假设我们要做一个“公告系统”,需要一张Announcement表:

model Announcement { id String @id @default(cuid()) title String body String pinned Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

改完 schema 后执行迁移:

pnpm --filter @t3code/db db:migrate

迁移成功以后,Prisma Client 的类型就会包含Announcement模型。注意这里一定要明确:Prisma 的类型不是靠人手工写的,它是从 schema 生成出来的。我经常看到有人改完表结构,跑完迁移,然后发现前端 TS 报错,原因就是没重新执行pnpm prisma generate。t3code 里我把这个步骤挂到了postinstall脚本里,可以少踩一次这个坑。

3.3 编写 tRPC Router 并串联数据库

有了模型之后,下一步就是在packages/api下新增一个announcement.tsrouter。这里我会演示 tRPC 的核心用法:输入用 zod 做运行时校验,输出靠 Prisma 返回值自动推断。整个过程没有一行手写的 interface,这恰恰是 t3code 想传达的核心理念。

// packages/api/src/router/announcement.ts import { z } from 'zod'; import { protectedProcedure, publicProcedure, router } from '../trpc'; const announcementInput = z.object({ title: z.string().min(1).max(200), body: z.string().min(1), pinned: z.boolean().optional(), }); export const announcementRouter = router({ list: publicProcedure.query(async ({ ctx }) => { return ctx.prisma.announcement.findMany({ orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }], }); }), detail: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.announcement.findUnique({ where: { id: input.id }, }); }), create: protectedProcedure .input(announcementInput) .mutation(async ({ ctx, input }) => { return ctx.prisma.announcement.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), });

这段代码里最值得玩味的是list的排序:置顶的排前面,再按创建时间倒序。这样一个很小的排序逻辑放在数据库查询层用 Prisma 表达,比拉回所有数据在前端排序高效得多。create这边没有从请求体取authorId,而是从 session 里取,这样用户永远无法伪造作者身份,这在业务上是安全底线。

然后再把它注册到根 router:

// packages/api/src/router/index.ts import { announcementRouter } from './announcement'; export const appRouter = router({ auth: authRouter, post: postRouter, announcement: announcementRouter, });

3.4 前端页面接入类型安全的 query

API 层完成后,前端接入的体验是 t3code 最爽的部分。新建一个客户端组件,调用trpc.announcement.list.useQuery(),不需要 import 任何手写的类型,编辑器会自动补全返回字段。

// apps/web/app/(main)/announcements/page.tsx 'use client'; import { trpc } from '@/trpc/client'; export default function AnnouncementListPage() { const { data, isLoading, error } = trpc.announcement.list.useQuery(); if (isLoading) return <div className="p-6 text-slate-500">加载中...</div>; if (error) return <div className="p-6 text-red-500">加载失败:{error.message}</div>; return ( <ul className="space-y-4 p-6"> {data?.map((item) => ( <li key={item.id} className="rounded-lg border border-slate-200 p-4"> <div className="flex items-center gap-2"> {item.pinned && ( <span className="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700"> 置顶 </span> )} <h2 className="font-semibold">{item.title}</h2> </div> <p className="mt-2 text-sm text-slate-600">{item.body}</p> </li> ))} </ul> ); }

这里要注意,我在组件顶部写了'use client',因为 tRPC 的 hooks API 依赖 React 状态和 effect,只能在客户端组件里使用。服务端组件有另外的调用方式,比如直接创建 caller 在服务端取数。初学者最容易犯的错误就是把带 hooks 的组件当成服务端组件用,结果报了一堆 RSC 相关的错。

tRPC 的 query 会在数据从服务端返回之后自动推断出data的类型。如果后端把title字段改名成headline,前端的item.title立刻就会标红,编译都过不去,而不是等上线之后接口报错才发现。这种“类型闭环”带来的安全感,用熟了真的会回不去。

3.5 构建、运行与部署配置

本地开发跑通后,接下来要面对的是构建与部署。t3code 里我默认提供了一套构建配置,但有几个点必须手动确认。

首先是确认 tRPC endpoint 的路径已经正确设置。在 Next.js App Router 模式下,apps/web/app/api/trpc/[trpc]/route.ts负责把 HTTP 请求转发给 tRPC server。这个文件里 endpoint 字符串/api/trpc必须和前端 provider 里的httpBatchLink的 url 一致,否则测试环境经常遇到 404。

// apps/web/app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import { appRouter, createContext } from '@t3code/api'; const handler = (req: Request) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext: () => createContext({ req }), }); export { handler as GET, handler as POST };

然后是数据库和 Prisma Client 的部署问题。我部署到 Vercel 的时候,构建环境是只读的,prisma generate和 migration 都得在构建阶段处理。t3code 的 Dockerfile 里我会选择多阶段构建:先在 builder 阶段安装依赖、跑类型检查、执行prisma generate,再把产物拷贝到 runtime 镜像里。这样能避免代码里偶然出现@prisma/client未生成的运行时报错。

最后一个建议是开启 Next.js 的 standalone output。在next.config.mjs里设置output: 'standalone',部署到容器时镜像体积能小很多,启动速度也会更快。我用这个配置跑同一个项目,镜像从 1.2GB 降到了 400MB 左右,效果非常直观。

4. t3code 实战常见问题与排查技巧实录

4.1 tRPC 类型不刷新,前端提示找不到字段怎么办

t3code 里我遇到最多的一个问题是这样的:后端 router 新加了一个 query,前端组件调用trpc.xxx.yyy.useQuery()时,输入提示还是老一套,怎么刷新都不更新。这个现象的根因通常是 TypeScript 的AppRouter类型没有重新加载,或者编辑器缓存了旧的.tsbuildinfo。

我的排查步骤是固定的。第一,先确认 tRPC context 里的AppRouter是从packages/api导出的最新类型。第二,杀掉 TS Server 进程,在 VSCode 里执行TypeScript: Restart TS server,如果还不行就删掉node_modules/.cache和.next再重启。第三,检查apps/web的 tsconfig 里有没有把packages/api的源码目录排除在外,如果被排除了,TS 会直接从旧构建产物里读取类型,自然不会刷新。

还有一个隐藏很深的坑:monorepo 里packages/api如果发布了独立版本,而apps/web的 lockfile 没有更新,前端引用的其实是旧版本。本地开发时因为 pnpm workspace 默认软链到源码,没问题;但 CI 构建时如果用了--frozen-lockfile,就会碰到这类版本错位。处理方法是保证 workspace 协议统一,每次改完 API 层都重新生成一下 lockfile。

4.2 Prisma 在构建阶段报“找不到引擎文件”或“Query engine library”

Prisma 的报错信息有时很含糊,最常见的两种是Query engine library for current platform "debian-openssl-3.0.x" could not be found和PrismaClientInitializationError。这类问题几乎都是因为构建环境中没有完整生成 Prisma Client 的二进制引擎。

在 Docker 里我踩过一次很深的坑。我用的是node:20-slim作为基础镜像,它少了 openssl 依赖,导致 Prisma 引擎运行不了。后续我在 Dockerfile 里加了:

RUN apt-get update -y \ && apt-get install -y openssl \ && apt-get clean

但更保险的做法是在postinstall里强制执行prisma generate,别依赖安装阶段的隐式行为。你可以在package.json里看到这两行:

{ "scripts": { "postinstall": "prisma generate" } }

另外,Vercel 环境默认没有持久化的文件系统,如果你把数据库迁移命令放在构建阶段直接执行,有时候会因为权限问题失败。我的方案是把 migration 放到部署后的单独命令里,或者用一个 migration runner 服务,而不是在静态构建过程中执行数据库写操作。

4.3 NextAuth 的类型扩展不生效,session.user.id 报错

Next.js 生态里有名的“类型自闭”问题,在 t3code 里也出现过。默认情况下,Session['user']只有name、email、image三个字段,但业务代码里经常需要拿user.id。如果在保护路由后面直接写ctx.session.user.id,TypeScript 一定会报id不存在。

解决方法是模块增强。在 t3code 里我放了一个types/next-auth.d.ts文件:

import type { DefaultSession } from 'next-auth'; declare module 'next-auth' { interface Session { user: { id: string; } & DefaultSession['user']; } }

这里面有个细节:必须用& DefaultSession['user']把默认字段合并进来,否则原本的name和email类型会丢失。很多项目就是在这里直接覆盖user类型,导致原有的字段全变 undefined,后面不得不靠非空断言硬扛,这非常不 TypeScript。

4.4 Tailwind 与 Next.js 构建顺序导致的样式丢失

在 t3code 里我把 Tailwind 作为默认方案,但它偶尔会和 Next.js 的缓存机制打架。症状是改完tailwind.config.ts,页面样式没有按预期变化,或者生产构建里样式类名丢失。

第一个检查点:确认你修改的是tailwind.config.ts里的content数组,它必须包含所有使用类名的文件目录。如果 monorepo 里packages/api里的某段代码包含 className(比如统一封装的 UI 组件),这部分路径也要加进去,否则 build 阶段 Tailwind 扫描不到,自然生成不了对应样式。

第二个检查点:Next.js 14 项目里如果启用了 Turbopack 的开发模式,有时候 Tailwind 的热更新比 webpack 慢半拍。我遇到时通常重启 dev server 就好,但如果是生产构建样式丢失,我会优先怀疑content配置,而不是怀疑 Tailwind 本身。t3code 现在默认使用 Tailwind 4,它的 CSS-first 配置方式和旧版差异较大,如果你从旧项目迁移过来,不要直接套用旧版配置,先在新项目里跑一个最小的颜色类验证链路。

5. t3code 后续扩展方向与我的个人体会

5.1 往 t3code 里继续加什么

当前版本的 t3code 已经可以支撑大多数中小型业务的前后端开发,但我自己心里清楚它离“好用”还有不少距离。下一步我最想加的能力,是把 “generate crud” 做得更智能,让它不只是生成 CRUD 骨架,而是能识别 model 里的关系字段,比如comments: Comment[],然后自动生成嵌套路由和关联表单。再往后,我想把 t3code 的模板拆成更细的 feature 包,让用户可以按需安装“文件上传”“软删除”“操作审计日志”这些垂直能力,而不是一个模板全家桶全部带上。

对我来说,工具永远要服务于真实业务。我不太想把 t3code 做成一个沉重的框架,而是希望它的核心理念能被别人抄走:用一套类型系统把数据库、接口、UI 三层彻底打通。哪怕别人不用 t3code,只要他意识到“类型安全不是靠自觉,而是靠架构”,这个方向就对了。

5.2 实际使用三个月后的真实感受

我带着 t3code 实际做过一个 CMS 后台和一个内部工具站,最大的体感变化发生在跨端联调环节。以前前后端对接,最怕文档滞后、字段语义含糊、环境不一致,现在大家面对同一套类型定义,路由跑不通时先把类型检查过一遍,大部分问题在编辑器里就被拦截了。尤其是 tRPC 的输入校验,zod schema 写好后,后端再也不需要相信“调用方一定传对参数”这件事。

当然也有不完美的地方。t3code 对多数据源的支持还很弱,默认假设业务只有一个 PostgreSQL 实例;如果未来遇到需要同时读写 MySQL 的场景,当前这套 Prisma client 的封装就需要再加一层抽象。另一个问题是 tRPC 的调用模型在极端高并发下没有 REST + OpenAPI 那么“标准”,如果你的项目未来一定要做对外 B 端 OpenAPI 开放平台,那需要额外加一层协议适配。这些限制我在项目文档里都写了,避免别人走一半才发现路不同。

最后再分享一个小技巧:如果你只是想要类型安全的前后端通信,不想引入整套 t3code,也可以直接把 tRPC 的 router 层加上 zod 校验,单独抽成一个包,前端用createTRPCClient消费它。这样即便你最终不采用 T3 全家桶,也能享受“类型即契约”的好处。t3code 对我来说,就是一个把这种理念产品化的尝试,代码是写出来的,但真正有价值的是背后那套让类型把整个业务串起来的设计方式。

返回列表