☰
用T3 Stack搭建任务看板:t3code全栈实践复盘
2026/10/26 16:55:21 网站建设 项目流程

t3code是我最近一直在维护的一个真实项目,背后要解决的事情其实很小:团队里想要一个轻量任务看板,能建项目、分任务、指派成员,不想为这点需求引入一套重型的项目管理系统。我刚开始也考虑过直接用现成的看板工具,折腾一圈发现权限模型、字段定制和现有账号体系全都对不上,最后干脆自己动手用代码写一个。项目代号就叫t3code——字母T3取自这套技术组合的简称,code代表把整套组合完整落地成可运行的源代码。

选择t3code这个名字还有一个私心:我想验证一下,T3 Stack(Next.js、tRPC、Prisma、NextAuth.js、Tailwind CSS)在真实业务里到底能不能像社区说的那样,让一个人干完整条全栈链路。事实证明它确实帮我省掉了大量重复劳动,但过程中也踩了不少文档里不会写的坑。这篇主要围绕t3code从零到上线的全过程,讲清楚每一步为什么这么选,以及哪些细节容易把人卡住。如果你正准备做一个全栈项目,或者已经在用T3 Stack但只停留在跑通demo阶段,t3code这份实践正好可以作为参考:依赖怎么搭,数据模型怎么设计,tRPC怎么跟NextAuth接上,线上部署又要注意哪些环节。我会把能直接照抄的代码片段放出来,也会把当时走弯路的过程一并复盘。

1. t3code从哪来:一个任务看板为什么选T3 Stack整套

1.1 业务痛点与t3code名字的由来

先说业务模型。小团队五到八个人,大家要的不是金丝雀、里程碑、甘特图那套完整体系,而是三件事:创建一个项目,往里面加任务,把任务指派给具体的人并跟踪状态。就这么简单。可市面上的工具要么功能堆得过满,要么权限模型跟实际协作方式脱节,改起来还很费劲。于是t3code最初的产品需求其实只有三个页面:项目列表页、项目详情里的任务看板页、以及一个简单的成员管理入口。

t3code这个名字,一眼看过去就知道跟T3 Stack有关。T3并不是某个框架的名字,而是一组工具的集合:Next.js负责页面和服务端渲染,tRPC负责前后端接口调用,Prisma负责数据库访问,NextAuth.js负责登录认证,Tailwind CSS负责样式。这个组合在社区里被叫作T3 Stack,数了一下正好是五个工具。T3这个叫法常见解释是Three Tier,也就是数据库、服务端、客户端三层高度集成。把名字往项目上一挂,后面跟人介绍技术选型时直接说“你去看t3code的代码就行”,比翻一堆文档方便多了。

1.2 端到端类型安全到底是什么体验

很多文章在讲T3 Stack时都爱提“端到端类型安全”,但没真正上手过的读者很难想象这到底改变了什么。传统前后端分离开发里,后端写一个REST接口,返回JSON,前端拿着接口文档自己定义对应的TypeScript类型。于是经常出现这种情况:后端把某个字段改名了,文档忘了更新,前端还在用旧字段名,编译一点问题没有,直到运行时拿到undefined才炸出来。这种问题靠Code Review很难全部拦住。

tRPC的思路完全不同。它在后端定义了一个个可以直接调用的函数,TypeScript会把函数的入参类型和返回值类型从服务端一路推导到前端组件里。前端写api.project.create.useMutation(),参数类型、返回类型都是后端直接给出的。字段一旦改名,前端的类型检查立刻报错,根本轮不到运行时才发现。可以理解成以前前后端各拿一份合同,合同过期了也不一定有人通知;现在编译器替你盯着合同,任何一方改条款,另一方马上就会在编译阶段收到提示。当然,类型安全不是万能药,像复杂的权限校验这种逻辑仍然要在后端兜底,这一点后面会专门讲。

2. 初始化到改造:create-t3-app生成的工程还有多少家务活

2.1 安装命令与模块勾选的选择

t3code在初始化时直接用了create-t3-app脚手架,一条命令拉起来:

npx create-t3-app@latest t3code

交互式界面里会让你勾选需要的模块。我当时的勾选是:Next.js(App Router模式)、TypeScript、tRPC、Prisma、NextAuth.js、Tailwind CSS,再加上ESLint和Prettier。这里说一个容易被忽略的点:create-t3-app是必选Next.js和TypeScript的,其他都可以不选。如果你只想用其中几个工具,完全可以在这里去掉tRPC或者Prisma。但既然叫t3code,我建议既然选择了T3这套组合,就保持它全家桶的完整性,否则后面类型推导的优势会打折扣。

