☰
T3 Stack全栈重写实战:从tRPC到Prisma的类型安全闭环
2026/10/14 9:58:18 网站建设 项目流程

先交代一下背景。最近我把自己的一个全栈小项目推倒重写了,代号就叫 t3code。为什么要推倒重写?因为我受够了"前端一个仓库、后端一个仓库、接口文档靠 Markdown 文件维护"的协作方式。这个项目本身不算大,但前后端在联调阶段经常因为字段名对不上、接口文档没人更新、类型定义各自为政而互相甩锅。某次后端悄悄把password改成了passwordHash,前端在毫不知情的情况下白屏了一下午;某次一个接口的参数名和实际代码对不上,排查了一整晚才发现文档早就没人维护了。最离谱的是,前后端对"时间格式到底传字符串还是时间戳"产生了分歧,两边各自在自己代码里做了兼容,结果线上数据存了两套格式。

后来我下定决心把项目整体改造成 T3 Stack 这套组合,也就是 Next.js + tRPC + Prisma + Tailwind CSS + TypeScript,再加上 NextAuth 做认证。整个过程从初始化脚手架到部署上线,踩了不少坑,也彻底体会到了"前端和后端共享一份类型"到底是什么体验。这篇文章就是把 t3code 从零到上线这段路完整拆给你看,包含选型理由、初始化细节、tRPC 的类型闭环、Prisma 建模、认证接入、部署踩坑。如果你也正想在中小型项目里用这套方案,或者单纯想了解 T3 到底值不值得用,这篇应该能帮你省不少时间。

1. 为什么把项目推倒重写:被前后端类型割裂折磨后的选择

1.1 旧方案最痛的三个点

先说旧方案。t3code 最开始是典型的前后端分离:一个 React 前端仓库,一个 Express 后端仓库,接口约定靠一份 Markdown 文档维护。听起来没毛病,但实际跑起来之后痛点特别明显。

第一个痛点是接口文档永远不可信。文档最开始还能跟上代码,但项目一旦有几个人同时提交,文档就几乎没人更新了。你照着文档调接口,一半时间是错的,最后还是要直接翻后端代码或者直接问人。第二个痛点是类型割裂。前端有自己的 TypeScript 类型定义,后端也有自己的类型定义,两边各维护一份"你以为的前端数据形状"。数据一多,你根本不知道哪个类型才是真的,经常出现前端定义里有个字段后端早就不返回了,或者后端加了字段前端类型里根本没有。第三个痛点是上下文切换。每次改接口都要打开两套代码、两套路由、两套错误处理,心态真的很崩。

说到底,中小型项目最怕的不是代码复杂,而是"每一处改动都要靠人肉同步"。只要任何一个环节忘记同步,问题就会被推迟到浏览器里才暴露,然后开始漫长的联调、复现、甩锅循环。

1.2 T3 栈的核心思路:把"类型安全"贯彻到每一层

T3 Stack 不是什么新框架,它是一整套技术选型的组合:Next.js 负责框架和路由,tRPC 负责 API 调用,Prisma 负责数据库层,Tailwind CSS 负责样式,TypeScript 是贯穿全部代码的底层语言,NextAuth 负责认证。

这套组合最关键的地方在于:它没有前后端两个仓库的概念。Next.js 自己就能同时承载页面和 API,tRPC 让前端像调用本地函数一样调用服务端函数,Prisma 把数据库表结构变成 TypeScript 类型。这样一条数据从数据库里出来,经过服务端处理,再传给前端渲染,所有环节的类型都是连通的。

打个比方,旧做法像是两个人隔着一堵墙对话,一个人把纸条塞进墙洞,另一个人只能凭猜测解读纸条内容;T3 的做法是把这堵墙拆掉,两个人直接面对面传递同一个物体,你拿到什么,你看到的样子就是它真实的类型。这不是简单的"类型更好看了",而是从根本上消除了"我以为你返回了这个字段"这类问题。

1.3 T3 社区的三个原则:类型优先、端到端优先、安全优先

T3 背后有三条设计原则,虽然听起来像口号,但实际用下来确实是这样:

  • 类型安全优先:能用 TypeScript 静态检查解决的问题,绝不留到运行时。
  • 端到端优先:类型和逻辑从前到后是一条线,不需要中间再搞一套独立的接口描述文件。
  • 安全优先:认证从第一天就接入,而不是等项目写完了再补。

