Prisma

Chapter 5 — Database with Prisma | Next.js Course
Next.js Rebuild Course Chapter 5 of 10

Database with Prisma

Connect to the existing osztromok.com MySQL database using Prisma ORM. Introspect the live schema, generate a type-safe client, and replace every placeholder array with a real database query. By the end of this chapter the site runs on live data.

🏁

Chapter milestone: The home page, subject pages, and subtopic pages all read from the real MySQL database. Every subject, subtopic, and page from the existing osztromok.com is queryable with full TypeScript types — no any, no guessing column names.

What Prisma is — and why it fits this project

Prisma is a TypeScript-first ORM for Node.js. You define your database schema in a single schema.prisma file, run one command, and get a fully type-safe database client. Every table becomes a TypeScript type — no more runtime surprises from mistyped column names.

FeatureRaw SQL / mysql2Prisma ORM ✓
Type safety Returns any[] — no autocomplete, easy to mis-spell columns Full TypeScript types — autocomplete for every field, compile error if column renamed
Schema Lives only in the DB — no single source of truth schema.prisma is the source of truth — version controlled, readable
Migrations Write SQL files by hand prisma migrate dev generates SQL from schema diff automatically
Relations Manual JOINs every time include: { subtopics: true } — nested data in one query
Existing DB prisma db pull introspects existing schema automatically
Coming from Java / JPA: Prisma is closest to Spring Data JPA with Hibernate — you define entities (models), get a repository-style client, and the framework handles SQL generation. The main difference: Prisma's schema file is simpler than JPA annotations, and the client API is fully async/await rather than blocking.

The existing osztromok.com schema

The site's database has four tables forming a strict hierarchy. This matches what was built in the FastAPI course — so if you already have that database, you're connecting to the exact same structure.

Database schema — one-to-many relationships
Subject
idIntPK
nameString
slugString
descriptionString?
positionInt
Subtopic
idIntPK
nameString
slugString
descriptionString?
positionInt
subjectIdIntFK → Subject
Page
idIntPK
titleString
slugString
descriptionString?
positionInt
subtopicIdIntFK → Subtopic
PageContent
idIntPK
headingString
body String (TEXT)
positionInt
pageIdIntFK → Page

Install Prisma and initialise

Terminal — in osztromok/ project root
# Install Prisma CLI (dev only) + the runtime client
$ npm install prisma --save-dev
$ npm install @prisma/client

# Initialise — creates prisma/schema.prisma and adds DATABASE_URL to .env
$ npx prisma init --datasource-provider mysql

✓ Your Prisma schema was created at prisma/schema.prisma
  Next steps:
  1. Set the DATABASE_URL in the .env file to point to your database
  2. Run prisma db pull to turn your database schema into a Prisma schema
Note on env files: prisma init creates a .env file (not .env.local). Next.js loads .env.local; Prisma CLI loads .env. The simplest solution: put DATABASE_URL in both files, or create a .env for Prisma CLI and keep everything else in .env.local. The generated .env is already in .gitignore.

Setting DATABASE_URL

Update both .env (for Prisma CLI) and .env.local (for Next.js) with the real connection string:

.env and .env.local — both need this Environment · never commit
# MySQL connection string format:
# mysql://USER:PASSWORD@HOST:PORT/DATABASE

# ── Local development (MySQL running on your machine) ──────────
DATABASE_URL="mysql://root:yourpassword@localhost:3306/osztromok"

# ── Remote database via SSH tunnel (same approach as FastAPI Ch 9) ──
# First open the tunnel in a separate terminal:
#   ssh -L 3307:localhost:3306 user@osztromok.com -N
# Then connect to the forwarded port:
DATABASE_URL="mysql://dbuser:dbpassword@localhost:3307/osztromok"

# ── Production (on the server — set this in the real environment) ──
# DATABASE_URL="mysql://dbuser:dbpassword@localhost:3306/osztromok"

prisma db pull — read the live database

Because the database already exists (from the FastAPI rebuild or the original site), we use prisma db pull to read the schema directly from MySQL and write it into schema.prisma. No need to write models from scratch.

Terminal
$ npx prisma db pull

Prisma schema loaded from prisma/schema.prisma
Environment variables loaded from .env
Datasource "db": MySQL database "osztromok" at "localhost:3306"

