最近手里攒了个代号叫 t3code 的小项目。说起来也没什么惊天动地的功能,就是一套 To-Do + 简单内容发布的全栈应用。真正让我决定把过程整理出来的原因是:这套东西从零到上线,骨架几乎全是create-t3-app一行命令生成的,而 TypeScript 的静态类型从数据库一路贯穿到页面组件,中间几乎没有断层。这种"类型安全感"对常年在前后端接口文档里来回对照的开发者来说,是一种很治愈的体验。
如果你也在纠结要不要入坑 T3 Stack(Next.js + TypeScript + Tailwind CSS + tRPC + Prisma + NextAuth),或者已经生成了项目但不知道怎么组织业务代码,这篇就以 t3code 为样板,把链路里我认为最关键的几个决策点、代码组织和踩过的坑串起来讲。适合想快速搭建全栈应用、又不想在接口定义上反复拉扯的个人开发者和小团队。
1. t3code 想解决的问题:全栈类型断层和脚手架思维
很多个人项目其实挂掉的原因不是功能难做,而是"工程成本被低估了"。一个人写前端要定义请求层、写后端要定义路由和参数校验、两边还要维护一份接口文档。前端改了一个字段名,后端不知道;后端调整了返回结构,前端直接渲染崩了。类型断层带来的沟通成本在团队协作里还能靠技术方案去弥合,偏偏个人项目里最容易犯懒,于是越到后期越不想动代码。
t3code 这个名字虽然是我随手起的,但背后对"全栈类型安全"的执念是明确的。T3 Stack 的核心思路是:TypeScript 作为唯一真相来源,让协议定义、参数解析、数据库模型、会话状态全部成为可推断的类型。前端拿到的数据天然带着后端模型的所有属性,后端收到的输入天然被解析成明确的结构。这相当于把"前后端约定"从文档搬进了编译器。
我选择这套组合还有一层实用考虑:脚手架帮你把目录、数据库连接、会话处理和基础鉴权都铺好了。create-t3-app生成的骨架本身就是一个足够规范的"起步工程",你不需要在项目初始化阶段反复纠结结构,可以直接把精力花在业务逻辑上。对独立开发者来说,少做一次"从空文件夹开始布局"的决策,就多一份落实到主线功能的精力。
1.1 技术选型背后的取舍
先说清楚 t3code 用的是经典组合:Next.js App Router 做页面和服务端渲染,Tailwind 处理样式,tRPC 承担 API 层通信,Prisma 负责数据建模和迁移,NextAuth(现在叫 Auth.js)管理登录态。这套搭配是 T3 Stack 的默认主流配置,也是社区里被验证过最多的组合。
如果不去细看,很多人会问:REST 不是也够用吗?是的,简单的 CRUD 用 REST 完全没问题。但 tRPC 的优势在于过程调用直接映射到 TypeScript 类型。你写一个post.create的 mutation,前端api.post.create.useMutation()拿到的入参和返回值就是同一个函数签名的两侧,中间没有按字符串拼接 URL、没有手动维护interface PostDTO这种冗余代码。类型错误在写代码的当下就会被报出来,而不是等跑起来才发现字段对不上。
还有一层是数据库层的选择。Prisma 在建表、迁移、查询三层都给了很舒服的开发体验,尤其是 migration 的版本记录能力,在本地改 schema、同步到生产环境时特别重要。后面我会专门讲模型设计和迁移过程的坑。
1.2 这个项目适合谁参考
我把 t3code 的完整流程写出来,主要面向三类人:第一种是刚接触全栈开发,想找一个"从生成脚手架到部署上线"完整路径的初学者;第二种是用惯了传统前后端分离,但对前后端重复定义类型感到厌倦的开发者;第三种是本来就在用 Next.js 写页面,想看看能不能引入 tRPC 简化数据层的团队。
如果你做的是纯展示型网站、静态博客、或者已经有成熟 REST API 的后端服务,那 T3 Stack 不一定是最优解。它的强项在于"快速验证一个需要登录、需要写数据、需要前后端紧密协作的产品"。这个边界想清楚了,后面每一步都会顺畅很多。
2. 从 create-t3-app 开始的骨架搭建:选项和目录结构
生成项目这一步没什么花活,一条命令的事:
npx create-t3-app@latest t3code执行之后脚手架会问你要启用哪些模块。我的选择是:TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth、ESLint、Prettier 全选。第一次玩的话不要纠结,默认勾选的全选即可,Docker 和 CI 看个人习惯。这里我建议保留 ESLint 和 Prettier,因为脚手架生成的模板里就已经帮你配好了路径别名和基础规则,删掉反而要自己补。
安装依赖建议用 pnpm。T3 相关模板对 pnpm 的兼容性一直很好,而且 pnpm 的软链接机制在 Next.js 的 monorepo 场景下比 npm 少很多玄学问题。脚手架默认也会识别包管理器,如果你是 npm 用户直接回车也没问题,但更换包管理器时注意 node_modules 要彻底删掉重装一次。
2.1 生成的目录结构到底该怎么看
刚生成完,面对src/app、src/server、src/trpc这一堆目录,很多人会懵。其实核心就三层:
src/app:页面层,Next.js App Router 的page.tsx、layout.tsx都在这里,边栏、导航这类布局组件也在这里;src/server/api:后端逻辑层,routers/目录放业务路由,trpc.ts放 tRPC 实例和公共中间件;src/trpc:前后端桥接层,负责初始化 React Query 的 hooks 包装,让前端组件能直接调用后端过程。
理解这个结构之后,我的习惯是:页面文件只做"数据展示和交互转发",所有涉及数据库查询和业务规则的代码都收进src/server/api/routers里的 router 中。不要觉得"只是一个查询直接写在 page 里不就行了",一旦你在页面里堆了三处db.query,后面改动查询条件时就会到处找。集中到 router 层,等于给逻辑画了一条清晰的边界线。
2.2 版本和依赖的坑:别被旧教程带偏
这是所有 T3 新手都会遇到的第一个坑:网上大量教程基于 Next.js 13 的 Pages Router 和旧版 tRPC v10,而create-t3-app的新模板早就切换到 App Router + tRPC v11 了。很多细节都不一样:旧教程里src/utils/trpc.ts的createTRPCNext写法在新版本里变成了src/trpc/react.tsx的createTRPCReact;旧模板用pages/api/trpc/[trpc].ts做入口,新模板则用app/api/trpc/[trpc]/route.ts。
我一开始照着旧教程改造一个功能,结果发现createTRPCNext在 App Router 的 Server Component 里根本没法用,后来查了模板源码才发现官方早把客户端 hooks 封装进了src/trpc/react.tsx。所以不管你在哪个版本,第一优先级应该是阅读生成出来的模板代码,而不是去搜索引擎找教程。脚手架版本升级时,升级日志里列出的 breaking changes 也要认真过一遍,这是省时间的关键。
3. tRPC:让逻辑不再分前后端两套
tRPC 是整个 t3code 里我最想展开讲的部分。因为它的心智模型和 REST 差别很大:REST 是先定资源、再定 URL、再定 method,tRPC 则是直接复用函数调用。你写一个后端函数的过程,前端得到一个同名 hook 的过程,中间的"协议"完全由 TypeScript 代管。
核心概念就三样:Router、Procedure、Middleware。
- Router 是后端逻辑的命名空间,你可以理解成一组端点的集合;
- Procedure 是端点的具体定义,分 query 和 mutation 两种,query 用于读数据,mutation 用于写数据;
- Middleware 是执行前的拦截逻辑,最典型的是"判断用户是否登录"。
3.1 用代码说话:一个包含列表和创建的 router
以 t3code 里的 Post(内容发布)为例,src/server/api/routers/post.ts大概长这样:
import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { author: { select: { id: true, name: true } } }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1).max(5000), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });这里有几个细节值得注意:publicProcedure和protectedProcedure的区别,zod负责输入校验,ctx里挂载了数据库实例和会话信息。protectedProcedure是模板里预置好的中间件,它内部会检查ctx.session,没有用户就直接抛UNAUTHORIZED,有这个保护就不用在每个写操作的 mutation 里重复登录判断。
3.2 前端调用:类型是"长"出来的,不是"写"出来的
在use client组件里调用刚才的接口,体验完全不同:
"use client"; import { api } from "~/trpc/react"; export function PostList() { const { data: posts, isLoading } = api.post.list.useQuery(); const createPost = api.post.create.useMutation(); return ( <div> {posts?.map((post) => ( <div key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> <span>{post.author?.name}</span> </div> ))} </div> ); }当我打出api.post.list的时候,编辑器自动补全了useQuery、useMutation等等。posts这个变量的类型里,title、content、author.name都是后端 Prisma 模型推断出来的。如果有一天我在 schema 里把Post.title改名成Post.headline,前端代码里所有用到.title的地方会立刻标红。这种全链路类型失效提示,就是 tRPC 最大的价值来源。
注意一点:useQuery只能在 Client Component 中使用,Server Component 里要用createCaller或直接调用 router 的方法。如果你在服务端页面里也需要读数据,可以参考模板生成的src/server/api/root.ts和createCallerFactory的用法,这里不再展开。
3.3 一次真实排查:为什么我的 mutation 永远报 500
跑通基本流程之后,我在一个"上传文章"的功能里遇到了 500 错误。当时第一反应是查数据库,但 Prisma 执行没有任何报错。后来仔细看 tRPC 的错误信息,发现 zod 校验失败了——我传给content的字段名是text,而 schema 里定义的是content。
这个案例听上去很蠢,但它代表了一类问题的方向:tRPC 的输入校验是严格的,前后端任何一个字段名不一致,都不会被"静默吞掉"。排查的方向应该先看服务端日志里返回的code是BAD_REQUEST还是INTERNAL_SERVER_ERROR。前者是输入问题,后者才是业务或数据库问题。把这两类错误分开处理,能让排错效率高很多。
4. Prisma 数据层:模型、迁移和种子数据
t3code 里的数据模型很简单,用户和内容发布两张大表。但就是这两张表,我把"设计模型"和"跑通迁移"当成了两个不同的工作段位来对待。很多人直接照抄教程里的示例 schema,这不叫设计,这叫复制。设计的关键在于:你预判了未来哪些字段会追加、哪些关系会被查询。
4.1 schema 设计时不要先画 ER 图
传统做法是先画 ER 图再建表,但在 T3 项目里我更推荐先写下"查询场景",再反推模型。t3code 的查询场景只有四个:首页列出全部内容、个人页列出当前用户的内容、用户详情显示最近内容、登录态里拿当前用户。基于这些场景,Post 表我只需要一个authorId外键,用户表有一个posts关系字段,这就够了。
具体 schema 如下:
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 author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }三个实践要点:
- 主键用
cuid()而不是自增整数。公开场景下自增 ID 容易被遍历抓取,而且分布式环境下生成有竞争。cuid 在客户端友好性和唯一性之间很平衡; - 时间字段用
@default(now())和@updatedAt,这样创建和更新时无需手动填时间; @relation里必须同时声明fields和references,否则 Prisma 在 migrate 时会报不完整关系的老大难问题。
4.2 迁移命令和"生产环境千万别用 dev"
本地玩的时候一条命令:
npx prisma migrate dev --name init它会同步 schema 到数据库、生成 migration 文件、再生成 Prisma Client。每次改 schema 后按同样的流程操作即可。
但到了部署环节,记得改用prisma migrate deploy。migrate dev面向本地开发环境,会在安全性和幂等性上打折扣;migrate deploy只按序执行 migration 目录里尚未应用的记录,在 CI/CD 和云端环境里更可靠。我在 Vercel 的构建命令里就写成了:
npx prisma migrate deploy && next build如果不加这一段,你的生产数据库永远停留在本地最后一次迁移的状态。很多人部署完发现"本地好好的,上线查不到表",十有八九是忽略了这一步。
4.3 种子数据:不是只有测试才需要
t3code 开发环境里我配了一份种子数据,方便每次重置数据库后立刻有内容可看。模板自带prisma/seed.ts文件,只要在package.json里配置好prisma.seed字段(通常指向tsx prisma/seed.ts),然后npx prisma db seed即可。种子数据里我造了三个假用户、每个用户两三条 Post,配合 NextAuth 登录后就能立刻看到自己的内容列表,而不必每次手动通过页面表单填半天。
这个习惯帮我避免了一个大坑:很多页面在"空数据"状态下能跑,但在"有真实数据"的状态下 UI 会崩。除了花哨的骨架屏,标题太长、内容为空、作者名为 null 这些边界情况,只有有了真实量级的数据样本才容易暴露。
5. NextAuth 权限:让受保护接口真正受保护
t3code 的登录目标很明确:支持 GitHub 登录一键完成。很多独立项目也希望用第三方登录降低注册门槛。NextAuth 在脚手架里已经默认接好了 GitHub Provider,如果你要用 Google、邮箱验证码,改src/server/auth.ts的 providers 数组即可。
5.1 与 tRPC 中间件联动,权限只在服务端判断
这个项目里我没有在前端写任何"如果登录了显示什么"的硬编码判断。正确的做法是让服务端直接拒绝未授权的调用。
src/server/api/trpc.ts里模板默认生成了这样的逻辑:
import { initTRPC, TRPCError } from "@trpc/server"; import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; import { auth } from "~/server/auth"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await auth(); return { session }; }; const t = initTRPC.context<{ session: Awaited<ReturnType<typeof auth>> }>().create(); export const createTRPCRouter = 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, user: ctx.session.user } }, }); });看到这段你就能理解,为什么前面 post router 里的create能直接用ctx.session.user.id。整个流程就是:请求进来,先走 context 拿到会话,再走中间件判断登录态,最后才进业务逻辑。前端拿到的 401 错误,是服务端派发的,而不是前端模拟的。
这里特别提醒一个错误做法:不要把session的判断写在 React 组件里然后"不显示按钮"。那只影响视觉,不影响接口安全。任何人绕过界面直接调用 mutation 依旧能成功写入。所有关键写操作,必须被服务端中间件保护住。
5.2 会话和 token 的坑
用 Auth.js v5 时,有两个环境变量非常重要:AUTH_SECRET和AUTH_TRUST_HOST。AUTH_SECRET是会话签名的私钥,生产环境必须设置,本地开发脚手架会自动生成一个.env,但上线后要自己用openssl rand -base64 32生成一个强随机值并配置;AUTH_TRUST_HOST在部署到自定义域名时需要显式设置为true,否则 Auth.js 会因为你无法通过从请求头读取的 host 匹配允许列表而拒绝请求。
我在本地测得好好的,部署到 Vercel 后发现跳转登录页总是一圈又回到登录页,最后在控制台日志里看到UntrustedHost错误才反应过来。这类问题只要知道原因,一分钟就能修复,但不知道原因就会被卡很久。
6. 部署清单:Vercel 和环境变量的坑
t3code 的部署我选的是 Vercel,理由是 Next.js 在 Vercel 上的支持最顺滑,而且 Serverless 架构天然贴合 tRPC 的请求模型。
部署前需要准备的配置我都列成了一张清单:
| 项目 | 说明 |
|---|---|
DATABASE_URL | 生产数据库连接串 |
AUTH_SECRET | 用openssl rand -base64 32生成 |
AUTH_GITHUB_ID/AUTH_GITHUB_SECRET | GitHub OAuth App 的凭证 |
AUTH_TRUST_HOST | 自定义域名或云平台反代时设为true |
| 根目录 | 默认即可,Vercel 自动识别 Next.js |
数据库方面,本地开发我用的是 SQLite(脚手架默认),生产环境换成了 PostgreSQL。T3 脚手架对这两种数据库的兼容性都很好,只要把DATABASE_URL改掉再跑一次 migrate deploy 就行。Serverless 环境里如果数据库连接数吃紧,可以给 Prisma 配置连接池地址(例如直接使用PrismaAccelerate或云数据库提供的连接池端点)。
部署完之后我的建议是跑一遍"冷启动冒烟测试":退出登录状态访问首页、未登录调用写接口、用刚注册的新账号创建内容。这三步能覆盖整个链路里最容易出问题的权限和服务端渲染部分。本地开发调试通过的路径,在云端常常因为 host、cookie、数据库连接数不同而暴露问题,提前跑一遍,能省掉上线后手忙脚乱的排障时间。
7. 踩坑复盘与适合场景的再思考
项目收尾阶段,我重新想了想这套技术栈在整个 t3code 里到底值不值。结论是:对于"一个人快速验证想法、需要登录、需要数据库、需要前端紧密配合的产品"来说,T3 Stack 的收益非常高。我几乎没有写过一条 restful URL、没有手动解析过任何 JSON 字段名、没有因为前后端字段不一致和任何人吵过架,这种体验在传统技术栈里几乎做不到。
但也要承认它的边界。如果你的项目有大量公开接口要提供给第三方调用,tRPC 这种过程调用协议并不适合直接暴露,更合理的做法是外层再包一个 REST 或 GraphQL 网关。如果团队里的前端和后端分工明确,要求接口文档和服务端路由能够独立演进,tRPC 紧凑的"函数调用"模型反而会造成耦合过深、边界不清晰的困扰。
个人复盘的几个具体心得放在这里:
- 每次新建小项目,直接用
create-t3-app起手。与其花半天从零搭目录,不如让模板把规范替你铺好,再把精力投到业务细节上; - t3code 里的 tRPC router 按业务领域拆文件,不要按"查询/变更"类型拆。例如
post.ts、user.ts,而不是queries.ts、mutations.ts。前者在业务扩张时更稳定,后者很快就变成难以维护的横向清单; - 关注脚手架升级日志。T3 生态迭代速度很快,Next.js 的 App Router 还在演进,每次大版本更新都会带来部分 API 变化。如果不跟上节奏,半年后你项目的模板代码就会和官方示例严重脱节,而这个问题解决起来很耗时。
7.1 如果从头再写一次,我会改什么
如果再给我一次机会,我会在项目初期更早地引入 ESLint 的规则约束。不是为了应付 CI,而是让import顺序、类型断言习惯在最初就保持一致。这个项目里,我在后端写了一个as any来绕过复杂类型,结果前端直接失去了那个字段的自动补全。这件事让我意识到:类型安全是一个"全有或全无"的系统工程,你在一处放宽,它就会在另一处悄悄偷走一部分开发效率。
另外,我应该为 tRPC 的错误处理写一个统一的 converter。目前模板默认的错误信息对开发者宽容、但对用户不友好,前端需要拦截TRPCClientError再做一层翻译。这个可以等业务多起来之后再补,但一开始预留位置会更从容。
7.2 这条路线还能扩展什么
t3code 本身不会就此停住。后面我想给它加两个方向:一是接入文件上传,探索 tRPC 和文件流的边界;二是把 React Query 的缓存失效做成自动化的版本,例如 mutation 成功后自动失效相关的列表查询,减少手动invalidate的代码量。这两个方向都会触及 tRPC 和 Prisma 的深层用法,如果有新的心得,再单独写一篇展开。
最后分享一个我在收尾时的小经验:跑完next build后盯着终端输出看一会儿。T3 项目里很多类型问题的暴露不在开发态,而在构建态——开发模式下 Next.js 会吞掉部分类型错误,而生产构建会严格执行。如果你发现构建时间越来越长,大概率是页面组件里的类型推断变得复杂了,这也是一个值得定期审视工程健康度的信号。t3code 这套链路让我对"快速验证产品"这件事有了更踏实的信心,也希望这篇文章能让你少走一些我已经踩过的弯路。