这三条原则对小项目尤其友好。大项目可能需要考虑性能、微服务、多团队并行,类型安全很难完美追求;但中小项目完全可以用类型安全这套思路把研发体验拉满。t3code 重写之后,我最大的感受是:不是代码变少了,而是改代码时心里有底了。

2. 初始化 t3code:create-t3-app 的每个选项我都帮你权衡了一遍

2.1 初始化命令与交互式选项

T3 项目官方推荐用create-t3-app脚手架初始化,一条命令就能生成包含整个技术栈的模板项目。我当时执行的是:

npx create-t3-app@latest t3code

命令跑起来后会进入交互式问答,让你选择需要哪些模块。这里我建议不要嫌麻烦而跳过,每个选项都值得认真考虑一下:

  • Next.js 版本:默认就是基于 App Router 的新版本,推荐保留。App Router 和 tRPC 的配合现在已经很成熟了。
  • TypeScript:必须勾,这是整套类型安全的基础。
  • Tailwind CSS:建议勾。T3 里的样式方案默认就是它,和组件模型的配合很顺。
  • tRPC:必须勾,这是全链路类型核心。
  • Prisma:如果你的项目需要数据库,就勾。如果你纯做个不需要持久化的 Demo,可以暂时不勾,但建议后期加上。
  • NextAuth:建议一开始就勾上。认证这种东西后面补最痛苦,模板里帮你接好了一半的坑。
  • ESLint / Prettier:都勾上,格式化问题后面省心。

初始化完成后,模板会自动帮你装好依赖,并且在src/server/db.ts里准备好 Prisma 客户端、在src/server/api/trpc.ts里配置好 tRPC 上下文,还会有一个现成的用户表和示例路由。你可以先跑一遍pnpm dev,确认模板自带链路是通的,再开始改自己的业务。

2.2 项目目录结构:第一次跑通时我记下的笔记

初始化完成后的目录结构大概是这样的,我只挑重点说:

t3code/ prisma/ schema.prisma # 数据库模型定义 src/ app/ layout.tsx # 根布局 page.tsx # 首页 api/trpc/[trpc]/route.ts # tRPC 的 HTTP 入口 server/ api/ root.ts # 汇总所有子路由 trpc.ts # 创建 tRPC 实例、上下文、公共中间件 routers/ post.ts # 示例路由,演示 query / mutation auth.ts # NextAuth 配置 db.ts # Prisma 客户端单例 trpc/ react.tsx # 前端 tRPC React 封装 server.ts # 服务端调用 tRPC 的入口 .env.example # 环境变量示例 tailwind.config.ts tsconfig.json

这里最重要的一条链路是:prisma/schema.prisma定义数据模型,server/api/routers里写业务路由,app目录里写页面组件,前端通过trpc/react.tsx里导出的api对象发起调用。整个链路是通的,新手最容易忽略的一点是:不要自己去拼一个 fetch URL,而是始终通过api对象调用,这样才能享受到类型安全。

2.3 初始化阶段容易踩的三个细节

细节一:Node 版本。模板要求 Node 18 以上,18 以下的版本跑起来会遇到各种兼容问题。建议直接用一个带 Node 版本管理的工具,固定项目在 20 LTS 上,省心很多。

细节二:包管理工具。官方模板默认生成pnpm-lock.yaml,推荐直接用 pnpm。你要是手痒用 npm 装,后面 lockfile 冲突会烦死你。如果公司内网不支持 pnpm 镜像,换 npm 也行,但最好从初始化那天就统一。

细节三:.env文件。模板给的是.env.example,不会自动帮你生成.env。初始化之后第一件事就是复制一份:cp .env.example .env,然后填上数据库连接串。很多人一上来直接pnpm dev,页面报错半天找不到原因,其实就是环境变量缺失。

3. tRPC 端到端类型安全的底层逻辑:不写 REST 路由也能前后端共享类型

3.1 tRPC 到底是什么:一次函数调用替代一整条 API 链路

tRPC 本质上是一个 RPC 风格的远程调用方案。它没有自己的协议,底层仍然是 HTTP JSON 传输,但前端写的代码看起来就像在调用本地函数:

const { data } = api.post.getAll.useQuery();

这一行代码背后做的事情是:发送一个 HTTP 请求到/api/trpc/post.getAll,服务端执行对应的查询逻辑,然后返回数据。由于前后端都引用了同一个AppRouter类型,前端在写api.post.getAll的时候,TypeScript 就已经知道这个函数的入参类型和返回类型了。改了后端返回结构,前端编译立刻飘红,而不是等到浏览器白屏才反应过来。