✓ Introspected 4 models and wrote them into prisma/schema.prisma
  - Subject
  - Subtopic
  - Page
  - PageContent

Run prisma generate to generate Prisma Client.

After pulling, you'll have a generated schema.prisma. We then clean it up manually — the introspected schema is accurate but verbose. Here's the clean version with relation names that match the rest of the course:

prisma/schema.prisma — cleaned up after db pull Prisma Schema Language
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Subject {
  id          Int       @id @default(autoincrement())
  name        String    @db.VarChar(255)
  slug        String    @unique @db.VarChar(255)
  description String?   @db.Text
  position    Int       @default(0)

  subtopics   Subtopic[]        // one Subject → many Subtopics

  @@map("subjects")             // maps to the "subjects" table in MySQL
}

model Subtopic {
  id          Int       @id @default(autoincrement())
  name        String    @db.VarChar(255)
  slug        String    @db.VarChar(255)
  description String?   @db.Text
  position    Int       @default(0)
  subjectId   Int

  subject     Subject           @relation(fields: [subjectId], references: [id], onDelete: Cascade)
  pages       Page[]

  @@unique([subjectId, slug])    // slug unique within a subject
  @@map("subtopics")
}

model Page {
  id          Int       @id @default(autoincrement())
  title       String    @db.VarChar(255)
  slug        String    @db.VarChar(255)
  description String?   @db.Text
  position    Int       @default(0)
  subtopicId  Int

  subtopic    Subtopic          @relation(fields: [subtopicId], references: [id], onDelete: Cascade)
  pageContent PageContent[]     @relation("PageToContent")

  @@unique([subtopicId, slug])
  @@map("pages")
}

model PageContent {
  id       Int     @id @default(autoincrement())
  heading  String  @db.VarChar(255)
  body     String  @db.LongText     // TinyMCE HTML — can be large
  position Int     @default(0)
  pageId   Int

  page     Page    @relation("PageToContent", fields: [pageId], references: [id], onDelete: Cascade)

  @@map("page_content")
}
@@map vs model name: The model name (Subject) is what TypeScript sees — PascalCase, singular. The @@map("subjects") attribute tells Prisma what the actual table is called in MySQL. If your table names differ from these, just update the @@map values to match.

prisma generate — build the TypeScript types

Terminal
$ npx prisma generate

✓ Generated Prisma Client (v5.x) to ./node_modules/@prisma/client
  - 4 models
  - TypeScript types: Subject, Subtopic, Page, PageContent

Run this command every time you change schema.prisma. The generated client lives in node_modules/@prisma/client — never edit it directly.

Add to package.json scripts for convenience:
After npm install on a fresh clone, the Prisma client needs to be re-generated. Add a postinstall script so it happens automatically:

"scripts": { "postinstall": "prisma generate" }

lib/prisma.ts — one client instance

In development, Next.js hot-reloads modules when you save a file. Without a singleton, each reload creates a new PrismaClient instance and opens a new database connection — you'd hit MySQL's connection limit fast. The singleton pattern caches the client on the global object to survive hot reloads.

lib/prisma.ts TypeScript · the only place PrismaClient is instantiated
import { PrismaClient } from '@prisma/client'

// Attach to globalThis so the instance survives Next.js hot reloads in dev.
// In production there is only one module evaluation so this is just a const.

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development'
      ? ['query', 'error', 'warn']  // log SQL in dev
      : ['error'],                      // errors only in production
  })

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

Import it from anywhere in server-side code:

Any Server Component or API route TypeScript
import { prisma } from '@/lib/prisma'

// Always await — Prisma queries are always async
const subjects = await prisma.subject.findMany()

// Never import prisma in a Client Component ('use client') file
// — it will fail because the browser has no database access

The Prisma Client API — queries you'll use most

Common query patterns for this site TypeScript
import { prisma } from '@/lib/prisma'

// ── 1. Get all subjects (home page) ──────────────────────────
const subjects = await prisma.subject.findMany({
  orderBy: { position: 'asc' },
  include: {
    _count: { select: { subtopics: true } },  // count subtopics per subject
  },
})
// subjects[0].name         → string  ✓ fully typed
// subjects[0]._count.subtopics → number ✓

