1. 项目整体设计:t3code 为什么选择 T3 Stack
1.1 t3code 是什么,解决什么问题
t3code 是我近期维护的一个全栈项目,核心思路很简单:用 T3 Stack 那套“类型安全优先”的哲学,把常见的 Web 应用底座——认证、数据库、API、页面——完整地搭起来,并沉淀成一个可以反复复用的脚手架。如果你打开项目仓库,最先看到的就是 Next.js 的目录结构,往里走会发现 TypeScript 的类型定义、Prisma 的模型文件、tRPC 的 router 定义以及 NextAuth 的配置,它们彼此咬合得很紧。
这个项目解决的痛点非常具体:做全栈应用时,前后端之间的类型断层。传统的 REST API 模式下,你写完后端接口,还要在前端手工维护一份请求类型,改一个字段名就要全局搜一遍。t3code 则通过 tRPC 把服务端函数直接暴露给前端调用,类型从数据库一路推到页面组件,中间不需要手写任何 API 契约。数据库 schema 改动,Prisma 重新生成客户端,tRPC 的类型立刻跟着变,编辑器里马上显示错误。很多第一次用的人会觉得“这不就是远程函数调用吗”,实际上它解决的是团队协作中最损耗效率的那部分——沟通成本。
适合谁来参考这本实践记录?如果你正在评估全栈框架选型,或者已经决定用 Next.js + TypeScript 但还没有找到一套顺手的 API 方案,又或者你被 REST 接口的文档维护折磨过,那 t3code 这条路径值得你花十分钟过一遍。它不复杂,但每一步都有明确的设计意图。
1.2 技术选型:四件套背后的逻辑
T3 Stack 并不是一个全新的框架,它是四个成熟工具的组合:Next.js、TypeScript、Tailwind CSS、tRPC,再搭配 Prisma 和 NextAuth 作为数据层与认证层。我最初搭建 t3code 时也犹豫过,要不要换用 GraphQL?要不要上 NestJS?都考虑过,最终选择这套组合,理由有三个。
第一是端到端类型安全。这是 t3code 存在的根本理由。tRPC 的工作方式是在服务端定义一个 router,每个路由是一个 procedure,前端通过类型安全的调用链直接调用。没有 OpenAPI 规范,没有手写类型声明,没有运行时校验的重复劳动。数据库模型、API 入参出参、组件 props 全部由 TypeScript 编译器串联起来,改一处,全链路报错。
第二是开发体验的连贯性。Next.js 的 App Router 同时承担页面渲染和 API 路由,Prisma 用 schema 文件管理数据模型,Tailwind 用原子类写样式,整个开发过程几乎不需要在不同心智模型之间来回切换。写一个功能,先加 Prisma model,再跑 migrate,然后写 tRPC procedure,最后在 React 组件里 useQuery,一条线下来,工具链没有断裂感。
第三是学习成本可控。这套技术栈的每一环都有大量文档和社区案例,组合起来之后,你只需要理解几个核心抽象——router、procedure、session、query/mutation——就能开始干活。相比自己拼装一堆中间件,T3 Stack 给出了一个经过验证的默认组合,这让我不用花时间纠结“用什么”,而是把精力放在“做什么”上。
1.3 目录结构与项目初始化
t3code 的初始化直接用了社区维护的 create-t3-app 工具,而不是手动逐个安装依赖。这个选择值得说明:手动搭一套带 Prisma、NextAuth、tRPC 相互集成的项目,需要处理很多细节,比如 tsconfig 的路径别名、环境变量的运行时校验、Prisma 客户端在 tRPC context 中的注入方式。这些 boilerplate 代码一次两次写没问题,但每次新建项目都重复一遍,纯属浪费生命。
npm create t3-app@latest t3code执行之后,工具会用交互式问答确认你要启用哪些模块。我建议至少勾选 tRPC、Prisma、NextAuth,这三个是 T3 全栈能力的核心。Tailwind 可勾可不勾,取决于你是否有现成的 UI 方案。初始化完成后,核心目录结构是这样的:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── components/ │ ├── pages/ │ └── server/ │ ├── api/ │ │ ├── auth/ │ │ │ └── [...nextauth].ts │ │ └── trpc/ │ │ └── [...].ts │ ├── auth.ts │ ├── db.ts │ └── api/trpc/ │ ├── router.ts │ └── trpc.ts ├── .env └── next.config.mjs这个结构把“服务端相关代码”集中在 src/server 下,前端组件和页面放在 src 上层,职责边界很清楚。初次使用的人最容易犯的错误是往 src/pages 里塞业务逻辑——包括数据库访问、认证判断——结果服务端代码和页面代码混在一起,类型安全依然存在,但维护成本上去了。t3code 从一开始就强制把逻辑收敛到 router 层,页面组件只做数据消费和交互反馈。
2. 核心机制拆解:类型安全如何贯穿全栈
2.1 tRPC 原理:API 层的一次范式切换
很多人第一次接触 tRPC 时会问:它不是 REST 也不是 GraphQL,那它到底是什么?我自己的理解是,tRPC 把服务端函数变成了“类型安全的远程过程调用”。前端不是通过 URL 和请求体去描述一次 API 调用,而是直接引用服务端导出的函数,框架负责序列化、传输和反序列化。
在 t3code 中,tRPC 的入口是 src/server/api/trpc/trpc.ts:
import { initTRPC, TRPCError } from "@trpc/server"; import { type Context } from "./context"; const t = initTRPC.context<Context>().create(); export const createTRPCRouter = t.router; export const publicProcedure = t.procedure;这里有一个容易被忽略的关键点:initTRPC.context 把 Context 类型注册进了整个 tRPC 类型系统。Context 里通常包含 db(Prisma 客户端)和 session(登录态),一旦注入,你在任何 procedure 的 resolver 里都能安全地访问 ctx.db、ctx.session,而且编辑器会给出完整提示。
Router 的定义方式类似声明式路由表:
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { bookRouter } from "./book"; import { userRouter } from "./user"; export const appRouter = createTRPCRouter({ book: bookRouter, user: userRouter, }); export type AppRouter = typeof appRouter;前端调用时,t3code 在 src/utils/api.ts 中导出了一个类型化的 React Hooks 实例:
import { createTRPCReact } from "@trpc/react-query"; import { type AppRouter } from "~/server/api/root"; export const api = createTRPCReact<AppRouter>();之后在组件里写api.book.getAll.useQuery(),如果 book 的 router 里没有 getAll,编译器立刻抛错;如果返回类型变了,页面上的数据字段也会立刻变红。这种体验用一句话总结就是:类型定义即 API 文档。
2.2 数据层设计:Prisma 模型与迁移
t3code 的数据库访问层选了 Prisma,原因不复杂:schema 文件声明式管理数据模型,迁移流程清晰,生成出来的类型天然可以被 tRPC 复用。用其他 ORM 也能做,但 Prisma 在 TypeScript 生态里的类型生成质量确实是最省心的。
一个典型的 schema 定义如下:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? createdAt DateTime @default(now()) books Book[] } model Book { id String @id @default(cuid()) title String description String? published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) }关于 id 用自增整型还是 cuid 字符串,社区一直有争论。t3code 选择了 cuid,原因是它在分布式环境下不需要数据库端递增,对多环境迁移更友好,而且字符串 id 在 URL 中不会暴露业务规模。代价是索引体积稍大,但现代数据库完全扛得住。
改动 schema 后,执行:
npx prisma migrate dev --name add_book_model npx prisma generatemigrate 负责生成 SQL 迁移文件并把数据库结构更新到最新版,generate 负责重新生成 Prisma Client 类型。这两个命令在 t3code 的开发流程里几乎每天都要跑。有一个异常容易踩的坑:generate 之后,如果 tRPC 和 React 组件代码同时引用了 Prisma 类型,IDE 偶尔会因为缓存原因仍然显示旧类型。这种情况下的处理方式很简单,重启 TypeScript 服务(VS Code 里执行 TypeScript: Restart TS Server),不要折腾代码。
2.3 认证模块:NextAuth 与用户体系
认证是大多数全栈项目绕不开的部分,t3code 直接接入了 NextAuth,并且让 session 类型与 tRPC 的 Context 深度绑定。这样做的直接收益是:在 protectedProcedure 里,你可以默认 ctx.session.user 一定存在,不需要在业务代码里再手动判空。
NextAuth 的配置集中在 src/server/auth.ts:
import { type NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { db } from "./db"; export const authOptions: NextAuthOptions = { session: { strategy: "jwt" }, providers: [ CredentialsProvider({ name: "credentials", credentials: { email: { label: "邮箱", type: "email" }, password: { label: "密码", type: "password" }, }, async authorize(credentials) { // 这里需要接入实际的用户校验逻辑,t3code 里使用 bcrypt 比对密码哈希 const user = await db.user.findUnique({ where: { email: credentials?.email }, }); if (!user) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ], callbacks: { jwt({ token, user }) { if (user) token.id = user.id; return token; }, session({ session, token }) { if (session.user && token.id) { session.user.id = token.id; } return session; }, }, };这里最关键的是扩展 session 类型。NextAuth 默认的 session.user 没有 id 字段,而业务逻辑几乎一定需要当前用户 id。t3code 在 src/types/next-auth.d.ts 里做类型增强:
import { type DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }不补这个类型,后面写“创建属于当前用户的记录”时会寸步难行,每次拿 ctx.session.user.id 都要断言一次。这是 tRPC 类型安全链条中最需要人工干预的一环,也是初学者最常漏掉的。
3. 实操环节:从零跑通一个完整 CRUD
3.1 需求与数据模型设计
用一个最常见场景来展示 t3code 的完整工作流:做一个简单的“读书笔记”功能,用户可以创建书籍条目,并能标记是否已读。先设计数据模型:
model Book { id String @id @default(cuid()) title String author String? read Boolean @default(false) rating Int? userId String user User @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }这里要解释一下为什么要把 userId 放进 Book 模型而不是独立建一个“收藏关系表”。因为你的业务语义是“每本书属于一个用户”,而不是“用户与书是多对多关系”。如果一开始就把关系设计复杂,后面的查询和权限判断都会跟着复杂。t3code 的经验是:先用最贴着需求表述的模型,等真的出现多对多场景再迁移,不要提前泛化。
然后执行迁移并触发类型生成:
npx prisma migrate dev --name init_book_model npx prisma generate3.2 tRPC Router 与页面接入
数据模型就绪后,直接写业务接口。在 src/server/api/routers/book.ts:
import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; export const bookRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ readOnly: z.boolean().optional() })) .query(({ ctx, input }) => { return ctx.db.book.findMany({ where: { userId: ctx.session.user.id, ...(input.readOnly ? { read: true } : {}), }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1, "书名不能为空"), author: z.string().optional(), }), ) .mutation(({ ctx, input }) => { return ctx.db.book.create({ data: { title: input.title, author: input.author, userId: ctx.session.user.id, }, }); }), toggleRead: protectedProcedure .input(z.object({ id: z.string(), read: z.boolean() })) .mutation(({ ctx, input }) => { return ctx.db.book.update({ where: { id: input.id }, data: { read: input.read }, }); }), });然后把 book router 挂载到根 router,前端组件里直接调用:
import { api } from "~/utils/api"; export function BookList() { const { data: books, refetch } = api.book.list.useQuery({}); const toggleRead = api.book.toggleRead.useMutation({ onSuccess: () => refetch(), }); return ( <ul> {books?.map((book) => ( <li key={book.id}> <span>{book.title}</span> <input type="checkbox" checked={book.read} onChange={(e) => toggleRead.mutate({ id: book.id, read: e.target.checked }) } /> </li> ))} </ul> ); }整套流程最大的感受是:不需要为每个接口写 request/response 类型,不需要在调用处做字段映射,数据库字段到前端 UI 的状态是直接对齐的。
3.3 部署到 Vercel 的关键配置
t3code 最终部署在 Vercel 上,整个部署过程有几个容易出问题的地方。第一,环境变量必须先配置好,否则构建阶段 Prisma 无法连接数据库。Vercel 项目设置里需要配置:
DATABASE_URL=你的数据库连接串 NEXTAUTH_SECRET=随机生成的一串密钥 NEXTAUTH_URL=https://你的域名第二,构建命令注意 Prisma 的 generate 需要在构建过程中执行。t3code 的 package.json 脚本写的是:
{ "scripts": { "build": "prisma generate && next build", "start": "next start", "dev": "next dev" } }第三,一个常见的部署陷阱:不要在 Vercel 构建阶段直接执行 migrate。Vercel 每次部署都会触发构建,如果构建脚本里带 migrate,同一个迁移文件可能被并发执行多次,导致数据库锁表。正确的做法是在本地跑 migrate 生成迁移文件并提交到 git,部署前在本地或 CI 中手动执行一次prisma migrate deploy。数据库 schema 变更应该作为部署的前置步骤,而不是构建步骤。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
把 t3code 开发过程中遇到的典型问题整理成一张表,这些问题在官方文档里都有线索,但实际触发时的报错信息往往更隐蔽,列在这里方便你快速定位。
| 问题现象 | 报错特征 | 解决思路 |
|---|---|---|
| Prisma 类型未更新 | Type 'xxx' does not satisfy the constraint | 执行npx prisma generate,并重启 TS Server |
| tRPC 调用 401 | UNAUTHORIZED或NOT_LOGGED_IN | 检查是否使用了 protectedProcedure,以及 NextAuth session 是否真的存在 |
| 前端拿不到 session.user.id | user.id undefined | 检查 src/types/next-auth.d.ts 的模块增强是否生效,没有则手动添加 |
| 环境变量运行时为空 | env.DATABASE_URL is undefined | 检查 .env 文件名、是否提交到 Vercel,以及 env.mjs 的 runtimeEnv 映射 |
| 迁移文件冲突 | Migration already exists | 不要删除已有迁移文件,执行prisma migrate resolve手动标记 |
| 热更新后 API 报错 | Unable to determine the type of this procedure | 通常是 tRPC 根文件没有被重新加载,重启 dev server |
其中“迁移文件冲突”我单独说一句:多人协作时,如果两个人同时改了 schema 并各自生成了迁移文件,合并后会出现多个同等级迁移。不要图省事删掉其中一个迁移重来,正确做法是用prisma migrate resolve --applied <migration_name>标记状态,然后重新生成新迁移。
4.2 踩坑记录:三个容易忽略的细节
第一个坑是关于 tRPC 的 procedure 输入校验。t3code 中用 zod 做运行时校验,但 zod 的 error 默认会暴露给前端,这有时候不是你想看到的。生产环境建议在 trpc.ts 里做一次错误格式化,把 zod 错误信息精简后再返回,避免把内部表结构或字段名泄露给用户。
第二个坑是服务端组件和 tRPC 的调用边界。T3 Stack 默认是基于 Pages Router 的,但如果你用了 App Router,直接在 Server Component 里调用 tRPC 是不可行的。tRPC 的 useQuery hooks 依赖 React Query 的客户端上下文。解决方案是单独封装一个 Client Component 来承载数据请求逻辑,或者使用 tRPC 的 server caller。t3code 里我优先用 client component 方案,心智负担最小。
第三个坑是环境变量的客户端脚本污染。T3 Stack 提供了 @t3-oss/env-nextjs 做环境变量校验,但你必须刻意区分 server 和 client。任何以 NEXT_PUBLIC_ 开头的变量都会被打进浏览器 bundle,这没问题;但如果你不小心把 DATABASE_URL 写进 client 配置,构建时会直接报错,这是好事——它阻止了你的数据库地址被暴露。不要为了消错误而把 server 变量挪到 client 段,那是在给安全埋雷。
4.3 团队协作中的类型纪律
t3code 投入使用后,我最大的体会是:类型安全的收益不是自动发生的,需要团队配合一些纪律。首先,业务函数不要通过 any 或者 as 绕过类型。一旦有人用as unknown as强转一个 tRPC 的返回结果,类型链条就断了,后面所有人都在为一个虚假的契约买单。
其次,zod schema 作为输入校验和类型定义的双重来源,必须保持单一职责。不要在 procedure 里再写一套手动的 if 判断,也不要为了省事把 input 类型定义成宽松的 Record。每写一个 procedure,输入格式越严格,前端的使用者就越轻松。
最后,审代码时重点关注 Prisma model 的变更是否同步更新了所有相关 router。因为数据库模型是类型链条的源头,它的变化会波及查询结果、组件 props、表单提交。t3code 会在 PR 描述里默认附加迁移文件清单,评审的人先看迁移,再决定是否需要调整 router,这个习惯能拦住大部分回归问题。
我个人在实际操作中的体会是,t3code 这套组合最值得坚持的一点不是某个具体工具,而是“把类型当成一等公民”的工作方式。最初迁入时确实要多花一点时间适应——写 schema、配 auth、理解 tRPC 的 context 注入——但一旦跑通,后面加功能的速度反而比传统 REST 方案快很多,因为调试和沟通的损耗都大幅下降。如果你也受够了前后端各自维护类型定义的日子,不妨拿 t3code 当起点,把一个小功能完整走一遍,再决定要不要把它应用到下一个正式项目里。