☰
T3 Stack实战:用Next.js、tRPC和Prisma构建全栈类型安全应用
2026/10/26 8:44:09 网站建设 项目流程

前段时间手头负责了一个内部项目,代号就叫 t3code。名字起得随意,其实含义很直白:用 T3 技术栈做的代码管理工具。项目本身不算复杂——一个给开发团队用的代码片段与最佳实践管理平台,支持发布片段、标签检索、打分评价、管理员审核。但真正有意思的是整个过程的技术决策:我们从一开始就定下"TypeScript 贯通全栈"的基调,最终落在了 Next.js + tRPC + Prisma + Tailwind 这套组合上,前后从搭建到上线花了六周。这篇文章就把完整的选型逻辑、落地细节、性能优化和踩坑记录都整理出来,给正在评估 T3 Stack,或者准备用这套组合做内部工具、中后台项目的朋友一个真实参考。

1. t3code 是什么:项目背景与要解决的问题

1.1 团队代码知识散落的真实痛点

t3code 这个项目的起因,其实是所有研发团队都会遇到的一个老大难问题:代码知识散落得到处都是。我们团队日常的片段、模板、踩坑记录分布在聊天记录、个人笔记、共享文档和一堆没人维护的 wiki 里。新同学入职想查"公司统一的 axios 封装怎么写的",要在三个平台翻二十分钟;老同学写相似功能时,经常凭记忆重写一遍,风格和细节对不上。

所以 t3code 要解决的,就是把"代码片段和使用场景"沉淀成一个可检索、可复用、可讨论的集中地。核心功能很聚焦:

  • 用户登录后发布代码片段,支持多语言、多标签
  • 按标签、语言、关键词检索,支持分页加载
  • 团队成员可以对片段打分、评论,高质量内容会浮上来
  • 管理员负责审核、归档和删除低质量内容

单看功能量级,这不算一个高难度项目。但我们对它有四个硬性约束:第一,前后端所有数据类型必须共享,绝不允许接口文档和代码脱节;第二,数据库模型改动时要第一时间暴露编译错误;第三,部署要足够简单,一个小团队不能养一个专职运维;第四,后续要能平滑扩展成内部 API 服务,给其他系统复用。

这四个约束直接排除了很多"常规做法"。

1.2 T3 这个命名到底指什么

T3 不是某个公司或者产品的代号,而是社区里一套技术组合的统称。它最早由 create-t3-app 这个脚手架推广开来,命名来自三个以 T 开头的东西:TypeScript、tRPC、Tailwind CSS。在此基础上,社区约定俗成地配套了 Next.js 作为框架、Prisma 作为 ORM、NextAuth.js 做认证。

我当时看到这套组合的第一反应是"又一个大杂烩"——毕竟热门的全栈方案太多了,什么 MERN、Jamstack、Nuxt 全家桶。但真正把 t3code 的需求往这套组合上套了一遍之后,我意识到它不是简单的技术堆叠,而是围绕一个核心思想组织起来的:端到端类型安全。数据库的字段、后端的接口、前端的调用,全部由 TypeScript 类型系统串起来,改一个字段名,编译器直接帮你把该改的地方全部标出来。

对 t3code 这种"内部工具优先、多人维护、需求会快速迭代"的项目来说,这个特性不是锦上添花,而是刚需。项目上线后我们确实尝到了甜头:整个开发周期里,因为没有前后端联调的类型不匹配问题,光这一项就省了不少时间。

1.3 这个项目适合谁参考

如果你属于下面几类情况,这篇复盘应该对你有用:

  • 准备用 T3 Stack 做全栈项目,想先看一遍真实落地过程的人
  • 做内部工具、管理后台、团队协作类产品,正在纠结要不要上 tRPC 的人
  • 已经用 Next.js + Prisma,但还不确定 tRPC 值不值得引入的人
  • 想了解全栈类型安全怎么落地、有哪些坑的人

接下来我按时间线把整个项目的关键节点拆开讲,从选型、建模、实现到部署,每一步都会解释"为什么这么选"。

2. 技术选型复盘:为什么是 Next.js、tRPC、Prisma 这一套

2.1 TypeScript 贯穿全栈,到底省了多少事

