Next.js + Drizzle ORM 全栈数据库实战:从零搭建到生产部署

从零搭建 Next.js App Router + Drizzle ORM + PostgreSQL 全栈数据库层。涵盖连接池单例模式、Schema 设计、Relations v2、CRUD 操作、Zod 验证集成与 Vercel + Neon 生产部署实践。

Next.js + Drizzle ORM全栈指南:PostgreSQL 2026

引言:数据库层——你 Next.js 全栈应用的"最后一块拼图"

如果你一直在跟我们之前的文章系列,到这里你应该已经知道怎么用 Server Actions 做数据变更、怎么用 fetch + use cache 搞定数据获取和缓存、怎么用中间件拦截请求、怎么用纵深防御策略做认证。但说实话,有一块关键拼图一直缺着——数据库层

你有没有注意到,之前那些文章里的代码示例总是冒出来一个 db.post.create() 或者 db.product.findMany(),仿佛数据库连接是天上掉下来的一样?

但现实要残酷得多。数据库层的选型、配置和架构设计,往往才是决定一个 Next.js 全栈应用成败的关键。选错了 ORM,连接池配不对,Schema 管理一团糟——开发阶段可能啥事没有,一上生产立马原形毕露。我自己就踩过这个坑。

那为什么选 Drizzle ORM?直接说结论吧:2026 年,Drizzle ORM 已经是 Next.js 全栈开发的首选 ORM 了。体积只有约 7.4KB(minified + gzipped),零依赖,原生支持 Edge Runtime,TypeScript 类型推断做得非常完整,而且——这点很关键——它用的就是你熟悉的 SQL 语法。跟 Prisma 那 2MB+ 的客户端和一堆 Edge 函数限制比起来,Drizzle 在 Serverless 架构下的优势,说"碾压级"毫不夸张。

好了,这篇文章会从零开始,手把手带你搭一套完整的 Next.js + Drizzle ORM + PostgreSQL 数据库层。连接管理、Schema 设计、迁移策略、CRUD 操作、关系查询、Zod 验证集成、生产部署——全都覆盖。读完之后,你就能把前面所有文章里那些"假设已存在"的 db 对象真正落地了。

一、项目初始化:安装与配置

1.1 安装依赖

我们以 PostgreSQL 为例(Drizzle 同样支持 MySQL 和 SQLite)。根据你的部署目标选择对应的数据库驱动:

# 核心依赖
npm install drizzle-orm

# 本地/传统 Node.js 环境 —— 使用 postgres.js 驱动
npm install postgres

# Serverless/Vercel 部署 —— 使用 Neon Serverless 驱动
npm install @neondatabase/serverless

# 开发工具
npm install -D drizzle-kit

这里其实要先想清楚一个问题:你的数据库打算跑在哪里?

  • 本地/自托管 PostgreSQL:用 postgres(postgres.js 驱动),走 TCP 连接
  • Neon Serverless Postgres:用 @neondatabase/serverless,走 HTTP 或 WebSocket
  • Vercel Postgres:底层也是 Neon,用 @vercel/postgres 或直接用 Neon 驱动
  • Supabase:用 postgres 驱动,走标准 TCP 连接

本文会同时演示 postgres.js(本地开发)和 Neon Serverless(生产部署)两种方案,这样不管你选哪个路线都能对号入座。

1.2 项目结构

一个组织良好的数据库层,目录结构一定要清晰。我推荐这种布局:

📂 src/
  📂 db/
    📜 index.ts          # 数据库连接实例(单例)
    📜 schema.ts         # 所有表定义(或拆分为多个文件)
    📜 relations.ts      # 表关系定义(Drizzle Relations v2)
    📜 migrate.ts        # 迁移执行脚本
  📂 lib/
    📂 data/
      📜 posts.ts        # 文章相关的数据访问函数
      📜 users.ts        # 用户相关的数据访问函数
    📂 validators/
      📜 post.ts         # 文章相关的 Zod 验证 Schema
      📜 user.ts         # 用户相关的 Zod 验证 Schema
  📂 app/
    📂 actions/
      📜 post.ts         # 文章相关的 Server Actions