所以 tRPC 不是"更快的接口框架",它是"消灭接口定义这件事本身"的方案。你不再需要单独维护一份接口文档、不再需要根据路由去猜后端返回什么,类型定义就是文档,就是契约。

3.2 核心概念:initTRPC、router、procedure、context、middleware

tRPC 的入口是initTRPC,它负责创建整个应用所需的上下文类型。context是每次请求进来时构建的初始对象,在模板里它包含db和session,也就是数据库实例和当前登录用户信息。

router就是路由集合,procedure是最小的调用单元。一个 procedure 可以是query(查数据),也可以是mutation(改数据)。中间件(middleware)可以做鉴权等逻辑,模板里有一个protectedProcedure就是publicProcedure加了一个登录判断。

看一段标准代码,来源是模板里的server/api/routers/post.ts:

import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, createdById: ctx.session.user.id, }, }); }), });

getAll是公开查询,直接读数据库返回。create是受保护的写操作,先用 zod 校验输入,再通过ctx.session.user.id拿到当前登录用户的 ID,作为createdById写入。整个过程类型完整,输入输出都在编译期被检查。

3.3 一个完整的 Post 功能闭环:从后端路由到前端组件

服务端定义好路由之后,还需要在server/api/root.ts里汇总:

import { postRouter } from "~/server/api/routers/post"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter; export const createCaller = createCallerFactory(appRouter);

前端这边,组件内直接用 hooks 调用:

"use client"; import { api } from "~/trpc/react"; export function PostList() { const { data: posts, isLoading } = api.post.getAll.useQuery(); if (isLoading) return <div>加载中...</div>; return ( <ul> {posts?.map((post) => ( <li key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </li> ))} </ul> ); }

写完post.getAll.useQuery()后,data的类型会自动推导成Post[]。你不需要在任何地方手动声明"这个接口返回一个数组"。这还不是最爽的,最爽的是改模型的时候:比如你在 Prisma 里给Post加了一个status字段,然后删掉它,前端所有用到这个字段的地方会在一瞬间全部变红,你可以在提交之前就修完所有引用。

3.4 tRPC 相关的几个实际坑:superjson、zod error、scoped client

坑一:日期类型。默认情况下 JSON 不能准确还原Date对象,所以模板里配置了superjson作为序列化器。只要前后端都走 tRPC,Date字段就能保持为Date类型。如果你绕过 tRPC 自己写 fetch,拿到手的就是字符串,所以尽量统一走api调用。

坑二:zod 错误的处理。前端在调用api.post.create时如果输入不合法,tRPC 默认返回错误对象。模板里配置了errorFormatter,会把 zod 的错误flatten后放进data.zodError,方便前端在表单上做字段级错误展示。如果你项目里没有这个配置,建议自己加上,体验差异很大。

坑三:服务端调用和客户端调用要分开。前端项目里不该直接 importserver/api/root.ts的caller,那个入口是留给服务端组件或路由处理器用的。前端统一走~/trpc/react里的包装。两边的 client 不要混用,否则类型好是好,但请求地址和 header 处理会出问题。

4. Prisma 建模与迁移:数据层怎么和类型系统无缝咬合

4.1 为什么选 Prisma 而不是手写 SQL 或 TypeORM

Prisma 最大的价值是:数据库表结构是 TypeScript 类型的唯一事实来源。你在schema.prisma里定义模型,执行迁移和 generate 之后,Prisma Client 会自动生成对应的类型。前端不直接接触这些类型,但它们最终会通过 tRPC 的返回类型一路传到组件里,形成闭环。

相比手写 SQL,Prisma 的好处是迁移脚本可靠、关系管理直观、类型生成完整;相比 TypeORM,Prisma 的 schema 文件更清晰,没有装饰器满天飞的问题,写起来更接近"声明式描述数据"而不是"用代码搭数据库"。当然它也不是万能药,超大数据库的复杂查询优化、存储过程这类场景,Prisma 并不擅长。但对 t3code 这种中小项目来说,Prisma 是阻力最小、收益最高的选择。

4.2 为 t3code 设计第一个可用的数据模型

按 t3code 的需求,我先设计了用户、文章、标签三个核心模型。Prisma 的模型定义写在prisma/schema.prisma里:

model User { id String @id @default(cuid()) name String? email String @unique image String? posts Post[] accounts Account[] sessions Session[] } model Post { id String @id @default(cuid()) title String content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt createdById String createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) tags Tag[] } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }

这里的Post.tags和Tag.posts是多对多关系,Prisma 会自动生成中间关联表。createdById和createdBy是对 User 的外键关系,设置了onDelete: Cascade后,用户被删掉时他的文章也会一并清理,避免留下孤儿数据。

4.3 从建模到迁移的完整流程:migrate dev 和 generate 的区别

写完模型后,执行:

npx prisma migrate dev --name init

这个命令会做三件事:对比当前 schema 和上一次迁移的差异、生成迁移 SQL、执行迁移到数据库。它还会自动触发prisma generate,重新生成 Prisma Client 的类型定义。

这里我要特别提醒:改完 schema 一定要跑 migrate,否则类型不会更新,代码里根本看不到你新加的字段。而且 migrate dev 如果检测到数据不兼容,会给出风险提示,不会默默把数据扔掉。生产环境发布前要执行的是prisma migrate deploy,它不会重新生成迁移文件,只会把已提交的迁移应用到线上数据库。

4.4 把 db 挂进 tRPC context:单例连接和请求上下文

server/db.ts主要负责创建 Prisma Client 并导出。模板里用了globalThis来做单例缓存,是因为 Next.js 开发模式的热更新会反复执行模块,如果每次都新建 Prisma Client,数据库连接数会爆炸。这属于"改一行代码换来稳定"的典型优化:

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

在trpc.ts的createTRPCContext里,db和session会被一起放进每个请求的上下文:

export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, headers: opts.headers }; };

之后所有 procedure 里都能安全地从ctx.db拿数据库操作对象,不需要每个路由都手动 import。

4.5 被我 TypeScript 类型系统拦住的两个真实 Bug

第一个 Bug 是漏字段。写createmutation 时,我一开始忘了传createdById,Prisma 生成的类型立刻报错说createdById是必填字段。这个错误如果在运行时才暴露,那又是"接口 500、前端白屏、联调群里开始互相甩锅"的熟悉剧本;但在类型系统的拦截下,我在保存文件的那一刻就发现了。

第二个 Bug 是关系字段名搞错。我在 Prisma 模型里写过authorId,但另一个地方引用的是createdBy,两边对不上,TS 直接告诉我"当前的类型上没有这个属性"。这类错误靠人眼检查真的很难找,编译器几秒钟就能定位。这种感觉不能说多爽,但确实让我确认了"这套栈值得用"。

5. 页面开发:Next.js App Router 与 Tailwind 在实际项目里的配合方式

5.1 App Router 的目录组织:哪些组件必须标 "use client"

Next.js 的 App Router 默认所有组件都是服务端组件,只有在需要交互、状态、或者使用 tRPC hooks 时,才需要标记"use client"。这个边界刚开始很容易搞混。我的经验是:

  • 普通展示型组件:服务端组件,直接放在app目录下。
  • 表单、按钮、动态交互组件:必须标"use client"。
  • 使用api.post.xxx.useQuery的组件:必须标"use client",因为 tRPC hooks 依赖浏览器端的 React Query 状态管理。

实际搭建中,页面可以保持服务端组件,然后在需要数据的位置拆出一个小的客户端组件,这样服务端的渲染优势和客户端的交互能力都能兼顾。

5.2 用 Tailwind 搭出 t3code 的主体 UI:配置与断点经验

Tailwind 在这套栈里属于"不用动脑也能写得好"的部分。我会先在tailwind.config.ts里扩展主题颜色,把主色调和辅助色定义成语义化名称,比如primary、muted,而不是在业务代码里到处写十六进制色值。这样统一换主题色时只改一个配置文件就行。

断点方面我的习惯是移动端优先:先按小屏尺寸写基础样式,再用md:、lg:前缀做增强。t3code 的页面布局比较简单,顶部一个导航栏,中间内容区,列表用栅格自适应,几行 Tailwind 类就搞定了:

<div className="mx-auto grid max-w-5xl grid-cols-1 gap-4 px-4 md:grid-cols-2"> {posts?.map((post) => ( <article key={post.id} className="rounded-lg border border-gray-200 p-4"> <h2 className="text-lg font-semibold">{post.title}</h2> </article> ))} </div>

Tailwind 最大的好处不是省 CSS 代码,而是你不用再给一个组件专门起类名、找选择器、考虑样式覆盖优先级。样式就在你眼前的类名里,改起来非常直接。