先聊一个反直觉的结论:在 t3code 项目里,我们省下的最大成本不是"写代码"的时间,而是"对接口"的时间。做过前后端分离项目的人都知道,传统模式下最大的时间黑洞往往不是功能本身,而是联调:前端等后端出接口文档、字段名对不上、类型对不齐、某个返回值可能是 null 但前端没处理……

TypeScript 贯穿全栈之后,这个过程被压缩到了极致。t3code 里数据库表结构由 Prisma Schema 定义,Prisma Client 根据 Schema 生成类型完整的查询 API;tRPC 的 router 直接复用这些类型,自动推导出 all 形式的输入输出类型;前端调用 API 时,从回调参数到返回结果,全部有自动补全和编译期检查。也就是说,从数据库到浏览器按钮,中间所有环节的类型是同一个源头长出来的,不存在"两边各维护一份类型定义"的问题。

这个体验跟以前写 REST API 完全是两个维度。之前做接口,我至少要维护 OpenAPI 文档、前后端各自的 interface、手动写请求封装;现在这些都不用管。曾经我们花了几个小时重构 Snippet 模型,加了几个字段、改了关联关系,放在以前至少要拉一个会议通知前后端同步改,而这次改动前端代码时编译器把报错点一个个列出来,改完直接通过,数据库迁移一跑,功能就上线了。

2.2 tRPC 为什么比 REST 和 GraphQL 更适合这个场景

这是选型时间最长的一个话题。我们内部也争论过:用 REST + OpenAPI 不是更主流吗?用 GraphQL 不是更灵活吗?最后选择 tRPC,核心原因有两个:场景匹配和心智负担。

先看 REST。REST 本身没有任何问题,它是互联网的基石。但对于 t3code 这种内部项目,REST 意味着要为每个资源写一套 controller,配路由、配状态码、配 DTO 校验,还要维护 OpenAPI 文档,然后前端再根据文档生成 client。这套链路在大型对外系统里是必要的,因为你要为第三方调用方提供稳定契约。可 t3code 的调用方只有我们自己,这一大套流程全都是纯成本。

再对比 GraphQL。GraphQL 的优势在于"按需取数"和"复杂的多端聚合查询",但代价是引入一层 Schema 语言(SDL)、resolver 结构、客户端缓存配置,还有相当陡峭的学习曲线。对 t3code 这样一个模式相对固定、没有十几个端要服务的项目来说,GraphQL 提供的灵活性是过剩的,反而徒增复杂度。

tRPC 的思路完全不同:它不定义额外的接口描述语言,而是把后端函数直接暴露给前端调用,接口就是函数,类型就是 TypeScript 的类型。前端导入一个 hook,传入参数,拿到的是带完整类型的返回值;后端声明一个 query,写出输入校验规则,参数会自动校验。整个过程没有 schema 文件、没有代码生成步骤、没有运行时中间层。

当然 tRPC 也有自己的适用边界。它更适合"单一服务端 + 少量客户端"的场景,如果要做开放平台、多个异构客户端(比如小程序 + iOS + Web),那 REST/OpenAPI 或者 GraphQL 仍然是更稳妥的契约方案。这一点要在选型前想清楚。

2.3 Prisma 与数据库建模的关系

数据库访问层我们选了 Prisma。理由很直接:它把表和关系的描述集中在一个 Schema 文件里,通过prisma migrate管理迁移,生成的 Client 自带类型推导,和 tRPC 的配合几乎是无缝的。

用 Prisma 写数据模型就是声明式的,不用手写 SQL 建表语句,也不用单独维护迁移 SQL。更重要的是它的关系查询体验:在 tRPC 的 resolver 里,一行include就能把关联数据一次性查出来,返回给前端的对象结构直接就是嵌套好的。

对比我之前用过的一些 ORM,Prisma 在类型安全上的表现是最完整的。查询条件、排序字段、关联筛选都能在编译期校验,写错一个字段名立刻报错,而不是运行时才爆炸。对 t3code 这种频繁迭代数据模型的内部项目来说,这个保障非常值钱。

2.4 Tailwind 和 NextAuth:省时间的配套

