Curriculum1 of 11
+ Module 16

Databases & ORM

Store data that survives a restart, and read the SQL running underneath.

Phase 3 · Full stack · 11 sections · about 10 minutes

+ 01 / 11

Why This Matters

Your data lives here now. Everything you built in Module 15, the waitlist signups, the blog post drafts, the comment threads, was in memory. Refresh the browser, gone. Real products need real persistence.

A database is not magic storage. It's a precise, reliable engine for organizing information into tables and relationships. You'll think differently once you understand it: not "where do I save this?" but "what shape should this data have?" That shape, your schema, is the contract between your code and your data. Get it right once, and the database takes care of the rest.

This module teaches you to think in tables and relationships. We use Drizzle, a TypeScript ORM, to write queries without leaving your code. Then we peek underneath to see the actual SQL, because understanding what the ORM generates is what separates "I know how to use the tool" from "I know what's happening."


+ 02 / 11

Part 1: Relational Thinking

A relational database organizes data into tables. Each table is a collection of rows (records), and each row has columns (fields). That's it. Everything else follows from that simple shape.

+ JavaScript
1users
2├─ id (primary key)
3├─ email
4├─ created_at
5└─ ...
6 
7posts
8├─ id (primary key)
9├─ user_id (foreign key → users.id)
10├─ title
11├─ content
12└─ created_at
13 
14comments
15├─ id (primary key)
16├─ post_id (foreign key → posts.id)
17├─ user_id (foreign key → users.id)
18├─ text
19└─ created_at

Primary key: A unique identifier for each row. Usually id. Every row has one.

Foreign key: A column that references another table's primary key. posts.user_id points to users.id. This is how you model relationships. One user can have many posts. Many posts can have many comments.

Your TypeScript types (User, Post, Comment) are semantic. Your database schema is structural. A good schema makes your types obvious. A bad schema makes your types confused. Drizzle bridges this gap: your schema generates your types, so they stay in sync forever.


+ 03 / 11

Part 2: Schemas and Migrations

A schema is the blueprint. It says: "A users table has these columns with these types. A posts table connects to users like this."

Here's a real schema using Drizzle:

+ TypeScript
1// lib/db/schema.ts
2import {
3 pgTable,
4 text,
5 timestamp,
6 uuid,
7 boolean,
8 integer,
9} from "drizzle-orm/pg-core";
10 
11export const users = pgTable("users", {
12 id: uuid("id").primaryKey(),
13 email: text("email").unique().notNull(),
14 name: text("name"),
15 created_at: timestamp("created_at").defaultNow().notNull(),
16});
17 
18export const posts = pgTable("posts", {
19 id: uuid("id").primaryKey(),
20 user_id: uuid("user_id")
21 .notNull()
22 .references(() => users.id, { onDelete: "cascade" }),
23 title: text("title").notNull(),
24 content: text("content").notNull(),
25 published: boolean("published").default(false),
26 created_at: timestamp("created_at").defaultNow().notNull(),
27});
28 
29export const comments = pgTable("comments", {
30 id: uuid("id").primaryKey(),
31 post_id: uuid("post_id")
32 .notNull()
33 .references(() => posts.id, { onDelete: "cascade" }),
34 user_id: uuid("user_id")
35 .notNull()
36 .references(() => users.id, { onDelete: "cascade" }),
37 text: text("text").notNull(),
38 created_at: timestamp("created_at").defaultNow().notNull(),
39});
40 
41// Export for type inference
42export type User = typeof users.$inferSelect;
43export type NewUser = typeof users.$inferInsert;

What's happening:

  • pgTable creates a PostgreSQL table definition.
  • Each field has a type (text, uuid, timestamp, etc.) and constraints (notNull, unique, default, etc.).
  • .references() creates a foreign key relationship. onDelete: 'cascade' means if a user is deleted, their posts and comments are too.
  • The type exports (User, NewUser) are automatically inferred from the schema. No manual type duplication.

Migrations. A schema doesn't magically appear in your database. You create a migration, a file that says "create this table" or "add this column." Drizzle's migration system tracks them, so you can version your schema like you version your code.

+ Terminal
1# Generate a migration from your schema changes
2npx drizzle-kit generate:pg
3 
4# This creates a file like:
5# drizzle/0001_create_users_posts_comments.sql
6 
7# Run the migration
8npx drizzle-kit migrate:pg

The generated SQL looks like:

+ SQL
1CREATE TABLE IF NOT EXISTS "users" (
2 "id" uuid PRIMARY KEY NOT NULL,
3 "email" text UNIQUE NOT NULL,
4 "name" text,
5 "created_at" timestamp DEFAULT now() NOT NULL
6);
7 
8CREATE TABLE IF NOT EXISTS "posts" (
9 "id" uuid PRIMARY KEY NOT NULL,
10 "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
11 "title" text NOT NULL,
12 "content" text NOT NULL,
13 "published" boolean DEFAULT false,
14 "created_at" timestamp DEFAULT now() NOT NULL
15);
16 
17CREATE TABLE IF NOT EXISTS "comments" (
18 "id" uuid PRIMARY KEY NOT NULL,
19 "post_id" uuid NOT NULL REFERENCES "posts"("id") ON DELETE CASCADE,
20 "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
21 "text" text NOT NULL,
22 "created_at" timestamp DEFAULT now() NOT NULL
23);

The migration is your code. The SQL is the platform's language. Drizzle generates the SQL, but you're writing the schema in TypeScript. You stay in your language; the ORM handles the translation. This is the core value of an ORM.


+ 04 / 11

Part 3: What an ORM Is

An ORM (Object-Relational Mapper) is a bridge. On one side: your JavaScript objects. On the other: SQL queries. The ORM writes the SQL so you don't have to (most of the time).

Without an ORM, you'd write:

+ JavaScript
1const result = await db.query("SELECT * FROM users WHERE email = $1", [
2 userEmail,
3]);

With Drizzle:

+ TypeScript
1import { eq } from "drizzle-orm";
2 
3const user = await db.query.users.findFirst({
4 where: eq(users.email, userEmail),
5});

Both do the same thing. The ORM version is type-safe (if userEmail is wrong, TypeScript yells before you run it) and readable (the intent is clear).

Important: An ORM is not magic. It's a thin layer. The SQL is still there. Understanding what it generates is crucial. A query that looks simple in Drizzle can be slow if you don't know what SQL it's creating.


+ 05 / 11

Part 4: CRUD Operations

CRUD = Create, Read, Update, Delete. The four operations that cover 90% of database work.

Create

+ TypeScript
1// Insert a new user
2const newUser = await db
3 .insert(users)
4 .values({
5 id: crypto.randomUUID(),
6 email: "alice@example.com",
7 name: "Alice",
8 })
9 .returning();
10 
11// Insert multiple posts
12const posts = await db
13 .insert(posts)
14 .values([
15 {
16 id: crypto.randomUUID(),
17 user_id: newUser.id,
18 title: "First Post",
19 content: "Hello world",
20 },
21 {
22 id: crypto.randomUUID(),
23 user_id: newUser.id,
24 title: "Second Post",
25 content: "More thoughts",
26 },
27 ])
28 .returning();

The ORM generates INSERT statements. .returning() tells the database to send back the inserted rows (with generated IDs, timestamps, etc.).

Read

+ TypeScript
1// Get one user by email
2const user = await db.query.users.findFirst({
3 where: eq(users.email, "alice@example.com"),
4});
5 
6// Get all posts by a user
7const userPosts = await db.query.posts.findMany({
8 where: eq(posts.user_id, userId),
9 orderBy: (posts, { desc }) => [desc(posts.created_at)],
10});
11 
12// Get a post with all its comments
13const postWithComments = await db.query.posts.findFirst({
14 where: eq(posts.id, postId),
15 with: {
16 comments: true,
17 },
18});