📜 drizzle.config.ts     # Drizzle Kit 配置文件
📂 drizzle/              # 自动生成的迁移文件(纳入 Git 管理)

这个结构把数据库连接Schema 定义数据访问层验证逻辑Server Actions 分得清清楚楚。接下来我们逐一展开。

二、数据库连接:单例模式与连接池

2.1 为什么需要单例模式

Next.js 开发环境有个"隐藏陷阱":热重载(HMR)会频繁重新加载模块。如果每次重载都创建新的数据库连接,连接池很快就会被耗尽。

这不是理论风险——我真的见过有人本地开发跑着跑着,PostgreSQL 就甩过来一个 too many connections 错误。那场面相当令人崩溃。

解决方案是经典的 globalThis 单例模式:

2.2 本地开发连接(postgres.js)

// src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

const createConnection = () => {
  const connectionString = process.env.DATABASE_URL!;

  const client = postgres(connectionString, {
    max: 10,              // 最大连接数
    idle_timeout: 20,     // 空闲连接超时(秒)
    connect_timeout: 10,  // 连接超时(秒)
  });

  return drizzle(client, { schema });
};

// 单例模式:开发环境复用连接,避免热重载导致连接泄漏
const globalForDb = globalThis as unknown as {
  db: ReturnType<typeof createConnection> | undefined;
};

export const db = globalForDb.db ?? createConnection();

if (process.env.NODE_ENV !== "production") {
  globalForDb.db = db;
}

2.3 Serverless 连接(Neon)

如果你部署到 Vercel 或其他 Serverless 平台,就得用 HTTP 或 WebSocket 驱动了——TCP 连接在无状态环境里水土不服:

// src/db/index.ts(Serverless 版本)
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle({ client: sql, schema });

Neon 的 HTTP 驱动不需要连接池管理——每次查询都是独立的 HTTP 请求,天然适合 Serverless 函数的无状态模型。不过要注意,如果你需要事务支持,HTTP 就不够用了,得切换到 WebSocket 驱动:

// 需要事务时使用 WebSocket 驱动
import { Pool, neonConfig } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-serverless";
import ws from "ws";

// 本地开发环境需要 WebSocket polyfill
if (process.env.NODE_ENV === "development") {
  neonConfig.webSocketConstructor = ws;
}

const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
export const db = drizzle({ client: pool, schema });

2.4 环境自适应连接

实际项目中,你大概率希望本地开发用 TCP、生产环境用 HTTP。下面是一种很常见的双模式配置方式:

// src/db/index.ts(环境自适应)
import * as schema from "./schema";

function createDb() {
  if (process.env.NODE_ENV === "production") {
    // 生产环境:Neon HTTP 驱动
    const { neon } = require("@neondatabase/serverless");
    const { drizzle } = require("drizzle-orm/neon-http");
    const sql = neon(process.env.DATABASE_URL!);
    return drizzle({ client: sql, schema });
  } else {
    // 开发环境:postgres.js TCP 驱动
    const postgres = require("postgres");
    const { drizzle } = require("drizzle-orm/postgres-js");
    const client = postgres(process.env.DATABASE_URL!, { max: 10 });
    return drizzle(client, { schema });
  }
}

const globalForDb = globalThis as unknown as {
  db: ReturnType<typeof createDb> | undefined;
};

export const db = globalForDb.db ?? createDb();

if (process.env.NODE_ENV !== "production") {
  globalForDb.db = db;
}

三、Schema 设计:用 TypeScript 定义数据模型

3.1 基础表定义

Drizzle 的 Schema 定义是纯 TypeScript——不需要额外的 Schema 文件,也不需要什么代码生成步骤。来看一个博客系统的完整 Schema(我觉得这是最好的入门示例,因为大多数开发者都能直觉理解博客的数据模型):

// src/db/schema.ts
import {
  pgTable,
  serial,
  text,
  varchar,
  integer,
  boolean,
  timestamp,
  pgEnum,
  index,
  uniqueIndex,
} from "drizzle-orm/pg-core";

// 定义枚举类型
export const postStatusEnum = pgEnum("post_status", [
  "draft",
  "published",
  "archived",
]);