另一个取舍是数据库ORM。create-t3-app在较新版本里也支持选择Drizzle ORM,我最后仍然选的Prisma。理由很实在:团队里对Prisma的迁移流程更熟,遇到问题查资料方便;另外Prisma生成的类型和tRPC配合得很顺,数据库模型的字段类型几乎原样映射到前端类型系统里,这在t3code里是个很大的收益。不是说Drizzle不好,而是对一个需要快速交付又不想折腾的项目来说,成熟方案优先。

初始化完成后,项目根目录会有.env文件,里面放着数据库连接串、NextAuth密钥等关键配置。这时候仓库还没有数据库,别急着跑起来。

2.2 清理示例代码与目录职能划分

create-t3-app落地后会生成一套示例代码,通常包含一个贴吧帖子之类的简单业务,用来演示tRPC和Prisma全流程怎么走。很多人在上面直接改,改到最后示例代码的残留到处都是。我在t3code里做的第一件事就是把示例业务删干净,只保留工程骨架,再把我们自己的业务代码按目录填进去。

清理完之后的目录结构大致长这样:

src/ app/ layout.tsx page.tsx projects/ page.tsx server/ api/ root.ts trpc.ts routers/ project.ts task.ts db.ts auth.ts trpc/ server.ts react.tsx prisma/ schema.prisma

几个关键文件的职责我简单梳理一下:

  • src/server/api/trpc.ts是tRPC的初始化文件,在这里创建tRPC上下文和基础procedure。
  • src/server/api/root.ts汇总所有子路由,最终导出appRouter。
  • src/server/db.ts创建PrismaClient实例。
  • src/server/auth.ts配置NextAuth登录逻辑。
  • src/trpc/server.ts和src/trpc/react.tsx分别是服务端调用tRPC和客户端挂载Provider的入口。

清完示例代码后,还需要把src/app下示例相关的页面文件一并删掉,否则编译会报错。这个“删干净”的过程看着不起眼,但如果不做,后面的业务代码会和示例代码纠缠在一起,排查问题时非常痛苦。

3. 先定数据模型再谈接口:t3code里Prisma的三张核心表

3.1 User、Project、Task的模型与关系设计

写接口之前,我习惯先把数据模型画清楚。t3code的业务最终落到三张核心表:用户表User、项目表Project、任务表Task。除此之外,由于使用了NextAuth,还需要保留脚手架生成的Account、Session、VerificationToken这几个配套模型,它们负责登录会话的存取。

我设计的schema长这样:

enum TaskStatus { todo in_progress done } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? ownedProjects Project[] @relation("OwnedProjects") memberProjects Project[] @relation("MemberProjects") assignments Task[] @relation("Assignee") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Project { id String @id @default(cuid()) name String slug String @unique ownerId String owner User @relation("OwnedProjects", fields: [ownerId], references: [id]) members User[] @relation("MemberProjects") tasks Task[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Task { id String @id @default(cuid()) title String description String? status TaskStatus @default(todo) projectId String project Project @relation(fields: [projectId], references: [id]) assigneeId String? assignee User? @relation("Assignee", fields: [assigneeId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

几个设计决策值得展开说说。

主键用cuid()而不是自增整数,原因有两个。第一,cuid在分布式环境下生成不会冲突,不必等数据库分配ID,写代码时可以先拿ID再关联其他数据。第二,URL里暴露自增ID会让别人轻易猜到项目数量,对任务看板这种内部工具来说没太大必要冒这个风险。

Project和User的关系我拆成了两个:ownerId表示创建者,members表示参与成员。这两者分开建关系比在Project上加一个role字段干净得多。创建者的权限天然比普通成员高,后面写删除项目、修改项目信息这类接口时,直接判断ownerId等于当前登录用户的id就行了。如果用一个role字段,还要每次先查成员关系再判断角色等级。

Task里的assigneeId设计成可空,是因为任务在创建时可以暂时不指派给任何人,等成员想明白再挂。TaskStatus用Prisma的enum而不是字符串,能避免写错状态值,比如把“in_progress”写成“inprogress”这种低级错误,数据库层面就会拒绝。任务状态需要扩展时,在enum里加一个值再生成迁移即可。

3.2 迁移流程与改表的正确节奏

模型定义完之后,下一步是同步到数据库。t3code本地开发用的是一台PostgreSQL实例,初始化时执行:

npx prisma migrate dev --name init

这条命令会做三件事:生成迁移文件、把迁移应用到数据库、触发Prisma Client的类型更新。执行完之后,node_modules/.prisma/client里就有完整的数据库模型类型了,tRPC后端可以直接用ctx.db.project.findMany()这种带类型提示的调用。

我要特别提醒改表的节奏。很多人开发时改一次schema就执行一次migrate dev,忘了给迁移取一个有意义的名称,结果迁移历史变成一串“init2”、“init3”,后期想回滚都不知道哪个文件对应哪个改动。t3code的维护过程中,我一直保持“一个小改动一个迁移”的节奏,比如“add-task-status-column”就只做一件事。万一上线后某次迁移出问题,只需要回滚那一个文件,不会殃及鱼池。

另外,migrate dev是开发环境用的命令,它会尝试重置数据库、重新执行迁移。生产环境部署时绝不能用它,要用prisma migrate deploy去应用迁移文件。我在t3code部署踩坑时发现很多人分不清这两条命令,后面第六章还会再提到。

4. 类型安全贯穿前后端:tRPC在t3code中的接线与校验

4.1 不用URL,用函数:router与query/mutation

数据模型有了,接口层怎么写?传统做法是定义一堆REST端点,比如GET /api/projects、POST /api/projects,然后前端自己拼URL、处理响应。tRPC把这一层变成了TypeScript函数调用。以t3code里的项目模块为例:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const projectRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.project.findMany({ include: { owner: { select: { name: true, email: true } } }, }); }), create: protectedProcedure .input(z.object({ name: z.string().min(1, "项目名不能为空").max(40), slug: z.string().regex(/^[a-z0-9-]+$/, "slug只能包含小写字母、数字和横线"), })) .mutation(async ({ ctx, input }) => { return ctx.db.project.create({ data: { name: input.name, slug: input.slug, ownerId: ctx.session.user.id, }, }); }), });

后端定义的是publicProcedure.query和protectedProcedure.mutation,分别对应查询和写操作。前端调用时:

const { data: projects, refetch } = api.project.list.useQuery(); const createProject = api.project.create.useMutation({ onSuccess: () => { void utils.project.list.invalidate(); }, });

useQuery和useMutation是tRPC自动生成的前端hooks。注意这里的create方法的入参和返回值类型,完全由后端router定义推导,前端不需要单独维护任何DTO类型。这就是t3code整个项目最核心的开发体验:改后端函数签名,前端编译立刻报错,整个链路在编译期就能发现一半以上的接口问题。

有人会问,既然如此,为什么还需要REST API?答案是不一定需要。t3code这种以内部工具为定位的项目,没有第三方开发者对接的需求,tRPC这种函数式调用反而最合适。如果未来真的有对外提供API的需求,可以在tRPC外面再包一层Route Handler,两者不冲突。

4.2 Zod输入校验:类型和规则只写一遍

tRPC的input方法接收一个Zod schema,这是它做输入校验的方式。Zod是一个TypeScript首选校验库,定义出来的schema可以直接从类型层面推出来,不需要再手动写一遍interface。

t3code里我把每个业务对象的核心校验都抽成独立模块,比如src/validation/project.ts:

import { z } from "zod"; export const createProjectSchema = z.object({ name: z.string().min(1, "项目名不能为空").max(40), slug: z.string() .regex(/^[a-z0-9-]+$/, "slug只能包含小写字母、数字和横线") .min(3) .max(30), }); export type CreateProjectInput = z.infer<typeof createProjectSchema>;

抽出来的好处很明显。前端在React Hook Form里可以直接拿同一个schema做表单校验,用户填完表单看到的中文错误提示和后端返回的错误提示完全一致。比如slug字段不符合规则,前端输入框下方立刻提示“slug只能包含小写字母、数字和横线”,后端也会用同样的schema拒绝非法请求。规则只写一遍,维护成本直接减半。

这里有个实战细节值得说:tRPC的Zod校验错误默认返回的message字段是英文的,如果你想自定义错误提示,用errorFormatter做一层转换,把Zod错误转换成统一的{ field, message }结构。我在t3code里就是这么做的,否则前端拿到一堆嵌套的fieldErrors,解析起来又臭又长。

4.3 protectedProcedure:把登录态变成中间件

业务里很多操作需要登录后才能执行,比如创建项目、指派任务。t3code的create接口用了protectedProcedure,这个procedure本身在create-t3-app里已经定义好了。它的核心逻辑是:如果ctx.session里没有用户,直接抛出一个UNAUTHORIZED错误,让调用方拿不到数据;如果有用户,就把session带给后续的handler。

代码展开后大致是这样:

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 }, }, }); });

