1. 为什么我会选择T3 Stack作为t3code的底座
先说一下背景。我在做t3code之前,其实已经受够了传统全栈开发里的那种“打通类型”的痛苦:后端定义一个User接口,前端又复制一份User类型,数据库加一个字段,接口文档、TypeScript类型、校验逻辑要同步改三四处。那个项目不算复杂,几十个接口,但一到联调阶段就到处都是“类型对不上”的报错。后来我在社区看到T3 Stack这套组合,核心思路是用TypeScript把整个链路串起来,当时就决定拿一个真实项目来验证。t3code就是那个验证项目。
T3 Stack不是什么新框架,而是Next.js、tRPC、Tailwind CSS、Prisma或Drizzle这些工具的组合。这里面最重要的是tRPC,它能让前端像调用本地函数一样调用后端的API,而且所有入参、出参都是类型安全的。举个例子,我在服务端写了一个createPost的procedure,前端直接调用api.post.create就能拿到完整的类型提示,参数错了编译期直接报错。这对团队协作来说价值极大,因为类型定义本身就是文档,还是一个不会过期的文档。
t3code要解决的核心问题有三个。第一是消除类型重复定义,让数据库Schema、服务端逻辑、前端组件共享同一套类型来源。第二是减少样板代码,不用再为了一个接口写controller、service、validator、api client这些一堆东西。第三是让新人也能快速上手,tRPC的写法与普通函数几乎没有区别,学习成本远低于GraphQL那套schema和resolver机制。
合适谁参考呢?如果你正在做全栈TypeScript项目,尤其是中小型应用、内部工具、创业项目MVP,这套方案能极大缩短从想法到上线的时间。如果你已经用过Next.js但还没接触过tRPC,这篇文章里我记录的踩坑过程应该能帮你少走弯路。就算你以后不打算用T3这套组合,理解端到端类型安全的思路对任何全栈项目都有启发。
1.1 T3 Stack的四件套具体解决什么问题
拆开看,t3code选用的每一件工具都有明确的职责边界,而不是为了酷炫而堆砌。
Next.js负责应用框架和路由。App Router的约定式路由让页面结构非常清晰,每个page.tsx就是一个页面,每个route.ts就是一个API端点。服务端组件(Server Components)允许我们在服务端直接读取数据库、调用tRPC,把渲染好的HTML发给客户端,首屏性能很好;客户端组件则处理交互逻辑。这个边界一旦理解清楚,整个应用的数据流就很自然了。
tRPC负责API层。它最大的特点是不需要额外的API定义语言,直接在服务端写一个router对象,每个procedure就是一个可调用的方法,支持query和mutation两种类型。然后通过一个React Hook在前端调用,所有输入输出类型都从服务端自动推断到前端。这里我不需要维护任何API文档,也不用定义任何DTO,因为类型都是从服务端的函数签名里“长”出来的。
Tailwind CSS负责样式。我知道有人觉得原子化CSS类名很丑,但实际用它做了几个项目后,我最大的感受是迭代速度快。改样式不用来回切换HTML和CSS文件,组件内的每个元素都在同一段JSX里能看到完整的样式类。配合Headless UI这类无样式组件库或者shadcn/ui这类带Tailwind的组件库,做出一个像样的界面花的时间可以压缩一个数量级。
Prisma负责数据库访问和模型定义。它通过Schema文件定义数据模型,然后自动生成TypeScript类型。这个生成的类型会被tRPC和Next.js共享,真正做到“数据库Schema一变,全链路类型跟着变”。后续我也可以提到Drizzle,它比Prisma更轻量,SQL风格更接近原生,但当前t3code用的是Prisma,稳定性更好,文档更全。
2. 核心细节解析:t3code里每一个关键部件的原理
很多教程会直接教你跑一遍create-t3-app,然后丢给你一个能跑的Demo,但不知道为什么每个文件这么写。这里我把t3code里最核心的机制讲透,你理解了原理之后再动手,遇到问题就能自己排查。
2.1 端到端类型安全是怎么实现的
这是整套方案的灵魂。传统REST API在前后端之间存在一个“类型断层”:后端有类型,前端也有类型,但两者之间没有自动的关联,全靠人肉同步。tRPC的本质是把服务端的类型定义通过TypeScript的类型推断能力,直接传递到前端的调用点。
tRPC的Router定义在服务端,比如:
// server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 50, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().min(1), })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content }, }); }), });然后通过createTRPCReact生成前端Hooks。TypeScript会从服务端router的类型逐层向下推断,前端调用api.post.getAll.useQuery()时,data的类型自动就是Post[],input的类型自动就是{ title: string, content: string }。这个过程中没有任何代码生成步骤,也没有需要手动发布的SDK,仅仅是TypeScript的泛型推断。
这样的好处除了类型自动同步,还有一个容易被忽略的点:重构时的安全感。假设我把Post模型里title改名为heading,Prisma生成的类型、tRPC的返回值类型、前端所有用到post.title的地方会同时报错。这意味着我能在编译期知道所有受影响的代码位置,而不是上线后通过监控发现某处渲染塌了。
2.2 tRPC的query与mutation应该怎么区分
tRPC只有两种操作类型。query用于获取数据,应该保证无副作用;mutation用于修改数据,可以安全地重试。这个区分直接对应HTTP的GET和非GET语义,也让React Query(tRPC底层使用TanStack Query)的缓存机制可以有效工作。
t3code里我定的一个规范是:所有读操作一律用query,所有写操作一律用mutation,绝不混搭。有些初学者为了让代码少写几行,把带副作用的状态修改也放进query里,导致缓存失效、数据不更新。这是一个必须避开的坑。
2.3 Next.js App Router中服务端组件和客户端组件的边界
这是t3code里最容易让人困惑的机制。先给出判断标准:如果这个组件需要用户交互、需要使用React Hooks(如useState、useEffect),它就是客户端组件;如果只是展示静态数据、或者从服务端拿数据,优先做服务端组件。
在t3code里,我通常这样划分:
- 页面布局、从数据库读取列表并渲染的组件,使用服务端组件。
- 表单、按钮点击、弹窗、输入框联动,使用客户端组件。
- 客户端组件可以通过props接收服务端组件传来的数据,保持数据获取和交互逻辑的清晰边界。
有一个非常典型的错误:在服务端组件里用了onClick或者useState,Next.js会直接报错,提示你去掉或加"use client"指令。反过来,在客户端组件里直接写tRPC的query也是可以的,因为createTRPCReact生成的hooks本身就是客户端专用的。至于在服务端组件里调用tRPC,需要改成调用函数形式,tRPC暴露了caller,我后面在实操环节会详细展示。
3. 实操过程:用create-t3-app从零构建t3code并跑通核心功能
下面这部分我假设你是一个有一定TypeScript基础、但没动手搭过T3项目的开发者。我会把每个关键步骤写清楚,包括命令、改哪些文件、为什么要这么改。
3.1 环境准备
我在新机器上搭t3code时用的环境是:Node.js 20 LTS,包管理器用pnpm,数据库用PostgreSQL。checked一下Node版本,低于18会跑不起来,因为Next.js 14要求Node >= 18.17。安装pnpm的命令是:
npm install -g pnpm数据库我直接用了本地的Docker容器,一条命令启动PostgreSQL:
docker run --name postgres-t3code -e POSTGRES_PASSWORD=devpassword -p 5432:5432 -d postgres:16虽然t3code生产环境可以用Vercel Postgres或Neon这种Serverless数据库,但本地开发用Docker最方便,改Schema后可以重新生成表结构,不产生额外费用。注意这只是本地开发数据库,生产环境不要用这个方式。
3.2 初始化T3项目
用create-t3-app命令初始化:
pnpm create t3-app@latest交互式问题里面会让你选:TypeScript、Tailwind CSS、tRPC、Next.js App Router、Prisma。建议把这些全部勾选,因为这是T3的完整形态。包管理选pnpm,初始化后目录结构会自动生成下面这些核心文件:
src/app/ api/trpc/[trpc]/route.ts # tRPC的HTTP入口 layout.tsx # 根布局 page.tsx # 首页 src/server/ api/ root.ts # root router,汇总所有子路由 routers/ post.ts # 各个业务子路由 trpc.ts # tRPC上下文和初始化逻辑 db.ts # Prisma客户端实例 auth.ts # 认证相关(如果勾选了) prisma/ schema.prisma # 数据模型定义先不急着写业务代码,跑一遍pnpm dev,确认默认首页能打开,然后再往下改。
3.3 配置环境变量和数据库连接
初始化项目默认用的数据库是SQLite,但生产环境我建议用PostgreSQL。把.env改成本地PostgreSQL连接串:
DATABASE_URL="postgresql://postgres:devpassword@localhost:5432/t3code"记得先在PostgreSQL里创建对应的数据库:
docker exec -it postgres-t3code psql -U postgres -c "CREATE DATABASE t3code;"接下来把Prisma的provider改成postgresql:
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") }然后执行迁移:
pnpm prisma migrate dev --name init这个命令有两个作用:一是生成SQL迁移文件推送到数据库,二是重新生成Prisma Client的类型定义。
3.4 定义数据模型:以“笔记”应用为例
t3code是为一个Markdown笔记分享应用写的,所以数据模型不复杂,但能覆盖一对多、身份关联和分页查询。打开prisma/schema.prisma,写入:
model User { id String @id @default(cuid()) name String? email String? @unique posts Post[] likes Like[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String likes Like[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Like { id String @id @default(cuid()) user User @relation(fields: [userId], references: [id]) userId String post Post @relation(fields: [postId], references: [id]) postId String @@unique([userId, postId]) }这里我特意给Like加了联合唯一约束@@unique([userId, postId]),防止同一个用户重复点赞同一篇文章,在数据库层就拦住脏数据。
每次改动Schema后,执行pnpm prisma migrate dev --name add_like_model让数据库和类型同步。
3.5 编写tRPC Router:完成笔记的增删改查
有了数据模型,接下来写业务逻辑。打开src/server/api/routers/post.ts,把postRouter的代码补全:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ getAll: publicProcedure .input( z.object({ limit: z.number().min(1).max(100).default(20), cursor: z.string().nullish(), }) ) .query(async ({ ctx }) => { const posts = await ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 100, include: { author: { select: { name: true } }, _count: { select: { likes: true } }, }, }); return posts; }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id }, include: { author: { select: { name: true } }, likes: true }, }); if (!post) throw new Error("Post not found"); return post; }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().min(1), })) .mutation(async ({ ctx, input }) => { const post = await ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); return post; }), toggleLike: protectedProcedure .input(z.object({ postId: z.string() })) .mutation(async ({ ctx }) => { const userId = ctx.session.user.id; const postId = ctx.input.postId; const existing = await ctx.db.like.findUnique({ where: { userId_postId: { userId, postId } }, }); if (existing) { await ctx.db.like.delete({ where: { id: existing.id } }); return { liked: false }; } await ctx.db.like.create({ data: { userId, postId }, }); return { liked: true }; }), });注意第9行的protectedProcedure,它意味着这个接口只有登录用户能调用。这个认证判断在context里统一完成,比在每个procedure里手动判断安全得多,也省代码。
为了让新写的Router生效,还需要在root router里注册:
// src/server/api/root.ts import { postRouter } from "~/server/api/routers/post"; import { createTRPCRouter } from "~/server/api/trpc"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;3.6 前端页面调用:列表页、详情页、点赞交互
页面部分我按照“服务端组件负责数据获取、客户端组件负责交互”的方式来拆。
首页src/app/page.tsx可以直接从服务端调用tRPC:
import { api } from "~/trpc/server"; export default async function HomePage() { const posts = await api.post.getAll({ limit: 20 }); return ( <main className="mx-auto max-w-3xl p-8"> <h1 className="text-2xl font-bold">最新笔记</h1> <div className="mt-6 flex flex-col gap-4"> {posts.map((post) => ( <PostCard key={post.id} post={post} /> ))} </div> </main> ); }这里的api是tRPC暴露的server caller,我通常放在src/trpc/server.ts:
import { createCallerFactory } from "~/server/api/trpc"; import { appRouter } from "~/server/api/root"; const createCaller = createCallerFactory(appRouter); export const api = createCaller({});注意服务端调用时传的context通常只需要db,不需要session对象(除非要做服务端渲染用户信息)。这个api不是React Hook,而是直接执行router函数,所以可以在async服务端组件里直接await。
点赞按钮属于交互逻辑,必须放在客户端组件里。新建src/components/PostCard.tsx:
"use client"; import { api } from "~/trpc/react"; import type { Post } from "@prisma/client"; export function PostCard({ post }: { post: Post & { _count: { likes: number } } }) { const utils = api.useUtils(); const toggleLike = api.post.toggleLike.useMutation({ onSuccess: () => { // 使首页的列表数据失效并重新获取 utils.post.getAll.invalidate(); }, }); return ( <div className="rounded-lg border p-4"> <h2 className="text-lg font-semibold">{post.title}</h2> <p className="text-sm text-gray-500">{post._count.likes} 人点赞</p> <button onClick={() => toggleLike.mutate({ postId: post.id })} className="mt-2 rounded bg-blue-600 px-4 py-2 text-white" > {toggleLike.isPending ? "处理中..." : "点赞"} </button> </div> ); }这里有一个非常关键的tRPC实战技巧:写操作完成后,要用invalidate主动触发相关query的重新获取。因为tRPC底层是TanStack Query,它不会自动知道某个mutation改变了哪个query的数据。我刚开始写t3code时经常遇到点赞数不刷新,排查了很久才意识到要手动失效缓存。记住这条规则:每一个mutation的onSuccess里,都去invalidate受影响的数据查询。
3.7 部署:Vercel + Neon Serverless Postgres
本地跑通后,t3code的部署我用了Vercel加Neon。Neon提供一个PostgreSQL连接串,而且是Serverless模式,适合Vercel这种无服务器环境。部署步骤很简单:
- 在Neon创建项目,复制
DATABASE_URL(记得用连接池地址,带?pgbouncer=true或-pooler后缀)。 - 在Vercel项目中设置环境变量
DATABASE_URL。 - 推送Prisma Schema到生产数据库:
pnpm prisma migrate deploy。 - 在Vercel关联Git仓库,push后自动部署。
有个大坑必须提醒:Vercel的Serverless Function有冷启动,Prisma Client首次连接会激活大量的PostgreSQL连接,很容易打满Neon免费档的连接数限制。解决办法是在Prisma连接串里加连接池参数,比如?connection_limit=1或者用Neon提供的连接池地址。另外可以在prisma/client.ts里使用PrismaClient的单例模式,避免Serverless环境下每次调用都重新实例化:
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;4. 常见问题与排查技巧实录
写t3code这大半年来,我踩过不少坑,下面这些是最常见、也最值得记录的。
4.1 Prisma类型名和tRPC返回值类型不一致
出现场景:我在tRPC里用include查出了author关联数据,前端组件里明明能拿到post.author.name,但类型却报“Property 'author' does not exist”。
原因:Prisma生成的类型是“平铺的”,除非你显式声明并导出带include的类型。对于包含关系的返回类型,最省事的做法是让TypeScript推断而不是手动写类型。在组件props里,我建议直接用Awaited<ReturnType<typeof api.post.getAll>>[number]来推导单个帖子类型,而不是笨拙地导入Prisma的Post类型。更优雅的方式是使用tRPC暴露的RouterOutputs工具类型:
import type { RouterOutputs } from "~/trpc/react"; type PostItem = RouterOutputs["post"]["getAll"][number];这样你的类型永远跟服务端实际返回的数据结构同步,不会出现多定义、少定义的情况。
4.2 浏览器报错“Text content does not match server-rendered HTML”
出现场景:页面首屏在服务端渲染了点赞数、创建时间等内容,客户端激活时发现数据不一致。
常见原因有二。第一个是直接用了Date.now()、Math.random()之类的不稳定值做渲染,服务端和客户端生成的结果不一样。第二个是Time Zone问题——服务端时区是UTC,本地浏览器用本地时区,显示时间文本不同。解决办法是时间格式化统一在客户端组件里执行,或者使用UTC时间作为服务端渲染文本,客户端用Hydration后再格式化。我自己现在生成时间文本时会在服务端只输出原始ISO时间戳,客户端再调用Intl.DateTimeFormat格式化,保证两端一致。
4.3 tRPC的Error:Unexpected token 'o' 等JSON解析错误
这个问题最开始是Next.js在请求参数里带了NaN或Date对象,tRPC的JSON序列化不认识。t3code里所有输入参数都用Zod schema严格验证,不要试图把Date对象或undefined作为input key直接传。解决方法是传ISO字符串或timestamp数字,在后端再转换。
4.4 Next.js build时“Page could not be resolved”或server-only报错
出现在App Router下把只在服务端使用的代码误引入了客户端组件。例如我把prisma/client直接import到某个带"use client"的组件里,构建直接失败。排查方法很简单,检查报错文件是否被客户端导入,把prisma相关的逻辑挪到服务端组件或tRPC router里。另外一个经验是给Prisma访问单独建一层API模式,前端UI不要直接感知数据库模型的存在。
4.5 样式类被Tailwind CSS的Preflight重置
不要自定义如果组件里用的是动态拼接的类名,比如className={text-${color}-500},Tailwind在build时扫不到这些字符串,不会生成对应的样式。这是Tailwind的扫描机制决定的——它静态扫描源码文件中的类名文本,运行时的字符串拼接它无法识别。解决方法是写全完整的类名:className={condition ? "text-red-500" : "text-blue-500"},或者用Safelist配置把动态类名提前声明。这个问题排查过一次就不会再犯,属于“看起来像运行时问题、其实是构建期问题”的典型。
4.6 Serverless环境下的内存泄漏和连接数打满
生产环境的另一个坑:在Neon或Vercel Postgres上,每次冷启动都会新建Prisma连接,连接数很快见顶。解决办法是调整连接串参数:DATABASE_URL="postgresql://user:pass@host:5432/db?pgbouncer=true&connection_limit=1"。此外可以把Prisma的日志降到error级别,避免在Serverless环境产生太多日志而拖慢冷启动。
5. t3code后续还能这样扩展
主体功能跑通之后,t3code的架构可以很方便地往上叠加能力。我这里分享几个实际项目里很常用的扩展方向,每个都不需要大改现有代码。
5.1 加认证和权限
t3code用的Next.js版本已经可以无缝集成Auth.js(NextAuth)。核心是在tRPC的context里读取session:
// server/api/trpc.ts export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, ...opts }; };这样protectedProcedure就能通过ctx.session.user拿到当前登录用户。我给t3code加过的策略是:所有写操作(创建、更新、删除)默认用protectedProcedure,读操作如果不需要登录尽量用publicProcedure,减少未登录用户的阻塞。再到具体业务上判断资源所有权,比如只有文章作者本人才能删除,这个逻辑放在procedure内部判断,不依赖前端传任何权限参数,安全边界在服务端收口。
5.2 加文件上传
笔记应用经常要插入图片。传统做法是让前端直接上传到对象存储,拿到URL后存到数据库。t3code里我建议用tRPC作为签名的入口:后端生成一个临时的上传凭证(比如腾讯云COS或AWS S3的预签名URL),前端拿到之后直传对象存储。流程是:前端调api.upload.createPresignedUrl-> 得到URL -> 直传文件 -> 调api.post.updateWithImage把URL写入数据库。这样文件内容不走t3code应用服务器,避免请求体过大导致的内存压力。
5.3 加测试和类型校验
t3code作为追求类型安全的项目,测试也不能落下。tRPC的测试比REST API测试简单得多,直接实例化router的caller来调用procedure,不启动HTTP服务器。例如:
import { appRouter } from "~/server/api/root"; import { createContext } from "~/server/api/trpc"; test("create post", async () => { const ctx = await createContext({ headers: new Headers() }); const caller = appRouter.createCaller(ctx); const result = await caller.post.create({ title: "测试标题", content: "测试内容", }); expect(result.title).toBe("测试标题"); });这个方式跑得飞快,不需要mock HTTP请求,也不需要额外搭测试数据库(当然如果你要验证数据库行为,建议连一个独立测试库)。尤其适合团队项目里保护核心的读多写少逻辑。
一些真实的体会
t3code这个项目让我最深印象的不是“快”,而是“稳”。在传统全栈项目里,前后端对接总是最耗时间、最容易出bug的环节;切换到这个架构之后,我的联调时间大概压缩到原来的三分之一,不是因为代码写得快,而是因为类型系统替我挡掉了大量低级错误。
如果你准备在下一个项目里尝试这个组合,我的建议是:先在小型业务模块上跑通整个链路,不要一上来就追求覆盖所有场景。把登录、一个列表页、一个写操作做通,你会很快体会到“类型从数据库一直流到按钮点击的地方”是一种什么样的开发体验。
还有一个小技巧:环境变量记得区分.env和.env.production。我因为把本地数据库地址当成生产地址用在Neon上,浪费了整整半天排查连接失败,最后发现是环境变量文件搞混了。这类低级错误,一个命名规范就能避免:.env.local只给本地开发用,生产环境的连接串永远只出现在Vercel或部署平台里,不需要提交到Git仓库。
t3code本身已经跑了一段时间,数据量不大,但每一行Schema变更都能在40秒内从数据库模型反应到前端页面,这种感觉是这个技术栈最大的红利。如果你也想体验一下“改一个字段、全链路自动同步”的爽感,建议直接照这篇文章的操作步骤搭一个最小示例,亲手跑一遍,一定比看十遍教程都有用。