资讯动态

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

发布时间:2026/10/14 0:30:39 来源:尧图企业网站定制
从零搭建 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-applatest 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.tst3code 在这种结构下启动可以直接修改根路由和示例的 post router上手很快。2.2 环境变量与数据库连接配置脚手架在根目录生成了.env示例关键变量有DATABASE_URLPrisma 连接数据库的地址。NEXTAUTH_SECRETNextAuth 的加密密钥。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_URLpostgresql://t3code:t3codelocalhost:5432/t3code写对剩下的交给 Prisma。这里有个小提醒本地开发数据库和生产数据库的环境变量一定要分开管理别把生产库字符串写进仓库。我在团队里吃过亏有人不小心提交了真实环境连接串后来虽然轮换了账号但还是浪费了半天排查。2.3 NextAuth.js 接入时的初始配置t3code 需要用户登录功能所以我在初始化时选择了 NextAuth。它会生成src/server/auth.ts这个文件里配置认证方案和 session 回调。默认配置支持 GitHub OAuth 和邮箱登录需要填GITHUB_CLIENT_IDxxxxx GITHUB_CLIENT_SECRETxxxxx之前用 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: demot3code.local }, update: {}, create: { name: Demo User, email: demot3code.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 与 mutationtRPC 的核心单位是 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 classNamegrid gap-4 p-6 sm:grid-cols-2 lg:grid-cols-3 {projects?.map((project) ( div key{project.id} classNamerounded-2xl border border-slate-200 bg-white p-5 shadow-sm hover:shadow-md h2 classNametext-lg font-semibold text-slate-800{project.name}/h2 p classNamemt-1 text-sm text-slate-500{project.description}/p span classNamemt-3 inline-block rounded-full bg-emerald-100 px-2 py-1 text-xs text-emerald-700 {project.status} /span /div ))} /divTailwind 刚上手会觉得类名很长但用顺手后设计调整效率提升非常明显。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 createTRPCNextAppRouter({ 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 写进 sessioncallbacks: { 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 在服务端和客户端的获取方式不同折腾了好一阵。后来简化成统一用客户端useQueryssr: 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 的扩展路径很清晰类型系统会一路提醒你哪里改了会影响到哪里。这种开发体验用过一次就很难回去了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