Prisma
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.
| Feature | Raw SQL / mysql2 | Prisma 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 |
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.
Install Prisma and initialise
# 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
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:
# 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.
$ 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:
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")
}
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
$ 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.
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.
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:
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
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
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:
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:
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.
$ 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.
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:
{
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},
"scripts": {
"postinstall": "prisma generate"
}
}
# 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.
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.
/[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.