5.3 一个完整页面的组装过程:从服务端布局到客户端数据

t3code 的主页结构可以分为三层:根布局、页面容器、数据列表。根布局放全局导航,页面容器决定整体排版,数据列表负责展示内容和交互。

根布局:

export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="zh-CN"> <body> <nav className="border-b"> <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3"> <span className="font-bold">t3code</span> </div> </nav> <main className="mx-auto max-w-5xl px-4 py-8">{children}</main> </body> </html> ); }

页面内容交给客户端组件渲染。注意一个细节:如果你的正常页面里包裹了TRPCReactProvider,里面再用api.post.getAll就没有问题。如果你在服务端组件里直接用useQuery,编译阶段就会报错说 hooks 不能用在服务端组件里。这个错误我踩过,看到之后第一反应就是检查组件有没有标"use client",基本百发百中。

5.4 服务器端渲染与服务端数据预取:什么时候用 caller

t3code 早期版本,列表页进来自动带一个 loading 状态。我后来发现首屏还是白了一下,于是改用 tRPC 的createCaller在服务端直接查询数据,然后传给客户端组件做初始值:

import { createCaller } from "~/server/api/root"; import { db } from "~/server/db"; const caller = createCaller({ db, session: null, headers: new Headers() }); const initialPosts = await caller.post.getAll();

这样首屏数据在服务端已经拿到,客户端不需要再等一次请求。如果你对首屏体验敏感,这个用法值得研究;如果项目是后台管理系统,那直接走客户端useQuery就行,别过度设计。

6. 认证接入:session 类型从 NextAuth 到 tRPC 再到页面组件的流转

6.1 为什么用 Auth.js(NextAuth)而不是自己写登录

认证是很多项目的后补项,但 T3 模板默认就把 NextAuth 接好了。我自己的体会是:自己写登录要处理密码哈希、会话存储、Cookie 安全、忘记密码、第三方 OAuth 回调等一系列问题,这些轮子再造一遍没有任何意义。Auth.js 生态成熟,支持 GitHub、Google、邮件魔法链接等 Provider,配合 Prisma Adapter,用户数据直接落在数据库里。

如果你之前完全没用过认证,我的建议是:先跑通模板默认的配置,理解 session 是怎么来的,再决定要不要换成其他 Provider。

6.2 配置 providers 与 session 策略:以 GitHub 登录为例

在src/server/auth.ts里配置 Provider。这里以 GitHub 为例:

import NextAuth from "next-auth"; import GitHubProvider from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const authOptions = { adapter: PrismaAdapter(db), providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], session: { strategy: "database" }, callbacks: { session: ({ session, user }) => ({ ...session, user: { ...session.user, id: user.id }, }), }, };

session: { strategy: "database" }表示 session 记录存储在数据库里。这个策略的好处是,你可以在任何地方通过 Prisma 查 session 对应的用户,而不只是从 JWT 里拿信息。缺点是每次请求都会查一次数据库,不过对中小项目来说完全不是问题。

6.3 把 session 注入 tRPC 的 protectedProcedure

在src/server/api/trpc.ts里,我已经在 context 中返回了session。接下来就是把它和 procedure 绑定起来:

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

这样,所有用protectedProcedure定义的路由,在进入业务逻辑之前就已经完成了登录校验。如果 session 不存在,tRPC 会直接返回未授权错误,前端不需要自己判断"要不要跳登录页"。

在前端组件里,你可以通过useSession判断当前登录用户,再决定要不要显示"创建帖子"入口:

const { data: session } = useSession(); {session?.user ? <CreatePostButton /> : <p>登录后才能创建帖子</p>}

6.4 认证接入后我遇到的三个坑

第一个坑是 GitHub OAuth 回调地址没配对。在 Provider 后台配置的回调 URL 必须和实际部署域名完全一致,本地开发和线上环境各配各的。这个错误很隐蔽,因为报错信息是 "redirect_uri 不匹配",你容易怀疑是代码问题,其实是配置问题。

第二个坑是session.user.id类型缺失。Auth.js 默认 session 的 user 类型里没有id字段,如果你要在 tRPC 里直接用ctx.session.user.id,必须像上面那样在callbacks.session里把id塞进去,并且对类型做 module augmentation。不做这一步,TS 会在你写代码的时候报错说属性不存在。

