Databases and Deployment · Lesson 22 of 24
Next.js with a Database: Drizzle ORM
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>
);
}
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:
rowsisCourse[], 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 callupdateTagin 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
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> ); }• 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.