// ── 2. Get one subject by slug (subject page) ─────────────────
const subject = await prisma.subject.findUnique({
  where:   { slug: 'japan' },
  include: { subtopics: { orderBy: { position: 'asc' } } },
})
// subject is Subject & { subtopics: Subtopic[] } | null
if (!subject) notFound()

// ── 3. Get one subtopic with its pages ────────────────────────
const subtopic = await prisma.subtopic.findFirst({
  where: {
    slug:    'kanji',
    subject: { slug: 'japan' },  // filter through the relation
  },
  include: {
    pages:   { orderBy: { position: 'asc' } },
    subject: true,
  },
})

// ── 4. Get a full page with content sections ──────────────────
const page = await prisma.page.findFirst({
  where: {
    slug:    'stroke-order',
    subtopic: {
      slug:    'kanji',
      subject: { slug: 'japan' },
    },
  },
  include: {
    pageContent: { orderBy: { position: 'asc' } },
    subtopic:    { include: { subject: true } },  // for breadcrumbs
  },
})

// ── 5. Count pages for a subject (used in SubjectCard) ────────
const pageCount = await prisma.page.count({
  where: { subtopic: { subject: { slug: 'japan' } } },
})

// ── 6. findFirst vs findUnique ────────────────────────────────
// findUnique: only works on @id or @unique fields → fastest
// findFirst:  works on any field combination → use when filtering by slug + parent slug
Filtering through relations (where: { subtopic: { subject: { slug: 'japan' } } }) is one of Prisma's most useful features. It generates an efficient JOIN internally — you never need to write JOIN subtopics ON ... JOIN subjects ON ... manually. TypeScript enforces that the nested filter matches the schema shape.

Home page — real subjects from the database

Replace the hardcoded SUBJECTS array in app/page.tsx:

app/page.tsx — now reads from MySQL TypeScript · Server Component
import { prisma }      from '@/lib/prisma'
import SubjectCard    from '@/components/SubjectCard'

export default async function HomePage() {
  // One query — fetches subjects + subtopic count + total page count
  const subjects = await prisma.subject.findMany({
    orderBy: { position: 'asc' },
    include: {
      _count: { select: { subtopics: true } },
    },
  })

  // Fetch page counts in parallel — one Promise per subject
  const pageCounts = await Promise.all(
    subjects.map((s) =>
      prisma.page.count({ where: { subtopic: { subjectId: s.id } } })
    )
  )

  return (
    <>
      <section className="py-12 border-b border-site-border mb-10">
        <p className="section-label mb-3">Learning site</p>
        <h1 className="text-4xl font-bold text-site-text mb-3">
          Welcome to <span className="text-site-accent">osztromok.com</span>
        </h1>
        <p className="text-site-muted max-w-lg leading-relaxed">
          Philip's personal learning site — languages, programming, and more.
        </p>
      </section>

      <section>
        <p className="section-label mb-5">Subjects</p>
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
          {subjects.map((subject, i) => (
            <SubjectCard
              key={subject.id}
              name={subject.name}
              slug={subject.slug}
              description={subject.description}
              subtopicCount={subject._count.subtopics}
              pageCount={pageCounts[i]}
            />
          ))}
        </div>
      </section>
    </>
  )
}
Promise.all() for parallel queries. The page count queries are independent of each other — there's no reason to run them one by one. Promise.all() fires them all simultaneously and waits for all to finish. For 5 subjects this is ~5× faster than awaiting each query in a loop.

Nav.tsx — replace placeholder with real query

Remove the PLACEHOLDER_SUBJECTS array added in Chapter 3:

components/Nav.tsx — the key change TypeScript · replace placeholder data
import { prisma } from '@/lib/prisma'

export default async function Nav() {
  // Was: const subjects = PLACEHOLDER_SUBJECTS
  const subjects = await prisma.subject.findMany({
    orderBy: { position: 'asc' },
    select: { id: true, name: true, slug: true },  // only fetch what we need
  })

  // rest of Nav component unchanged…
}
select vs include: include fetches the full model plus specified relations. select fetches only the fields you list — useful when you only need id, name, and slug for the nav bar. Smaller queries = less data transferred = faster pages.

Prisma Studio — a visual DB browser

Prisma comes with a built-in web-based database browser. Useful for inspecting data, checking queries worked, and manually editing records during development.

