ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零搭建 T3 Stack 的 TypeScript 全栈项目:t3code 实操记录

从零搭建 T3 Stack 的 TypeScript 全栈项目:t3code 实操记录

从零搭建 t3code:一个基于 T3 Stack 的 TypeScript 全栈项目实操记录

我是在一个内部工具项目里第一次完整用上 T3 Stack 的,项目代号就叫 t3code。当时的需求挺直接:一个小团队需要快速搭一个带用户登录、数据录入、列表查询和简单权限划分的后台工具,前端要能实时看到后端数据类型的改动,不想维护一套 REST 接口文档。第一反应是上 Next.js 加 Express 加 Prisma,前后端各写各的类型定义,但想到以前的联调经历——接口字段名改一下,前端不报错,运行时才炸,就觉得这次必须换一种玩法。T3 Stack 把 Next.js、tRPC、Prisma、Tailwind CSS、NextAuth.js 拧成一套类型安全的全栈方案,t3code 就是在这个技术栈上长出来的。这篇文章不聊太多理论,主要记录我怎么从空目录一步步把项目搭起来,以及过程中踩过的坑和我觉得值得长期使用的实践。

1. t3code 的技术选型:T3 Stack 到底拆解了什么

1.1 T3 Stack 的核心构成与设计理念

T3 Stack 不是一个框架,而是一组经得起实战考验的工具组合,核心成员包括:

  • Next.js:作为应用框架,负责页面路由、SSR、SSG、API 路由,也是整个应用的骨架。
  • tRPC:用它替代传统的 REST API 层,让前端调用后端方法像调用本地函数一样,且全程类型安全。
  • Prisma:数据访问层,用 schema 定义模型,自动生成类型安全的查询客户端。
  • Tailwind CSS:原子化 CSS 框架,主要负责页面样式。
  • NextAuth.js:认证方案,支持 OAuth、邮箱、凭证登录,搭配 tRPC 做身份信息传递。

这套组合的核心设计理念就是"类型安全优先"。换句话说,从一个数据库模型到一个 React 组件的 props,全程 TypeScript 类型贯穿,改一处模型,前端立刻报错,能极大减少低级的接口数据不匹配问题。同时 T3 Stack 也倡导"少即是多",不强制你引入复杂的状态管理库、不用单独部署一个 API 服务,甚至不需要在客户端单独封装 Axios。

1.2 放弃 REST + 手写类型方案的理由

我最早做前后端分离项目时,API 层的流程是这样的:后端写接口,用 Swagger 或 Postman 维护文档,前端按照文档手写接口函数和 TypeScript 类型。听上去没什么大问题,但一迭代就痛苦。接口一多,文档容易滞后;字段类型一变,漏改一处前端,编译期不报错,跑了半天才发现数据是 undefined;再叠加 React Query、Redux 缓存,排错链路很长。

T3 Stack 的做法完全不同。tRPC 直接定义后端 Procedure,前端的 hooks 自动带类型。比如后端的getProjects返回Project[],前端调用trpc.project.getProjects.useQuery()返回的 data 类型就是Project[],不需要额外写任何接口类型定义。后端模型改一个字段,更新完 Prisma schema 并执行生成后,所有的引用点会立刻出现类型错误。

1.3 适合用 T3 Stack 的场景,也应知道边界

适合的场景:内部工具、全栈应用、以 CRUD 为主的业务系统、需要快速交付的 MVP。类型安全带来的收益在这种场景里特别明显,因为迭代频繁,字段经常增删,改类型发现问题的成本远比运行时低。

边界也很明显:如果是纯前端静态站、或者后端需要暴露大规模公开 API 给第三方调用,tRPC 不是首选,它更偏内部前后端调用。如果是高并发、极度需要精细控制数据库连接和缓存的服务,建议再考虑 BFF 层拆分。

2. t3code 的工程初始化与目录落地

2.1 用 create-t3-app 初始化项目

官方推荐用create-t3-app脚手架初始化项目,很省事。我用的命令:

npm create t3-app@latest t3code

创建过程中会问需要哪些模块,我选择了 Next.js、tRPC、Prisma、Tailwind、NextAuth.js、Drizzle 没选,因为 Prisma 的项目我太熟了。初始化完的目录结构已经带好了依赖、基础路由和一套可直接运行的src/pages示例。

初始化的代码结构大概是:

t3code/ prisma/ schema.prisma src/ pages/ api/ trpc/ [trpc].ts auth/ [...nextauth].ts index.tsx server/ api/ root.ts routers/ post.ts auth.ts db.ts trpc.ts utils/ api.ts

t3code 在这种结构下启动,可以直接修改根路由和示例的 post router,上手很快。

2.2 环境变量与数据库连接配置

