🚀 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 直连 的方案。这个方案有几个优势:
- 类型安全:Drizzle 的类型推导比 Prisma 更轻量
- 性能更好:直连 PostgreSQL,没有 Supabase 客户端层的额外开销
- 更灵活:可以使用原生 SQL,不受客户端库的限制
- 更轻量 :不需要引入
@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 数据库连接字符串?
- 登录 Supabase 控制台
- 选择你的项目
- 进入 Settings → Database
- 找到 Connection string → URI
- 复制连接字符串,替换密码即可
⚠️ 重要提示:
- 密码中如果有特殊字符(如
!@#$%),需要进行 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 }
关键配置说明:
-
prepare: false:- Supabase 使用 PgBouncer 作为连接池
- PgBouncer 不支持 prepared statements
- 如果不设置这个选项,会报错:
prepared statement "xxx" does not exist - 这是最常见的坑,一定要注意!
-
ssl: "require":- Supabase 要求 SSL 加密连接
- 如果不设置,会报错:
no pg_hba.conf entry for host
-
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 说明:
-
pgEnum:- 定义 PostgreSQL 枚举类型
- 比使用
varchar更安全,只能存储预定义的值
-
pgTable:- 定义数据库表结构
- 第一个参数是表名
- 第二个参数是列定义
-
references:- 定义外键关系
onDelete: "cascade"表示删除用户时,自动删除其所有会话
-
命名约定:
- 数据库列使用 snake_case(如
created_at) - TypeScript 属性使用 camelCase(如
createdAt) - Drizzle 会自动处理转换
- 数据库列使用 snake_case(如
第六步:配置 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:push 和 db: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:如何查看数据库中的数据?
有两种方式:
-
Drizzle Studio(推荐):
bashnpm run db:studio会打开一个 Web 界面,可以查看、编辑、筛选数据。
-
Supabase 控制台 : 登录 Supabase Dashboard → Table Editor,可以直接查看和操作数据。
💡 经验总结
-
prepare: false是必须的:只要使用 Supabase 的连接池模式,就必须关闭 prepared statements。 -
ssl: "require"是必须的:Supabase 强制要求 SSL 加密连接。 -
开发阶段用
db:push,生产环境用db:migrate:db:push:快速同步,适合开发db:migrate:生成迁移文件,适合生产
-
使用 Drizzle Studio 查看数据:
bashnpm run db:studio可以在浏览器中查看和编辑数据库数据。
-
Schema 命名约定:
- 数据库列:snake_case(如
created_at) - TypeScript 属性:camelCase(如
createdAt) - Drizzle 会自动处理转换
- 数据库列:snake_case(如
-
使用枚举类型:
- 比使用
varchar更安全 - 只能存储预定义的值
- 数据库层面的约束
- 比使用
-
外键设置
onDelete: "cascade":- 删除父记录时,自动删除子记录
- 避免数据不一致
🔗 参考资料
💬 交流讨论
你在使用 Drizzle ORM + Supabase 时遇到过什么问题?欢迎在评论区分享你的经验!
如果这篇文章对你有帮助,请给我一个 点赞👍 + 收藏⭐ + 关注👆,你的支持是我持续创作的动力!
📢 下一篇预告:《Next.js 全栈项目认证系统实战:自定义 Session + httpOnly Cookie》,手把手教你实现完整的用户认证流程,敬请期待!
关于作者:一个热爱技术的全栈开发者,专注于 Next.js、TypeScript、数据库等技术领域。
GitHub :
https://github.com/structures-man/danci