Menu

12. Databases

Next.js Master Roadmap - IT Technology

This chapter covers SQL fundamentals including concepts, CRUD, joins, indexing; explores PostgreSQL installation, table design, relationships, optimization; and introduces Prisma ORM setup, schema modeling, migrations, relations, and transactions within a Next.js context.

No MCQ questions available for this chapter.

12. Databases

12.1 SQL Fundamentals

Structured Query Language (SQL) is the standard language for interacting with relational databases. Mastering its core concepts enables you to design efficient schemas, write safe queries, and optimize performance.

12.1.1 Database Concepts

A relational database organizes data into tables (relations) consisting of rows (tuples) and columns (attributes). Key theoretical foundations include:

  • ACID properties – Atomicity, Consistency, Isolation, Durability – guaranteeing reliable transaction processing.
  • Normalization – Process to reduce redundancy and improve data integrity. Normal forms:
    1. 1NF – Atomic column values, no repeating groups.
    2. 2NF – 1NF + no partial dependency on a composite primary key.
    3. 3NF – 2NF + no transitive dependency.
    4. BCNF – Every determinant is a candidate key.
  • Keys and Constraints – Primary keys uniquely identify rows; foreign keys enforce relationships. Additional constraints: NOT NULL, UNIQUE, CHECK, DEFAULT.
  • Schema Design Principles – Choose appropriate data types, minimize nullable columns, use indexing strategically, and document relationships.

12.1.2 CRUD Operations

CRUD (Create, Read, Update, Delete) maps directly to SQL statements. Parameterized queries protect against SQL injection.

-- CREATE
INSERT INTO users (name, email) VALUES ('John', 'john@example.com');

-- READ
SELECT * FROM users WHERE id = 1;

-- UPDATE
UPDATE users SET name = 'Jane' WHERE id = 1;

-- DELETE
DELETE FROM users WHERE id = 1;

Using placeholders (e.g., $1, ?) lets the driver safely bind values.

12.1.3 Joins

Joins combine rows from two or more tables based on related columns.

Join TypeDescription
INNER JOINReturns only matching rows from both tables.
LEFT JOINReturns all rows from the left table, plus matched rows from the right (NULL if none).
RIGHT JOINMirror of LEFT JOIN.
FULL OUTER JOINReturns all rows when there is a match in either table.
CROSS JOINCartesian product – every row of left combined with every row of right.
SELF JOINTable joined to itself, useful for hierarchical data.

Example:

SELECT u.name, p.title
FROM users u
INNER JOIN posts p ON u.id = p.user_id;

Performance tip: ensure join columns are indexed; analyze with EXPLAIN ANALYZE.

12.1.4 Indexing

Indexes speed up data retrieval by providing quick lookup paths.

  • B-tree indexes – Default, efficient for range queries.
  • Hash indexes – Equality lookups only.
  • Partial indexes – Index a subset of rows (WHERE clause).
  • Composite indexes – Multiple columns; order matters.

Creation example:

CREATE INDEX idx_users_email ON users(email);

Key concepts:

  • Selectivity – Ratio of distinct values to total rows; higher selectivity yields better index benefit.
  • Covering indexes – Include all columns needed by a query, allowing index‑only scans.
  • Clustered vs non‑clustered – In PostgreSQL, the table heap is unordered; indexes are always non‑clustered, but you can cluster a table on an index physically.
  • Avoid over‑indexing – Each index adds write overhead; monitor with pg_stat_user_indexes.

12.2 PostgreSQL

PostgreSQL is a powerful, open‑source object‑relational database known for extensibility, standards compliance, and robust performance.

12.2.1 Installation

You can run PostgreSQL locally, via Docker, or on managed services.

  • Local – Homebrew (brew install postgresql), apt (sudo apt-get install postgresql), Windows installer.
  • Docker
    docker run --name postgres -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres:15
    
  • Managed – Supabase, Neon, Railway, AWS RDS (provide ready‑to‑use instances).

Connection string format:

postgresql://user:pass@host:5432/dbname?schema=public

12.2.2 Tables

Define tables with CREATE TABLE. Choose appropriate data types:

CategoryTypes
Serial/Auto‑incrementSERIAL, BIGSERIAL
IdentifierUUID (via uuid-ossp extension)
TextVARCHAR(n), TEXT
TemporalTIMESTAMP, TIMESTAMPTZ (timezone‑aware)
JSONJSONB (binary, indexable)
BooleanBOOLEAN
NumericNUMERIC(precision, scale) for exact decimals

Example:

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(100),
    created_at TIMESTAMPTZ DEFAULT now(),
    is_active BOOLEAN DEFAULT TRUE,
    profile_data JSONB
);

Modify schemas safely with ALTER TABLE (add/drop columns, change types, add constraints). For large tables, consider partitioning by range or list to improve query performance and maintenance.

12.2.3 Relationships

Foreign keys enforce referential integrity.

  • One‑to‑One – e.g., usersprofiles (unique foreign key).
  • One‑to‑Many – e.g., usersposts (many posts per user).
  • Many‑to‑Many – requires a join table, e.g., poststags via post_tags.

Define with ON DELETE CASCADE or SET NULL to control child row behavior when a parent is removed.