export const userRoleEnum = pgEnum("user_role", [
  "admin",
  "editor",
  "reader",
]);

// 用户表
export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  name: varchar("name", { length: 100 }).notNull(),
  email: varchar("email", { length: 255 }).notNull(),
  avatarUrl: text("avatar_url"),
  role: userRoleEnum("role").default("reader").notNull(),
  createdAt: timestamp("created_at", { withTimezone: true })
    .defaultNow()
    .notNull(),
  updatedAt: timestamp("updated_at", { withTimezone: true })
    .defaultNow()
    .notNull(),
}, (table) => [
  uniqueIndex("users_email_idx").on(table.email),
]);

// 分类表
export const categories = pgTable("categories", {
  id: serial("id").primaryKey(),
  name: varchar("name", { length: 100 }).notNull(),
  slug: varchar("slug", { length: 100 }).notNull(),
  description: text("description"),
}, (table) => [
  uniqueIndex("categories_slug_idx").on(table.slug),
]);

// 文章表
export const posts = pgTable("posts", {
  id: serial("id").primaryKey(),
  title: varchar("title", { length: 255 }).notNull(),
  slug: varchar("slug", { length: 255 }).notNull(),
  content: text("content").notNull(),
  excerpt: text("excerpt"),
  status: postStatusEnum("status").default("draft").notNull(),
  authorId: integer("author_id")
    .references(() => users.id, { onDelete: "cascade" })
    .notNull(),
  categoryId: integer("category_id")
    .references(() => categories.id, { onDelete: "set null" }),
  viewCount: integer("view_count").default(0).notNull(),
  publishedAt: timestamp("published_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true })
    .defaultNow()
    .notNull(),
  updatedAt: timestamp("updated_at", { withTimezone: true })
    .defaultNow()
    .notNull(),
}, (table) => [
  uniqueIndex("posts_slug_idx").on(table.slug),
  index("posts_author_idx").on(table.authorId),
  index("posts_category_idx").on(table.categoryId),
  index("posts_status_idx").on(table.status),
]);

// 标签表
export const tags = pgTable("tags", {
  id: serial("id").primaryKey(),
  name: varchar("name", { length: 50 }).notNull(),
  slug: varchar("slug", { length: 50 }).notNull(),
}, (table) => [
  uniqueIndex("tags_slug_idx").on(table.slug),
]);

// 文章-标签 多对多关联表
export const postTags = pgTable("post_tags", {
  postId: integer("post_id")
    .references(() => posts.id, { onDelete: "cascade" })
    .notNull(),
  tagId: integer("tag_id")
    .references(() => tags.id, { onDelete: "cascade" })
    .notNull(),
}, (table) => [
  index("post_tags_post_idx").on(table.postId),
  index("post_tags_tag_idx").on(table.tagId),
]);

这里有几个细节值得留意:

  • 索引:在外键列和高频查询列上建索引,生产环境的查询性能靠这个撑着
  • 枚举:用 pgEnum 定义数据库级别的枚举类型,比裸字符串字段安全得多
  • 外键约束:通过 .references() 定义,别忘了指定 onDelete 的级联行为
  • 时间戳:一定要用 withTimezone: true,不然时区问题能折腾你大半天

3.2 Drizzle Relations v2:定义表关系

从 Drizzle v1.0.0-beta.1 开始,关系定义 API 做了重大更新。好消息是,新 API 更统一了——你不需要为每个表单独定义关系对象,所有关系在一个地方声明就行:

// src/db/relations.ts
import { defineRelations } from "drizzle-orm";
import * as schema from "./schema";

export const relations = defineRelations(schema, (r) => ({
  // 用户的关系
  users: {
    posts: r.many.posts(),
  },

  // 分类的关系
  categories: {
    posts: r.many.posts(),
  },

  // 文章的关系
  posts: {
    author: r.one.users({
      from: schema.posts.authorId,
      to: schema.users.id,
    }),
    category: r.one.categories({
      from: schema.posts.categoryId,
      to: schema.categories.id,
    }),
    postTags: r.many.postTags(),
  },

  // 标签的关系
  tags: {
    postTags: r.many.postTags(),
  },

  // 关联表的关系
  postTags: {
    post: r.one.posts({
      from: schema.postTags.postId,
      to: schema.posts.id,
    }),
    tag: r.one.tags({
      from: schema.postTags.tagId,
      to: schema.tags.id,
    }),
  },
}));

有一点要明确:关系定义是应用层的概念,不会影响数据库 Schema 或迁移文件。它们的作用仅仅是告诉 Drizzle 的关系查询 API(RQB)表之间怎么关联,以便生成高效的 JOIN 查询。

3.3 类型推断:告别手动类型定义

Drizzle 的类型推断能力算是它的一大杀手锏。你完全不需要手动去定义 TypeScript 类型:

// 从表定义推断类型
import type { InferSelectModel, InferInsertModel } from "drizzle-orm";
import { posts, users } from "./schema";

// 查询结果类型(所有字段)
type Post = InferSelectModel<typeof posts>;
// { id: number; title: string; slug: string; content: string; ... }

// 插入数据类型(自动生成的字段变为可选)
type NewPost = InferInsertModel<typeof posts>;
// { title: string; slug: string; content: string; id?: number; createdAt?: Date; ... }

// 也可以用表的 $inferSelect / $inferInsert 快捷方式
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;

这意味着什么?当你改了 Schema,所有相关类型自动更新,根本不用维护单独的类型文件。少一个手动同步步骤,就少一个出 bug 的机会。

四、数据库迁移:安全地演进你的 Schema

4.1 配置 Drizzle Kit

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  strict: true,   // 检测到模糊变更(如列重命名)时会提示确认
  verbose: true,  // 输出详细的迁移信息
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

划重点strict: true 一定要开。没有它的话,Drizzle 可能把"重命名列"理解成"删掉旧列 + 新建新列",直接导致数据丢失。这不是吓你,我真的见过有项目因为忘了开这个选项而丢了用户数据。血的教训。

4.2 开发工作流:generate → migrate

# 1. 根据 Schema 变更生成迁移文件
npx drizzle-kit generate

# 2. 查看即将执行的 SQL(可选但强烈推荐)
# 生成的迁移文件在 ./drizzle 目录下,是纯 SQL

# 3. 应用迁移到数据库
npx drizzle-kit migrate

# 或者在开发阶段,直接推送 Schema 变更(跳过迁移文件)
npx drizzle-kit push

简单区分一下 pushgenerate + migrate

  • push:直接把 Schema 变更同步到数据库,不生成迁移文件。本地快速迭代时用这个就行
  • generate + migrate:先生成可版本控制的迁移文件,再执行。团队协作和生产部署必须走这个路线

生产环境务必用 generate + migrate,而且要把迁移文件纳入 Git 管理。这一点没有商量余地。

4.3 在应用启动时执行迁移

你也可以在应用启动时通过代码执行迁移,这在 CI/CD 流水线里特别方便:

// src/db/migrate.ts
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";

const runMigrations = async () => {
  const connection = postgres(process.env.DATABASE_URL!, { max: 1 });
  const db = drizzle(connection);

  console.log("正在执行数据库迁移...");
  await migrate(db, { migrationsFolder: "./drizzle" });
  console.log("迁移完成!");

  await connection.end();
};

runMigrations().catch(console.error);

五、CRUD 操作:用 SQL 思维写 TypeScript

终于到了动手写查询的环节。Drizzle 提供两种查询方式:SQL-like 查询构建器关系查询 API(RQB),你可以根据场景灵活切换。

5.1 查询(Read)

// 方式一:SQL-like 查询构建器——适合精确控制
import { db } from "@/db";
import { posts, users, categories } from "@/db/schema";
import { eq, desc, and, like, sql } from "drizzle-orm";

// 简单查询
const allPosts = await db.select().from(posts);

// 条件查询 + 排序 + 分页
const publishedPosts = await db
  .select()
  .from(posts)
  .where(eq(posts.status, "published"))
  .orderBy(desc(posts.publishedAt))
  .limit(10)
  .offset(0);

// JOIN 查询
const postsWithAuthor = await db
  .select({
    id: posts.id,
    title: posts.title,
    authorName: users.name,
    categoryName: categories.name,
  })
  .from(posts)
  .leftJoin(users, eq(posts.authorId, users.id))
  .leftJoin(categories, eq(posts.categoryId, categories.id))
  .where(eq(posts.status, "published"));

// 聚合查询
const postCountByCategory = await db
  .select({
    categoryName: categories.name,
    count: sql<number>`count(*)`,
  })
  .from(posts)
  .leftJoin(categories, eq(posts.categoryId, categories.id))
  .groupBy(categories.name);

如果你觉得 SQL-like 构建器写起来还是太啰嗦了(尤其是嵌套关系查询),RQB 才是真正的大招:

// 方式二:关系查询 API(RQB)——适合嵌套数据获取
// Drizzle 会自动生成高效的 JOIN SQL,始终只有一条查询

// 获取文章及其作者和标签
const postWithRelations = await db.query.posts.findFirst({
  where: eq(posts.slug, "my-first-post"),
  with: {
    author: true,
    postTags: {
      with: {
        tag: true,
      },
    },
  },
});

// 分页获取文章列表
const paginatedPosts = await db.query.posts.findMany({
  where: eq(posts.status, "published"),
  orderBy: [desc(posts.publishedAt)],
  limit: 10,
  offset: 0,
  columns: {
    id: true,
    title: true,
    slug: true,
    excerpt: true,
    publishedAt: true,
  },
  with: {
    author: {
      columns: {
        name: true,
        avatarUrl: true,
      },
    },
    category: {
      columns: {
        name: true,
        slug: true,
      },
    },
  },
});

RQB 的最大优势就是始终生成单条 SQL 查询,无论嵌套多深。这从根本上杜绝了 N+1 查询问题。在 Serverless 环境下这一点格外重要——每次数据库往返都意味着额外的延迟和费用。

5.2 插入(Create)

// 插入单条记录
const newPost = await db
  .insert(posts)
  .values({
    title: "我的第一篇文章",
    slug: "my-first-post",
    content: "文章内容...",
    authorId: 1,
    categoryId: 2,
  })
  .returning(); // 返回插入后的完整记录

// 批量插入
const newTags = await db
  .insert(tags)
  .values([
    { name: "Next.js", slug: "nextjs" },
    { name: "React", slug: "react" },
    { name: "TypeScript", slug: "typescript" },
  ])
  .returning();

// 插入时处理冲突(Upsert)
await db
  .insert(users)
  .values({
    name: "张三",
    email: "[email protected]",
    role: "reader",
  })
  .onConflictDoUpdate({
    target: users.email,
    set: { name: "张三", updatedAt: new Date() },
  });

5.3 更新(Update)

// 简单更新
await db
  .update(posts)
  .set({
    title: "更新后的标题",
    updatedAt: new Date(),
  })
  .where(eq(posts.id, 1));

// 基于当前值的更新(比如浏览量 +1)
await db
  .update(posts)
  .set({
    viewCount: sql`${posts.viewCount} + 1`,
  })
  .where(eq(posts.id, 1));

5.4 删除(Delete)

// 删除单条记录
await db.delete(posts).where(eq(posts.id, 1));

// 条件删除(比如清理 30 天前的草稿)
await db
  .delete(posts)
  .where(
    and(
      eq(posts.status, "draft"),
      sql`${posts.createdAt} < NOW() - INTERVAL '30 days'`
    )
  );

5.5 事务

当多个操作需要"全部成功或全部回滚"时,事务就该上场了:

// 创建文章并关联标签(事务保证原子性)
const result = await db.transaction(async (tx) => {
  // 插入文章
  const [newPost] = await tx
    .insert(posts)
    .values({
      title: "事务示例",
      slug: "transaction-example",
      content: "这是在事务中创建的",
      authorId: 1,
    })
    .returning();

  // 关联标签
  await tx.insert(postTags).values([
    { postId: newPost.id, tagId: 1 },
    { postId: newPost.id, tagId: 2 },
  ]);

  return newPost;
});

注意:如果你用的是 Neon HTTP 驱动,事务是不支持的——HTTP 协议本身就是无状态的。需要事务的话,请切换到 WebSocket 驱动或者 postgres.js 驱动。这是个容易踩的坑,提前知道能省不少调试时间。

六、数据访问层(DAL):封装你的查询逻辑

还记得我们认证文章里提到的"数据访问层"吗?现在它终于要有具体实现了。DAL 的核心理念很简单:所有数据库操作都走封装好的函数,组件和 Server Actions 绝不直接写 SQL

// src/lib/data/posts.ts
import { cache } from "react";
import { db } from "@/db";
import { posts, users, categories, postTags, tags } from "@/db/schema";
import { eq, desc, and, sql } from "drizzle-orm";

// cache() 确保同一请求周期内的重复调用只执行一次查询
export const getPublishedPosts = cache(
  async (page: number = 1, pageSize: number = 10) => {
    const offset = (page - 1) * pageSize;

    const results = await db.query.posts.findMany({
      where: eq(posts.status, "published"),
      orderBy: [desc(posts.publishedAt)],
      limit: pageSize,
      offset,
      with: {
        author: { columns: { name: true, avatarUrl: true } },
        category: { columns: { name: true, slug: true } },
      },
    });

    const [{ count }] = await db
      .select({ count: sql<number>`count(*)` })
      .from(posts)
      .where(eq(posts.status, "published"));

    return {
      posts: results,
      total: count,
      totalPages: Math.ceil(count / pageSize),
    };
  }
);

export const getPostBySlug = cache(async (slug: string) => {
  return db.query.posts.findFirst({
    where: eq(posts.slug, slug),
    with: {
      author: true,
      category: true,
      postTags: {
        with: { tag: true },
      },
    },
  });
});

export const getPostsByCategory = cache(
  async (categorySlug: string) => {
    return db.query.posts.findMany({
      where: and(
        eq(posts.status, "published"),
        eq(categories.slug, categorySlug)
      ),
      orderBy: [desc(posts.publishedAt)],
      with: {
        author: { columns: { name: true } },
      },
    });
  }
);

在 Server Components 里直接调用这些函数就行了,干净利落:

// app/blog/page.tsx
import { getPublishedPosts } from "@/lib/data/posts";

export default async function BlogPage({
  searchParams,
}: {
  searchParams: Promise<{ page?: string }>;
}) {
  const { page } = await searchParams;
  const currentPage = Number(page) || 1;
  const { posts, totalPages } = await getPublishedPosts(currentPage);

  return (
    <div>
      <h1>博客文章</h1>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>作者:{post.author.name}</p>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}

七、Zod 验证集成:从 Schema 自动生成验证规则

Drizzle 和 Zod 的集成是我个人很喜欢的一个特性——你能直接从表定义生成验证 Schema,不用手动维护两套类型定义。少维护一份代码,就少一个出错的地方。

7.1 基础用法

// src/lib/validators/post.ts
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-orm/zod";
import { posts } from "@/db/schema";
import { z } from "zod";

// 从表定义生成插入验证 Schema
// 自动生成的字段(id、createdAt 等)变为可选
export const insertPostSchema = createInsertSchema(posts, {
  // 可以对特定字段添加额外验证
  title: z.string().min(1, "标题不能为空").max(255, "标题不能超过 255 个字符"),
  slug: z.string()
    .min(1, "Slug 不能为空")
    .regex(/^[a-z0-9-]+$/, "Slug 只能包含小写字母、数字和连字符"),
  content: z.string().min(10, "内容至少需要 10 个字符"),
}).omit({ id: true, createdAt: true, updatedAt: true, viewCount: true });

// 更新验证 Schema(所有字段可选)
export const updatePostSchema = createUpdateSchema(posts, {
  title: z.string().min(1).max(255).optional(),
}).omit({ id: true, createdAt: true, updatedAt: true });

// 导出推断类型,供组件使用
export type InsertPostInput = z.infer<typeof insertPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;

7.2 在 Server Actions 中使用

结合我们之前 Server Actions 文章里的模式,完整的实际流程是这样的:

// app/actions/post.ts
"use server";

import { db } from "@/db";
import { posts } from "@/db/schema";
import { insertPostSchema, updatePostSchema } from "@/lib/validators/post";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";

type ActionState = {
  errors?: Record<string, string[]>;
  message?: string;
};

export async function createPost(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  // 1. 提取并验证表单数据
  const rawData = {
    title: formData.get("title"),
    slug: formData.get("slug"),
    content: formData.get("content"),
    excerpt: formData.get("excerpt"),
    categoryId: Number(formData.get("categoryId")) || undefined,
    authorId: Number(formData.get("authorId")),
  };

  const validated = insertPostSchema.safeParse(rawData);

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors as Record<string, string[]>,
      message: "验证失败,请检查输入",
    };
  }

  // 2. 插入数据库
  try {
    await db.insert(posts).values(validated.data);
  } catch (error) {
    return { message: "创建文章失败,请稍后重试" };
  }

  // 3. 重新验证缓存并重定向
  revalidatePath("/blog");
  redirect("/blog");
}