第三个坑是环境变量NEXTAUTH_SECRET。本地开发时模板会生成一个默认值,但生产环境必须自己设置一个足够随机且固定的 secret,否则重新部署后 session 失效,用户会被全部登出。我的做法是生成一个足够长的随机字符串写进环境的 secrets 里,写完就不动了。

7. 部署与线上维护:t3code 上线的最后一公里

7.1 部署到 Vercel 的常规流程

t3code 部署用的是 Vercel。流程并不复杂:把代码推到 GitHub 仓库,然后在 Vercel 里导入项目,选择根目录,框架它会自动识别为 Next.js。接下来最重要的一步是在 Vercel 的环境变量设置里配置:

环境变量作用说明
DATABASE_URL数据库连接串必须指向生产数据库,不能是本地地址
NEXTAUTH_SECRET会话加密密钥用足够长的随机字符串
GITHUB_CLIENT_IDGitHub OAuth 客户端 ID生产环境的回调地址也要一起配好
GITHUB_CLIENT_SECRETGitHub OAuth 客户端密钥和 ID 成对配置

NEXTAUTH_URL在 Vercel 上会自动推断成你的部署域名,一般不用手动设置。但如果你绑定了自定义域名,最好显式写出来,避免回调地址推断错误。

7.2 Serverless 环境里的数据库连接策略

Vercel 上的函数是 Serverless 模式,每次冷启动都会建立新的请求上下文。如果数据库连接数没有上限,并发一上来,数据库很容易被连接请求打满。我的方案是:

  • 数据库连接串里显式限制连接数。以 Postgres 为例,在DATABASE_URL后面加上?connection_limit=1之类的参数,让每个实例只保持少量连接。
  • 用支持连接池的数据库服务。比如 Neon、Supabase 这类云数据库本身有连接池,Serverless 函数连上去不容易爆。
  • 生产环境的 Prisma 迁移在发布前执行prisma migrate deploy,不要在发布过程中触发迁移。

这条经验不是 T3 独有的,但如果你按照普通单机数据库的思路部署到 Serverless,迟早会撞上连接数告警。提前把连接池方案定下来,上线时才不会慌。

7.3 部署后最容易翻车的三件事

翻车第一名:.env没同步。本地跑通了,推到线上直接报数据库连接错误,一看是DATABASE_URL没设。Vercel 不会读取你本地的.env,所有环境变量都要在后台手动配置。

翻车第二名:生产迁移没执行。所有 Prisma schema 改完了,但线上数据库还没建表,页面显示"relation does not exist"。解决方式是发布前跑一次prisma migrate deploy,并且确认迁移脚本已经被提交到仓库。

翻车第三名:Node 版本不一致。本地用的新版 Node,线上模板默认跑的旧版本,装依赖或者跑构建时突然报错。Vercel 项目设置里可以指定 Node 版本,本地和线上最好固定同一个 LTS。

7.4 我现在的维护习惯:先类型检查再提交

项目跑起来之后,我给自己定了一个不算复杂但很有效的流程:每次改动,先跑pnpm lint和pnpm type-check,都通过了再提交。这个习惯救了我很多次,因为 t3code 是一套全链路类型绑定的项目,你改了后端的一个字段,前端可能有一堆地方需要跟着改。如果跳过类型检查直接提交,CI 里红了再回头改,反而更费时间。

另外我养成了一个习惯:数据库 schema 的改动永远先出迁移预览再执行。prisma migrate diff可以看即将执行的 SQL 大致是什么样,确认没删掉不该删的数据再继续。如果只是本地开发,直接用migrate dev就行,但涉及生产环境的操作,我会多花一分钟确认。

如果你问我这次重写到底值不值,我的感受是:代码量没有变少,但改代码的心态完全变了。以前改一个接口字段,需要前后端确认、翻文档、查旧类型、再担心别的地方没用上;现在编译器会在第一时间告诉你哪里没跟上。T3 不是银弹,它只是把"类型安全"贯彻得足够彻底,然后逼着你用正确的方式组织代码。对中小型项目来说,这套组合的性价比确实很高。

最后再分享一个小技巧。如果你也准备用这套栈,前端组件里如果发现api.xxx.yyy没有自动提示,多半是 AppRouter 的类型没有正确引用,或者没有在 tsconfig 的 paths 里配置好 alias。遇到这种情况,别急着怀疑框架,先重新跑一次tsc,看看类型引用路径通不通,十有八九是环境配置的锅。

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

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

立即咨询