LearnFrenzy
Sign in Take a free test

Databases and Deployment · Lesson 22 of 24

Next.js with a Database: Drizzle ORM

7 min read Updated Nov 8, 2026 Saurabh Samir

Because Server Components and Server Actions run on the server, a Next.js app can talk to its database directly – no separate backend needed. This lesson connects an app to a database with Drizzle ORM, a lightweight, type-safe query builder that's popular in the Next.js community. The examples use SQLite so they run anywhere; switching to PostgreSQL or MySQL changes only the driver.

In this lesson

  • A schema and a database client
  • Reading rows in a Server Component
  • Inserting and deleting with Server Actions
  • Migrations, connection tips, and Prisma

Install

npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3
# PostgreSQL instead: npm install drizzle-orm postgres   (or @neondatabase/serverless)

Schema and client

// db/schema.ts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const courses = sqliteTable("courses", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  lessons: integer("lessons").notNull().default(0),
});

export type Course = typeof courses.$inferSelect;     // { id: number; title: string; lessons: number }
// db/index.ts
import "server-only";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "./schema";

const sqlite = new Database(process.env.DATABASE_FILE ?? "app.db");

// demo only: create the table and some rows on first start (real apps use migrations – see below)
sqlite.exec(`CREATE TABLE IF NOT EXISTS courses (
  id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, lessons INTEGER NOT NULL DEFAULT 0)`);
const { n } = sqlite.prepare("SELECT COUNT(*) AS n FROM courses").get() as { n: number };
if (n === 0) sqlite.exec(`INSERT INTO courses (title, lessons) VALUES ('React.js', 31), ('Next.js', 24)`);

export const db = drizzle(sqlite, { schema });

Create the client once in its own module and import it everywhere – the module is evaluated once per server process, so connections are reused. import "server-only" guarantees it can never end up in a browser bundle.

Read and write

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

import { eq } from "drizzle-orm";
import { refresh } from "next/cache";
import { z } from "zod";
import { db } from "@/db";
import { courses } from "@/db/schema";

const NewCourse = z.object({ title: z.string().trim().min(2), lessons: z.coerce.number().int().min(0) });

export async function addCourse(formData: FormData) {
  const parsed = NewCourse.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return;
  await db.insert(courses).values(parsed.data);           // parameterised: safe from SQL injection
  refresh();
}

export async function deleteCourse(id: number) {
  await db.delete(courses).where(eq(courses.id, id));
  refresh();
}
// app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { asc, sum } from "drizzle-orm";
import { db } from "@/db";
import { courses } from "@/db/schema";
import { addCourse, deleteCourse } from "./actions";

async function CourseTable() {
  await connection();                                       // live data, read per request
  const rows = await db.select().from(courses).orderBy(asc(courses.title));
  const [{ total }] = await db.select({ total: sum(courses.lessons) }).from(courses);
  return (
    <>
      <ul>
        {rows.map((c) => (
          <li key={c.id}>
            {c.title} – {c.lessons} lessons{" "}
            <form action={deleteCourse.bind(null, c.id)} style={{ display: "inline" }}>
              <button>Delete {c.title}</button>
            </form>
          </li>
        ))}
      </ul>
      <p>{rows.length} courses, {total ?? 0} lessons</p>
    </>
  );
}

export default function Admin() {
  return (
    <main>
      <h1>Courses</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <CourseTable />
      </Suspense>
      <form action={addCourse}>
        <input name="title" aria-label="Title" placeholder="Title" />
        <input name="lessons" aria-label="Lessons" placeholder="Lessons" />
        <button>Add course</button>
      </form>
    </main>
  );
}

The page, with a real SQLite database

Courses
• Next.js – 24 lessons
[Delete Next.js]
• React.js – 31 lessons
[Delete React.js]
2 courses, 55 lessons
[Title…] [Lessons…] [Add course]

› type “TypeScript” into Title
Courses
• Next.js – 24 lessons
[Delete Next.js]
• React.js – 31 lessons
[Delete React.js]
2 courses, 55 lessons
[TypeScript] [Lessons…] [Add course]