脚手架在根目录生成了.env示例,关键变量有:

  • DATABASE_URL:Prisma 连接数据库的地址。
  • NEXTAUTH_SECRET:NextAuth 的加密密钥。
  • NEXTAUTH_URL:应用地址,本地开发一般是http://localhost:3000。

我建议本地直接用 Docker 跑一个 PostgreSQL。docker-compose 文件大概这样:

version: "3.8" services: db: image: postgres:15 restart: always environment: POSTGRES_USER: t3code POSTGRES_PASSWORD: t3code POSTGRES_DB: t3code ports: - "5432:5432" volumes: - db_data:/var/lib/postgresql/data volumes: db_data:

启动后把.env里的DATABASE_URL="postgresql://t3code:t3code@localhost:5432/t3code"写对,剩下的交给 Prisma。

这里有个小提醒:本地开发数据库和生产数据库的环境变量一定要分开管理,别把生产库字符串写进仓库。我在团队里吃过亏,有人不小心提交了真实环境连接串,后来虽然轮换了账号,但还是浪费了半天排查。

2.3 NextAuth.js 接入时的初始配置

t3code 需要用户登录功能,所以我在初始化时选择了 NextAuth。它会生成src/server/auth.ts,这个文件里配置认证方案和 session 回调。默认配置支持 GitHub OAuth 和邮箱登录,需要填:

GITHUB_CLIENT_ID=xxxxx GITHUB_CLIENT_SECRET=xxxxx

之前用 NextAuth 时有个经验:NEXTAUTH_SECRET一定要设置,别用默认。生成一个好的随机串可以用:

openssl rand -base64 32

然后把结果放到环境变量里。这个不设置的话,部署到线上会出现 session 不稳定的问题。

3. 用 Prisma 建模 t3code:从第一个 schema 到数据迁移

3.1 核心模型的设计思路

t3code 的业务模型比较直接:用户、项目和项目记录。我把相关模型拆成三个核心表:

model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] projects Project[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Project { id String @id @default(cuid()) name String description String? status Status @default(ACTIVE) userId String user User @relation(fields: [userId], references: [id]) records ProjectRecord[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model ProjectRecord { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) content Json version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum Status { ACTIVE ARCHIVED }

这个设计的几个关键点:

  • 用cuid()而不是自增 ID,分布式环境下不容易冲突。
  • ProjectRecord 里存 JSON 字段,因为记录的字段比较动态。
  • Project 关联 User,这样天然有数据归属的概念,后续做权限过滤很方便。
3.2 数据库迁移的常规流程

第一次建模完成后,执行:

npx prisma migrate dev --name init

这个命令会生成一个 migration 并同步应用到本地开发库,同时生成 Prisma Client。以后每次改 schema,再起一个新的迁移名称就行。需要注意:生产环境的迁移要使用prisma migrate deploy,而不是prisma migrate dev。dev会对比开发库状态,在 CI 或生产环境上直接执行很容易出现状态错乱。

有个印象很深的坑:有次我在改字段名时直接换掉了 model 里的属性名,但忘记改业务代码里的引用,虽然 Prisma Client 生成时没报错,前端 tRPC 的调用处却一起红了。好在 T3 的类型系统让这个错误在编译期暴露了,这比运行时才发现要省太多时间。

3.3 种子数据的准备方式

为了方便开发,我在prisma/seed.ts里写了种子数据,创建了一个测试用户和几个示例项目:

import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const user = await prisma.user.upsert({ where: { email: "demo@t3code.local" }, update: {}, create: { name: "Demo User", email: "demo@t3code.local", image: "https://github.com/shadcn.png", }, }); await prisma.project.createMany({ data: [ { name: "示例项目 A", userId: user.id }, { name: "示例项目 B", userId: user.id }, ], }); } main() .then(() => console.log("seed ok")) .finally(async () => { await prisma.$disconnect(); });

然后在package.json里配置 seed 命令,用tsx跑:

"prisma": { "seed": "tsx prisma/seed.ts" }

开发时用npx prisma db seed就可以初始化数据。这个小步骤很多人会忽略,但一个能一键还原的开发环境真的能省下不少时间。

4. tRPC 路由设计:t3code 的类型安全核心

4.1 tRPC 的基础结构:procedure、query 与 mutation

tRPC 的核心单位是 Procedure,可以理解为一个后端函数入口。它分为query(查数据)和mutation(改数据),还可以挂中间件。

t3code 初始化的src/server/api/routers/目录下已经有示例。我在里面新建了一个project.ts,把项目相关的操作全部收拢到这个 router 里:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const projectRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ includeArchived: z.boolean().default(false) }).optional()) .query(async ({ ctx, input }) => { return ctx.db.project.findMany({ where: { userId: ctx.session.user.id, status: input?.includeArchived ? undefined : "ACTIVE", }, orderBy: { updatedAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ name: z.string().min(1), description: z.string().optional() })) .mutation(async ({ ctx, input }) => { return ctx.db.project.create({ data: { name: input.name, description: input.description, userId: ctx.session.user.id, }, }); }), });