Terminal — runs separately from npm run dev
$ npx prisma studio

Prisma Studio is up on http://localhost:5555

Open http://localhost:5555 to browse and edit your tables with a GUI. Changes made in Studio go directly to the database — useful for fixing test data without writing SQL.

prisma/seed.ts — populate a fresh database

If you're setting up a fresh MySQL instance (e.g. a new dev machine or a CI environment) rather than connecting to the existing database, a seed script populates it with starter data. This is optional if you're pointing at the live database.

prisma/seed.ts TypeScript · run with: npx prisma db seed
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

async function main() {
  // upsert = insert if not exists, update if exists — safe to run multiple times
  const japan = await prisma.subject.upsert({
    where:  { slug: 'japan' },
    update: {},
    create: {
      name:     'Japanese',
      slug:     'japan',
      position: 1,
      description: 'Hiragana, katakana, kanji, vocabulary and grammar',
      subtopics: {
        create: [
          { name: 'Hiragana', slug: 'hiragana', position: 1 },
          { name: 'Katakana', slug: 'katakana', position: 2 },
          { name: 'Kanji',    slug: 'kanji',    position: 3 },
        ],
      },
    },
  })

  console.log(`Seeded subject: ${japan.name}`)
}

main()
  .catch((e) => { console.error(e); process.exit(1) })
  .finally(async () => { await prisma.$disconnect() })

Register the seed script in package.json:

package.json — add prisma.seed config JSON
{
  "prisma": {
    "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
  },
  "scripts": {
    "postinstall": "prisma generate"
  }
}
Terminal
# Install ts-node first if not already installed
$ npm install ts-node --save-dev

# Run the seed script
$ npx prisma db seed
Seeded subject: Japanese

Working with Prisma's generated types

Prisma generates TypeScript types for every model. Here's how to use them effectively — especially for the include patterns that return models with nested relations attached.

types/index.ts — update with Prisma-derived types TypeScript
import type { Subject, Subtopic, Page, PageContent } from '@prisma/client'
import type { Prisma } from '@prisma/client'

// Re-export base types — components can import from here
export type { Subject, Subtopic, Page, PageContent }

// ── Composite types for pages that include relations ───────────

// Subject with subtopic count (home page)
export type SubjectWithCount = Subject & {
  _count: { subtopics: number }
}

// Subject with its subtopics (subject page)
export type SubjectWithSubtopics = Prisma.SubjectGetPayload<{
  include: { subtopics: true }
}>

// Page with content sections and full breadcrumb data
export type PageWithContent = Prisma.PageGetPayload<{
  include: {
    pageContent: true
    subtopic: { include: { subject: true } }
  }
}>

// Usage: the type exactly matches what Prisma returns — no casting needed
// const page: PageWithContent = await prisma.page.findFirst({ include: { … } })
Prisma.ModelGetPayload<…> is the cleanest way to type a Prisma query result with include. It derives the exact TypeScript type from your include specification — so if you add a field to the include, the type updates automatically. No manual interface duplication.

Verify the database connection is working

Home page shows real subjects

http://localhost:3000/ — the subject cards show names, descriptions, and counts pulled from the actual MySQL database. If you see the placeholder data still, check that DATABASE_URL is set in .env.local and restart npm run dev.

Nav bar shows real subjects

The navigation links at the top match the subjects in the database — not the hardcoded placeholder array. Ordering matches the position column.

Prisma Studio shows your data

Run npx prisma studio and open http://localhost:5555 — all four tables (Subject, Subtopic, Page, PageContent) are visible and populated.

TypeScript compiles cleanly

npx tsc --noEmit — zero errors. The Prisma-generated types satisfy every interface. If there's a mismatch, check that npx prisma generate was run after the schema was finalised.

SQL logged in terminal during dev

With log: ['query'] in the PrismaClient options, the raw SQL is printed to the terminal during development. You should see SELECT statements when the home page loads — useful for spotting N+1 query issues early.

Up next — Chapter 6: wire the dynamic routes from Chapter 2 to these Prisma queries. /[subject], /[subject]/[subtopic], /[subject]/[subtopic]/[page], and /japan/kanji/[...char] will all render real content from the database — and call notFound() for slugs that don't exist.