☰
t3code实战:从初始化到部署的TypeScript全栈开发记录
2026/10/28 5:46:24 网站建设 项目流程

看到"t3code"这个标题的时候,我第一反应不是某个具体产品,而是一类越来越常见的需求:想要一套足够现代、类型安全、能直接落地的全栈代码骨架。T3 在社区里通常指 TypeScript、Tailwind、tRPC 这一套组合,而"code"就点明了它的本质——把这三样东西真正揉进工程代码,而不是停留在技术选型 PPT 里。

这篇想写的是我自己用一个内部代号为 t3code 的项目,从初始化到部署上线的完整记录。不是教程式的"照着敲就能跑",更多是分享我在选型、写数据层、调 tRPC、部署排错过程中踩过的坑和做过的取舍。如果你正准备做一个中小型全栈应用,又想省掉前后端接口对接的胶水代码,这套思路值得你花十分钟看完。

1. "t3code"是一个项目代号,更是一套工程约束

1.1 T3 源于哪三个 T

先说清楚"t3code"里的 T3 到底指什么。社区常说的 T3 Stack,核心是 TypeScript、Tailwind CSS、tRPC 三样东西,都是 T 开头,所以叫 T3。再加上 Next.js 作为框架底座,整个技术栈就变得非常统一:从头到尾都是 TypeScript,前端组件用 Tailwind 写样式,接口层用 tRPC 做端到端类型安全。

我把这套底座命名为 t3code,其实是在强调一个事实:代码不只是"能跑",而是要形成一条完整的类型链路。数据库表结构由 Prisma Schema 定义,自动生成的类型可以直接被服务端调用;服务端的 tRPC 路由类型可以被前端直接导入,调用远程接口就像调用本地函数一样,不需要手写 API 文档,也不需要来回对字段。

这套约束的代价是什么?学习曲线确实比传统的 REST + 手写类型要陡。但收益也很明显:一旦链路打通,改一个字段,编译器会帮你把所有关联的地方全部找出来。对于三五个人的小团队,这比任何代码评审都管用。

1.2 为什么这套约束适合中小型全栈项目

我见过太多项目死在接口对齐上。后端改了个返回结构,前端不知道;前端传参多传了一个字段,后端直接忽略;联调阶段一半时间花在翻接口文档上。T3 这套约束天生就是来治这个病的。

但我不建议所有项目都上 t3code。如果你们团队有大量非 TypeScript 的历史代码,如果前端和后端分别部署在两套体系里,如果接口需要开放给第三方调用,那 tRPC 就不太合适。t3code 最舒服的应用场景是:前后端同仓库、同团队、同语言,项目规模在中小型,追求开发速度而不是接口规范化。

1.3 我这次要做的具体内容

我这次用 t3code 做了一个团队内部的任务追踪小系统。功能不复杂:用户登录、项目创建、任务分配、状态流转、简单评论。典型的小型全栈 CRUD 项目,但足够覆盖 tRPC、Prisma、鉴权、部署这些关键环节。

选这个场景还有一个原因:任务系统涉及多个实体之间的关联,比如项目下挂任务、任务分配给用户、用户可以评论任务。这些关系如果靠手写 API 对接,催生出来的问题足够让人头大。正好可以用它验证 t3code 在复杂数据关联下的类型安全能力。

2. 初始化项目时最容易忽略的细节

2.1 版本锁定与 Node 环境准备

初始化 t3code 之前,我先把环境统一了。Node 版本锁在 18.18 以上,包管理器我选了 pnpm。为什么不用 npm?pnpm 的软链接结构在 monorepo 场景下优势明显,尤其后续如果要拆 packages,依赖管理会干净很多。

创建项目我直接用的 create-t3-app,但有一个关键步骤很多人会选错:初始化时的 Prisma 与 Next.js 版本选项,建议全部选最新稳定版,不要选 beta。我在一次实验里选了 Next 15 的 beta,结果某个中间件 API 和 tRPC 适配器不兼容,排查了整整一个下午。

初始化完成后第一件事不是写代码,而是把 lockfile 提交到 Git,同时确认.env.example里所有环境变量都有注释说明。这个习惯救过我很多次——新同事克隆项目后,照着.env.example五分钟就能拉起本地环境,不用追着人问"数据库密码是多少"。

2.2 先定目录边界,再写代码