protectedProcedure是脚手架里已经实现的中间件,会校验 session 是否存在。如果没有登录,直接抛错,不会执行后面的逻辑。

4.2 在 React 端使用 tRPC hooks

前端调用非常顺滑。在src/pages/projects.tsx里这样写:

import { api } from "~/utils/api"; export default function ProjectsPage() { const { data: projects, isLoading } = api.project.list.useQuery({ includeArchived: false }); const createProject = api.project.create.useMutation(); if (isLoading) return <p>加载中...</p>; return ( <div> {projects?.map((project) => ( <div key={project.id}> <h2>{project.name}</h2> <p>{project.status}</p> </div> ))} </div> ); }

api.project.list.useQuery和api.project.create.useMutation都是自动生成的 React hooks,参数类型与后端 Zod 校验一一对应。修改后端输入 schema,前端组件里的参数类型会立刻报错。这套模式彻底告别了从前"边界类型靠手写、接口文档靠自觉"的状态。

4.3 使用中间件做用户维度的数据隔离

tRPC 的中间件很灵活。我在protectedProcedure基础上,写了一个projectOwnerProcedure,专门用来校验当前用户对项目是否有权限:

const projectOwnerProcedure = protectedProcedure.use(async ({ ctx, next, getRawInput }) => { const input = await getRawInput() as { projectId?: string }; if (!input?.projectId) throw new Error("缺少 projectId"); const project = await ctx.db.project.findFirst({ where: { id: input.projectId, userId: ctx.session.user.id }, }); if (!project) throw new Error("无权限访问该项目"); return next({ ctx: { project } }); });

用的时候:

update: projectOwnerProcedure .input(z.object({ projectId: z.string(), name: z.string().min(1) })) .mutation(async ({ ctx, input }) => { return ctx.db.project.update({ where: { id: ctx.project.id }, data: { name: input.name }, }); }),

这样把权限校验从业务代码里剥离出来,路由层面就锁定了"必须是项目所有者"这一条件。对中小项目来说是够用且清晰的做法。

5. t3code 的前端开发体验:Tailwind、页面路由与组件划分

5.1 Tailwind CSS 在 t3code 里的具体应用

T3 Stack 默认带 Tailwind,初始化后就直接能在 JSX 里写原子类。t3code 的项目列表页面我做了很简单的卡片式布局:

<div className="grid gap-4 p-6 sm:grid-cols-2 lg:grid-cols-3"> {projects?.map((project) => ( <div key={project.id} className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm hover:shadow-md"> <h2 className="text-lg font-semibold text-slate-800">{project.name}</h2> <p className="mt-1 text-sm text-slate-500">{project.description}</p> <span className="mt-3 inline-block rounded-full bg-emerald-100 px-2 py-1 text-xs text-emerald-700"> {project.status} </span> </div> ))} </div>

Tailwind 刚上手会觉得类名很长,但用顺手后,设计调整效率提升非常明显。t3code 里我很少写自定义 CSS 文件,基本都在组件类名直接完成响应式和状态表达。

5.2 页面路由与数据加载方案

t3code 的页面不多,目前包含:

  • /:登录后的项目总览。
  • /projects/[id]:项目详情,展示该项目下的所有记录。
  • /settings:用户信息和偏好设置。

对于数据加载,我优先用页面级useQuery,配合 React Query 的缓存避免重复请求。tRPC 的 query hook 底层是 React Query,所以staleTime、refetchOnWindowFocus这些配置都直接可用。我在src/utils/api.ts里做过全局配置:

import { httpBatchLink, loggerLink } from "@trpc/client"; import { createTRPCNext } from "@trpc/next"; import type { AppRouter } from "~/server/api/root"; export const api = createTRPCNext<AppRouter>({ config() { return { links: [ loggerLink({ enabled: (opts) => process.env.NODE_ENV === "development" }), httpBatchLink({ url: "/api/trpc" }), ], queryClientConfig: { defaultOptions: { queries: { staleTime: 60_000, }, }, }, }; }, ssr: false, });

httpBatchLink会自动把多个查询合并成一个 HTTP 请求,这是内部后台工具很实用的优化点。批量请求在弱网环境下减少了很多往返时间。

5.3 组件划分与实际状态管理心得

t3code 的组件划分思路是:页面组件只负责收集数据和布局,业务组件负责具体展示。比如ProjectCard、RecordTable、StatusBadge这些是独立组件,文件放在src/components/下。

状态管理方面,T3 Stack 几乎不需要 Redux。tRPC + React Query 已经把服务端状态管住了,剩下的 UI 状态(弹窗开关、表单值)用useState完全够用。只有跨组件共享且复杂的本地状态,我才会用useContext或 Zustand。对这个项目来说,Zustand 甚至都没用上。

实际体会是:类型安全的技术栈天然减少了对"全局状态"的需求,因为数据来源清晰、查询缓存可控,组件自己维护临时状态即可。

6. 踩坑与边界问题:t3code 开发中的五个真实教训

6.1 NextAuth 会话类型与 tRPC 上下文不一致

首次接入时,ctx.session.user.id一直是undefined。排查后发现是src/server/auth.ts里的session回调没有把 user id 写进 session:

callbacks: { session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, },

同时需要在src/types/next-auth.d.ts里扩展会话类型,否则session.user.id在 TypeScript 里根本不存在。这一步很基础,但忘记做会导致后续所有 protectedProcedure 拿不到用户 ID。

6.2 服务端页面数据获取时的 tRPC 调用方式

t3code 有过一版服务端预取数据,在getServerSideProps里直接调 tRPC 方法,但很快发现 session 在服务端和客户端的获取方式不同,折腾了好一阵。后来简化成统一用客户端useQuery,ssr: false,通过 loading 状态展示,这在小项目中交互体验完全足够。如果确实要 SSR 拉取数据,tRPC 有专门的createServerSideHelpers,配合superjson序列化,只是 context 里要手动注入服务端 session。

6.3 Prisma 在开发环境连接数爆炸

有段时间开发环境数据库连接数不断上涨,排查发现是 Next.js 开发模式下热更新导致 Prisma Client 实例被反复创建。解决方法是全局复用 Prisma 实例。脚手架生成的src/server/db.ts实际上已经处理了这个问题:

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;

这个模式值得保留,别自作聪明删掉。生产情况可能不明显,但开发时每热更新一次就多几个连接,数据库很快就被拖垮。

6.4 tRPC 输入校验与日期序列化的问题

t3code 里有日期字段,最初用 tRPC 传递 Date 对象,在服务端打印正常,但到客户端后变成字符串。这是因为默认的 JSON 序列化处理不了 Date。脚手架里其实已经配置了superjson作为 transformer,在createTRPCNext的 config 和createTRPCContext里都要一并使用。如果你看到日期类型"神秘"变成了字符串,先检查有没有显式加上transformer: superjson。

6.5 部署时的环境与构建问题

t3code 部署到服务器时,第一次构建失败,原因是生产环境没有.env,而 Prisma generate 和 Next.js build 都需要数据库连接信息。我的解决方法是:服务器上把.env放在应用根目录,构建前执行npx prisma generate && npx prisma migrate deploy,然后next build。另外需要注意 NextAuth 的NEXTAUTH_URL必须设置成实际的线上地址,否则认证回调地址会指向 localhost。

建议在 CI/CD 流水线里单独加一个 migrate 步骤,而不是在应用启动时执行迁移。原因很简单:多个副本同时启动时,迁移命令会互相冲突,容易导致数据锁。

7. 跑完 t3code 之后我最想分享的几个习惯

t3code 这个项目从初始化到能跑出第一个完整功能,花的时间比我预想少很多。T3 Stack 最大的价值在于把"类型安全"这件事从理念落地成了默认配置,开发者不需要额外自律去维护类型定义。

我想分享的三个习惯:

第一,从一开始就把 Prisma schema 当接口契约来看。每次改动模型前,先想想对 router 输入输出和页面展示的影响。因为类型安全,这些影响会直观地变成编译错误,不要用// @ts-ignore绕过去,修改源头才是治本。

第二,为所有 mutation 设计明确的错误处理。tRPC 的错误默认会抛给客户端,但不同场景需要的提示不同。我在 t3code 里统一用 Zod error 处理入参问题,用自定义 error code 区分"未登录"和"无权限",前端根据 error shape 展示不同提示。

第三,善用批量请求和缓存配置。内部工具页面往往同时拉多个 query,用 httpBatchLink 加合理的 staleTime 能明显降低服务器压力。我给列表页设置了 60 秒缓存,数据变更后用utils.project.list.invalidate()手动刷新,体验很顺。

t3code 的下一步,我计划加一个简单的角色权限系统,把"项目所有者"之外的协作权限也纳入模型,再配合 Next.js 的 middleware 做页面级访问控制。T3 Stack 的扩展路径很清晰,类型系统会一路提醒你哪里改了会影响到哪里。这种开发体验,用过一次就很难回去了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进