› type “17” into Lessons
Courses
• Next.js – 24 lessons
[Delete Next.js]
• React.js – 31 lessons
[Delete React.js]
2 courses, 55 lessons
[TypeScript] [17] [Add course]

› click “Add course”
Courses
• Next.js – 24 lessons
[Delete Next.js]
• React.js – 31 lessons
[Delete React.js]
• TypeScript – 17 lessons
[Delete TypeScript]
3 courses, 72 lessons
[Title…] [Lessons…] [Add course]

› click “Delete React.js”
Courses
• Next.js – 24 lessons
[Delete Next.js]
• TypeScript – 17 lessons
[Delete TypeScript]
2 courses, 41 lessons
[Title…] [Lessons…] [Add course]
  • Drizzle queries read like SQL and are fully typed: rows is Course[], and a typo in a column name is a compile error.
  • deleteCourse.bind(null, c.id) creates an action with the id already filled in – Next.js encrypts bound values, and the action still validates nothing but a number, so check permissions there in a real app.
  • Values are always sent as parameters, never pasted into SQL strings, so user input can't inject SQL.

Migrations

Instead of CREATE TABLE in code, let drizzle-kit generate SQL migrations from your schema and apply them during deployment:

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

export default defineConfig({
  schema: "./db/schema.ts",
  out: "./drizzle",
  dialect: "sqlite",                               // "postgresql" | "mysql"
  dbCredentials: { url: process.env.DATABASE_FILE ?? "app.db" },
});
npx drizzle-kit generate   # write SQL migration files from schema changes
npx drizzle-kit migrate    # apply them to the database
npx drizzle-kit studio     # browse the data in a local UI

Connection tips and alternatives

  • Serverless hosting (Vercel, Netlify) runs many short-lived instances: use a connection pooler or an HTTP-based driver (Neon, Supabase, PlanetScale, Turso) so you don't exhaust database connections.
  • Caching: wrap read functions in "use cache" with a tag, and call updateTag in the actions that write – the caching chapter's pattern.
  • Prisma is the other popular ORM: a schema file, a generated client and excellent tooling. Drizzle is lighter and closer to SQL; both work the same way with Server Components and Actions.

Practice

Try it yourself

  1. Add a query that returns only courses with more than 20 lessons, and show it on a page.

    Show solution
    // db/schema.ts
    import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
    
    export const courses = sqliteTable("courses", {
      id: integer("id").primaryKey({ autoIncrement: true }),
      title: text("title").notNull(),
      lessons: integer("lessons").notNull().default(0),
    });
    // db/index.ts
    import "server-only";
    import Database from "better-sqlite3";
    import { drizzle } from "drizzle-orm/better-sqlite3";
    
    const sqlite = new Database(":memory:");
    sqlite.exec(`CREATE TABLE courses (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, lessons INTEGER NOT NULL DEFAULT 0);
    INSERT INTO courses (title, lessons) VALUES ('React.js', 31), ('Next.js', 24), ('TypeScript', 17);`);
    
    export const db = drizzle(sqlite);
    // app/page.tsx
    import { Suspense } from "react";
    import { connection } from "next/server";
    import { gt } from "drizzle-orm";
    import { db } from "@/db";
    import { courses } from "@/db/schema";
    
    async function BigCourses() {
      await connection();
      const rows = await db.select({ title: courses.title }).from(courses).where(gt(courses.lessons, 20));
      return <ul>{rows.map((r) => <li key={r.title}>{r.title}</li>)}</ul>;
    }
    
    export default function Page() {
      return (
        <Suspense fallback={<p>Loading…</p>}>
          <BigCourses />
        </Suspense>
      );
    }

    Page

    • React.js
    • Next.js

Quick check

Where can you query the database directly in Next.js?

In Server Components, Server Actions and Route Handlers – never in Client Components.

Why create the db client in one module?

So it's created once per server process and reused, instead of opening a connection per request.

What generates migrations from a Drizzle schema?

npx drizzle-kit generate.

Summary

  • Define tables in a Drizzle schema and create one server-only client.
  • Query in Server Components; validate and write in Server Actions, then refresh or update the cache.
  • Use drizzle-kit migrations, and a pooler or HTTP driver on serverless hosts.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Search LearnFrenzy

Esc

Quick links