export async function updatePost(
  id: number,
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const rawData = Object.fromEntries(formData);
  const validated = updatePostSchema.safeParse(rawData);

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors as Record<string, string[]>,
    };
  }

  try {
    await db
      .update(posts)
      .set({ ...validated.data, updatedAt: new Date() })
      .where(eq(posts.id, id));
  } catch (error) {
    return { message: "更新失败" };
  }

  revalidatePath("/blog");
  revalidatePath(`/blog/${validated.data.slug || ""}`);
  redirect("/blog");
}

看到这条链路了吗?Drizzle Schema → Zod 验证 → Server Action → 数据库操作。整条链路都有完整的类型安全保障。从表单输入到数据库写入,任何类型不匹配在编译时就会被抓出来。

八、性能优化:让你的查询更快

8.1 预编译语句(Prepared Statements)

对于那些被反复调用的高频查询,预编译语句可以跳过 SQL 解析阶段直接执行,性能提升是实打实的:

import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq } from "drizzle-orm";

// 预编译查询(模块加载时编译一次就够了)
const getPostById = db.query.posts
  .findFirst({
    where: eq(posts.id, sql.placeholder("id")),
    with: {
      author: true,
      category: true,
    },
  })
  .prepare("get_post_by_id");

// 使用时传入参数
const post = await getPostById.execute({ id: 42 });

