🚀 Next.js 全栈项目数据库连接实战:Drizzle ORM + Supabase 从零到跑通

🚀 Next.js 全栈项目数据库连接实战:Drizzle ORM + Supabase 从零到跑通

摘要:很多 Next.js 全栈开发者在连接云端数据库时都会踩坑。本文以一个真实的管理后台项目为例,手把手教你使用 Drizzle ORM + Supabase PostgreSQL 直连方案实现数据库连接,涵盖 TypeScript ORM 配置、Schema 定义、数据流转架构、CRUD 操作全流程,以及 4 个高频踩坑点的解决方案。


📑 目录

  • [📌 前言](#📌 前言 "#-%E5%89%8D%E8%A8%80")
  • [🎯 本文适合谁](#🎯 本文适合谁 "#-%E6%9C%AC%E6%96%87%E9%80%82%E5%90%88%E8%B0%81")
  • [📚 第一步:技术栈选择](#📚 第一步:技术栈选择 "#-%E7%AC%AC%E4%B8%80%E6%AD%A5%E6%8A%80%E6%9C%AF%E6%A0%88%E9%80%89%E6%8B%A9")
  • [📦 第二步:安装依赖](#📦 第二步:安装依赖 "#-%E7%AC%AC%E4%BA%8C%E6%AD%A5%E5%AE%89%E8%A3%85%E4%BE%9D%E8%B5%96")
  • [🔧 第三步:配置环境变量](#🔧 第三步:配置环境变量 "#-%E7%AC%AC%E4%B8%89%E6%AD%A5%E9%85%8D%E7%BD%AE%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F")
  • [🔌 第四步:创建数据库连接](#🔌 第四步:创建数据库连接 "#-%E7%AC%AC%E5%9B%9B%E6%AD%A5%E5%88%9B%E5%BB%BA%E6%95%B0%E6%8D%AE%E5%BA%93%E8%BF%9E%E6%8E%A5")
  • [📋 第五步:定义数据库 Schema](#📋 第五步:定义数据库 Schema "#-%E7%AC%AC%E4%BA%94%E6%AD%A5%E5%AE%9A%E4%B9%89%E6%95%B0%E6%8D%AE%E5%BA%93-schema")
  • [⚙️ 第六步:配置 Drizzle Kit](#⚙️ 第六步:配置 Drizzle Kit "#%EF%B8%8F-%E7%AC%AC%E5%85%AD%E6%AD%A5%E9%85%8D%E7%BD%AE-drizzle-kit")
  • [🔄 第七步:数据流转架构](#🔄 第七步:数据流转架构 "#-%E7%AC%AC%E4%B8%83%E6%AD%A5%E6%95%B0%E6%8D%AE%E6%B5%81%E8%BD%AC%E6%9E%B6%E6%9E%84")
  • [💻 第八步:实际使用示例](#💻 第八步:实际使用示例 "#-%E7%AC%AC%E5%85%AB%E6%AD%A5%E5%AE%9E%E9%99%85%E4%BD%BF%E7%94%A8%E7%A4%BA%E4%BE%8B")
  • [🐛 踩坑记录](#🐛 踩坑记录 "#-%E8%B8%A9%E5%9D%91%E8%AE%B0%E5%BD%95")
  • [❓ 常见问题 FAQ](#❓ 常见问题 FAQ "#-%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98-faq")
  • [💡 经验总结](#💡 经验总结 "#-%E7%BB%8F%E9%AA%8C%E6%80%BB%E7%BB%93")
  • [🔗 参考资料](#🔗 参考资料 "#-%E5%8F%82%E8%80%83%E8%B5%84%E6%96%99")

📌 前言

最近在做一个 Next.js 全栈项目(单词管理后台),需要连接数据库。一开始我也很纠结:用 Prisma 还是 Drizzle?用 Supabase 客户端库还是直连 PostgreSQL?

经过一番调研和实践,最终选择了 Drizzle ORM + Supabase PostgreSQL 直连 的方案。这个方案有几个优势:

  1. 类型安全:Drizzle 的类型推导比 Prisma 更轻量
  2. 性能更好:直连 PostgreSQL,没有 Supabase 客户端层的额外开销
  3. 更灵活:可以使用原生 SQL,不受客户端库的限制
  4. 更轻量 :不需要引入 @supabase/supabase-js,包体积更小

今天就把这套方案分享给大家,希望能帮到有同样需求的朋友。

🎯 本文适合谁

  • 正在做 Next.js 全栈项目的开发者
  • 想要使用 Supabase 但不想用 Supabase 客户端库的开发者
  • 想要学习 Drizzle ORM 的开发者
  • 对数据库连接原理感兴趣的开发者

📚 核心内容

第一步:技术栈选择

在开始之前,先明确一下我们的技术栈:

组件 技术选型 版本 说明
框架 Next.js 16 全栈框架
ORM drizzle-orm 0.45.2 轻量级 TypeScript ORM
数据库驱动 postgres 3.4.9 Postgres.js 驱动
数据库 Supabase - 云端 PostgreSQL
迁移工具 drizzle-kit 0.31.10 Drizzle 的 CLI 工具

为什么不用 Supabase 客户端库?

很多教程都会教你用 @supabase/supabase-js,但这个库实际上是 Supabase 的 REST API 封装,而不是直接连接 PostgreSQL。使用直连方案有以下优势:

对比项 Supabase 客户端库 直连 PostgreSQL
连接方式 REST API TCP 直连
性能 有额外开销 更快
类型安全 需要手动定义 Drizzle 自动推导
SQL 支持 受限于 API 完整 SQL
包体积 较大 较小

第二步:安装依赖

bash 复制代码
# 安装核心依赖
npm install drizzle-orm postgres

# 安装开发依赖
npm install -D drizzle-kit dotenv

依赖说明

  • drizzle-orm:ORM 核心库
  • postgres:PostgreSQL 驱动(注意不是 pg,是 postgres
  • drizzle-kit:用于生成迁移文件和管理数据库
  • dotenv:用于加载环境变量

第三步:配置环境变量

在项目根目录创建 .env 文件:

env 复制代码
# Supabase 数据库连接字符串
# 格式:postgresql://用户名:密码@主机:端口/数据库名
DATABASE_URL=postgresql://postgres.项目ID:密码@aws-0-region.pooler.supabase.com:5432/postgres

如何获取 Supabase 数据库连接字符串?

  1. 登录 Supabase 控制台
  2. 选择你的项目
  3. 进入 Settings → Database
  4. 找到 Connection string → URI
  5. 复制连接字符串,替换密码即可

⚠️ 重要提示

  • 密码中如果有特殊字符(如 !@#$%),需要进行 URL 编码
  • 例如:!%21@%40#%23
  • 可以使用 URL Encode/Decode 工具 进行转换

第四步:创建数据库连接

创建 db/index.ts 文件:

typescript 复制代码
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import * as schema from "./schema"

// 从环境变量获取数据库连接字符串
const connectionString = process.env.DATABASE_URL

if (!connectionString) {
  throw new Error("缺少 DATABASE_URL 环境变量,请检查 .env 文件")
}

// 创建 PostgreSQL 连接
const client = postgres(connectionString, {
  prepare: false, // Supabase 连接池模式下必须关闭 prepare
  ssl: "require", // 强制 SSL 加密
})

// 创建 Drizzle 实例
export const db = drizzle(client, { schema })

// 导出 schema,方便在其他地方使用
export { schema }

关键配置说明

  1. prepare: false

    • Supabase 使用 PgBouncer 作为连接池
    • PgBouncer 不支持 prepared statements
    • 如果不设置这个选项,会报错:prepared statement "xxx" does not exist
    • 这是最常见的坑,一定要注意!
  2. ssl: "require"

    • Supabase 要求 SSL 加密连接
    • 如果不设置,会报错:no pg_hba.conf entry for host
  3. schema 参数

    • 传入 schema 可以启用 Drizzle 的关系查询功能
    • 例如:db.query.adminUsers.findMany({ with: { sessions: true } })

第五步:定义数据库 Schema

创建 db/schema.ts 文件:

typescript 复制代码
import { pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"

// 定义角色枚举
export const roleEnum = pgEnum("role", ["super_admin", "admin"])

// 定义状态枚举
export const statusEnum = pgEnum("status", ["active", "disabled"])

// 管理员用户表
export const adminUsers = pgTable("admin_users", {
  // 用户 ID,使用 UUID 作为主键
  id: uuid("id").defaultRandom().primaryKey(),

  // 用户名
  name: varchar("name", { length: 100 }).notNull(),

  // 邮箱,唯一
  email: varchar("email", { length: 255 }).notNull().unique(),

  // 密码哈希值
  passwordHash: varchar("password_hash", { length: 255 }).notNull(),

  // 角色,默认为 admin
  role: roleEnum("role").notNull().default("admin"),

  // 状态,默认为 active
  status: statusEnum("status").notNull().default("active"),

  // 创建时间
  createdAt: timestamp("created_at").notNull().defaultNow(),

  // 更新时间
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
})

// 管理员会话表
export const adminSessions = pgTable("admin_sessions", {
  // 会话 ID
  id: uuid("id").defaultRandom().primaryKey(),

  // 关联的用户 ID
  userId: uuid("user_id")
    .notNull()
    .references(() => adminUsers.id, { onDelete: "cascade" }),

  // 会话令牌
  token: varchar("token", { length: 255 }).notNull().unique(),

  // 过期时间
  expiresAt: timestamp("expires_at").notNull(),

  // 创建时间
  createdAt: timestamp("created_at").notNull().defaultNow(),
})

Schema 说明

  1. pgEnum

    • 定义 PostgreSQL 枚举类型
    • 比使用 varchar 更安全,只能存储预定义的值
  2. pgTable

    • 定义数据库表结构
    • 第一个参数是表名
    • 第二个参数是列定义
  3. references

    • 定义外键关系
    • onDelete: "cascade" 表示删除用户时,自动删除其所有会话
  4. 命名约定

    • 数据库列使用 snake_case(如 created_at
    • TypeScript 属性使用 camelCase(如 createdAt
    • Drizzle 会自动处理转换

第六步:配置 Drizzle Kit

创建 drizzle.config.ts 文件:

typescript 复制代码
import "dotenv/config"
import { defineConfig } from "drizzle-kit"

export default defineConfig({
  // Schema 文件路径
  schema: "./db/schema.ts",

  // 迁移文件输出目录
  out: "./drizzle",

  // 数据库方言
  dialect: "postgresql",

  // 数据库连接配置
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
})

package.json 中添加数据库脚本

json 复制代码
{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:push": "drizzle-kit push",
    "db:studio": "drizzle-kit studio"
  }
}

脚本说明

命令 说明 使用场景
db:generate 生成迁移文件 修改 schema 后,生成迁移文件
db:migrate 执行迁移 将迁移文件应用到数据库
db:push 直接推送 schema 开发阶段,快速同步 schema
db:studio 打开 Drizzle Studio 可视化查看和编辑数据

⚠️ 开发阶段建议使用 db:push

  • 不需要生成迁移文件
  • 直接将 schema 同步到数据库
  • 更快、更方便
  • 但不适合生产环境

第七步:数据流转架构

理解了各个组件后,我们来看看完整的数据流转链路。以用户登录为例,数据经过 5 层处理:

sql 复制代码
浏览器 (Browser)
  │  fetch("/api/auth/signin", { method: "POST", body: { email, password } })
  ▼
Next.js API Route (服务端)          ← app/api/auth/signin/route.ts
  │  import { db } from "@/db"
  │  db.select().from(adminUsers).where(eq(adminUsers.email, email))
  ▼
Drizzle ORM (ORM 层)                ← 将 TypeScript 调用转换为 SQL
  │  SELECT * FROM admin_users WHERE email = $1
  ▼
postgres.js (驱动层)                 ← 建立 TCP 连接,发送 SQL,接收结果
  │  SSL 加密连接,prepare: false
  ▼
Supabase Connection Pooler (PgBouncer)  ← 连接池管理、连接复用、负载均衡
  ▼
Supabase PostgreSQL Database        ← admin_users / admin_sessions 表

💡 关键点:整个链路中,Drizzle ORM 负责将 TypeScript 代码转换为 SQL,postgres.js 负责建立连接和传输数据,Supabase 的 PgBouncer 负责连接池管理。三层各司其职,职责清晰。

数据库表结构

表名 主要字段 说明
admin_users id(uuid), name, email(unique), password_hash, role(enum), status(enum), created_at, updated_at 管理员用户表
admin_sessions id(uuid), user_id(FK → admin_users), token(unique), expires_at, created_at 会话管理表

📌 提示 :如果你更喜欢图形化的架构图,可以用 Mermaid Live Editor 生成流程图,然后截图插入文章。

第八步:实际使用示例

示例 1:用户注册

typescript 复制代码
// app/api/auth/signup/route.ts
import { db } from "@/db"
import { adminUsers } from "@/db/schema"
import { NextResponse } from "next/server"

export async function POST(request: Request) {
  const { name, email, password } = await request.json()

  // 检查邮箱是否已存在
  const existingUser = await db
    .select()
    .from(adminUsers)
    .where(eq(adminUsers.email, email))

  if (existingUser.length > 0) {
    return NextResponse.json(
      { error: "邮箱已被注册" },
      { status: 400 }
    )
  }

  // 创建新用户
  const newUser = await db
    .insert(adminUsers)
    .values({
      name,
      email,
      passwordHash: await hashPassword(password), // 密码哈希
      role: "admin",
    })
    .returning() // 返回插入的数据

  return NextResponse.json(newUser[0])
}

示例 2:用户登录查询

typescript 复制代码
// app/api/auth/signin/route.ts
import { db } from "@/db"
import { adminUsers } from "@/db/schema"
import { eq } from "drizzle-orm"
import { NextResponse } from "next/server"

export async function POST(request: Request) {
  const { email, password } = await request.json()

  // 根据邮箱查询用户
  const [user] = await db
    .select()
    .from(adminUsers)
    .where(eq(adminUsers.email, email))

  if (!user) {
    return NextResponse.json({ error: "用户不存在" }, { status: 404 })
  }

  // 验证密码
  const isValid = await verifyPassword(password, user.passwordHash)
  if (!isValid) {
    return NextResponse.json({ error: "密码错误" }, { status: 401 })
  }

  return NextResponse.json({ message: "登录成功", user: { id: user.id, name: user.name, role: user.role } })
}

📤 API 返回示例

json 复制代码
{ "message": "登录成功", "user": { "id": "a1b2c3d4-...", "name": "张三", "role": "admin" } }

示例 3:更新用户信息

typescript 复制代码
// app/api/admin-users/[id]/route.ts
import { db } from "@/db"
import { adminUsers } from "@/db/schema"
import { eq } from "drizzle-orm"
import { NextResponse } from "next/server"

export async function PUT(request: Request, { params }: { params: { id: string } }) {
  const { name, role, status } = await request.json()

  // 更新用户信息,.returning() 返回更新后的数据
  const [updatedUser] = await db
    .update(adminUsers)
    .set({
      name,
      role,
      status,
      updatedAt: new Date(), // 手动更新修改时间
    })
    .where(eq(adminUsers.id, params.id))
    .returning()

  if (!updatedUser) {
    return NextResponse.json({ error: "用户不存在" }, { status: 404 })
  }

  return NextResponse.json(updatedUser)
}

📤 API 返回示例

json 复制代码
{ "id": "a1b2c3d4-...", "name": "李四", "role": "super_admin", "status": "active", "updatedAt": "2026-08-26T10:30:00Z" }

示例 4:删除用户

typescript 复制代码
// app/api/admin-users/[id]/route.ts
import { db } from "@/db"
import { adminUsers } from "@/db/schema"
import { eq } from "drizzle-orm"
import { NextResponse } from "next/server"

export async function DELETE(request: Request, { params }: { params: { id: string } }) {
  // 删除用户并返回被删除的数据
  // 注意:admin_sessions 表设置了 onDelete: "cascade"
  // 所以删除用户时,其所有会话记录会自动删除
  const [deletedUser] = await db
    .delete(adminUsers)
    .where(eq(adminUsers.id, params.id))
    .returning()

  if (!deletedUser) {
    return NextResponse.json({ error: "用户不存在" }, { status: 404 })
  }

  return NextResponse.json({ message: "删除成功", user: deletedUser })
}

📤 API 返回示例

json 复制代码
{ "message": "删除成功", "user": { "id": "a1b2c3d4-...", "name": "张三", "email": "zhangsan@example.com" } }

示例 5:关联查询(Session + User)

typescript 复制代码
// lib/auth.ts
import { db } from "@/db"
import { adminUsers, adminSessions } from "@/db/schema"
import { eq, and, gt } from "drizzle-orm"

export async function getSession(token: string) {
  // 关联查询:通过 session token 查找对应的用户信息
  const [session] = await db
    .select({
      // 选取需要的字段
      sessionId: adminSessions.id,
      expiresAt: adminSessions.expiresAt,
      userId: adminUsers.id,
      userName: adminUsers.name,
      userEmail: adminUsers.email,
      userRole: adminUsers.role,
      userStatus: adminUsers.status,
    })
    .from(adminSessions)
    .innerJoin(adminUsers, eq(adminSessions.userId, adminUsers.id))
    .where(
      and(
        eq(adminSessions.token, token),
        gt(adminSessions.expiresAt, new Date()) // 只查找未过期的 session
      )
    )

  return session ?? null
}

📤 查询结果示例

json 复制代码
{
  "sessionId": "e5f6g7h8-...",
  "expiresAt": "2026-09-02T10:30:00Z",
  "userId": "a1b2c3d4-...",
  "userName": "张三",
  "userEmail": "zhangsan@example.com",
  "userRole": "admin",
  "userStatus": "active"
}

🐛 踩坑记录

问题 1:prepared statement 错误

现象

go 复制代码
error: prepared statement "xxx" does not exist

原因: Supabase 使用 PgBouncer 作为连接池,而 PgBouncer 不支持 prepared statements。

解决 : 在创建连接时设置 prepare: false

typescript 复制代码
const client = postgres(connectionString, {
  prepare: false, // 必须设置
})

问题 2:SSL 连接错误

现象

yaml 复制代码
error: no pg_hba.conf entry for host

原因: Supabase 要求 SSL 加密连接。

解决 : 在创建连接时设置 ssl: "require"

typescript 复制代码
const client = postgres(connectionString, {
  ssl: "require", // 必须设置
})

问题 3:密码中的特殊字符

现象

go 复制代码
error: password authentication failed

原因 : 密码中包含特殊字符(如 !@#$%),没有进行 URL 编码。

解决: 对密码进行 URL 编码:

typescript 复制代码
// 错误示例
const url = "postgresql://postgres:pass!word@host:5432/db"

// 正确示例
const url = "postgresql://postgres:pass%21word@host:5432/db"

问题 4:表名不存在

现象

go 复制代码
error: relation "admin_users" does not exist

原因: 数据库中还没有创建对应的表。

解决 : 使用 drizzle-kit push 将 schema 同步到数据库:

bash 复制代码
npm run db:push

❓ 常见问题 FAQ

Q1:为什么不用 Prisma 而用 Drizzle?

对比项 Prisma Drizzle
类型安全 需要 codegen 原生 TypeScript 推导
性能 较重,有运行时开销 轻量,接近原生 SQL
学习成本 独立的 Schema 语言 原生 TypeScript 写法
包体积 较大 较小
SQL 灵活度 受限于 Prisma API 可直接写原生 SQL

💡 结论:如果你追求轻量、类型安全、且熟悉 SQL,Drizzle 是更好的选择。如果团队更习惯图形化操作,Prisma 也不错。

Q2:Supabase 的连接池模式和直连模式有什么区别?

模式 端口 特点 适用场景
Transaction (Pooler) 5432 通过 PgBouncer 连接池,prepare: false 推荐用于生产环境
Session (直连) 5432 直接连接 PostgreSQL,支持 prepared statements 需要 LISTEN/NOTIFY 等高级功能时

💡 建议:大多数场景使用连接池模式即可,本文就是基于此模式。

Q3:db:pushdb:migrate 到底该用哪个?

  • db:push :直接将 Schema 变更推送到数据库,不生成迁移文件
    • ✅ 适合开发阶段,快速迭代
    • ❌ 不适合生产环境,无法追踪变更历史
  • db:migrate :生成 SQL 迁移文件,然后执行迁移
    • ✅ 适合生产环境,变更可追踪、可回滚
    • ❌ 开发阶段稍显繁琐

💡 建议 :开发时用 db:push,准备上线时用 db:generate + db:migrate

Q4:Drizzle 支持哪些数据库?

Drizzle ORM 支持三大主流数据库:

数据库 驱动 文档
PostgreSQL postgres / @neondatabase/serverless / pg 文档
MySQL mysql2 文档
SQLite better-sqlite3 / libsql 文档

Q5:如何查看数据库中的数据?

有两种方式:

  1. Drizzle Studio(推荐):

    bash 复制代码
    npm run db:studio

    会打开一个 Web 界面,可以查看、编辑、筛选数据。

  2. Supabase 控制台 : 登录 Supabase Dashboard → Table Editor,可以直接查看和操作数据。


💡 经验总结

  1. prepare: false 是必须的:只要使用 Supabase 的连接池模式,就必须关闭 prepared statements。

  2. ssl: "require" 是必须的:Supabase 强制要求 SSL 加密连接。

  3. 开发阶段用 db:push,生产环境用 db:migrate

    • db:push:快速同步,适合开发
    • db:migrate:生成迁移文件,适合生产
  4. 使用 Drizzle Studio 查看数据

    bash 复制代码
    npm run db:studio

    可以在浏览器中查看和编辑数据库数据。

  5. Schema 命名约定

    • 数据库列:snake_case(如 created_at
    • TypeScript 属性:camelCase(如 createdAt
    • Drizzle 会自动处理转换
  6. 使用枚举类型

    • 比使用 varchar 更安全
    • 只能存储预定义的值
    • 数据库层面的约束
  7. 外键设置 onDelete: "cascade"

    • 删除父记录时,自动删除子记录
    • 避免数据不一致

🔗 参考资料

💬 交流讨论

你在使用 Drizzle ORM + Supabase 时遇到过什么问题?欢迎在评论区分享你的经验!

如果这篇文章对你有帮助,请给我一个 点赞👍 + 收藏⭐ + 关注👆,你的支持是我持续创作的动力!

📢 下一篇预告:《Next.js 全栈项目认证系统实战:自定义 Session + httpOnly Cookie》,手把手教你实现完整的用户认证流程,敬请期待!


关于作者:一个热爱技术的全栈开发者,专注于 Next.js、TypeScript、数据库等技术领域。

GitHubhttps://github.com/structures-man/danci

相关推荐
姚杨24 分钟前
聊了三年 DDD,代码里全是贫血模型:老陈一句话点破,落地先过这几关
后端·orm
雪花凌落的盛夏2 小时前
PostgreSQL 14 主备集群实战部署教程:流复制 + 双机 WAL 归档 + 自动化备份
数据库·postgresql·自动化
像风一样自由20202 小时前
14.什么时候用pgvector什么时候单独部署Milvus
postgresql·大模型·微调·milvus
IvorySQL15 小时前
PostgreSQL 日报|PG19 外键快速路径隐患已修复(8 月 23 日)
数据库·postgresql
YIAN16 小时前
Next.js App Router 全栈实战:从 0 到 1 写一个 Todo 应用,前端后端一个项目搞定
前端·全栈·next.js
进哥AI研习社17 小时前
图片优化全链路——AVIF/WebP 自适应与懒加载策略
next.js·图片优化·懒加载·avif·lcp·模糊占位·cdn 加速
风哥2号19 小时前
PostgreSQL数据库恢复工具FGPDU(FGEDU PostgreSQL DUL)
数据库·postgresql
像风一样自由202019 小时前
13.pgvector入门用PostgreSQL直接实现向量检
人工智能·postgresql·大模型·rag·智能体
JavaPub-rodert19 小时前
Ontop 详解:不搬数据库,也能把 MySQL / PostgreSQL 变成知识图谱
数据库·mysql·postgresql