Tailwind 其实没什么好犹豫的。t3code 是内部工具,没有复杂的设计系统需求,用 Tailwind 写工具类,样式就在 JSX 旁边,改起来快,也不会因为 CSS 命名冲突打架。另外 Tailwind 的响应式工具类和暗色模式支持都很成熟,我们后期加暗色主题只花了一个多小时。

NextAuth.js 则承担了所有认证相关的脏活:OAuth 登录流程、Session 管理、CSRF 防护、JWT/数据库会话策略。t3code 最开始只支持 GitHub OAuth 登录,后面加了邮箱登录,整个过程都是配置式的,没有手工实现认证逻辑。

不过 NextAuth 的配置项和回调函数确实容易让人懵,我后面在踩坑章节会详细展开。

3. 从零搭建 t3code:脚手架、目录结构与数据模型

3.1 用 create-t3-app 初始化项目

T3 Stack 最大的入口就是 create-t3-app 脚手架,它把技术选型从"讨论"变成了"勾选"。我当时用的命令:

pnpm create t3-app@latest t3code

命令行会交互式询问启用哪些模块。我的选择是:

  • Next.js 用 App Router
  • TypeScript 开启(默认)
  • tRPC 开启
  • Tailwind CSS 开启
  • Prisma 开启
  • NextAuth.js 开启
  • ESLint / Prettier 保持默认

有一点要注意:脚手架生成的版本会跟随最新依赖,尤其是 Next.js 和 tRPC 的 major 版本,后续升级时一定要看官方迁移指南。我们项目跑到中途就遇到了 tRPC v10 到 v11 的升级冲击,这个坑后面细说。

初始化完成后,项目自带了一套完整的基础代码,包括 tRPC 的 server/client 封装、NextAuth 的默认配置、Prisma 的标准 schema 和示例路由。第一次跑pnpm dev就能看到首页,再加上一个 POST 的 demo API 可以直接调用。

3.2 目录结构与关注点分离

脚手架默认生成的目录结构,我在此基础上做了少量调整,关键路径如下:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── components/ │ ├── env.mjs │ ├── pages/ (或 app/) │ │ ├── api/ │ │ │ └── auth/[...nextauth]/route.ts │ │ │ └── trpc/[trpc]/route.ts │ │ └── index.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── routers/ │ │ │ │ ├── snippet.ts │ │ │ │ ├── tag.ts │ │ │ │ └── user.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ └── trpc/ │ ├── server.ts │ ├── client.ts │ └── react.tsx └── .env

这套结构的核心逻辑是"服务端代码集中在 src/server,前端可安全引用的 API 访问层集中在 src/trpc"。在 App Router 模式下,这个边界尤其重要:服务端代码如果被客户端组件意外引用,会造成 bundle 体积膨胀甚至安全泄露。

env.mjs是脚手架额外提供的一个贴心设计——它用 Zod 校验所有环境变量,启动时如果不满足格式要求会直接报错。这个设计帮我挡住了很多"本地好好的,部署就挂了"的尴尬场景。

3.3 Prisma 数据模型设计实例

t3code 的核心数据模型有四个实体:用户、代码片段、标签、评价。我把 Schema 的核心部分贴出来:

generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String @unique role String @default("user") snippets Snippet[] reviews Review[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Snippet { id String @id @default(cuid()) title String content String language String status String @default("pending") author User @relation(fields: [authorId], references: [id]) authorId String tags SnippetTag[] reviews Review[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([language]) @@index([authorId, createdAt]) } model Tag { id String @id @default(cuid()) name String @unique snippets SnippetTag[] } model SnippetTag { snippet Snippet @relation(fields: [snippetId], references: [id]) tag Tag @relation(fields: [tagId], references: [id]) snippetId String tagId String @@id([snippetId, tagId]) } model Review { id String @id @default(cuid()) rating Int comment String? author User @relation(fields: [authorId], references: [id]) authorId String snippet Snippet @relation(fields: [snippetId], references: [id]) snippetId String createdAt DateTime @default(now()) @@unique([snippetId, authorId]) @@index([snippetId]) }

几个设计决策解释一下:

  • 用户角色用普通 String 而不是 enum,因为产品早期角色类型不稳定,等固定了再迁移成 enum 也不迟;用 Prisma 的话 enum 迁移成本也不高。
  • 多对多用中间表SnippetTag,而不是直接在数组字段里存字符串。虽然 PostgreSQL 支持 string 数组,但中间表让"标签维度统计"和"按标签精确过滤"都变得简单,SQL 查询还能走索引。
  • Review上加了@@unique([snippetId, authorId]),保证一个用户对一个片段只能评分一次。这个约束如果不放在数据库层,应用层的并发校验基本是漏的。

3.4 环境变量用 Zod 兜底

脚手架生成的env.mjs长这样:

import { z } from "zod"; const envSchema = z.object({ DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(32), NEXTAUTH_URL: z.string().url().optional(), GITHUB_ID: z.string().optional(), GITHUB_SECRET: z.string().optional(), }); export const env = envSchema.parse(process.env);

这套做法的好处是:环境变量缺失、格式错误、密钥太短,都会在应用启动时直接抛错。部署时如果某个生产环境变量忘了配,你会在日志里立刻看到清晰的 Zod 错误消息,而不是等到某个接口跑起来才 500。t3code 上线前就靠它抓住过一个漏配的NEXTAUTH_SECRET。

4. 核心功能实现:认证、接口与前端调用

4.1 NextAuth 接入流程与 session 类型

NextAuth 的接入本身是机械的:配置 providers、设置authOptions、在src/pages/api/auth/[...nextauth]/route.ts挂载。真正的坑在 TypeScript 类型扩展。

默认情况下,session.user只有name、email、image这几个字段,没有我们业务需要的id和role。为了让 tRPC 的 protected procedure 里访问ctx.session.user.id时类型正确,需要扩展类型声明:

// src/types/next-auth.d.ts import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; role: string; } & DefaultSession["user"]; } }

然后在authOptions的callbacks.session里把数据库里的用户信息塞进去:

callbacks: { async session({ session, user }) { if (session.user) { session.user.id = user.id; const dbUser = await db.user.findUnique({ where: { email: session.user.email ?? "" }, }); session.user.role = dbUser?.role ?? "user"; } return session; }, }

这个"类型扩展 + 回调填充"的模式,是 NextAuth 集成里最值得先搞清楚的一块。官方的类型定义比较绕,社区里不少项目都栽在这里——要么 session 里取不到 id,要么类型一直报红。

4.2 用 tRPC 写带鉴权的路由

脚手架已经把底层的createTRPCContext、publicProcedure、protectedProcedure封装好了。其中protectedProcedure核心逻辑是:从请求里解析 session,没有就抛UNAUTHORIZED错误。我在它的基础上加了一个管理员专用 procedure:

export const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.session.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN" }); } return next({ ctx }); });

有了这几个 procedure 之后,写业务路由就是很直白的事了。以 Snippet 的创建为例:

export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ tag: z.string().optional(), language: z.string().optional(), cursor: z.string().optional(), limit: z.number().int().min(1).max(50).default(10), }), ) .query(async ({ ctx, input }) => { const items = await ctx.db.snippet.findMany({ where: { status: "approved", language: input.language, tags: input.tag ? { some: { tagId: input.tag } } : undefined, }, include: { author: true, reviews: true, tags: { include: { tag: true } } }, orderBy: { createdAt: "desc" }, take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, }); let nextCursor: string | undefined; if (items.length > input.limit) { const extra = items.pop(); nextCursor = extra?.id; } return { items, nextCursor }; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string(), tagNames: z.array(z.string()).min(1).max(5), }), ) .mutation(async ({ ctx, input }) => { const tagIds = await Promise.all( input.tagNames.map(async (name) => { return ctx.db.tag .upsert({ where: { name }, create: { name }, update: {}, }) .then((tag) => tag.id); }), ); return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, authorId: ctx.session.user.id, tags: { create: tagIds.map((tagId) => ({ tagId })), }, }, }); }), approve: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.update({ where: { id: input.id }, data: { status: "approved" }, }); }), });

你注意create里的一个细节:标签用upsert而不是先查再建,这避免了并发创建同名标签时的唯一约束冲突。这类细枝末节如果不提前想到,联调阶段会浪费不少时间。

4.3 前端类型推导的体验

tRPC 前端最爽的部分,是它消除了所有的手动 API 封装。列表页的代码大概长这样:

import { api } from "~/trpc/react"; function SnippetList({ activeTag }: { activeTag?: string }) { const { data, fetchNextPage, hasNextPage, isFetching, } = api.snippet.list.useInfiniteQuery( { tag: activeTag }, { getNextPageParam: (lastPage) => lastPage.nextCursor }, ); const utils = api.useUtils(); const createReview = api.snippet.review.useMutation({ onSuccess: () => { utils.snippet.list.invalidate(); }, }); // 渲染逻辑... }

这里有几个值得说的点:

  • api.snippet.list的对象路径和 router 定义一一对应,方法名、参数类型、返回值类型全部自动推导。
  • 翻页用useInfiniteQuery,配合后端返回的nextCursor,天然适合"加载更多"的场景,不用自己手写状态机。
  • 提交评价后调用utils.snippet.list.invalidate()让相关列表自动失效并重新拉取,不需要手动改本地缓存。React Query 的失效机制比我自己处理的任何缓存方案都干净。

4.4 列表搜索与标签过滤的实现细节

搜索和过滤是 t3code 的核心交互。实现上,我们把关键词搜索放在数据库层做简单的contains模糊匹配,标签和语言过滤则走索引字段:

// tRPC query 里叠加过滤条件 where: { status: "approved", language: input.language, title: input.keyword ? { contains: input.keyword, mode: "insensitive" } : undefined, tags: input.tag ? { some: { tag: { name: input.tag, }, }, } : undefined, }

前端这里有个体验优化:搜索框做了 300ms 的防抖,输入停止后再触发 query。在 React Query 的keepPreviousData配合下,连续输入不会造成接口风暴;数据加载中原有列表不会被清空,视觉上也不会有闪烁。

5. 部署上线与性能优化:t3code 的真实落地方案

5.1 部署方案选择:为什么没有无脑上 Vercel

T3 Stack 社区默认推荐 Vercel,确实 Next.js 部署到 Vercel 是零成本的事。但 t3code 选择了自托管 Docker + PostgreSQL,原因很实际:第一,公司内部一些服务要求数据留在自有机器上,不支持推到第三方 PaaS;第二,我们除了 Web 应用,后续还有内部 API 服务的扩展计划,直接部署在一台云服务器上更方便统一管理;第三,自托管可以在网络层面和公司内网打通,内网访问体验更好。

最终部署拓扑不复杂:一台 2 核 4G 的云服务器,Docker Compose 编排两个服务——Node.js 的 Next.js 应用和 PostgreSQL 数据库。反向代理用 Nginx,挂好 HTTPS 证书。

services: web: build: . ports: - "3000:3000" environment: - DATABASE_URL=postgresql://t3code:${DB_PASSWORD}@db:5432/t3code - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - NEXTAUTH_URL=https://t3code.internal.example.com depends_on: - db restart: unless-stopped db: image: postgres:16-alpine volumes: - pgdata:/var/lib/postgresql/data environment: - POSTGRES_USER=t3code - POSTGRES_PASSWORD=${DB_PASSWORD} restart: unless-stopped volumes: pgdata:

这个配置里有两个容易被忽略的细节:

  • depends_on只能保证容器启动顺序,不能保证数据库已就绪。我因此遇到过一次应用启动时连不上数据库直接崩掉,最后给应用容器加了健康检查脚本,等pg_isready通过后再启动。
  • 数据库连接串里用的是服务名db而不是 IP,Docker Compose 内置 DNS 会解析到正确地址。本地开发和生产环境的DATABASE_URL不同,别写死在schema.prisma里。

5.2 环境变量、密钥与安全配置

生产环境的环境变量管理,我们的做法比较朴素但也够用:.env.production文件由运维同学单独保管,内容只包括非敏感的配置项;所有密钥类信息(数据库密码、NEXTAUTH_SECRET、OAuth Client Secret)通过 CI/CD 的 secret 注入,不落盘。

NEXTAUTH_SECRET是特别要强调的一个配置。它用于签名 Session Cookie(JWT 模式),长度至少 32 字节,可以用这个命令生成:

openssl rand -base64 32

一定不要把它提交到 Git 仓库,也不要在多环境之间复用。之前见过有项目把开发环境的 secret 直接用于生产,一旦泄露,攻击者可以伪造管理员的 session cookie。

5.3 数据库性能与缓存优化

t3code 的数据量在初期非常小,但为了不让它"一开始就不健康",我们还是做了一些预防性优化:

  • 给Snippet的language、authorId + createdAt建了复合索引,给Review的snippetId建了索引。这些字段都是查询和排序的热点。
  • 列表接口开启 React Query 的staleTime,默认 30 秒内不重新请求;标签列表这类低频数据直接设置gcTime做长期缓存。
  • 静态资源(JS/CSS)由 Next.js 自动处理,但我们在 Nginx 层加了Cache-Control: public, max-age=31536000, immutable,对带 hash 的资源开启长缓存。

一个很实在的优化收益是:首页加载的 LCP 时间从首次实测的 2.8 秒降到了 1.2 秒左右。主要贡献来自去掉不必要的客户端渲染、开启图片懒加载,以及上面说的静态资源缓存。

5.4 监控与日志

自托管方案带来的额外成本是监控要自己解决。t3code 阶段我用了最轻量的方案:

  • 应用日志走 stdout,Docker 的 json-file 日志驱动自动收集,滚动保留 30 天。
  • 用 Uptime Kuma 做外部探活监控,5 分钟一次,挂了会发通知到企业群。
  • Next.js 的错误上报暂时接的 Sentry,通过环境变量动态开关,线上开了后端错误的采集。

这套组合虽然谈不上专业可观测性,但对一个内部工具来说已经足够:故障能发现、日志能回溯、错误能排查。

6. 踩坑实录:我在 t3code 上花掉的时间

6.1 tRPC 版本升级带来的破坏性变更

t3code 开发到一半的时候,tRPC 从 v10 升到了 v11。当时 create-t3-app 默认生成的还是 v10,但因为要装一个依赖,我把 lockfile 一刷新,tRPC 直接拉到了 v11,然后全项目大面积报错。

v11 的核心变化有几个影响比较大:createReactQueryHooks改造为基于函数返回的 hook 集合;不少内部 API 从createTRPCReact的返回方式上做了调整;旧的/trpc/client路径也变了。当时排查的过程其实就是看官方迁移指南,把trpc.ts里的初始化代码整体替换一遍,再把靠近 tRPC 边界的那层调用代码微调。

这件事给我两个教训。第一,lockfile 一定要认真管理,在非升级任务里不要轻易执行pnpm update这种宽泛命令。第二,脚手架生成后立刻把依赖版本固定下来,给tRPC、Next.js这类核心依赖锁好 minor 版本,避免"被动升级"。

6.2 Next.js App Router 与 tRPC 的兼容问题

App Router 是 t3code 默认用的路由模式。它的服务端组件(Server Components)默认不能调用 tRPC 的客户端 hook,因为那些 hook 依赖 React Query 的 Provider,只能在客户端边界以内使用。

我最初想省事,直接在服务端组件里调用数据库查询,绕开 tRPC。结果发现违背了"端到端类型安全"的初衷——服务端组件里用 Prisma 裸查固然快,但界面层直接依赖数据库结构,模型一改,组件就要跟着改,耦合度上来了。

最终我采用的分层策略是:

  • 页面骨架和 SEO 相关的内容用服务端组件渲染
  • 需要登录、交互、列表数据刷新的区块全部拆成客户端组件,通过 tRPC hook 取数
  • 服务端组件如果需要初始数据,通过 tRPC 的 server caller 方式调用同一套 router,保证逻辑不重复
// 服务端组件里调用 tRPC router import { createCaller } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; const ctx = await createTRPCContext({ headers: await headers() }); const caller = createCaller(ctx); const data = await caller.snippet.list({ limit: 5 });

这个方案的要点是"同一套业务逻辑,两种调用方式",既满足首屏性能,又不牺牲类型安全。

6.3 Prisma 在容器环境下的连接管理问题

上线后遇到的第一个线上故障,是数据库连接数暴涨。原因很典型:Prisma Client 默认会维护一个连接池,而我在 Next.js 开发服务器热更新时,反复创建 Client 实例,每个进程各自持有连接池。上线后虽然有容器的单实例,但应用频繁重启、连接没有及时释放,加上活跃查询一多,连接数直接打满 PostgreSQL 的默认上限。

解决办法有两步:

  • 在src/db.ts里用全局单例,避免热更新环境下的重复实例化。脚手架其实已经带了这段逻辑,但我在调整目录时不小心破坏过,教训是要理解脚手架为什么那么写。
  • 在生产环境限制连接池大小,DATABASE_URL里加上连接池参数:
DATABASE_URL="postgresql://user:pass@db:5432/t3code?connection_limit=5&pool_timeout=10"

把连接数从默认值压到 5,对 t3code 的流量来说完全够用,同时给数据库留出余量。

6.4 测试与联调阶段的经验

T3 脚手架默认不集成测试框架,这块是我后来补的。我的选择是:

  • 单元测试用 Vitest,重点覆盖工具函数和 tRPC 路由里的纯业务逻辑。tRPC 的 caller 方式很方便测试:直接把 context 里的 session 和 db 替换成 mock 对象,调用 router 的 procedure,断言输出。
  • 端到端测试用 Playwright,覆盖了"登录 → 发布片段 → 检索 → 评分"这条主路径,在每次部署前跑一遍。

写测试的过程帮我发现了两个真实 bug:一个是在upsert标签时没有处理空字符串,另一个是在管理员审核通过后没有清掉缓存导致列表仍显示旧状态。这两类问题靠手测确实很难稳定复现。

7. 项目复盘:如果重新做 t3code,我会在这些地方做调整

7.1 做得对的地方:可以迁移复用的经验

整个项目下来,我愿意保留并继续用的决策有这么几个:

第一,从第一天就坚持端到端类型安全。这让我们在后续所有模型调整、功能迭代中,几乎没有为"类型对不上"这件事返工过。如果你也在评估 T3 Stack,我建议把"类型安全"当成核心收益来看,而不是附带的便利。

第二,数据库层约束前置。唯一约束、索引、默认值都尽量在 Schema 里声明,而不是依赖应用层自己保证。数据一致性问题上,数据库永远是最可靠的防线。

第三,自托管部署碰到的问题都在可控范围内。虽然 Vercel 会更省心,但 Docker Compose 这套方案对我们这种内部工具而言,成本并不高,而且可控性明显更好。

7.2 我最后悔的几个决定

反过来,有几个决定如果重新选,我会改:

  • 前期没有第一时间固定核心依赖版本,导致 tRPC 被动升级,白白消耗了一天排查时间。任何脚手架项目,第一个动作都应该是把版本锁好。
  • 没有早一点引入 Playwright 端到端测试。手测在功能少的时候还行,功能一多,回归成本立刻上来了。测试基础设施应该在项目第一天搭好,而不是等功能稳定后补。
  • 日志和监控体系搭得太晚。上线前一周才加 Sentry,导致前期有些偶发错误只能靠翻 Docker 日志,效率很低。

7.3 几个可以直接抄走的建议

如果你打算启动一个类似 t3code 的项目,这几个建议可能比看一百篇教程都实在:

  • 初始化后先把pnpm-lock.yaml提交到仓库,之后所有依赖变更都通过pnpm add具体指定包来完成,不跑宽泛的 update。
  • protectedProcedure和adminProcedure的分层一定要从第一天就做好,后面每个新路由都按这个体系加,权限逻辑才不会漏。
  • Prisma 的连接池参数和环境变量校验(env.mjs)在本地开发阶段就该配好,别拖到部署时才补,否则你会在线上踩一遍我已经踩过的坑。
  • 给前端 hook 调用统一加上staleTime的合理配置,这能在不删一个接口的情况下,把大量重复请求直接降下来。

t3code 上线到现在跑了将近半年,团队里的使用频率一直不错,每天都有新的片段沉淀进来。技术选型这件事,本质上是"在合适的场景里做合适的取舍"。T3 Stack 不是万能的,但对我来说,它在一个内部工具项目里把"开发体验""类型安全"和"交付速度"这三件事平衡得很到位。如果看完这篇复盘你也手痒,拿 create-t3-app 起个实验项目,跑通一个带登录、带数据库的小功能,感受到那种"类型从数据库一路连到按钮"的顺畅感之后,你会明白我为什么愿意花时间把这段过程记录下来。

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

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

立即咨询