8.2 选择性字段查询

别每次都 SELECT *。只查你需要的字段,数据传输量能减不少:

// 列表页只需要摘要信息,不需要全文
const postList = await db.query.posts.findMany({
  columns: {
    id: true,
    title: true,
    slug: true,
    excerpt: true,
    publishedAt: true,
  },
  with: {
    author: {
      columns: { name: true },  // 只取作者名字就够了
    },
  },
});

8.3 与 React cache() 配合

在 Server Components 中,React 的 cache() 能实现请求级别的记忆化。同一个渲染周期内,哪怕页面上三个组件都调用了同一个函数,数据库查询也只会执行一次:

import { cache } from "react";

// 即使页面上三个组件都调用了 getPost(42),
// 数据库查询只执行一次
export const getPost = cache(async (id: number) => {
  return db.query.posts.findFirst({
    where: eq(posts.id, id),
    with: { author: true },
  });
});

这个优化几乎是"免费"的,用起来没什么心理负担。

九、生产部署:Vercel + Neon 最佳实践

9.1 环境变量配置

# .env.local(本地开发)
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

# Vercel 环境变量(生产)
# 使用 Neon 提供的 Pooled 连接字符串
DATABASE_URL="postgresql://user:[email protected]/mydb?sslmode=require"

9.2 CI/CD 迁移策略

