从半年前开始,我一直在维护一个叫t3code的个人全栈项目。这个项目本身并不复杂,业务上就是一个典型的“用户 - 内容 - 标签”管理后台,但底层选型非常聚焦:Next.js + TypeScript + tRPC + Prisma + Tailwind CSS,也就是社区里常说的 T3 Stack。今天想把这个项目从立项到上线过程中真正有价值的部分写出来,包括为什么我会把整套技术栈锁定在这几样东西上、tRPC 和 Prisma 搭配起来到底比传统 REST + 手写 ORM 好在哪里,以及部署上线时那些文档里查不到的经验。如果你正准备做一个中小规模的类型安全全栈应用,或者手里有一个已经选了 T3 技术栈但是还没跑顺的项目,这篇文章应该能帮你少走不少弯路。
1. 项目全貌:t3code 要解决的实际上是什么问题
在我最初构思 t3code 的时候,项目形态非常朴素:一个带登录、内容列表、标签筛选的管理后台。很多人会觉得这种系统随便用个 React + Express 甚至 Vue + Java 就能搞定,但真正动手后你会发现,核心工作量根本不在页面渲染上,而在数据流转的一致性和调试成本上。
1.1 项目的业务定位与核心模块
t3code 的定位是一个轻量级的内容管理平台,它允许用户创建文本片段、为片段打上标签、通过标签组合筛选内容,并支持简单的权限区分(普通用户只能管理自己的内容,管理员可进行全局审核)。项目上线后实际运行的模块大概有五个:
- 身份认证模块:基于 NextAuth.js 的账号密码登录,配合邮箱验证;
- 内容管理模块:文本片段的创建、编辑、删除、软删除;
- 标签系统:标签的自动补全、合并、统计;
- 检索与过滤:按关键词、标签、创建时间范围组合筛选;
- 后台审计:管理员查看内容变更记录。
这里有一个很多教程不会明说的事实:内容管理类应用最常见的性能瓶颈不在 SQL,而在前端重复发请求和不规范的状态同步。t3code 之所以选 tRPC,一个重要原因就是它能让前端像调用本地函数一样调用后端逻辑,从而在架构层面避免“为了拿一个字段拼一个接口地址,再为了同步状态写一堆 useEffect”的尴尬局面。
1.2 项目的目录结构与请求链路
我最终落地的目录结构如下(只保留了关键部分):
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ │ │ ├── db.ts │ │ └── auth.ts │ ├── app/ │ │ ├── (auth)/ │ │ ├── dashboard/ │ │ ├── snippets/ │ │ └── api/ │ └── styles/ ├── drizzle.config.ts ├── middleware.ts ├── next.config.mjs └── package.json这里面最关键的设计是src/server/api/routers目录,它集中了所有业务逻辑,而app目录下的页面组件只负责消费这些逻辑。在 t3code 中,一次完整的“创建内容并附带标签”的请求链路是这样的:
- 用户在表单里输入标题、正文,选择已有标签或创建新标签;
- React Hook Form 收集数据,交给 zod 做客户端校验;
- 校验通过后,调用
trpc.snippet.create.useMutation()——注意,这里没有拼 URL,没有写 fetch,也没有手动处理序列化; - tRPC 自动将参数传到服务端对应的
create函数中,Prisma 在事务里完成“插入 snippet + 关联标签”的操作; - 返回结果后,React Query 通过泛型自动推断出返回类型,前端拿到完完整整的 TypeScript 类型。
从第 3 步到第 5 步,全程没有任何手写的接口文档和手动的类型声明。这就是 t3code 这个项目最让我满意的地方。
2. 选型逻辑:我为什么把整套技术栈锁定在 T3 Stack
很多人问过我,t3code 只是一个中小型项目,用 T3 Stack 是不是杀鸡用牛刀?我的回答是:选择技术栈不能只看业务体量,要看你在维护这个项目时最痛苦的点是什么。
2.1 T3 Stack 的核心哲学:类型安全优先级最高
T3 Stack 不是一个框架,而是一套选型理念。它的发起人 Theo 曾反复强调一句话:你只需要选择那些能让你获得端到端类型安全的工具。
我对这句话的理解可以拆成三层:
第一层:前后端共享类型。传统 REST 架构里,前端为了知道后端返回的完整字段,可能需要打开 Swagger 文档,或者依赖后端同事口述。而在 T3 体系中,tRPC 的 router 一旦定义好,前端调用处就能实时出现对应的类型提示,字段改名或删除后,前端编译会直接报错,而不是上线后接口悄悄返回 undefined。
第二层:数据库模式与代码一致。Prisma Schema 是数据库结构的唯一事实来源。每次执行
prisma migrate之后,生成的 client 会同步更新类型。t3code 改过一次标签关联字段,Prisma 在迁移时自动帮我比对出了三个需要同步修改的前端引用。第三层:输入校验与业务规则一致。zod 的 schema 可以同时被表格校验和 tRPC 的输入解析复用。在 t3code 中,标签名称的“最长 32 字符,只能包含中英文和数字”规则只写了一份,前后端自动共同遵守。
2.2 与常见替代方案的对比
我不否认 MERN(MongoDB+Express+React+Node)或者 Spring Boot + Vue 依然有很多应用场景,但在 t3code 的具体诉求下,T3 方案的优势主要体现在下面这张表里:
| 对比维度 | t3code 实际方案(T3 Stack) | 传统前后端分离方案 |
|---|---|---|
| 类型安全链路 | 数据库到 UI 全链路类型推断 | 前后端各自维护类型,容易漂移 |
| 接口沟通成本 | 无接口文档,函数调用即接口 | 需要 OpenAPI / YApi 维护 |
| 联调耗时 | 几乎为零,编译期发现问题 | 运行时抓包才能发现问题 |
| 状态管理 | React Query 自动缓存/失效 | 需要手写 Redux Toolkit + thunk |
| 新手上手难度 | 需要理解 tRPC/Prisma 的约定 | 资料多,但心智负担分散 |
这并不意味着传统方案一无是处。如果项目需要对外开放大量第三方 API,或者团队里有多名后端专职开发,那 REST + 独立后端的模式依然更合适。t3code 是典型的内部工具型应用,API 只服务自己的前端,所以 T3 方案可以说是在“小团队、端到端类型安全”这个约束下的最优解。
2.3 哪些场景我不建议用 T3 Stack
这里必须说点反话,否则容易误导人。如果你遇到下面几种情况,不建议硬上 T3:
- 有移动端 App:tRPC 虽然支持 React Native,但 TypeScript 类型在跨端传输时的序列化桥接成本并不低;
- 需要多语言后端:如果团队同时写 Python 或 Go 服务,tRPC 只能在 Node 生态里自嗨,这就起不到统一类型的作用;
- 数据规模非常大:T3 Stack 并不限制你连 TiDB 或 PostgreSQL,但极复杂的报表查询还是更适合独立的查询服务(如 ClickHouse + 独立 API)。
t3code 恰恰避开了这些问题,它就是一个纯 Web + Node 后端的小而精项目,所以 T3 的一切便利我都能完整吃到。
3. t3code 的核心实现:从一行初始化命令到业务闭环
这一部分我打算按实际开发的顺序来讲,从脚手架初始化一直到把“创建内容 + 打标签 + 列表筛选”这条主链路跑通。每一步我都会附带关键代码和我在写的时候踩到过的细节。
3.1 初始化:create-t3-app 的参数取舍
T3 官方脚手架是create-t3-app,在 t3code 项目初始化时我执行的命令是:
npx create-t3-app@latest t3code交互式配置里我勾选了 Next.js、Tailwind CSS、tRPC、Prisma、NextAuth.js,没有选 Drizzle 和 Turborepo。这里有个关键取舍:当时 Drizzle 已经可用于生产环境,但 c3 默认链路里 Prisma 与 tRPC 的类型衔接文档更全,团队没有数据库直连的强需求,所以我保守地选了 Prisma。
初始化完成后,package.json的关键依赖如下:
{ "dependencies": { "@t3-oss/env-nextjs": "^0.10.1", "@trpc/client": "^10.45.0", "@trpc/next": "^10.45.0", "@trpc/react-query": "^10.45.0", "@trpc/server": "^10.45.0", "next": "^14.2.0", "next-auth": "^4.24.0", "@prisma/client": "^5.14.0", "zod": "^3.23.0" }, "devDependencies": { "prisma": "^5.14.0", "tailwindcss": "^3.4.0", "@types/react": "^18.2.0" } }3.2 数据模型设计:Prisma Schema 的落地调整
初始的prisma/schema.prisma我按“用户 - 片段 - 标签”三个核心模型设计,关系如下:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] snippets Snippet[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String userId String user User @relation(fields: [userId], references: [id]) snippets SnippetTag[] createdAt DateTime @default(now()) @@unique([userId, name]) // 同一用户下标签名唯一 } model Snippet { id String @id @default(cuid()) title String @db.VarChar(200) content String @db.Text userId String user User @relation(fields: [userId], references: [id]) tags SnippetTag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? } model SnippetTag { snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) snippetId String tagId String @@id([snippetId, tagId]) }这套模型有两个细节值得说明:
Tag上的@@unique([userId, name])是为了防止一个用户之下出现重名标签。实际使用中,标签补全组件需要根据用户维度查询,这个唯一约束避免了并发请求下出现的重复标签;SnippetTag这个中间表里两个外键都设置了onDelete: Cascade。内容被删除时,关联标签关系会自动清理,省得我写一堆手动清理逻辑。
跑迁移命令:
npx prisma migrate dev --name init npx prisma generate3.3 tRPC Router 的设计:把业务规则放在靠近数据层的位置
t3code 的 router 不是简单地把 CRUD 映射出来,而是把业务约束写在了 tRPC 的input校验和procedure内部。看下面这段片段相关的核心代码:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1, "标题不能为空").max(200), content: z.string().min(1, "内容不能为空"), tags: z.array(z.string().min(1).max(32)).max(10), }) ) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; return ctx.db.$transaction(async (tx) => { const snippet = await tx.snippet.create({ data: { title: input.title, content: input.content, userId, }, }); for (const tagName of input.tags) { const tag = await tx.tag.upsert({ where: { userId_name: { userId, name: tagName }, }, create: { name: tagName, userId }, update: {}, }); await tx.snippetTag.create({ data: { snippetId: snippet.id, tagId: tag.id, }, }); } return snippet; }); }), list: protectedProcedure .input( z.object({ keyword: z.string().optional(), tagIds: z.array(z.string()).optional(), page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) => { const where = { userId: ctx.session.user.id }; if (input.keyword) { where.keyword = { OR: [ { title: { contains: input.keyword } }, { content: { contains: input.keyword } }, ], }; } if (input.tagIds && input.tagIds.length > 0) { where.tags = { some: { tagId: { in: input.tagIds } }, }; } const [items, total] = await Promise.all([ ctx.db.snippet.findMany({ where, include: { tags: { include: { tag: true } }, }, orderBy: { createdAt: "desc" }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }), ctx.db.snippet.count({ where }), ]); return { items, total }; }), });这段代码里有两个关键点。第一,校验逻辑与业务逻辑放在同一个函数体前后,代码的阅读者不用跳到另一个文件查看“这个接口能传什么参数”。第二,result的类型是 Prisma 根据include自动推导的,前端拿到的items[0].tags[0].tag.name会有完整的类型提示,不会出现“明明数据库里有这个字段,前端却只能当成 any”的窘境。
3.4 React Query + tRPC 的前端消费方式
t3code 的前端数据消费基本没有手写 fetch。以“创建后自动刷新列表”这个最常见的动作为例,核心代码如下:
import { api } from "~/utils/api"; function SnippetForm() { const utils = api.useUtils(); const createMutation = api.snippet.create.useMutation({ onSuccess: async () => { await utils.snippet.list.invalidate(); }, }); const createSnippet = (values: { title: string; content: string; tags: string[] }) => { createMutation.mutate(values, { onError: (error) => { // tRPC 会把 zod 校验的错误信息统一带到这里的 error.message console.error(error.message); }, }); }; // ... 表单 UI }utils.snippet.list.invalidate()是 React Query 配合 tRPC 生成的“缓存失效函数”。创建成功后调用它,列表会自动重新请求,不需要手写setState或refetch。这套机制在小项目里看似不起眼,但当列表、详情、统计图分布在不同页面时,统一失效比逐一手动同步状态要省心太多。
3.5 NextAuth 登录接入时的振荡期
t3code 的登录逻辑选的 NextAuth Credentials Provider,配合 Prisma 的 User 模型。初始接 NextAuth 时我遇到了一个典型问题:登录接口总是报Callback route error,明明数据库里有用户。
排查过程如下:
- 打开终端看 Next.js 服务端日志,发现
PrismaClientKnownRequestError的 stack 在auth.ts里拉起 Prisma Client 失败; - 检查原因,开发模式下
next-auth与prisma使用了两个不同的 Prisma Client 实例,导致全局单例没有生效; - 解决方案是创建
src/server/db.ts中标准的 Prisma 全局单例,并确保 NextAuth 的配置文件中引用同一个实例。
最终src/server/auth.ts中的关键配置如下:
import NextAuth, { type NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { prisma } from "~/server/db"; import { z } from "zod"; export const authOptions: NextAuthOptions = { session: { strategy: "jwt" }, providers: [ CredentialsProvider({ name: "账号密码", credentials: { email: { label: "邮箱", type: "email" }, password: { label: "密码", type: "password" }, }, async authorize(credentials) { const parsed = z .object({ email: z.string().email(), password: z.string().min(6) }) .safeParse(credentials); if (!parsed.success) return null; const user = await prisma.user.findUnique({ where: { email: parsed.data.email }, }); if (!user) return null; // 这里应该用 bcrypt 比较哈希后的密码 const isValid = await comparePassword(parsed.data.password, user.passwordHash); if (!isValid) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ], callbacks: { jwt({ token, user }) { if (user) token.id = user.id; return token; }, session({ session, token }) { if (session.user) session.user.id = token.id as string; return session; }, }, pages: { signIn: "/login" }, };注意:“Callback route error”这个错误信息本身非常误导人——它并不一定是 callback 写错了,多数情况下是数据库连接或 Prisma Client 初始化出了问题。如果以后再遇到 NextAuth 的神秘报错,先看服务端完整日志而不是浏览器里的提示。
4. 部署与上线:Docker 化、数据库迁移与多环境配置
开发和测试没问题之后,部署又是一个完全不同的世界。t3code 的部署我采用的是 Docker Compose 跑单机应用,前端和 Node 服务由同一个 Next.js 进程承担,数据库使用托管 PostgreSQL。这套架构对中小型项目来说足够稳。
4.1 Dockerfile 的构造与镜像瘦身
t3code 的 Dockerfile 采用多阶段构建,分为依赖安装、编译和运行三个阶段:
FROM node:20-alpine AS deps RUN apk add --no-cache libc6-compat WORKDIR /app COPY package.json pnpm-lock.yaml* ./ RUN pnpm install --frozen-lockfile FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN npx prisma generate RUN pnpm build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma USER nextjs EXPOSE 3000 ENV PORT=3000 CMD ["node", "server.js"]这里有一个坑特别值得拿出来说:libc6-compat是必装的。Node 20 的 Alpine 镜像里,如果不装这个兼容库,Prisma Client 依赖的原生引擎初始化时会直接崩,报错类似于 “Cannot find module @prisma/engines”。我第一次构建时没加,镜像能 build 成功但容器一启动就退出,最后查日志才发现是这个库的问题。
4.2 数据库迁移流程:不要在启动容器时自动跑 migrate
t3code 部署方案里我明确禁止在容器启动命令里执行prisma migrate deploy。原因是多实例部署时,两个副本同时跑迁移会造成数据库锁竞争。我的流程是:
- 本地执行
prisma migrate dev,生成 SQL 迁移文件并提交到仓库; - CI/CD 流程中,在镜像构建完成后、容器滚动更新前,单独执行
npx prisma migrate deploy --schema prisma/schema.prisma; - 迁移成功后再推新副本,失败则回滚旧镜像。
实际执行的命令示例:
npx prisma migrate deploy npx prisma generatemigrate deploy与migrate dev最大的区别是:它不会检测状态漂移,也不会要求重置数据库,只会按顺序执行未应用的迁移记录。这在生产环境是唯一正确的选择。
4.3 环境变量管理的最佳实践
t3code 用了@t3-oss/env-nextjs来校验环境变量。生产部署时,变量分为两类:
| 类别 | 变量示例 | 说明 |
|---|---|---|
| 必须项 | DATABASE_URL、NEXTAUTH_URL、NEXTAUTH_SECRET | 缺失时应用启动直接报错 |
| 可选项 | SMTP_HOST、SMTP_PORT、REDIS_URL | 缺失时应用降级或跳过对应功能 |
在 Docker Compose 文件里,我倾向于用一个.env.production文件导入。特别注意NEXTAUTH_URL必须与外部访问的完整域名一致(包括 HTTPS 前缀),否则 NextAuth 回调地址会拼出http://的链接,登录跳转会一直失败。
我当时在这个问题上至少花了一个小时:域名是https://t3code.xxx.com,但.env里写的是NEXTAUTH_URL=http://localhost:3000,结果容器内登录始终重定向到容器的 3000 端口。这种问题不会报错,只会表现为“登录成功后页面打不开”,非常隐蔽。
4.4 生产环境实测:内存占用与冷启动优化
t3code 的生产容器配置是 1 核 1G 内存,Node 进程在NODE_ENV=production下启动后的干净占用大约 200MB。不过 Prisma 查询第一次命中的时候,连接池会建立多个连接,瞬时内存会冲到 400MB 以上。如果在低配服务器上用默认配置,很容易触发 OOM。
我给 t3code 的 Prisma 连接池做了两个调整:
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: ["warn", "error"], }); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = prisma; }同时,数据库连接串里加上连接池参数:
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=5&pool_timeout=10"connection_limit=5对 1 核 1G 的实例是安全值,生产实测并发 50 以内的请求完全够用。压测时把连接池调到 20,反而因为线程切换导致单请求 RT 变高,走了不少弯路才意识到连接数不是越大越好。
5. 我踩过的坑与沉淀下来的可复用经验
这一节写的是 t3code 开发过程中最值钱的几条经验。很多坑我都是查了一个晚上才搞明白,写出来能帮后来人直接跨过去。
5.1 tRPC 类型推导失效时的排查思路
t3code 中间有一段时间,前端调用api.snippet.list时一直提示类型报错:“Property 'items' does not exist on type 'never'”,但列表数据实际上能正常返回。这个报错看起来是类型问题,但根因不在 tRPC,而是在 zod schema 里。
后来我发现,input对象里我把page定义成了z.number().int().min(1).default(1),但是前端调用时传进来的类型是number | undefined,zod 的 default 只对undefined生效,并不会改变 TypeScript 编译时的类型。于是 tRPC 在解析输入后,推导出的类型变成了{ page: number | undefined }。在 Prisma 里skip和take接收了可能为undefined的值,导致后续的返回类型推不到items。
解决办法是给前端调用侧也声明完整的输入类型,或者在 zod schema 外面再包一层z.input与z.output的显式类型。最彻底的办法是不要依赖 zod 的default来改变 TypeScript 类型,而是用z.preprocess或在前端显式赋值默认值。
排查这类问题我总结出一个步骤顺序:
- 先看 zod 输入解析后的类型:在 tRPC 的 query/mutation 内部第一行打印
input,确认实际传入值; - 确认 type-level 与 runtime 是否一致:
tsc --noEmit或者在 IDE 里悬停变量看推断类型; - 尝试移除 input 中的 default,改为在
.query()调用处传默认参数,看类型是否恢复; - 如果还没解决,清掉
.next缓存并重启 dev server,有时 Next.js 的类型缓存也会导致假性错误。
5.2 Prisma 的 N+1 查询与 include 的正确配合
在 t3code 列表页,每条 Snippet 都要关联展示它的标签。我最开始的查询是:
const snippets = await prisma.snippet.findMany({ where: { userId } }); for (const snippet of snippets) { const tags = await prisma.snippetTag.findMany({ where: { snippetId: snippet.id } }); // ... }这就是典型的 N+1 问题。数据量为 20 条时感觉不到,当列表翻到第 5 页、每页 50 条时,一条列表请求会触发 50 次额外的查询,数据库连接池很容易被打满。
改成include之后:
const snippets = await prisma.snippet.findMany({ where: { userId }, include: { tags: { include: { tag: true }, }, }, });如果查询条件对标签有过滤需求,比如“只查包含某个标签的内容”,不要用include后再用 JS 过滤,而是用where: { tags: { some: { tagId: { in: selectedTagIds } } } },让过滤在 SQL 层完成。
实测数据:t3code 在 1000 条数据、每页 20 条的场景下,N+1 版本接口耗时约 300ms,改成include后稳定降到 40ms 左右。对于中小型管理后台,这个优化比加缓存收益大得多。
5.3 软删除设计要谨慎,别等到上线后改表
t3code 的内容删除我一开始用的是物理删除。后来产品角度希望用户能在 7 天内恢复误删内容,于是引入deletedAt字段,做软删除。
这里有个容易埋雷的细节:加了deletedAt之后,旧查询如果不加where: { deletedAt: null },会把已删除的数据也查出来。Prisma 本身不提供全局的软删除过滤,每一步涉及列表、统计、标签关联查询的代码都要手动追加这个条件。我因为漏加条件,管理员后台曾出现过“已删除内容依然显示在统计数字里”的问题。
解决方式是这样的:不要试图用 Prisma middleware 自动拼deletedAt: null——我试过一次,结果所有update操作也被 middleware 干扰,导致恢复删除的接口再也无法把deletedAt置回 null。后来我老老实实把所有 handler 里的where抽成一个公共函数:
const snippetWhere = (userId: string, extra: Prisma.SnippetWhereInput = {}) => ({ userId, deletedAt: null, ...extra, });这个函数虽然简单,但能保证团队所有人在写列表和统计时不会漏掉软删除条件。软删除是业务逻辑的延续,不是数据库的默认行为,必须自己控制好每一条查询路径。
5.4 Docker 容器里跑 Prisma 的时区与迁移问题
生产环境里我遇到过一个问题:定时任务生成的统计报表日期总是差 8 小时。查了半天发现是数据库连接的timezone配置导致 Prisma 写入的时间与 PostgreSQL 的TIMESTAMP WITH TIME ZONE不一致。
解决方式是统一时区:
const prisma = new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL + "&timezone=Asia/Shanghai", }, }, });同时,Docker 容器的系统时区也要保持一致:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone如果这两处不一致,你会在日志里看到createdAt和updatedAt的值差 8 小时,但是数据实际上在数据库里存的又是 UTC,排查起来极其痛苦。项目里的所有时间戳展示,最后我都统一在服务端转成 ISO 字符串,前端用 dayjs 按用户时区渲染,避免在不同时区出现“同一条数据时间不同”的尴尬。
6. 给准备上手 T3 Stack 的人的最后建议
t3code 从初始化到部署上线,完整周期大约两个周末。去掉睡眠时间,真正动手的时长在 20 小时左右。这个效率我并不意外,因为 T3 技术栈最大的优势不是“写代码快”,而是“改代码时不用猜”。每次我因为业务调整去动数据库 schema、去改某个路由参数、去重构一个组件,TypeScript 编译器都能在第一时间告诉我哪里漏了、哪里多了、哪里类型对不齐。这比任何单元测试都能更快地守住底线。t3code 现在仍然在跑着,日常维护主要集中在内容数据的清理和标签合并规则的调整。如果你正在考虑做一个类似的个人项目或内部系统,我建议你把 T3 Stack 放进选型对比表里,重点观察它是否能帮你把“类型安全”这个好处真正吃到嘴。用习惯了之后,再回头写 REST + 手动类型维护,你会明显感觉到哪里不对劲。