The .with syntax is key. It tells Drizzle to fetch related data (the post's comments). Without it, you'd only get the post, then you'd need a separate query to get the comments. That's the N+1 problem, which we'll cover next.

Update

+ TypeScript
1// Update one post
2await db
3 .update(posts)
4 .set({
5 title: "Updated Title",
6 published: true,
7 })
8 .where(eq(posts.id, postId));
9 
10// Update multiple posts (all unpublished posts by a user)
11await db
12 .update(posts)
13 .set({ published: false })
14 .where(and(eq(posts.user_id, userId), eq(posts.published, false)));

Delete

+ TypeScript
1// Delete one comment
2await db.delete(comments).where(eq(comments.id, commentId));
3 
4// Delete all comments on a post
5await db.delete(comments).where(eq(comments.post_id, postId));

Note: You don't usually delete users or posts directly. Cascading deletes handle it (defined in the schema). If a user is deleted, all their posts and comments go with them.


+ 06 / 11

Part 5: The SQL Detour

To understand your ORM, you need to see the SQL underneath. Let's take a read operation and trace it.

Drizzle query:

+ TypeScript
1const userWithPosts = await db.query.users.findFirst({
2 where: eq(users.email, "alice@example.com"),
3 with: {
4 posts: {
5 orderBy: (posts, { desc }) => [desc(posts.created_at)],
6 },
7 },
8});

The SQL Drizzle generates:

+ SQL
1SELECT
2 "users"."id",
3 "users"."email",
4 "users"."name",
5 "users"."created_at"
6FROM "users"
7WHERE "users"."email" = 'alice@example.com'
8LIMIT 1;
9 
10SELECT
11 "posts"."id",
12 "posts"."user_id",
13 "posts"."title",
14 "posts"."content",
15 "posts"."published",
16 "posts"."created_at"
17FROM "posts"
18WHERE "posts"."user_id" = 'user-id-from-above'
19ORDER BY "posts"."created_at" DESC;

Notice: two queries. First gets the user, then gets their posts. Drizzle orchestrates these for you and stitches the result back together. In your code, it's one object. In the database, it's two round trips.

Better SQL (a JOIN):

If you're in the database directly (or need maximum control), you'd write:

+ SQL
1SELECT
2 "users"."id",
3 "users"."email",
4 "users"."name",
5 "users"."created_at",
6 json_agg(
7 json_build_object(
8 'id', "posts"."id",
9 'title', "posts"."title",
10 'content', "posts"."content",
11 'created_at', "posts"."created_at"
12 ) ORDER BY "posts"."created_at" DESC
13 ) AS posts
14FROM "users"
15LEFT JOIN "posts" ON "posts"."user_id" = "users"."id"
16WHERE "users"."email" = 'alice@example.com'
17GROUP BY "users"."id"
18LIMIT 1;

This is one query. It's more efficient if the data is small. It's overkill if the post count is huge (you'd be sending megabytes of JSON). SQL's power is that you can express exactly what you want, but that power comes with complexity.

When to write raw SQL:

Use raw SQL when:

  • You need maximum performance and the ORM's generated SQL is too slow.
  • You're doing something the ORM can't express (complex window functions, recursive CTEs, etc.).
  • You're debugging and need to see exactly what's running.

In Drizzle, raw SQL falls back to:

+ TypeScript
1const result = await db.execute(sql`
2 SELECT * FROM users WHERE email = ${userEmail}
3`);

The backtick syntax prevents SQL injection. The ${} placeholders are parameterized.


+ 07 / 11

Part 6: N+1 Queries and Why They Matter

The N+1 problem is the most common performance trap. Here's what it looks like:

The Problem:

+ TypeScript
1// Get all posts
2const allPosts = await db.query.posts.findMany();
3 
4// Then, for each post, get the author
5for (const post of allPosts) {
6 const author = await db.query.users.findFirst({
7 where: eq(users.id, post.user_id),
8 });
9 post.author = author; // Now we have the author
10}

If there are 100 posts, you just made 101 queries. One for all posts, then one for each post's author. That's the "N+1": one query, then N queries in a loop.

The reason this ships is that it is invisible until the database is far away. Drag the round trip from a local database up to a real one and watch the gap open.

+ Try it21x slower

Twenty one queries, or one

A loop
const posts = await db.posts.findMany();

for (const post of posts) {
  post.author = await db.users.find(
    post.authorId
  );
}
21
Queries
168ms
Waiting
A join
const posts = await db.posts.findMany({
  include: { author: true },
});
1
Queries
8ms
Waiting

This is called an N+1 query: one query for the list, then N more, one per row. It scales with your data, so it is fastest on the day you write it and slowest on the day the product succeeds. Nothing in the loop looks wrong, which is why you have to look for it deliberately rather than wait for it to announce itself.

Both versions return the same posts with the same authors. The loop looks completely reasonable in the editor, which is the entire problem.

The Fix:

Use .with to fetch related data in one shot:

+ TypeScript
1const allPostsWithAuthors = await db.query.posts.findMany({
2 with: {
3 author: true, // Tell Drizzle: fetch the author too
4 },
5});
6 
7// No loop. No extra queries. Drizzle handles it.

But wait, Drizzle still makes two queries under the hood (we saw this in Part 5). Why isn't that N+1?

Because Drizzle is smart. It fetches all posts in one query, collects all the user_ids, then fetches all those users in one query. Two queries total, not 101. This is called a "batch fetch."

When N+1 happens in real code:

+ TypeScript
1// Bad: N+1
2async function getAllPostsWithComments() {
3 const posts = await db.query.posts.findMany();
4 
5 for (const post of posts) {
6 post.comments = await db.query.comments.findMany({
7 where: eq(comments.post_id, post.id),
8 });
9 }
10 
11 return posts;
12}
13 
14// Good: Drizzle's batch fetch
15async function getAllPostsWithComments() {
16 return db.query.posts.findMany({
17 with: {
18 comments: true,
19 },
20 });
21}
22 
23// Also good: raw SQL with a single JOIN
24async function getAllPostsWithComments() {
25 return db.execute(sql`
26 SELECT
27 posts.*,
28 json_agg(comments.*) as comments
29 FROM posts
30 LEFT JOIN comments ON posts.id = comments.post_id
31 GROUP BY posts.id
32 `);
33}

The key: always think about how many queries you're making. One query is fast. N+1 queries is slow. Drizzle's .with syntax prevents it most of the time.


+ 08 / 11

Build: Give Module 15's Data Real Persistence

Your assignment: take the waitlist and blog platform from Module 15 and give it real persistence.

Step 1: Define Your Schema

Start with the data you already have. A user who signed up, a blog post they wrote, the comments people left.

+ TypeScript
1// lib/db/schema.ts
2import { pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core";
3 
4export const waitlistMembers = pgTable("waitlist_members", {
5 id: uuid("id").primaryKey().defaultRandom(),
6 email: text("email").unique().notNull(),
7 created_at: timestamp("created_at").defaultNow().notNull(),
8});
9 
10export const blogPosts = pgTable("blog_posts", {
11 id: uuid("id").primaryKey().defaultRandom(),
12 title: text("title").notNull(),
13 content: text("content").notNull(),
14 published: boolean("published").default(false),
15 created_at: timestamp("created_at").defaultNow().notNull(),
16 updated_at: timestamp("updated_at").defaultNow().notNull(),
17});
18 
19export const postComments = pgTable("post_comments", {
20 id: uuid("id").primaryKey().defaultRandom(),
21 post_id: uuid("post_id")
22 .notNull()
23 .references(() => blogPosts.id, { onDelete: "cascade" }),
24 author: text("author").notNull(),
25 content: text("content").notNull(),
26 created_at: timestamp("created_at").defaultNow().notNull(),
27});

Questions to ask yourself:

  • What shape is the data right now? (Waitlist emails, blog posts, comments.)
  • What relationships exist? (A post can have many comments. A waitlist entry is standalone.)
  • What constraints matter? (Email must be unique. A comment needs a post. A post needs a title.)

Step 2: Generate and Run Migrations

+ Terminal
1npx drizzle-kit generate:pg
2npx drizzle-kit migrate:pg

Verify the tables exist:

+ Terminal
1psql $DATABASE_URL -c "\dt"

You should see waitlist_members, blog_posts, post_comments.

Step 3: Write CRUD Helpers

Create a file for your database operations:

+ TypeScript
1// lib/db/queries.ts
2import { db } from "./index";
3import { waitlistMembers, blogPosts, postComments } from "./schema";
4import { eq } from "drizzle-orm";
5 
6// Create
7export async function addToWaitlist(email: string) {
8 const [member] = await db
9 .insert(waitlistMembers)
10 .values({ email })
11 .returning();
12 return member;
13}
14 
15export async function createBlogPost(title: string, content: string) {
16 const [post] = await db
17 .insert(blogPosts)
18 .values({ title, content })
19 .returning();
20 return post;
21}
22 
23export async function addComment(
24 postId: string,
25 author: string,
26 content: string,
27) {
28 const [comment] = await db
29 .insert(postComments)
30 .values({ post_id: postId, author, content })
31 .returning();
32 return comment;
33}
34 
35// Read
36export async function getWaitlistMembers() {
37 return db.query.waitlistMembers.findMany({
38 orderBy: (t, { desc }) => [desc(t.created_at)],
39 });
40}
41 
42export async function getBlogPost(postId: string) {
43 return db.query.blogPosts.findFirst({
44 where: eq(blogPosts.id, postId),
45 with: {
46 comments: {
47 orderBy: (t, { desc }) => [desc(t.created_at)],
48 },
49 },
50 });
51}
52 
53export async function getAllBlogPosts() {
54 return db.query.blogPosts.findMany({
55 where: eq(blogPosts.published, true),
56 orderBy: (t, { desc }) => [desc(t.created_at)],
57 });
58}
59 
60// Update
61export async function publishPost(postId: string) {
62 await db
63 .update(blogPosts)
64 .set({ published: true, updated_at: new Date() })
65 .where(eq(blogPosts.id, postId));
66}
67 
68// Delete
69export async function deleteComment(commentId: string) {
70 await db.delete(postComments).where(eq(postComments.id, commentId));
71}

Step 4: Wire It Up

In your Module 15 API routes, replace in-memory storage with database calls:

+ TypeScript
1// app/api/waitlist/route.ts
2import { addToWaitlist } from "@/lib/db/queries";
3 
4export async function POST(req: Request) {
5 const { email } = await req.json();
6 
7 try {
8 const member = await addToWaitlist(email);
9 return Response.json(member, { status: 201 });
10 } catch (error) {
11 return Response.json(
12 { error: "Email already on waitlist" },
13 { status: 409 },
14 );
15 }
16}

Step 5: See the SQL

Before your code ships, peek at what SQL Drizzle generates. In development, enable query logging:

+ TypeScript
1// lib/db/index.ts
2import { drizzle } from "drizzle-orm/postgres-js";
3import postgres from "postgres";
4 
5const queryClient = postgres(process.env.DATABASE_URL!);
6 
7export const db = drizzle(queryClient, {
8 logger: process.env.NODE_ENV === "development", // Logs SQL to console
9});

Run a query. You'll see the SQL printed. Is it what you expected? That's the "peek under the hood" moment.

Step 6: Spot an N+1 Bug

In your code, intentionally introduce an N+1 query:

+ TypeScript
1// Bad: This will make many queries
2export async function getAllPostsWithCommentCounts() {
3 const posts = await db.query.blogPosts.findMany();
4 
5 for (const post of posts) {
6 const comments = await db.query.postComments.findMany({
7 where: eq(postComments.post_id, post.id),
8 });
9 post.commentCount = comments.length;
10 }
11 
12 return posts;
13}

With query logging enabled, you'll see dozens of queries. Then fix it:

+ TypeScript
1// Good: Fetch comments upfront
2export async function getAllPostsWithCommentCounts() {
3 const posts = await db.query.blogPosts.findMany({
4 with: {
5 comments: true,
6 },
7 });
8 
9 return posts.map((post) => ({
10 ...post,
11 commentCount: post.comments.length,
12 }));
13}

Log it again. Now you see two queries, not dozens.


+ 09 / 11

Checkpoint

Before you move on, verify:

Schema:

+ Checklist0 / 3

Persistence:

+ Checklist0 / 2

CRUD:

+ Checklist0 / 2

SQL:

+ Checklist0 / 2

Reflection questions:

  • What happens when you delete a user whose posts are still in the database? (Cascading delete should handle it.)
  • If you have 1000 blog posts and you want to show each post with its comment count on a homepage, how many queries do you make? (2: one for posts with .with: { comments: true }, then you count on the client. Not 1001.)
  • When would you write raw SQL instead of using Drizzle? (When the ORM can't express what you need, or for maximum performance on a slow query.)

+ 10 / 11

Where the ORM ends and SQL begins

Your schema and your types, one source of truth

Your TypeScript code and your database schema started as separate worlds. You wrote types in your code, defined columns in migrations, and hoped they stayed in sync. They didn't.

Drizzle closes this gap. Your schema generates your types. One source of truth. TypeScript checks at compile time that you're not trying to insert a string into a number column. This is the value of the bridge: your language and your platform align.

The cost: you're learning two systems (TypeScript and SQL) simultaneously. But the symmetry helps. eq(posts.id, postId) looks like TypeScript because it is. Underneath, it's WHERE "posts"."id" = $1 in SQL. The syntax changed, but the meaning is identical.

The database is doing the actual work

Your CRUD functions are your code. The database engine is the platform. The ORM is the conversation between them.

A tempting trap: thinking the ORM is enough. It's not. You still need to understand queries. An ORM that generates SELECT * FROM posts, users WHERE posts.user_id = users.id is a Cartesian product (every post matched with every user). The ORM made a mistake, and you need to spot it.

This is why Part 5 matters. Peek at the SQL. Learn to read it. The platform (Postgres) is doing the actual work. The ORM is translating your intent into its language. Knowing both languages makes you fluent.


+ 11 / 11

What's Next

Once your data persists, you can:

  • Add authentication (which user owns which post?).
  • Add authorization (which user can delete which comment?).
  • Optimize slow queries (add indexes to frequently-searched columns).
  • Back up and restore (your data is valuable now).
  • Scale (read replicas, caching, etc.).

For now: make the data reliable, make the queries correct, and make the schema match your code. Everything else follows.

+ Up nextAuth: Gating Pages Behind LoginPreviouslyData & APIs in depth