在 Vercel 部署时,最简单的方式就是在构建脚本里自动执行迁移:

// package.json
{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:push": "drizzle-kit push",
    "db:studio": "drizzle-kit studio",
    "build": "npm run db:migrate && next build"
  }
}

db:migrate 放在 build 命令前面,这样每次部署都会自动应用最新的迁移。简单粗暴但有效。

9.3 Drizzle Studio:可视化数据库管理

Drizzle Kit 还内置了一个浏览器端的数据库管理工具:

npx drizzle-kit studio

它会启动一个本地 Web 界面,让你浏览表数据、执行查询、编辑记录——有点像 Prisma Studio,但更轻量。日常开发调试的时候挺好用的。

十、完整架构回顾:从表单到数据库的全链路

到这里,让我们把所有层次串起来,完整看一遍数据是怎么流动的:

用户提交表单
    ↓
[Client Component] — 表单数据 → Server Action
    ↓
[Server Action] — Zod 验证(由 Drizzle Schema 生成)
    ↓
[数据访问层 DAL] — 认证检查 + 权限验证
    ↓
[Drizzle ORM] — 类型安全的 SQL 查询
    ↓
[数据库驱动] — postgres.js (TCP) 或 Neon (HTTP/WS)
    ↓
[PostgreSQL 数据库]
    ↓
