“t3code”这个名字,如果只看字母,很多人会以为是个开源库或者某个技术框架的代号。但我自己起这个项目名的时候,想得很简单:t3 指的是 T3 Stack 这条技术路线,code 就是代码。一个内部任务管理工具,用一套技术栈从头搭到尾,前后端、数据库、认证、部署全走一遍,项目代号就叫 t3code。
这个项目做完之后,我最大的感受是:T3 Stack 这套东西,表面上只是把 Next.js、TypeScript、Tailwind、tRPC 几个工具拼在一起,但真正跑起来之后,它改变的是写代码的方式。尤其是 tRPC 带来的类型安全调用链,让我这种习惯了“前端写接口请求、后端定义接口文档”的老全栈,第一次体会到“整个项目就是一个函数调用”的感觉。这篇文章就是围绕 t3code 这个项目,把我从技术选型到部署上线的完整过程、踩过的坑、以及最后沉淀下来的一套实操模板,都梳理出来,希望能给正在考虑或已经上手 T3 技术栈的朋友一些参考。
1. t3code 这个项目到底做了什么
很多人一听到全栈项目,第一反应就是电商、博客、内容管理这类系统。t3code 其实更像是一个小团队内部的“任务台账”工具。它的核心场景很简单:每天给团队成员分配任务,记录进度,自动汇总统计。这种系统听起来不复杂,但要把登录、权限、数据维护、统计展示、部署维护全部打通,涉及的环节一点也不少。
1.1 项目定位与核心诉求
t3code 的核心诉求,我用一句话概括:用最少的技术栈,把一条完整的数据链路跑通。所谓完整链路,是指从“用户在浏览器输入数据”到“数据落库”,再到“查询聚合展示回页面”的整个过程,中间不破坏 TypeScript 类型,不手写重复代码,不让前后端各自维护一套契约。
这种需求在真实工作中非常常见。小团队内部经常有这种“说起来很轻、做起来很碎”的工具型系统,如果采用传统的“前端 React + 后端 Express + 数据库 MySQL + 联调接口文档”模式,开发周期会拉得很长。t3code 选 T3 Stack 的目的,就是用更少的代码完成同样的事,把精力集中在业务本身。
1.2 适合谁来参考
如果你符合下面几种情况,t3code 的落地经验会比较有参考价值:一是想从写 Demo 过渡到完整全栈项目的开发者;二是团队里需要快速交付内部工具,但不想花太多时间维护前后端接口;三是已经在用 Next.js,但对 tRPC、Prisma 的串联方式还比较陌生;四是想了解一套“数据库 → 接口 → 页面”类型安全链路如何形成的后端或前端工程师。
如果你要做的是面向海量用户、需要超高并发和复杂权限体系的大型系统,T3 Stack 可能不是最优解,因为 tRPC 的强类型约束在超大项目里也会带来一些动态扩展成本。但作为中小型项目、内部系统和快速迭代产品,t3code 这种技术组合的性价比非常高。
1.3 项目的最终形态
t3code 做出来的最终形态,大致是这样:用户可以通过邮箱账号登录系统,看到自己的任务列表,管理员可以创建任务、指派给成员、修改状态和优先级;每个用户能看到自己的待办、进行中、已完成数量,管理员还能看到全团队的任务分布统计。整个系统部署在一台云服务器上,用 Docker 运行,数据库使用 PostgreSQL,反向代理用 Nginx 处理 HTTPS。
这个最终形态并不复杂,但它是完整自洽的。所有功能都围绕真实工作流设计,没有为了展示技术而硬塞模块。这也是我在做 t3code 时给自己定的一个原则:功能可以少,链路不能断。
2. 技术选型拆解:为什么是这套组合
2.1 Next.js 作为全栈骨架的选择逻辑
很多项目在做全栈选型时,会在 Vite + React + Express、Nuxt、Next.js、以及纯后端模板渲染之间犹豫。我自己这些年都试过,最后在 t3code 里选了 Next.js,不是因为它功能最多,而是因为它把“前端应用”和“后端 API”放进了同一个应用进程,这对中小型项目太关键了。
Next.js 的 Pages Router 或 App Router 都能提供服务端渲染能力,更重要的是它可以无缝承载 API Route。虽然 t3code 没有大量依赖 API Route,因为 tRPC 已经接管了数据接口,但 Next.js 提供的页面路由、中间件、环境变量管理等能力,都是后面非常顺手的基建。相比 Vite + React 需要自己搭路由、自己处理 SSR、自己起 Express,Next.js 在这些地方省掉了一个量级的体力活。
2.2 tRPC 的价值:把接口层变成函数调用
t3code 里最让我觉得“上手即回不去”的技术,就是 tRPC。传统的全栈开发里,前端要调用后端接口,需要经过这么几步:后端定义接口路径、编写业务逻辑、手动写返回类型;前端根据文档或类型定义创建请求函数;联调时发现字段对不上,再去两边改。这个过程每个项目都要重复一遍,而且类型定义一旦漂移,排查起来非常头疼。
tRPC 的思路完全不同。它在服务端定义一个 router 下的 procedure,前端通过类似trpc.task.getAll.useQuery()的方式直接调用。这个调用像本地函数,但实际是 HTTP 请求。关键在于:函数入参和返回值的 TypeScript 类型,是直接从服务端推导到客户端的。这意味着服务端改了返回结构,前端代码立刻会报类型错误,根本不可能出现“文档说返回 a,实际返回 b”的静默问题。
拿 t3code 的任务列表举例,服务端定义:
export const taskRouter = router({ getAll: publicProcedure.input(z.object({ userId: z.string().optional() })).query(async ({ ctx, input }) => { return ctx.prisma.task.findMany({ where: input.userId ? { assigneeId: input.userId } : {}, orderBy: { createdAt: 'desc' }, }) }), })前端组件里直接:
const { data: tasks } = trpc.task.getAll.useQuery({ userId: session?.user?.id })只要taskRouter的返回类型变了,前端这里就会在编辑器里直接报红。这种体验在实际项目里的价值,比想象中大得多,尤其是项目中期频繁改字段的时候,类型错误直接在编译期帮我把绝大多数问题拦住了。
2.3 类型安全链路中的其他关键拼图
tRPC 单独存在还不够,真正让类型安全“贯穿到底”的,是和 Prisma、Zod、TypeScript 三者的配合。
Prisma 是数据库访问层,它的价值在于通过 schema 文件定义表结构,然后自动生成完整的 TypeScript 类型。t3code 里的Task、User、Session等模型,在数据库里是什么样,在代码里就是什么样,tRPC 的返回类型又直接复用这些 Prisma 类型,这条链路从一开始就不会出现字段名不一致的问题。
Zod 则负责输入的校验。tRPC 的input接受一个 Zod schema,比如创建任务时要求标题非空、优先级只能是 LOW/MEDIUM/HIGH,这些规则写在taskRouter内部。客户端调用时,如果传了非法参数,tRPC 会直接返回校验错误,甚至在前端提交前就能用同样的 schema 做一次校验。
Tailwind 在这个技术栈里看起来最不起眼,但它在 t3code 中的实际作用很实在:当我需要在几天内完成十几个页面的布局和状态样式时,Tailwind 的原子类写法让我不用切换 CSS 文件,也不用为组件命名发愁。加上 Tailwind 的暗色模式变量和响应式工具类,后期调整界面非常快。
3. 从零搭建 t3code:初始化与配置细节
3.1 用 create-t3-app 初始化项目
t3code 的项目初始化,我直接使用了社区官方脚手架create-t3-app。执行命令如下:
npx create-t3-app@latest t3code初始化过程中,脚手架会问你要不要启用 tRPC、Prisma、NextAuth、Tailwind 等模块。我的建议是全部勾选,因为 t3code 要的就是一条完整链路,如果缺了认证或者数据库访问层,后面再手动接反而更浪费时间。脚手架会自动配置好目录结构:
prisma/ schema.prisma src/ server/ api/ routers/ root.ts auth.ts db.ts trpc/ server.ts client.ts app/ layout.tsx page.tsx api/ auth/ [...nextauth]/ route.ts trpc/ route.ts这个结构第一次看会觉得层级有点深,但用顺了之后会发现它把“服务端逻辑”“TRPC 路由”“前端页面”分得很清楚。src/server/db.ts是 Prisma 客户端实例,src/server/auth.ts是认证配置,src/trpc下是 tRPC 的上下文创建和客户端封装,业务路由全部放在src/server/api/routers里。
3.2 数据库设计与 Prisma Schema
t3code 采用的数据库是 PostgreSQL。为什么不用 SQLite?因为项目从一开始就计划部署到云服务器上,PostgreSQL 在并发写入、数据完整性、以及后续可能的分析查询方面都更稳。SQLite 做本地验证可以,但部署上生产环境的话,还是 Postgres 用着安心。
数据库表设计上,除了 NextAuth 需要的User、Account、Session、VerificationToken四张标准表之外,t3code 的核心业务表是Task表。Schema 里关键的模型定义如下:
model Task { id String @id @default(cuid()) title String description String? status TaskStatus @default(TODO) priority TaskPriority @default(MEDIUM) assigneeId String? assignee User? @relation(fields: [assigneeId], references: [id]) dueDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum TaskStatus { TODO IN_PROGRESS DONE }在设计时,我把assigneeId设为可空字符串关联到 User 表,这样可以支持“任务先建好,后续再分配人”的流程。任务状态用了枚举而不是字符串,好处是数据库层就已经限制了取值范围,应用层和页面层的类型也会自动同步,不会出现"done"和"DONE"这种字符串大小写不一致的尴尬。
3.3 配置 NextAuth 与受保护路由
认证模块是 t3code 的入口,所有业务数据都要求登录后可见。NextAuth 在 create-t3-app 中已经预置了基础配置,我需要做的只是适配自己的 Provider。为了减少外部依赖,我用了 Credentials Provider + 邮箱密码登录的方式,密码通过 bcrypt 加密后存储在 User 模型里。
配置核心部分如下:
export const authOptions: NextAuthOptions = { providers: [ CredentialsProvider({ name: 'credentials', credentials: { email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' }, }, async authorize(credentials) { const user = await db.user.findUnique({ where: { email: credentials?.email }, }) if (!user) return null const isValid = await bcrypt.compare(credentials?.password ?? '', user.passwordHash) if (!isValid) return null return { id: user.id, email: user.email, name: user.name } }, }), ], session: { strategy: 'jwt' }, }受保护路由我用了两层策略:页面层在 layout 或服务端组件里判断 session,API 层在 tRPC 的 protectedProcedure 校验。实际项目中,处理敏感数据必须用 protectedProcedure,不能只靠页面隐藏按钮,否则细心的人直接调接口就能绕过权限。
4. t3code 核心功能实现:从任务到统计
4.1 任务 CRUD 与类型安全调用链
任务的增删改查是 t3code 的主干功能。我按照业务把 Task 相关的操作全部组织在taskRouter里。这个 router 同时包含了列表查询、创建、更新、删除四个 procedure,删除操作我使用了 tRPC 的protectedProcedure,只允许登录用户执行。
export const taskRouter = router({ getAll: protectedProcedure .input(z.object({ status: z.nativeEnum(TaskStatus).optional() })) .query(async ({ ctx }) => { return ctx.prisma.task.findMany({ where: ctx.session?.user?.id ? { assigneeId: ctx.session.user.id } : {}, include: { assignee: { select: { name: true, email: true } } }, orderBy: { createdAt: 'desc' }, }) }), create: protectedProcedure .input( z.object({ title: z.string().min(1, '标题不能为空'), description: z.string().optional(), priority: z.nativeEnum(TaskPriority).default('MEDIUM'), dueDate: z.date().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.task.create({ data: { title: input.title, description: input.description ?? null, priority: input.priority, dueDate: input.dueDate ?? null, assigneeId: ctx.session.user.id, }, }) }), })在页面上,调用链非常直观。创建任务用useMutation:
const utils = trpc.useUtils() const createTask = trpc.task.create.useMutation({ onSuccess: () => utils.task.getAll.invalidate(), })这里的invalidate()是 React Query 提供的缓存失效方法。任务创建成功后,自动重新拉取任务列表,不需要手动刷新页面。整个过程中,前端代码和后端逻辑的类型是共享的,比如input.priority的值范围,编辑器会直接提示只能是 LOW、MEDIUM、HIGH。
4.2 统计面板与聚合查询
t3code 里有一个数据看板,用来展示每个人的任务状态分布。如果所有统计都靠前端遍历再算,页面数据量一大就会卡顿。更好的做法是把聚合逻辑放在服务端,只返回统计结果。
我在 taskRouter 里加了一个getStatisticsprocedure:
getStatistics: protectedProcedure.query(async ({ ctx }) => { const user = ctx.session.user const tasks = await ctx.prisma.task.findMany({ where: { assigneeId: user.id }, }) const statusCounts = tasks.reduce( (acc, task) => { acc[task.status] = (acc[task.status] ?? 0) + 1 return acc }, {} as Record<TaskStatus, number> ) const total = tasks.length const doneRate = total === 0 ? 0 : Math.round((statusCounts.DONE / total) * 100) return { statusCounts, total, doneRate } })这个实现虽然简单,但它体现了一个重要原则:不要在多个客户端页面里各算各的,聚合逻辑放在服务端,前端只管展示。后续如果要做更复杂的查询,比如按优先级分组、按日期筛选,也能在这个 procedure 里继续扩展,而不会影响前端调用方式。前端拿到doneRate后,我直接用简单的进度条组件展示,没有引入重量级图表库。
4.3 表单校验与错误处理
t3code 的表单页面没有引入 react-hook-form,因为这些表单字段不多,直接用受控组件加 Zod 校验就够了。关键点在于:客户端和服务端共用同一个 Zod schema,保证校验规则完全一致。
我把创建任务的输入 schema 单独提取到一个变量里:
const createTaskInput = z.object({ title: z.string().min(1, '任务名称不能为空'), description: z.string().max(500, '描述最多 500 字').optional(), priority: z.nativeEnum(TaskPriority).default('MEDIUM'), dueDate: z.date().optional(), })服务端在taskRouter.create的input中使用这个 schema,客户端提交时也用同样的 schema 先safeParse一次。这样用户在填完表单后,点击提交那一刻就能看到错误信息,而不是等到网络请求回来才报错。错误提示显示在按钮下方红色小字,体验很干净。
这里我的经验是,Zod 的错误文案不要偷懒只写默认英文信息,尽量在 schema 里用中文描述清楚,比如“标题不能为空”“优先级只能从三种值中选择”。因为这个文案会同时出现在前后端,一份投入、两头收益。
5. 部署上线与性能优化实录
5.1 部署方案选择
t3code 的部署,我最后选的是 Docker + Nginx 自托管在云服务器上,而不是托管平台一键部署。原因主要有两个:一是项目需要长期运行,自托管在成本和控制力上更灵活;二是 Docker 镜像一旦打好,从测试环境到生产环境的迁移成本非常低。
部署结构是这样的:
用户请求 -> Nginx(443/HTTPS) -> Next.js Docker 容器(3000) -> PostgreSQL 容器(5432)最前面用 Nginx 处理 HTTPS 证书和反向代理,Next.js 在容器内监听 3000 端口,PostgreSQL 在另一个容器里运行。这个架构没有引入 K8s 之类复杂的工具,对于一个内部工具系统来说,简单、可维护才是第一位的。
5.2 环境变量配置清单
部署时最容易出问题的就是环境变量。t3code 在启动时必须正确设置以下环境变量,我整理了一张清单,每次部署前都会检查一遍:
| 变量名 | 作用 | 注意事项 |
|---|---|---|
| DATABASE_URL | 连接 PostgreSQL | 格式:postgresql://user:pass@host:5432/db |
| NEXTAUTH_SECRET | 会话加密密钥 | 生产环境必须改成随机长字符串 |
| NEXTAUTH_URL | 应用对外地址 | 必须是 https 前缀,不能带路径 |
| NEXT_PUBLIC_APP_NAME | 前端展示的应用名 | 以 NEXT_PUBLIC_ 开头才会注入到客户端 |
特别注意NEXTAUTH_SECRET,默认值是 create-t3-app 生成的示例密钥,如果直接拿去生产环境部署,登录 session 的安全性很成问题。我建议用openssl rand -base64 32生成一个新的随机串。
5.3 性能优化:避免 N+1 查询与合理使用缓存
t3code 数据量级不大,但我在开发中还是预判了几个性能隐患。最大的一个是列表查询时如果每次都单独查每个任务的 Assignee 信息,就会出现 N+1 查询问题。Prisma 的include或select可以一次性 join 出关联数据,我上面的getAll里已经用了include: { assignee: ... }来避免这个问题。
另一个优化是 React Query 的缓存策略。t3code 里任务列表默认缓存 30 秒,这意味着用户在 30 秒内切换页面、返回列表时,数据不会重新请求,页面响应非常快。当用户做了增删改操作,再通过invalidate()主动刷新。这个策略兼顾了实时性和性能,是我实操下来觉得平衡得很好的一种模式。
6. 常见问题与排查技巧实录
6.1 部署和开发中的高频问题
我记录了 t3code 开发过程中最有代表性的几个问题,做成了一张速查表:
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 登录后回调地址不匹配 | NEXTAUTH_URL 没正确设置 | 检查环境变量,是否包含 http/https 前缀 |
| 页面显示 “Hydration failed” | 服务端和客户端渲染内容不一致 | 检查是否用了 Math.random、Date.now 等不稳定的值 |
| tRPC 类型报错 | 服务端 procedure input 类型和前端不一致 | 检查是否改了 schema 后没有重新生成 Prisma Client |
| 数据库连不上 | DATABASE_URL 错误或容器未启动 | 在服务器上手动执行 prisma db push 验证连接 |
| Prisma Client 版本漂移 | 更新 schema 后未重新构建 | 执行npx prisma generate后再启动 |
矩阵里Hydration failed是 Next.js 开发时比较常见的问题。t3code 里曾经有一个倒计时标签,我直接用了Date.now()计算剩余时间,导致服务端和客户端渲染结果不同。正确的做法是用useEffect在客户端挂载后再更新状态,确保首屏两端一致。
6.2 团队协作中的目录与命名约定
虽然我自己开发 t3code,但为了后续可以交给同事维护,我在代码组织上还是定了一些简单的约定:
- 所有 tRPC router 放
src/server/api/routers,按业务分文件,比如task.ts、user.ts - 页面组件放
src/app/(dashboard)这类路由分组里,与业务页面强相关 - 公共类型定义从
@prisma/client和src/server/api/routers导出,尽量不手写重复类型 - 前端调用 tRPC 时统一从
src/trpc/client.ts导入trpc,不直接在组件里 new 一个客户端
这些约定不需要文档写得特别复杂,约定越简单越容易被遵守。
6.3 从 t3code 沉淀下来的一套快速启动模板
做到项目后期,我发现 t3code 本身已经成为一个很好的模板项目:只要把schema.prisma改一改,把taskRouter换成自己的业务 router,剩下的认证、部署、样式体系全部可以直接复用。
如果下次再有人让我快速交付一个内部系统,我的做法是直接把 t3code 的目录结构和这套配置拿来当起点,不重新走一遍技术选型。这也是为什么我会说,T3 Stack 最适合的场景是“快速起一个有数据、有登录、有界面、能上线的完整应用”。
我个人在实际操作中的一点体会是:技术栈的复杂度要服务于项目规模,t3code 用到的这些工具没有一个是多余的,它们在需要强类型、快交付、易维护的场景下形成了协同效应。如果你正准备起一个全栈项目,不妨把这里面的思路当作参考,从一个最小的类型安全链路开始,跑通了再去扩展功能,这个顺序比一上来就铺开十几个模块要稳得多。