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

146 阅读10分钟

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

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


📑 目录


📌 前言

最近在做一个 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.js16全栈框架
ORMdrizzle-orm0.45.2轻量级 TypeScript ORM
数据库驱动postgres3.4.9Postgres.js 驱动
数据库Supabase-云端 PostgreSQL
迁移工具drizzle-kit0.31.10Drizzle 的 CLI 工具

为什么不用 Supabase 客户端库?

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

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

第二步:安装依赖

# 安装核心依赖
npm install drizzle-orm postgres

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

依赖说明:

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

第三步:配置环境变量

在项目根目录创建 .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 文件:

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 文件:

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 文件:

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 中添加数据库脚本:

{
  "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 层处理:

浏览器 (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_usersid(uuid), name, email(unique), password_hash, role(enum), status(enum), created_at, updated_at管理员用户表
admin_sessionsid(uuid), user_id(FK → admin_users), token(unique), expires_at, created_at会话管理表

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

第八步:实际使用示例

示例 1:用户注册

// 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:用户登录查询

// 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 返回示例:

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

示例 3:更新用户信息

// 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 返回示例:

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

示例 4:删除用户

// 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 返回示例:

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

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

// 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
}

📤 查询结果示例:

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

🐛 踩坑记录

问题 1:prepared statement 错误

现象:

error: prepared statement "xxx" does not exist

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

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

const client = postgres(connectionString, {
  prepare: false, // 必须设置
})

问题 2:SSL 连接错误

现象:

error: no pg_hba.conf entry for host

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

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

const client = postgres(connectionString, {
  ssl: "require", // 必须设置
})

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

现象:

error: password authentication failed

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

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

// 错误示例
const url = "postgresql://postgres:pass!word@host:5432/db"

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

问题 4:表名不存在

现象:

error: relation "admin_users" does not exist

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

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

npm run db:push

❓ 常见问题 FAQ

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

对比项PrismaDrizzle
类型安全需要 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:push 和 db:migrate 到底该用哪个?

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

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

Q4:Drizzle 支持哪些数据库?

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

数据库驱动文档
PostgreSQLpostgres / @neondatabase/serverless / pg文档
MySQLmysql2文档
SQLitebetter-sqlite3 / libsql文档

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

有两种方式:

  1. Drizzle Studio(推荐):

    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 查看数据:

    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、数据库等技术领域。

GitHub:https://github.com/structures-man/danci