返回结果 → revalidatePath() → UI 自动更新

每一层都有自己的职责,每一层都有类型安全保障。这就是 2026 年 Next.js 全栈应用该有的数据库架构——简洁、安全、性能靠谱。

常见问题解答

Drizzle ORM 和 Prisma 到底选哪个?

2026 年的简短回答:新项目优先考虑 Drizzle。它的包体积只有 Prisma 的约 1/30,原生支持 Edge Runtime(不需要额外的连接代理),TypeScript 类型推断是即时的不需要代码生成。当然 Prisma 也有自己的强项——更成熟的生态、更自动化的迁移工具、对 MongoDB 等非关系型数据库的支持。如果你的团队已经深度绑定了 Prisma 而且没有 Edge 部署需求,迁移过来的成本可能不划算。

Drizzle 能在 Next.js 的 Edge Runtime 中运行吗?

完全可以,而且零额外配置。Drizzle ORM 核心包就 7.4KB 左右,零依赖,能直接跑在 Cloudflare Workers、Vercel Edge Functions、Deno 等 Edge 环境里。搭配 Neon Serverless 驱动或 Cloudflare D1,你在 Edge 函数里就能直接执行数据库查询。作为对比,Prisma 得通过 Prisma Accelerate 代理才能在 Edge 环境中工作,多了一个中间环节。

