t3code 是我最近手头一个项目的代号。说得直白些,它就是一个把 T3 技术栈——TypeScript、Tailwind CSS、tRPC、Turborepo,再叠上 Next.js 和 Prisma——从前到后完整串起来的全栈应用。整套东西的核心只有一句话:以 TypeScript 为唯一语言,从数据库到组件,每一层类型都不丢。这个项目解决的是我长期以来的一个痛点:前后端类型割裂、联调靠猜、接口文档一过期就全废。如果你也在做全栈开发,或者正准备用 T3 技术栈起一个新项目,这篇分享可以帮你少走不少弯路。下面我按自己的实际推进顺序,把项目从设计到落地再到排坑的完整过程拆开讲。
1. 项目整体设计与思路拆解
1.1 为什么项目代号叫 t3code
团队里聊起技术栈时有个习惯,把“T3 技术栈写成的代码库”简称为 t3code,后来我自己也就这么叫顺口了。t3code 不是一个现成的开源框架,它是我个人对 T3 技术栈的一套工程化落地实践。所谓 T3 技术栈,通常指的是 TypeScript、Tailwind CSS、tRPC、Turborepo 这四件套,再配合 Next.js 与 Prisma 构成一个完整的全栈体系。选中这套组合,不是因为追逐热词,而是因为它把“类型安全”这件事从端到端做了延展:API 层不再需要手写类型,数据库模型改动后,相关页面代码会被 TypeScript 编译器直接标红。
我把 t3code 定位成一个小型内容管理应用:用户可以登录、创建带标签的文章、在列表页筛选、编辑并删除。这个功能集并不复杂,但刚好能把认证、数据库建模、受保护路由、前后端数据流通的每个环节都覆盖到。更重要的是,功能越简单,越能暴露出技术栈整合时的真实问题——比如 tRPC 的上下文怎么携带用户态、Prisma 的类型如何和 tRPC 的 Router 输出对齐、Turborepo 的缓存又是如何把旧产物带进新构建的。
1.2 端到端类型安全到底能带来什么
很多刚接触 T3 技术栈的人会问:类型安全真的有那么重要吗?我以前也持怀疑态度,直到在一次常规迭代中,后端把一个字段isPublished改名成status,前端的十来个组件瞬间编译失败,我不用跑测试就知道哪里要改。反过来,如果没有端到端类型安全,这种改名会变成一场铺天盖地的搜索——不仅浪费一两个小时,还极容易漏掉某个边缘场景。
把类型当作“从数据库到页面的共享契约”之后,整个项目的开发体验会发生根本变化。前端调用 tRPC 的useQuery时,返回值类型与 Prisma 模型严格对应;传给后端的入参也由 zod 执行运行时校验。一个词总结:不可能出现“前端传了userId,后端却在读authorId”这种低级错误。维护成本因此大幅下降,尤其当项目从单人开发走向团队协作时,这种收益会被不断放大——新人不需要翻文档,也能猜出某个方法的入参结构。
1.3 monorepo 架构与数据流设计
我采用的是 Turborepo 管理的 monorepo 结构,这也是 T3 技术栈的一个常见布局。根目录下有apps/web(Next.js 应用)、packages/db(Prisma schema 与数据库访问层)、packages/api(tRPC Router 定义)、packages/ui(Tailwind 组件库)。这样拆分并不只是为了“看起来高级”,而是因为后续加入移动端、管理后台或定时任务时,它们都可以复用packages/api中的同一套 Router 与类型,避免把 API 逻辑写死在某个页面组件里。
数据流在设计上是单向且清晰的:React 组件调用 tRPC 客户端,请求先通过 tRPC 的上下文(context)做身份识别,再进入 Router 上定义的 procedure,经过 zod 校验后调用 Prisma 服务访问 PostgreSQL,最终将结果回传给前端。每一层边界都明确,类型在边界处被自动保留。这里最需要注意的是:不要为了让“类型看起来统一”而随意把 Prisma 查询结果直接返回给前端,更好的做法是在 Router 中做一次字段裁剪,只暴露前端需要的字段,避免把密码哈希、内部状态等意外泄漏出去。
2. 核心细节解析与实操要点
2.1 tRPC 的 Router 设计与上下文机制
tRPC 的设计核心是 procedure(过程),它把“接收输入、执行逻辑、返回结果”封装成一个个可调用的端点。在 t3code 中,我围绕内容管理拆出了postRouter、tagRouter、authRouter三个 Router,再由一个根 Router 统一挂载。每个 procedure 都可以定义自己的输入校验与中间件,这种粒度非常适合做权限控制:公开可见的查询用publicProcedure,需要登录才能操作的方法则挂在protectedProcedure下。
上下文(context)是 tRPC 里最容易被忽略也最关键的部分。我在createContext中读取请求头里的 token 并解析出用户信息,然后放进 context。所有 procedure 都能通过ctx.user拿到当前用户。这段逻辑需要和 Next.js 的请求生命周期配合,通常在处理每个请求时新建一次上下文,而不是复用全局对象,否则用户身份会互相串。
// packages/api/src/context.ts import { initTRPC, TRPCError } from "@trpc/server"; import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; import { getServerSession } from "next-auth"; import { prisma } from "@t3code/db"; export const createContext = async (opts: CreateNextContextOptions) => { const session = await getServerSession(opts.req, opts.res); return { prisma, user: session?.user ?? null, req: opts.req, res: opts.res, }; }; const t = initTRPC.context<typeof createContext>().create(); export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" }); return next({ ctx: { ...ctx, user: ctx.user } }); });这里有个实操细节:我在protectedProcedure中通过next({ ctx: { ...ctx, user: ctx.user } })把类型收窄为“非空用户”。如果直接在各 Router 里反复判断ctx.user,类型依然是User | null,你会发现需要到处写空值保护。用中间件一次性收窄类型,后续代码清爽很多。
2.2 Prisma schema 与数据库建模
数据层我选择 Prisma 搭配 PostgreSQL。Prime schema 是前端类型体系的源头,表结构怎么定义,后续的 TypeScript 类型就怎么生成。我在 t3code 中建立了三张核心表:User、Post、Tag,并通过多对多关系让文章可以挂在多个标签下。这种关系在数据库里看起来只是一张中间表,但 Prisma 会在客户端类型中自动生成tags、posts这样的属性,查询时只需一次include就能拿到完整关系数据。
有一个很容易踩坑的地方是枚举类型。Prisma 支持enum,但如果你在未来某个版本中需要做枚举值的平滑迁移,会比为字符串字段组合变更更麻烦。我在项目里把文章状态设计成DRAFT、PUBLISHED、ARCHIVED三种状态的枚举,因为这类状态值几乎不会无限增长,用枚举反而能借助数据库层做约束;对于可能动态变化的字段(比如用户偏好),我尽量用普通字符串字段加应用层校验。
// packages/db/prisma/schema.prisma model User { id String @id @default(cuid()) email String @unique name String? posts Post[] } enum PostStatus { DRAFT PUBLISHED ARCHIVED } model Post { id String @id @default(cuid()) title String content String status PostStatus @default(DRAFT) author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }设计时我刻意给User的表名加了@@map("users")、给字段使用camelCase,这样数据库里是蛇形命名,但 TS 代码里是驼峰风格。这听起来是小事,实际会影响全项目的可读性,建议从一开始就统一。
2.3 Next.js App Router 与 tRPC 的前端整合
在 Next.js 的 App Router 下,tRPC 的接入方式与 Page Router 略有不同。客户端组件中必须单独维护一个trpc客户端实例,并且用TRPCReactProvider包裹你的应用根节点。服务器组件则可以借助 tRPC 的createCaller直接在服务端调用 Router,不需要发起 HTTP 请求,非常适合首屏渲染时需要查询数据的场景。我把这种调用方式用在了文章详情页:通过createCaller在服务端读取文章数据并渲染 SEO 标签,再交给客户端组件处理交互逻辑。
这里要特别留意“客户端还是服务端”的判断。一个常见的错误是在客户端组件中直接调用 Prisma,或者在服务端组件中使用useQuery。t3code 的约定是所有数据读写都走 tRPC Router,组件只负责渲染与调用。这样一套约定执行下来,代码检视时只要看到组件里出现了prisma或db的相关引用,立刻就能判断是设计出了问题。
// apps/web/src/app/providers.tsx "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { api } from "@/lib/api"; import { useState } from "react"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => api.createClient({ links: [httpBatchLink({ url: "/api/trpc" })], }) ); return ( <api.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </api.Provider> ); }2.4 Tailwind CSS 与 UI 组件体系
样式层我用 Tailwind CSS,并在此基础上加入了 shadcn/ui 风格的组件。为什么不用现成的组件库?因为我在做过几个中后台项目后,发现所谓“开箱即用”的组件库,往往会在定制业务样式时变成负担——你可能只是想改一个圆角,却因为覆盖样式优先级而大动干戈。Tailwind + shadcn/ui 的思路是:组件源码直接复制到项目里,想怎么改就怎么改。
t3code 中我把主题色定义在 CSS 变量中,通过hsl变量控制明暗模式,这让暗黑模式的切换无需在每一层 class 上做额外判断。另一个心得是:Tailwind 3 的内容扫描配置要明确指向所有需要编译的目录。在 monorepo 中,UI 组件放在packages/ui,如果你在根目录的tailwind.config中只配置了apps/web,就会发现组件库里的样式全部失效。
3. 实操过程与核心环节实现
3.1 用 Turborepo 初始化项目骨架
动手搭建的第一步是初始化 Turborepo。我推荐用pnpm,它的依赖管理对 monorepo 的支持最为成熟,磁盘占用也更小。我执行了这条命令:
pnpm dlx create-turbo@latest t3code初始化后会生成apps/web、packages/ui等基础目录。接着我清理掉模板自带的示例代码,加入packages/db与packages/api两个新的 workspace 包。每个 package 的package.json中需要声明"exports"字段,其他包才能正确引用它的子路径。比如@t3code/db要导出prisma客户端与类型定义:
{ "name": "@t3code/db", "exports": { ".": { "types": "./src/index.ts", "default": "./src/index.ts" } } }这里有个容易忽略的点:在开发环境直接导出.ts源文件,可以让 Next.js 的编译流程直接处理这些 TS 代码,避免每次改动都要手动构建packages。但部署时必然要经过正式构建,所以最好确认next.config中开启了transpilePackages配置,否则生产构建会报“找不到类型”的错误。
// apps/web/next.config.js module.exports = { transpilePackages: ["@t3code/api", "@t3code/db", "@t3code/ui"], };3.2 本地数据库与 Prisma 准备
本地我推荐用 Docker 运行 PostgreSQL,避免在本机安装数据库带来的管理负担。一条命令即可启动:
docker run --name t3code-db -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=t3code -p 5432:5432 -d postgres:16启动后,在packages/db里创建.env文件,写入DATABASE_URL="postgresql://dev:dev@localhost:5432/t3code?schema=public"。执行pnpm --filter @t3code/db exec prisma migrate dev --name init,Prisma 会根据 schema 生成迁移文件并同步数据库。随后运行prisma generate生成客户端类型。
这里我踩过一个坑:Prisma 默认会扫描schema.prisma中配置的output目录,但在 monorepo 里,生成文件的位置如果和实际引用路径不一致,就会导致 TypeScript 找不到模块。所以我在schema.prisma里显式指定了生成路径,并让packages/db/src重新导出 Prisma 客户端:
generator client { provider = "prisma-client-js" output = "../generated/prisma" }3.3 把 tRPC 全链路打通
当数据库和 tRPC 骨架都准备好之后,最关键的步骤是把它们串起来。我建议从最简单的查询开始:写一个getPostsprocedure,让前端页面能够展示文章列表。此时你会在前端代码里看到类似这样的调用:
const posts = api.post.list.useQuery({ status: "PUBLISHED" }); // 返回类型: { id: string; title: string; tags: { name: string }[] }[]如果你在后端改了Post模型,比如新增字段slug,前端useQuery后访问post.slug时,TypeScript 会直接提示类型正确或错误。这种“联调不用查文档”的体验,只有在真实项目里操作过一次才能体会。我把这条链路称为“黄金链路”,因为只要它通了,后续增加新表、新页面就只是重复这一模式。
3.4 部署时的环境与构建要点
t3code 我分别试过 Vercel 与自托管,两套部署方式的坑略有不同。上 Vercel 时,需要在项目环境变量中填入DATABASE_URL和认证相关的密钥,并且确保 Prisma 的 postinstall hook 会执行prisma generate,否则产物里会缺少客户端。自托管时更麻烦一点:编译阶段和运行阶段都要能访问数据库,构建机器与生产机器如果网络隔离,迁移命令要在发布脚本中单独处理。
部署前我还启动了一个明文禁止的操作:不要在生产环境跑prisma migrate dev。这个命令会触发数据重置,对于已经上线的系统可能造成不可逆影响。正确流程是本地执行prisma migrate dev,生成迁移文件后提交到仓库,部署时用prisma migrate deploy应用迁移。
4. 常见问题与排查技巧实录
4.1 类型“假安全”的经典案例
t3code 开发到中后期,我遇到过一个特别隐蔽的问题:后端某个 procedure 返回的Post中有author嵌套对象,前端从类型上看也可以访问author.passwordHash——因为 Prisma 模型里确实有这一字段。虽然我从来没有真正查询过密码字段,但只要某次include不小心带上了它,敏感信息就会被直接送到前端。
这就是“假安全”:类型安全不等于运行时安全。后来我在每个涉及鉴权数据返回的 Router 中都加了一道select裁剪,把不需要的字段排除在查询之外:
const post = await ctx.prisma.post.findUnique({ where: { id: input.id }, select: { id: true, title: true, content: true, status: true, author: { select: { name: true, email: true } }, tags: { select: { name: true } }, }, });这种写法让返回类型和查询语句天然对齐,既减少网络传输体积,又避免把敏感字段暴露出去。
4.2 Turborepo 缓存引发的“幽灵构建”
Turborepo 的远程缓存和本地缓存确实能大幅提升构建速度,但同时候也是一个隐患来源。我实测遇到过这种情况:packages/db中的 Prisma schema 改了,但没有触发重建@t3code/db,于是apps/web引用的还是旧类型的 Prisma 客户端,前端代码却通过了编译,上线后运行时报错。
排查方法很直接:执行turbo build --force关闭缓存重建一次,如果问题消失,就说明缓存键配置有问题。究其根源,还是我在turbo.json中没有把.env和 Prisma schema 纳入inputs,导致缓存系统认为上游没有变化。正确做法是把关键文件明确列为缓存输入:
{ "tasks": { "build": { "inputs": ["src/**", "prisma/schema.prisma", ".env"] } } }4.3 Prisma 在 CI 环境中生成失败
CI 流水线中跑 Prisma 生成时,一个高频报错是Could not find a Prisma schema.。通常是因为默认查找路径不对。monorepo 里如果@t3code/db的包路径比较深,Prisma CLI 不会自动感知 root 下的package.json。需要在打包脚本中显式指定路径:
prisma generate --schema=packages/db/prisma/schema.prisma另外,CI 环境一般没有本地数据库,即使只是生成 Prisma Client,某些版本的 Prisma 也会尝试连接数据库读取影子表。如果你遇到Error: P1001,可以在 CI 阶段跳过prisma migrate dev,只执行prisma generate。
4.4 常见问题速查表
| 症状 | 可能原因 | 快速解决 |
|---|---|---|
前端访问ctx.user类型报错 | context 中用户类型未收窄 | 用中间件next({ ctx: { user } })收窄类型 |
| 样式在 monorepo 中失效 | Tailwind 未扫描packages/ui | 在content中增加组件库路径 |
| 修改 Prisma 模型后前端类型未更新 | Prisma Client 未重新生成 | 运行prisma generate |
| 部署后路由全部 500 | 环境变量缺失 | 检查DATABASE_URL与认证密钥 |
Next.js 构建时提示Could not resolve @t3code/api | transpilePackages未配置 | 在next.config.js中增加对应包名 |
tRPC query 报UNAUTHORIZED | 请求未携带认证信息 | 检查getServerSession配置与 provider 包裹 |
5. 实践心得与后续扩展建议
5.1 最值得投入的三件事
整个 t3code 做完之后,如果要我复盘哪三件事投入产出比最高,我会说是:统一类型体系的约定、严格的 Router 边界划分,以及 monorepo 内包的职责明确。类型统一不是某个工具的功劳,而是团队约定在前,工具在后。你在项目第一天就定下“所有数据访问必须走 tRPC”的规矩,后面才不会出现组件直接 import Prisma 的臃肿代码。Router 边界划分则直接决定了项目的可维护性,我见过太多项目把全部业务逻辑塞进一个trpcRouter,那种代码三个月后自己都不想碰。
5.2 从 t3code 还能延伸什么
这个项目做完后,我又基于这套骨架扩展了两个方向:一个是给文章加全文检索,接入 PostgreSQL 的全文索引,通过 Prismaraw查询实现;另一个是把packages/api里的 tRPC Router 改造成多端复用——让后续可能出现的移动端也能调用同一套 API。如果你还想要更精细的权限控制,可以在protectedProcedure之上再加一层roleProcedure,用中间件做角色判断。这套扩展思路的核心是:不要为了增加复杂度而增加,先让主线链路稳定运行,再根据真实需求逐步丰富。
根据我个人经验,做一个全栈项目最怕的不是功能多,而是类型不同步。t3code 的价值不在于它多复杂,而在于它把一套端到端类型安全的链路完整跑通了。如果你正打算从零起一个带数据库的项目,花两天时间把这条链路搭好,后面省下的远不止两天的效率。