1. t3code是什么:我把全栈应用的赌注押在了T3技术栈上
如果你常逛前端社区,大概率见过 t3code 这个代号。它不是一个官方框架,也不是某个大厂出的工具,而是围绕 T3 技术栈(TypeScript + Tailwind CSS + tRPC + Next.js + Prisma)构建的全栈项目模板总称。我最早听说 t3code 的时候也挺好奇,这个名字本身就像个暗号——t3 指的是 The Trinity Stack,源自开发者社区里那句"认准 TypeScript、Tailwind、tRPC 这三个 T",后面的 code 则代表着"这套技术栈不仅能用,还能写出可复用的代码骨架"。
我第一次完整用 t3code 的方式从零搭一个应用,是在做一个内部数据管理后台。项目不大,但涉及用户认证、数据查询、实时更新,还要快速迭代。如果用传统的前后端分离,我得维护两套 API 文档、两套类型定义,再操心 CORS 和鉴权。用 t3code 这套组合拳下来,最大的感受是:类型安全从数据库一路贯穿到浏览器,中间几乎不需要手动维护任何接口契约。
这篇内容适合谁看?如果你正在纠结全栈项目的技术选型,或者已经被"前后端类型不一致""接口文档过期""数据库字段改了前端一堆报错"这类问题折磨过,那 t3code 的思路值得你花十分钟了解。即使你不打算全套照搬,单拿出 tRPC 和 Prisma 这两个组件,也能实打实解决掉日常开发里一半的繁琐活儿。
整套方案看似组件很多,其实各司其职:Next.js 管页面和服务端渲染,tRPC 管 API 调用,Prisma 管数据库模型,Tailwind 管样式,TypeScript 则在中间把所有类型像齿轮一样咬合在一起。下面我按实际搭建的顺序,把每个环节的核心思路和踩坑记录一次说清楚。
2. 技术选型背后的核心取舍:每层解决什么问题,为什么不用别的
2.1 Next.js:页面层选 App Router,还是 Pages Router?
t3code 的默认搭配里,Next.js 是扛起整个应用骨架的那一个。很多初学者会问:为什么非得是 Next.js,Vite 不是更轻快吗?原因在于 t3code 的定位往往是"服务端渲染 + 数据驱动"的应用,Next.js 的 App Router 天然支持 RSC(React Server Components)、路由分组和自动缓存,这些能力在纯 SPA 工具链里是没有的。
我在 t3code 项目里选 App Router 而不是老牌的 Pages Router,核心原因是它的 server layout 机制。比如我需要一个"登录后才可见"的后台区域,直接在一个 layout.tsx 里做服务端鉴权判断,后续所有子页面都会自动继承这个保护逻辑,不用每个页面重复写一遍getServerSideProps。
注意:App Router 的「服务端组件」默认不能直接使用 useState、useEffect,如果你习惯把所有组件都写成带交互的客户端组件,学习曲线会比预期更陡。初期最好把"数据获取和渲染"与"交互反馈"在组件层面拆清楚,否则会写出大量不必要的
'use client'指令。
2.2 tRPC:为什么说它是"API 的隐形胶水"
t3code 里最让我惊喜的组件是 tRPC。简单讲,它让你在前端直接调用后端方法,不需要手写 REST 端点,也不需要定义 GraphQL schema。方法就是函数,函数的入参和返回值都有类型约束,前端 import 进来就能直接调用,IDE 自动补全完全生效。
我当初第一次跑通 tRPC 的原子级 URL 跳转响应(也就是 route -> procedure 的映射)后,写了一个用于用户资料查询的 procedure:
// 服务端 router export const userRouter = router({ getProfile: publicProcedure .input(z.object({ userId: z.string() })) .query(async ({ ctx }) => { return ctx.db.user.findUnique({ where: { id: ctx.input.userId }, include: { posts: true }, }); }), });前端调用时是这样:
const profile = trpc.user.getProfile.useQuery({ userId: "123" });注意userRouter挂载到根 router 后,前端trpc.user.getProfile的路径、入参类型、返回值类型全部自动推出来了。即使后端把posts字段改成articles,前端代码在编译期就会报错,根本不会等运行到那一步才翻车。
这种体验和 REST 最大的区别在于:API 层不再是一个需要被"设计"的接口规范,而是代码结构的自然延伸。对于 rpc 新手,我建议先不要追求所有业务逻辑都收进 tRPC,优先把涉及数据库读写的方法接入,观察一段时间后你会爱上这种"函数即接口"的节奏。
2.3 Prisma:数据库表结构变成类型安全的 ORM 模型
t3code 的底座是 Prisma。数据库层往往是全栈项目最容易埋雷的地方,而 Prisma 用 schema 文件把表结构和 TypeScript 类型绑定在一起。我之前的做法是手动写 SQL migrations,后来换到 Prisma 后,流程变成三步:编辑 schema -> 运行prisma migrate dev-> 生成类型。字段增删改直接反映到node_modules/.prisma/client的生成类型里,前端不可能再出现"数据库有这个字段但接口类型里没有"的脱节。
Prisma schema 的一个例子:
model User { id String @id @default(cuid()) email String @unique name String? posts Post[] createdAt DateTime @default(now()) } model Post { id String @id @default(cuid()) title String content String? author User @relation(fields: [authorId], references: [id]) authorId String }注意模型之间的关联查询:在前端 tRPC procedure 里用include: { posts: true },Prisma 会自动生成多层级联的类型结构,不会出现"连表查询后类型仍是扁平"的尴尬。这个特性在写复杂详情页时省了太多事。
2.4 Tailwind CSS:原子化样式为什么配这套技术栈
Tailwind CSS 在 t3code 里负责样式。很多人觉得 Tailwind 无非是"写 class 更快",其实它和 T3 技术栈更核心的协同在于:样式逻辑和组件代码放在同一文件,并且通过 JIT 编译只生成实际用到的 class,生产环境 CSS 体积小得惊人。
我实测过一个小型管理后台,全部界面用 Tailwind 编写,最终压缩后的 CSS 只有十几 KB。对比之前用传统组件库的场景,虽然组件库省事,但样式定制和全局主题切换往往会引入冗余覆盖。Tailwind 的设计哲学与 t3code"少抽象、少魔法"的理念一致:你可以直观地在 HTML 结构里看到所有样式意图。
真要说 Tailwind 的适应成本,在于把"布局、颜色、间距"这些脑内概念转成对应的工具类。我的建议是先只记五类核心工具:flex、grid、p/m 间距、text-颜色、hover/响应式变体。其余查文档即可,上手两周就能熟练。
2.5 TypeScript:把上面一切串起来的"电"
如果 t3code 是一座桥,TypeScript 就是贯穿桥身的钢筋。为什么强调它?因为 tRPC 与 Prisma 的类型生成、Next.js 的组件 Props 校验、Tailwind 的 class 自动提示,全部依赖 TS 的静态分析。没有 TS 的严格模式,这套技术栈的「端到端类型安全」会直接瓦解。
启用严格模式的方式很简单,在tsconfig.json中确认:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true } }我强烈建议打开noUncheckedIndexedAccess。它会让arr[0]的类型变成T | undefined,强迫你处理边界情况。很多线上事故就是「读取了数组越界项」导致的。
3. 实操过程:从 0 到 1 用 t3code 思路搭一个内容发布后台
3.1 初始化项目与目录结构
官方推荐的初始化方式是使用 create-t3-app。一条命令就能生成带全部依赖的项目。
npx create-t3-app@latest t3code-demo交互式命令行会让你选择需要的模块,我一般选 Next.js、tRPC、Prisma、Tailwind、NextAuth.js。生成后的目录结构如下:
. ├── prisma │ └── schema.prisma ├── src │ ├── pages (或 app) │ ├── server │ │ ├── api │ │ └── db.ts │ ├── trpc │ │ ├── router │ │ ├── trpc.ts │ │ └── server.ts │ └── styles ├── package.json └── tsconfig.json这里重点说一下src/server/db.ts,它是 Prisma Client 的单例出口。很多新手会直接在 procedure 里 import PrismaClient,每调用一次就 new 一个实例,这在开发模式下会导致连接池爆满。t3code 默认生成的代码长这样:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;这段代码的核心意义是:开发模式下复用同一个 Prisma 实例,避免热重载时无限创建连接。如果你自己手动搭项目,千万别跳过这一步。
3.2 定义数据模型并完成首次迁移
在schema.prisma里,我按需求设计了三个核心模型:用户、文章、标签。关键是文章与标签的多对多关系,以及文章的发布状态枚举。
enum PublishStatus { DRAFT PUBLISHED ARCHIVED } model Article { id String @id @default(cuid()) title String content String status PublishStatus @default(DRAFT) tags Tag[] @relation("ArticleTags") author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique articles Article[] @relation("ArticleTags") }写完 schema 后运行:
npx prisma migrate dev --name init这个命令会生成 migration 文件、执行 SQL 并重新生成 Prisma Client。第一次跑的时候如果数据库是 SQLite,会自动创建dev.db文件;如果是 PostgreSQL,需要保证DATABASE_URL环境变量已经正确指向目标库。迁移完成后,建议立刻在编辑器中确认@prisma/client的类型已经包含新的 Article/Tag 类型,没有报错再进行下一步。
3.3 设计 tRPC 路由,创建文章发布接口
tRPC 的 Router 结构可以随意组织,但 t3code 惯例是「按业务域拆文件」。我建了src/server/api/routers/article.ts:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const articleRouter = createTRPCRouter({ listPublished: publicProcedure.query(async ({ ctx }) => { return ctx.db.article.findMany({ where: { status: "PUBLISHED" }, include: { tags: true, author: { select: { name: true } } }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(120), content: z.string().min(10), tagNames: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { const { title, content, tagNames } = input; const tags = await Promise.all( tagNames.map((name) => ctx.db.tag.upsert({ where: { name }, update: {}, create: { name }, }) ) ); return ctx.db.article.create({ data: { title, content, status: "PUBLISHED", authorId: ctx.session.user.id, tags: { connect: tags.map((t) => ({ id: t.id })) }, }, }); }), });这里有几个关键细节想提醒你:
publicProcedure和protectedProcedure的区别来自 trpc 上下文中的 session 判断。没有登录的用户调create会直接抛错,这比在页面里手动判断要安全得多。tagNames.map+ upsert 完成了「标签不存在则创建,存在则复用」的逻辑。如果标签数量很大,建议改用createMany的 skipDuplicates,不过 SQLite 对 createMany 支持有限,这里用循环是稳妥的。ctx.session.user.id来自 NextAuth 的 session 类型,前提是你在 createTRPCContext 里把 session 塞了进去。
3.4 构建前端页面:列表、详情、发布的调用链路
前端页面我放在src/app下。列表页用服务端组件直接调 tRPC 的listPublished:
import { api } from "~/trpc/server"; export default async function ArticleListPage() { const articles = await api.article.listPublished.query(); return ( <div className="max-w-3xl mx-auto py-10"> {articles.map((article) => ( <article key={article.id} className="border-b py-6"> <h2 className="text-xl font-semibold">{article.title}</h2> <p className="text-gray-500"> {article.author.name} ·{" "} {article.createdAt.toLocaleDateString()} </p> </article> ))} </div> ); }发布文章的页面我用客户端组件,调用createmutation:
"use client"; import { api } from "~/trpc/react"; export function ArticleForm() { const utils = api.useUtils(); const createArticle = api.article.create.useMutation({ onSuccess: () => { utils.article.listPublished.invalidate(); }, }); async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); const formData = new FormData(e.currentTarget); await createArticle.mutateAsync({ title: String(formData.get("title")), content: String(formData.get("content")), tagNames: ["t3code", "全栈"], }); } return <form onSubmit={handleSubmit}>{/* 内容省略 */}</form>; }注意useQuery / useMutation必须从~/trpc/react这个包装后的客户端入口 import,不能直接从~/trpc/server拿,否则会报「hooks 只能在客户端组件中使用」的错误。发布成功后的invalidate也很关键——它会自动重新拉取列表数据,保证新增内容立刻出现。
3.5 环境变量与认证配置的坑
t3code 默认使用 NextAuth.js,这一步也是新手百发百中的坑位。你需要准备:
DATABASE_URL="file:./dev.db" # 或 PostgreSQL 连接串 NEXTAUTH_SECRET="一个随机生成的至少32位字符串" NEXTAUTH_URL="http://localhost:3000"生成 NEXTAUTH_SECRET 可以用:
openssl rand -base64 32如果 NEXTAUTH_SECRET 不设置或太短,NextAuth 在运行时会直接抛错,控制台提示也不够直观。更严格的做法是在next.config.js里校验:
if (!process.env.NEXTAUTH_SECRET) { throw new Error("NEXTAUTH_SECRET 未配置"); }首次登录后我给用户加了一个「是否已初始化昵称」的字段,在authOptions的 callbacks 里往 session 上挂user.name,确保前端ctx.session.user.name不 undefined。这块配置容易漏,你可以在 trpc 的createContext中打印 session 调试:
export function createTRPCContext(opts: CreateNextContextOptions) { return { db: prisma, session: opts.req ? await getServerSession(authOptions) : undefined, }; }3.6 打包与部署的细节
本地跑通后,部署到 Vercel 或自托管都行。这里我踩过几个必须记录的坑:
- 构建时数据库连接失败:很多部署平台在构建阶段没有网络访问数据库。解决办法是把 migration 放到运行时,或者用
prisma migrate deploy在独立步骤执行,不要让构建命令next build试图访问数据库。 - SQLite 不适合生产:如果用了默认的 SQLite,部署到 Serverless 平台时文件系统是临时的,数据会丢。生产环境换 PostgreSQL,并把
DATABASE_URL指向托管数据库。 - Serverless 环境连接数限制:Prisma 默认连接池在 Serverless 平台容易被占满。官方推荐使用
prisma accelerate连接池,或者把数据库放到同一地域减少延迟。 - tRPC 错误日志不显示:在 server 端配置
TRPC_ENABLE_LOGGING=true,或者在createTRPCContext里加 logger middleware,不然线上出问题很难定位。
4. 常见问题与排查技巧实录
4.1 tRPC 类型推断失效,前端看不到路由
现象:trpc.article.create报红,提示属性不存在。
排查步骤:
- 确认根 router 有没有在
src/server/api/root.ts里把articleRouter挂到appRouter上。 - 确认
src/trpc/server.ts和src/trpc/react.ts中引用的AppRouter类型是从 root 导出的。 - 热更新偶尔跟不上类型生成,重启 dev server 验证。
- 手动检查
node_modules/.prisma/client是否生成成功,如果 schema 有问题,Prisma Client 类型残缺会导致下游 tRPC 类型全断。
4.2 NextAuth session 无法在 tRPC context 中读取
现象:ctx.session永远是 null。
原因多半是getServerSession没有对上 NextAuth 的authOptions。大多数情况下是你在 app 目录的api/auth/[...nextauth]/route.ts里导出了authOptions,却在 tRPC context 里又维护了一份不同的配置。建议只定义一个auth.ts或auth-options.ts,所有地方从那里 import,不要复制粘贴。
4.3 Prisma migrate 时数据库表没有更新
现象:运行migrate dev成功,但页面查询报「表不存在」。
原因是 migrator 连接到了和运行时不同的数据库。比如DATABASE_URL在.env里写的是测试库,而next dev启动时又读取了.env.local里的生产库。排查时先统一环境变量加载方式,建议只在.env维护一份配置,删除.env.local,避免两套配置互相覆盖。
4.4 Tailwind 样式在动态渲染的页面中丢失
现象:列表页中服务端组件返回的 class 呈现正常,但客户端组件渲染的弹窗样式空白。
原因是 Tailwind 的内容扫描范围没有包含新增文件。检查tailwind.config.ts的content配置,确保涵盖了./src/**/*.{ts,tsx},否则 JIT 不会生成动态插入组件的 class。
4.5 常见错误速查表
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
The table 'main.Article' does not exist in the current database | migration 没有执行 | 运行npx prisma migrate dev或prisma db push |
TypeError: Cannot destructure property 'user' of 'ctx.session' as it is undefined | procedure 用了 protectedProcedure 的 ctx,但 session 为空 | 检查 NextAuth 登录状态,区分 public/protected procedure |
Invalidprisma.article.create()invocation | 字段名或关联写错 | 根据报错对照schema.prisma的实际字段,不要盲改代码 |
Tailwind class 'flex-1' does not exist | JIT content 路径未覆盖对应文件 | 更新 tailwind.config 的 content 并重启构建 |
RSC safe相关报错 | 在服务端组件里调用了客户端 hook | 拆分组件,交互部分放到"use client"组件中 |
4.6 几个少有人提的细节技巧
我在 t3code 项目里跑了几个月的生产环境,有三个小技巧想额外分享:
第一个:给 tRPC router 做细粒度错误码。默认的 tRPC 错误只有BAD_REQUEST、UNAUTHORIZED之类,对前端业务来说不够精确。我用 zod 的.refine()在 input 层做业务校验,并附带可读 message,这样前端可以直接把 message 展示给用户,不用在后端再包一层 try/catch。
第二个:用 server components 和 tRPC 搭配时留意缓存。Next.js App Router 默认会缓存 RSC 的请求结果,如果你刚提交了文章 mutation,列表页可能还显示旧数据。这时除了invalidate(),还需要在页面级加export const revalidate = 0或dynamic = "force-dynamic",确保每次访问都拿最新数据。
第三个:本地提交 commit 前加一个 typecheck 脚本。tRPC 类型安全的好处只有在静态检查通过时才成立,很多问题都是改完 schema 忘了重新生成类型、改完 router 忘了前端调用链同步。在 package.json 配一个"typecheck": "tsc --noEmit",每次 commit 前跑一遍,比依赖 IDE 提示更保险。
5. 写在最后的项目感受
t3code 并不是一个需要"原样照搬"的神圣模板,它真正的价值在于把一套已经被验证过的技术选型和工程约束固化成了可复用的起点。我在这个项目里体会最深的一点是,当类型安全贯穿数据库到 UI 层之后,很多「低级错误」根本不可能发生——字段拼错、接口对不上、状态不同步,编译期直接拦下。代价是前期需要多花一点时间学习 Prisma schema 和 tRPC 的上下文机制,但中期迭代的速度提升非常可观。
如果你正在做小团队内部工具或全栈个人项目,我建议直接照着 t3code 的思路走一遍,哪怕最后不用 Next.js,单把 tRPC + Prisma 的组合抽出来放进你现有的技术栈里,也会立刻感受到"少写一半胶水代码"的快乐。最后再分享一个小技巧:初始化完项目后,先别急着写业务,把所有 procedure 的 hello 页跑通,再把数据库 schema 设计好,不然边写边改会非常磨人。