Performance tip: always index foreign‑key columns; use EXPLAIN to verify join plans.

12.2.4 Optimization

Scaling PostgreSQL involves connection management, replication, and query tuning.

  • Connection pooling – Tools like PgBouncer reduce overhead of frequent connections.
  • Read replicas – Offload SELECT queries to standby servers.
  • Query optimization
    • Avoid N+1 problems by fetching related data in a single query or using JOIN.
    • Prefer cursor‑based pagination (WHERE id > $1 ORDER BY id LIMIT $2OFFSET) over OFFSET for large datasets.
    • Use LIMIT/OFFSET only for small pages.
  • Materialized views – Store pre‑computed results; refresh periodically.
  • Monitoringpg_stat_statements tracks query execution times; VACUUM and autovacuum keep tables bloat‑free.
  • – Adjust wal_level, checkpoint_timeout, and max_wal_size for write‑heavy workloads.

12.3 Prisma ORM

Prisma is a modern TypeScript‑first ORM that simplifies database access in Node.js/Next.js applications.

12.3.1 Setup

Install the CLI and client, then initialize the project.

npm install prisma @prisma/client
npx prisma init

This creates a prisma folder with schema.prisma and a .env file. Add your database URL:

DATABASE_URL="postgresql://user:pass@localhost:5432/mydb?schema=public"

Generate the client:

npx prisma generate

In Next.js, instantiate PrismaClient as a singleton in development (to avoid hot‑reload creating many instances) and globally in production:

import { PrismaClient } from '@prisma/client'

const globalForPrisma = global as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ['query'],
  })

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

12.3.2 Schema

The prisma.schema file defines models, fields, enums, composite types, and indexes.

Example model:

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]   @relation("UserPosts")
  profile   Profile? @relation("UserProfile")
  createdAt DateTime @default(now())
}

model Profile {
  id        Int      @id @default(autoincrement())
  bio       String?
  userId    Int      @unique
  user      User     @relation("UserProfile", fields: [userId], references: [id])
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  authorId  Int
  author    User     @relation("UserPosts", fields: [authorId], references: [id])
  tags      Tag[]    @relation("PostTags")
  createdAt DateTime @default(now())
}

model Tag {
  id        Int      @id @default(autoincrement())
  name      String   @unique
  posts     Post[]   @relation("PostTags")
}

@@index([email])

Features:

  • Enums – Define a fixed set of values (enum Role { ADMIN USER }).
  • Composite types – Group related scalar fields (type Address { street String city String zip String }).
  • Indexes@@index([email]) creates a B‑tree index; @@unique enforces uniqueness.
  • Prisma‑level validations – Use @db.VarChar(255) or custom validation functions in application logic.

12.3.3 Migrations

Prisma Migrations manage schema changes safely.

  • Developmentnpx prisma migrate dev --name init creates a migration folder and applies changes.
  • Productionnpx prisma migrate deploy applies pending migrations.
  • Resetnpx prisma migrate reset drops and recreates the database (use with caution).
  • Baseline – If you have an existing database, npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script generates a baseline migration.

Migration history lives in prisma/migrations. Handle failed migrations by inspecting the error, fixing the schema, and re‑running prisma migrate resolve --rolled-back or prisma migrate reset in dev. Drift detection (prisma migrate check) alerts when the database diverges from the schema.

12.3.4 Relations

Prisma expresses relations via model fields and the @relation attribute.

  • One‑to‑Oneprofile Profile? @relation("UserProfile", fields: [profileId], references: [id]) plus @@unique([profileId]).
  • One‑to‑Manyposts Post[] on the User model; inverse author User @relation("UserPosts").
  • Many‑to‑Many – Two approaches:
    1. Implicit – Prisma creates a hidden join table when you define posts Post[] @relation("PostTags") on both sides.
    2. Explicit – Define a join model (model PostTag { post Post @relation(fields: [postId], references: [id]) tag Tag @relation(fields: [tagId], references: [id]) @@id([postId, tagId]) }) giving you control over extra columns.
  • Referential actions – Set onDelete: Cascade or onDelete: SetNull in the relation attribute to control cascading behavior.

12.3.5 Transactions

Prisma supports interactive and batch transactions to guarantee atomicity across multiple operations.

Interactive transaction (array of promises):

await prisma.$transaction([
  prisma.user.create({ data: { email: 'alice@example.com', name: 'Alice' } }),
  prisma.profile.create({ data: { bio: 'Hello', user: { connect: { email: 'alice@example.com' } } } })
])

Async callback transaction (for complex logic):

await prisma.$transaction(async (tx) => {
  const user = await tx.user.create({ data: { email: 'bob@example.com' } })
  await tx.post.create({ data: { title: 'First post', author: { connect: { id: user.id } } } })
  // additional steps…
})

You can specify isolation levels (ReadUncommitted, ReadCommitted, RepeatableRead, Serializable) by passing maxWait and timeout options. Errors inside the transaction trigger an automatic rollback; successful completion commits all changes.

Best practices:

  • Keep transactions short to reduce lock contention.
  • Handle validation errors before entering a transaction.
  • Use prisma.$queryRaw for raw SQL when needed, but prefer Prisma’s typed API for safety.