用中间件而不是在每个路由handler里手动判断ctx.session,好处是规则统一。以后想加“必须是项目成员才能看任务”这种更细的权限,可以在路由内部再做一次逻辑判断,而不是再堆一摞if else。我在t3code里的project删除接口就是先查project.ownerId,再决定是否允许删除。如果嫌这个写法重复,也可以封装一个ownerProcedure,但那属于锦上添花,业务简单时没必要过度设计。

前面提到NextAuth会把用户信息注入session。脚手架默认会在src/server/auth.ts里把数据库用户的id塞到session.user上,方便代码里直接取ctx.session.user.id。如果你重写过auth配置文件,务必保留这段逻辑,否则这里会是undefined,接口创建出来的数据ownerId就是空的,排查起来相当隐蔽。

5. 页面交互与服务端取数:t3code的前端落地细节

5.1 Server Components、客户端组件与API调用边界

Next.js的App Router模式把组件分成Server Components和Client Components,t3code的前端也面临这个边界问题。先用一句话总结我的经验:页面骨架和列表数据尽量在Server端取,涉及点击、输入、状态切换的交互部分再标记为client组件。

t3code的项目列表页就是一个典型场景。页面整体由服务端渲染,直接调用tRPC的caller拿数据:

// src/app/projects/page.tsx import { createCaller } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; import { headers } from "next/headers"; export default async function ProjectsPage() { const ctx = await createTRPCContext({ headers: await headers() }); const caller = createCaller(ctx); const projects = await caller.project.list(); return <ProjectList initialProjects={projects} />; }

如果你用的是create-t3-app,脚手架在~/trpc/server.ts里已经对服务端调用做了封装,可以直接import { api } from "~/trpc/server"然后await api.project.list(),比我手写的这段更简洁。

但这里有个坑必须提醒:Server Component的取数结果默认会被缓存。如果任务状态更新后列表页没有刷新,用户看到的就是旧数据。t3code在涉及实时性较强的页面里加了export const dynamic = "force-dynamic",强制每次请求都重新渲染。如果你的数据不经常变化,保留缓存反而能提升性能,这个开关要看业务场景来。

Client Components这边,涉及编辑按钮、表单、状态切换的组件统一挂上"use client"标记。tRPC客户端Provider已经在根布局里挂好,所以客户端组件里可以直接用api.project.create.useMutation()这类hooks。于是我的代码复用出现了一个自然分层:Server端负责初始数据,客户端负责后续交互和缓存更新,两边类型完全对齐。比以往“页面加载时发一个fetch”的写法更省事,也几乎没有重复代码。

5.2 Tailwind布局与交互组件的心得

样式层我用Tailwind CSS。t3code的看板页面里,任务卡片按状态分成三列,每一列的状态标签颜色、卡片hover效果、按钮禁用态,都是直接在className里拼出来的。比如任务状态徽章会这样写:

<span className={ task.status === "done" ? "bg-emerald-100 text-emerald-700" : task.status === "in_progress" ? "bg-amber-100 text-amber-700" : "bg-slate-100 text-slate-600" } >

这种原子化写法确实快,但有个地方要克制:颜色值不要散落在业务代码里。我会在tailwind.config.ts里定义统一的色板变量,比如primary、success、warning,业务里只用这些语义化类名。否则改一次主题色,所有相撞的颜色都要跟着改账号。

交互组件方面,像新增任务、编辑项目这类弹窗,用Headless UI或Radix UI的Dialog组件比自己造轮子省心很多。重点是把弹窗组件标成client,外层页面保持server,这样既不影响整体服务端渲染,交互又完全响应式。t3code里每个弹窗都封装了一层表单,把Zod校验规则和React Hook Form配合使用,用户提交时先在前端校验一遍,后端tRPC再用同一套规则兜底,体验相当顺滑。

6. 上线前躲不开的坑:t3code部署与验证清单

6.1 环境变量:最容易翻车的第一关

部署第一步往往是环境变量。t3code部署到云端时,下面这几个变量是绕不开的:

变量名用途典型坑
DATABASE_URLPrisma连接数据库本地连localhost,上线忘改成生产库地址
NEXTAUTH_URLNextAuth回调地址线上必须等于真实站点URL,否则登录后跳转404
NEXTAUTH_SECRETSession加密密钥多实例部署必须统一,不一致会导致登录态忽好忽坏

NEXTAUTH_SECRET这个坑我印象很深。本地开发时create-t3-app会在.env里自动生成一个密钥,很多人直接提交代码,部署平台的密钥又是另一个值。如果平台开了多实例,每个实例读到的密钥不一致,session的加密结果就互相不认,用户第一二次请求正常、第三次突然掉登录。排查方法很土但有效:检查所有实例的NEXTAUTH_SECRET是否完全一致。

还有一个跟部署平台相关的坑。有些平台会自动帮你设置NEXTAUTH_URL,这本身是好事,但如果你同时用了自定义域名和平台默认域名,“自动设置”的值未必是你期望的那个。我建议所有关键变量都显式声明,不要依赖平台的推理。

6.2 Prisma生成器与数据库连接在构建时的问题

t3code构建到云服务器的过程中,Prisma这块最容易出现两类报错。

第一类是构建阶段没有生成PrismaClient。构建镜像里如果跑的是npm run build,而build脚本没有前置执行prisma generate,那么TypeScript编译时引用.prisma/client就会失败。解决办法很简单,在package.json的build脚本前面加上:

"build": "prisma generate && next build",

但要注意,prisma generate只生成客户端代码,不会执行数据库迁移。上线时的迁移要单独跑prisma migrate deploy。这个命令必须在能连上生产数据库的环境里执行,否则会在连接阶段直接超时。如果数据库需要白名单,记得把构建机的IP加进去。

第二类是serverless环境的数据库连接数问题。t3code最开始部署到云函数平台时,每个函数实例都会创建一批数据库连接,流量稍涨连接数就爆。后来在DATABASE_URL上追加了连接池参数,类似这样:

DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require&pgbouncer=true&connection_limit=1"

不同数据库服务商支持的参数不一样,pgbouncer=true和connection_limit=1并非谁都能直接用。要不要加这两个参数,取决于你用的是独立数据库实例还是Serverless数据库,建议根据平台文档来。关键是理解这个链路:serverless实例越多,连接占用越夸张,连接池参数能限制单个实例占用的连接数,属于运维侧的一个重要调优点。

6.3 登录鉴权与页面跳转的顺序性坑位

t3code上线后遇到过这样的场景:用户未登录访问/projects,被中间件拦截并跳转到登录页,登录成功后又跳回/projects,但页面显示空白。排查下来发现有两个原因。

第一个原因是中间件里拦截路径的写法。NextAuth的中间件需要明确声明匹配哪些路径,如果只写了matcher: ["/projects/:path*"],那么/projects本身可能漏掉。正确写法通常要包含精确路径和通配路径两种。这类问题在本地开发时不太明显,因为登录状态一直存在,一旦清掉cookie立刻复现。

第二个原因是Server Component在没有拿到session时就去取数。t3code改版后我习惯在服务端组件里直接调caller.project.list(),但忘了先判断登录态。未登录用户虽然被中间件拦了,但中间件只负责跳转,服务端组件仍然可能先执行取数逻辑。正确顺序是:先判断session,没有则redirect("/api/auth/signin"),有再进行数据库查询。这个顺序反过来,就会多一次无意义的数据库调用,甚至在某些数据库配置下暴露错误信息。

最后还有一个容易被忽略的点:NextAuth的NEXTAUTH_URL在Docker等容器化部署中如果没设置,OAuth登录后的回调地址会变成http://localhost:3000。如果你组里有人通过线上域名访问,点了登录却又被弹回localhost,那基本就是这个变量的问题,别去怀疑中间件配置。

t3code从最初的一个临时脚本,变成了现在日常工作里真正在用的工具,每天打开浏览器第一件事就是看一眼任务状态。如果让我重新开始,我还是会先画Prisma的schema,再写tRPC的router,最后做页面——这个顺序在t3code里被验证是最高效的。这套组合真正值钱的不是它把五个工具拼在了一起,而是把类型安全坚持到了最后一公里。你在自己的项目里如果也打算上T3 Stack,不妨直接拿t3code当一个“已经踩过坑的参考实现”,然后在它的基础上长出你自己的业务逻辑。代码本身没什么神秘的,难的永远是边界条件如何处理,而这篇文章里讲的每一处坑,都是后来人可以用几分钟省掉一晚上的那种。

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

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

立即咨询