开发环境下热重载导致数据库连接过多怎么办?

globalThis 单例模式就行。把数据库连接实例存到 globalThis 上,确保热重载时复用同一个实例。本文第二章有完整代码,直接复制粘贴就能用。另外记得配置合理的连接池参数(比如 max: 10),别让单个实例占太多连接。

关系查询和手写 JOIN 有什么区别?

功能上没区别——Drizzle 的 RQB 最终生成的就是带 JOIN 的 SQL 语句,而且始终是单条查询,不存在 N+1 问题。区别在于开发体验:RQB 让你用嵌套对象的方式描述数据需求,代码更直观,类型推断也更完整。如果你需要 GROUP BY 或聚合函数这类 RQB 暂不支持的操作,直接用 SQL-like 查询构建器就好。两种方式在同一个项目里混着用,完全没问题。

生产环境的数据库迁移该怎么管理?

始终用 drizzle-kit generate 生成迁移文件,然后把这些文件提交到 Git。在 CI/CD 或 Vercel 构建流程中通过 drizzle-kit migrate 自动执行。记住一定要开 strict: true,避免列重命名被误判为"删除+新建"导致数据丢失。还有,永远不要在生产环境用 drizzle-kit push——它绕过了迁移文件,变更历史无从追溯。

关于作者 Tomasz Wieczorek

Tomasz is a senior frontend engineer with eleven years of experience, currently a tech lead at a Berlin-based fintech where he migrated 180 routes from CRA to Next.js 14 App Router over nine months without a single user-facing outage. Before that, four years at Klarna building the merchant dashboard in Next.js, and two years at a small agency in Krakow shipping marketing sites for European retailers. He maintains two small open-source libraries in the React ecosystem, one for typed environment variables and one for a Suspense-friendly data fetching wrapper that predates the React 19 use() hook. He prefers Drizzle over Prisma, NextAuth over Clerk for self-hosted setups, and has strong feelings about putting business logic in Server Components. He writes the kind of tutorials that show the broken intermediate state, not just the working final code.