create-t3-app 默认生成的目录结构够用,但清晰程度一般。我在 t3code 里做了一次调整,最终结构是这样的:

src/ app/ # Next.js App Router 页面 server/ db/ # Prisma Client 实例 auth/ # 鉴权相关封装 api/ # tRPC 路由入口 shared/ # 类型、工具函数、常量 styles/ # 全局 Tailwind 样式

这个结构的核心思路是:页面逻辑尽量薄,业务逻辑尽量落到 server 层,前端能共享的类型和工具函数全部放在 shared。tRPC 路由按业务域拆文件,而不是把所有路由堆在一个文件里。后面写任务、写评论、写项目成员时,这个边界帮了大忙。

2.3 环境变量与配置文件的早期处理

环境变量这块,我强烈建议在项目初期就做一次全局梳理。t3code 里我用的是 zod 来校验环境变量,在env.ts里集中声明,而不是散落在各个文件里。

import { z } from "zod"; export const envSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]), DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(32), NEXTAUTH_URL: z.string().url().default("http://localhost:3000"), }); export const env = envSchema.parse(process.env);

这样写的好处是,应用启动时如果缺了某个变量,会直接报出一行清晰的中文错误,而不是等运行时某个模块报出莫名其妙的undefined。我在多个项目里验证过,这一步投入极小,但排查问题的效率提升非常明显。

3. 数据模型设计:把类型安全延伸到数据库

3.1 Prisma Schema 的设计要点

数据层是 t3code 类型链路的起点。用 Prisma 的 Schema 定义模型,然后通过迁移命令生成数据库表结构和 TypeScript 类型。这个流程本身不复杂,真正考验人的是建模时的关系设计。

我这次设计了四个核心模型:User、Project、Task、Comment。它们之间的关系是这样的:User 和 Project 是多对多,通过 ProjectMember 关联;Project 和 Task 是一对多;Task 和 Comment 是一对多。下面这段 Schema 是经过三次调整后的最终版:

model User { id String @id @default(cuid()) name String? email String @unique passwordHash String? projects ProjectMember[] tasks Task[] comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Project { id String @id @default(cuid()) slug String @unique name String members ProjectMember[] tasks Task[] createdAt DateTime @default(now()) } model ProjectMember { userId String projectId String role MemberRole @default(MEMBER) user User @relation(fields: [userId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) @@id([userId, projectId]) } model Task { id String @id @default(cuid()) title String description String? status TaskStatus @default(TODO) priority Priority @default(MEDIUM) assigneeId String? assignee User? @relation(fields: [assigneeId], references: [id]) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Comment { id String @id @default(cuid()) content String authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) taskId String task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) }

这里有几个值得说的选择。

外键关系上的onDelete: Cascade是我特别强调的。如果删除项目时不级联删除项目下的任务和成员的关联,那数据库里就会残留一堆孤儿记录,前端列表拿到的数据不干净,排查起来非常痛苦。早期建模时我漏掉了这个配置,后来手工清理过两次数据库,记忆深刻。

3.2 枚举与关联的处理

任务状态和优先级这种固定取值字段,用 Prisma 枚举来定义,而不是直接存字符串。好处是,数据库层面就不会出现非法值,生成的 TypeScript 类型也会自动带上枚举的取值范围,前端下拉框可以直接引用同一个枚举。

enum TaskStatus { TODO IN_PROGRESS DONE CANCELED } enum Priority { LOW MEDIUM HIGH URGENT }

这个设计让前后端的取值完全同步。有一次我想增加一个 BLOCKED 状态,只需要改三处:Schema 枚举、前端状态标签组件、筛选逻辑。改完跑一下类型检查,所有用到 TaskStatus 的地方全部过一遍编译,根本不需要人肉搜索。

多对多关联我用 ProjectMember 这种中间表,而不是 Prisma 的隐式多对多。虽然隐式写法更简洁,但显式中间表能携带额外字段(比如角色 role),后续查"这个项目里谁是管理员"就非常方便。

3.3 第一次迁移要注意的事项

执行prisma migrate dev之前,我习惯先跑一次prisma validate确认 Schema 语法没问题。第一次迁移时还有一个重点:必须先确保数据库能连上,且数据库账号有建表权限。我在本地用 Docker 起 PostgreSQL 时踩过一次坑——容器里数据库账号的密码和.env不一致,迁移命令一直报连接失败。

如果你的表结构后期要改,不要嫌麻烦直接编辑 Schema 后重新迁移。Prisma 的迁移机制会自动生成增量 SQL,但有时候改字段类型会导致数据丢失。比如我把某个字段从 String 改成 Int 时,如果表里有非数字字符串,迁移会报错。所以关键字段的变更,我强烈建议先在 staging 环境跑一次迁移,确认没问题再动生产库。

4. tRPC 路由:端到端类型安全的关键一环

4.1 从 trpc 实例到 router 的拆分逻辑

tRPC 的类型安全不是魔法,而是靠 TypeScript 的泛型推导。核心思路是:先创建一个带上下文的 tRPC 实例,然后在不同业务域中创建子路由,最终合并成一个根路由。前端发起请求时,TypeScript 会根据路由路径自动推导出入参和出参类型。

// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { type Context } from "../context"; const t = initTRPC.context<Context>().create(); export const router = 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: { userId: ctx.session.user.id } }); });

这个文件的地位相当于整个后端路由的"地基"。publicProcedure 是公开接口,protectedProcedure 是所有需要登录的接口的基座。注意看protectedProcedure里我向下一步 context 注入了userId,这样下游路由就不需要再手动从 session 里取用户 ID,减少了很多样板代码。

4.2 输入校验与错误处理

每个路由用 zod 做输入校验。zod 和 tRPC 集成得非常好,定义 schema 之后,前端调用的入参类型会自动从 zod schema 中推断出来,不需要额外写接口文档。

// src/server/api/routers/task.ts import { z } from "zod"; import { router, protectedProcedure } from "../trpc"; export const taskRouter = router({ listByProject: protectedProcedure .input(z.object({ projectSlug: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.task.findMany({ where: { project: { slug: input.projectSlug }, }, include: { assignee: { select: { id: true, name: true } }, comments: { orderBy: { createdAt: "desc" } }, }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ projectId: z.string(), title: z.string().min(3).max(100), description: z.string().max(2000).optional(), assigneeId: z.string().optional(), priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"), }), ) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { projectId: input.projectId, title: input.title, description: input.description, assigneeId: input.assigneeId, priority: input.priority, }, }); }), updateStatus: protectedProcedure .input(z.object({ id: z.string(), status: z.enum(["TODO", "IN_PROGRESS", "DONE", "CANCELED"]) })) .mutation(async ({ ctx, input }) => { return ctx.db.task.update({ where: { id: input.id }, data: { status: input.status }, }); }), });

这里我想强调一个容易忽视的点:create路由里,projectId只校验了字符串格式,并没有校验用户是否真的是这个项目的成员。如果直接照这样上线,任何登录用户都能往别人的项目里塞任务。我在后期增加了一个检查步骤,在 mutation 里先查一下 ProjectMember,确认用户与项目有关联再执行 create。

const membership = await ctx.db.projectMember.findUnique({ where: { userId_projectId: { userId: ctx.userId, projectId: input.projectId } }, }); if (!membership) { throw new TRPCError({ code: "FORBIDDEN", message: "不是该项目成员" }); }

这种业务校验逻辑放路由里而不是放中间件里,因为不同操作对权限的要求不一样。比如查看任务列表可能只需要项目成员身份,但删除任务可能需要项目管理员身份。路由级别的校验更灵活。

4.3 鉴权中间件怎么写才不绕

鉴权这块我用了 NextAuth + tRPC 中间件搭配。NextAuth 负责登录、会话管理,tRPC 中间件负责校验会话并提取用户信息。两者通过 context 连接。

context 文件里做的事情很简单:从请求的 headers 中解析出 session cookie,交给 NextAuth 的getServerSession获取用户信息,然后把这个 session 挂到 tRPC context 上。

// src/server/context.ts import { getServerSession } from "next-auth"; import { authOptions } from "./auth"; import { prisma } from "./db"; export const createContext = async () => { const session = await getServerSession(authOptions); return { session, db: prisma, }; };

整个鉴权链路就三句话:请求进来 -> 解析 session -> 塞进 context。下游 protectedProcedure 直接判断ctx.userId是否存在。我在早期犯过一个错:把鉴权逻辑写在了每个路由内部,导致所有路由都重复一遍"取用户、查数据库、判断是否登录"。重构之后代码量减少了一半以上,也不容易再出现"某个路由忘了鉴权"的问题。

5. 前端集成:类型推断带来的开发体验变化

5.1 React Query 与 tRPC 的配合

前端部分,tRPC 的客户端会生成一套 hooks。底层的缓存机制是 TanStack Query,也就是原来的 React Query。这套组合在开发时最明显的感觉是:不需要手写fetch,不需要手写错误处理,调用远程过程就像调用本地异步函数一样。

tRPC 客户端的初始化代码通常是这样的:

// src/utils/api.ts import { createTRPCNext } from "@trpc/next"; import { httpBatchLink } from "@trpc/client"; import type { AppRouter } from "../server/api/root"; export const api = createTRPCNext<AppRouter>({ config() { return { links: [httpBatchLink({ url: "/api/trpc" })], }; }, });

使用的时候,组件里直接这样调用:

const { data: tasks, refetch } = api.task.listByProject.useQuery( { projectSlug: slug }, { enabled: !!slug }, ); const updateStatus = api.task.updateStatus.useMutation({ onSuccess: () => refetch(), }); // 某个按钮点击事件里 updateStatus.mutate({ id: task.id, status: "DONE" });

useQuery和useMutation的入参、返回类型都是从前端路由自动推导的,写错字段名编译器当场就会报出来。这种体验在习惯了 REST 手动对接之后真的回不去。

5.2 一个表单页面从编写到落地的完整流程

拿任务创建表单举例。最开始的版本,我在 React Hook Form 里把useForm的类型参数直接写成了TaskCreateInput,结果发现 tRPC mutation 的 input 类型和数据库字段类型并不完全一致——比如assigneeId是可选的,React Hook Form 里却需要给一个默认值。这类问题靠编译器就能发现,但需要理解"前端表单类型"和"API 入参类型"是两回事。

最终的做法是单独定义表单类型,提交时做一次显式转换:

interface TaskFormValues { title: string; description: string; priority: Priority; assigneeId?: string; } function TaskForm({ projectId }: { projectId: string }) { const { register, handleSubmit } = useForm<TaskFormValues>(); const createTask = api.task.create.useMutation(); const onSubmit = (values: TaskFormValues) => { createTask.mutate({ projectId, title: values.title, description: values.description || undefined, priority: values.priority, assigneeId: values.assigneeId || undefined, }); }; // 渲染表单... }

这个小项目让我体会到一个真谛:端到端类型安全不是让你彻底不用思考类型,而是把类型矛盾点都暴露在编译期。字段不一致、可选值没处理、枚举值写错,全都跑不了。

5.3 布局与 Tailwind 的主题化处理

Tailwind 在 t3code 里的角色是纯粹的样式工具。我用它快速搭了任务看板的布局:左侧列表、右侧详情、顶部状态筛选。真正值得记录的是主题化配置——在tailwind.config.ts里扩展了一些语义化颜色,而不是直接散落一堆bg-blue-500这种魔法值。

// tailwind.config.ts import type { Config } from "tailwindcss"; const config: Config = { theme: { extend: { colors: { primary: { DEFAULT: "#2563eb", hover: "#1d4ed8", }, surface: { DEFAULT: "#ffffff", muted: "#f9fafb", }, }, }, }, }; export default config;

这样写的好处是,后期做暗色模式时只需要替换颜色 token,不需要满文件搜索色值。Tailwind 的 class 名是写在 JSX 里的,如果不用 token 而是写死色值,暗色模式适配基本等于重写一遍前端。这件事我在 t3code 初期没考虑,后面改样式时深深后悔过。

6. 部署与排错:上线前的最后一道坎

6.1 构建阶段的类型检查问题

本地开发一切顺畅,不代表部署构建能通过。t3code 里最容易在 CI/CD 阶段翻车的是类型检查。Next.js 在生产构建时会开启严格类型检查,任何any、类型不匹配、未使用的变量(某些配置下)都会让构建直接失败。

我第一次部署时,构建失败是因为一个组件里useQuery的返回值类型可能为undefined,而我在渲染时没有做空值判断。本地开发时因为页面一直有数据,所以没有被触发;但生产构建的类型检查把它揪了出来。从那以后,我习惯在本地提交前先跑一遍pnpm build而不是只跑pnpm dev,提前暴露所有类型问题。

6.2 服务端无服务器部署的连接数风波

t3code 部署到 Vercel 时,遇到过一个棘手的数据库连接数问题。Prisma Client 默认会创建连接池,在 serverless 环境下每个函数实例都会新建连接,并发一高数据库就撑不住。官方推荐的解决方式是使用连接池代理,或者调整 Prisma 实例化方式,在全局复用客户端实例。

// src/server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = prisma; }

这段代码在本地开发时利用全局对象避免每次热更新都新建客户端,在生产环境则靠 serverless 平台的实例复用能力减少连接数。如果你的团队用的是传统服务器,直接用new PrismaClient()就够了;但 serverless 部署必须做这层处理,否则高并发时数据库连接数爆炸几乎是必然的。

6.3 日志与错误的可观测性

部署上线后,最怕的不是 Bug,而是不知道线上发生了什么。t3code 里我接了一个简单的日志体系:tRPC 中间件里记录每个请求的路径、耗时和调用者,错误统一走错误处理中间件。

export const loggingMiddleware = t.middleware(async ({ path, type, next }) => { const start = Date.now(); const result = await next(); const duration = Date.now() - start; if (process.env.NODE_ENV === "production") { console.log(JSON.stringify({ path, type, duration, success: result.ok })); } return result; });

别看不起 console.log,在 serverless 平台上它会自动聚合到日志系统里,配合过滤条件就能做基础的链路追踪。我靠这个简单的日志捕获过两次线上问题:一次是某个路由耗时突然飙升,顺藤摸瓜发现是数据库缺少索引;另一次是某个用户反复触发 403,定位到他在尝试访问一个不属于自己的项目。没有日志的话,这两类问题都要靠用户主动反馈才能发现。

7. 复盘:t3code 这类写法到底值不值得推广

7.1 收益最大的三个点

项目上线运行稳定之后,我做了次复盘。收益最大的三点,按对我自己的实际帮助排序:

首先,类型安全极大降低了重构成本。升级字段、改数据结构、调整权限逻辑时,编译器是最好的导航仪,哪里需要改、哪里改了会出错,一清二楚。第二个是开发效率。省去手写 API 层、省去 mock 数据结构、省去接口联调会议,这些时间加起来非常可观。第三是代码可读性。tRPC 路由的路径本身就是业务文档,api.task.updateStatus不管谁来看都知道是做什么的,不需要额外维护接口文档。

这三点在多人协作时体现得更加明显。新成员接手项目时,沿着类型定义就能摸清整个数据流,而不是先追着老人问"这个接口在哪定义的""这个字段哪来的"。

7.2 我仍然会保留非 t3 风格做法的位置

哪怕是现在,我也认为 t3code 不是银弹。有些场景我依然会用传统 REST 方式处理:

对外开放的第三方接口不能用 tRPC,因为外部客户端不会为了调用你的接口去安装 tRPC 客户端库。纯展示型页面、不需要复杂交互的站点,也用不着上全套 T3,直接 Next.js + Tailwind 就够了。团队里如果有人对 TypeScript 泛型非常不熟,学习 tRPC 和 zod 的抽象模式需要一段适应期,这个时间成本要考虑。

所以我的建议是:t3code 适合作为团队内部工具、中小型业务系统、快速验证想法的 MVP 项目的技术底座。一旦涉及开放 API、多方系统集成、大规模团队协作,应该回到更标准的接口方案。

7.3 适合接入 t3code 风格的项目画像

如果你正在犹豫要不要往这个方向走,我总结了一份简化的适用画像,可以对照着看:

维度适合 t3code不太适合
团队规模1-10 人,前后端同组大前端、大后端分离团队
系统类型内部工具、业务系统、MVP开放平台、公共 API
部署方式前后端同仓库部署独立部署、多环境网关
类型基础团队愿意接受 TypeScript资深后端主导、前端不碰 TS
接口变化频率高,业务迭代快低,接口长期稳定

说白了,t3code 这套思路的核心理念是"用一套类型系统贯穿全栈",它换取的是开发效率和重构安全感,代价是技术栈绑定和学习成本。只要你的团队认可这个交换,它就能给你带来超出预期的体验。

我到现在还记得第一次跑通完整类型链路时的感觉:前端还没写完,编译器已经提醒我后端某个字段改了名字;后端调整入参结构,前端所有调用点同时报错。那是一种被工具稳稳托住的安全感。如果你的项目也卡在"前后端反复对接"的泥潭里,花一个周末把 t3code 式的骨架搭起来,亲自体验一次端到端的类型安全,你大概率也会回不去手写接口的那套流程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询