Prisma und Datenbank
Jedes Web-Framework braucht eine Schicht, die zwischen der Anwendungslogik und der Datenbank vermittelt. In Rails übernimmt das ActiveRecord diese Aufgabe – und tut es mit beeindruckender Eleganz. In der Next.js-Welt hat sich Prisma als das Äquivalent dieser Rolle durchgesetzt. Es ist TypeScript-first, schema-getrieben und bringt eine eigene CLI mit, die sich nach wenigen Stunden sehr vertraut anfühlt.
Prisma – das ActiveRecord von Next.js
Wenn du ActiveRecord kennst, kannst du Prisma innerhalb eines Nachmittags produktiv nutzen. Die Kernaufgaben sind dieselben: Schema definieren, Migrationen verwalten, Datenbankabfragen formulieren. Was sich unterscheidet, ist die Philosophie dahinter.
ActiveRecord ist ein Active Record Pattern – das Modell-Objekt repräsentiert gleichzeitig eine Tabellenzeile und trägt die Geschäftslogik in Form von Callbacks, Validierungen und Scopes direkt mit. Ein User-Objekt in Rails weiß, wie es sich selbst speichert, validiert und mit anderen Modellen verknüpft.
Prisma trennt diese Verantwortlichkeiten konsequent. Das Schema in schema.prisma beschreibt die Datenbankstruktur. Der generierte Prisma Client ist ein typsicheres Query-Building-Objekt ohne eigene Geschäftslogik. Validierungen und Callbacks schreibst du selbst – mit Zod, mit Server Actions, mit eigenen Hilfsfunktionen. Das klingt nach mehr Arbeit, ist aber oft übersichtlicher: Du weißt genau, wo Validierungslogik steckt, weil sie nicht in einem Callback-Geflecht verborgen ist.
Ein weiterer fundamentaler Unterschied: Prisma ist TypeScript-first. Das bedeutet nicht nur, dass du TypeScript verwenden kannst – es bedeutet, dass Prisma den Client direkt aus deinem Schema generiert. Jedes Modell, jedes Feld, jede Relation ist zur Compile-Zeit bekannt. Wenn du prisma.note.findMany() schreibst, weiß der TypeScript-Compiler genau, welche Felder das zurückgegebene Objekt hat. Ein Tippfehler im Feldnamen ist kein Laufzeitfehler mehr, sondern ein roter Unterstrich im Editor.
ActiveRecord und Prisma verfolgen dasselbe Ziel, aber auf unterschiedlichen Wegen. ActiveRecord bettet Datenbanklogik direkt in Modell-Klassen ein – das User-Objekt ist gleichzeitig die Datenbankzeile. Prisma trennt Schema-Definition, Migrationen und den Query-Client sauber voneinander. Die Typsicherheit, die Rails erst mit Sorbet oder RBS nachrüstet, ist bei Prisma von Anfang an eingebaut.
schema.prisma – Schema, Migration und Modell in einer Datei
Das Herzstück von Prisma ist die Datei prisma/schema.prisma. Sie vereint, was in Rails auf drei verschiedene Orte verteilt ist: die schema.rb (Datenbankstruktur), Migrationsdateien (Änderungshistorie) und Modell-Validierungen (Feldtypen und Constraints).
Hier ist das vollständige Schema unseres DevNotes-Projekts:
// Prisma Schema — analog zu schema.rb in Rails
// Mehr Infos: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client"
output = "../app/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// User ≈ app/models/user.rb
model User {
id Int @id @default(autoincrement())
email String @unique
password String // bcrypt hash — niemals Klartext
name String?
notes Note[] // has_many :notes
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Note ≈ app/models/note.rb (belongs_to :user, has_and_belongs_to_many :tags)
model Note {
id Int @id @default(autoincrement())
title String
content String @db.Text
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tags Tag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Tag ≈ app/models/tag.rb (has_and_belongs_to_many :notes)
model Tag {
id Int @id @default(autoincrement())
name String @unique
notes Note[]
}Lass uns die einzelnen Abschnitte durchgehen:
generator client – Dieser Block sagt Prisma, was es generieren soll. provider = "prisma-client" ist der Standard (Prisma 7). Mit output steuern wir, wohin der generierte Client-Code landet. In unserem Projekt liegt er unter app/generated/prisma, damit er für den Next.js App Router gut erreichbar ist.
datasource db – Definiert die Datenbankverbindung. provider = "postgresql" legt den Datenbanktreiber fest. Die eigentliche Connection-URL kommt aus prisma.config.ts (dazu gleich mehr) und wird nie direkt ins Schema geschrieben – Secrets gehören in Umgebungsvariablen.
model User – Jedes model entspricht einer Datenbanktabelle. Die Felder folgen dem Muster feldname Typ @attribut. @id @default(autoincrement()) ist die Entsprechung von Rails' t.primary_key. String? – das Fragezeichen – bedeutet optional (nullable in SQL). Note[] ist keine echte Datenbankspalte, sondern eine virtuelle Relation – Prisma braucht sie, um Queries über Beziehungen formulieren zu können.
@relation – Das ist der Fremdschlüssel-Block. Bei Note sehen wir @relation(fields: [userId], references: [id], onDelete: Cascade). Das entspricht in einer Rails-Migration t.references :user, foreign_key: { on_delete: :cascade }. Prisma macht den Cascade-Delete explizit – in Rails passiert er oft implizit durch dependent: :destroy.
@db.Text – Native Datenbanktypen können mit @db.-Attributen gesetzt werden. String würde in PostgreSQL zu varchar(191), @db.Text zu text ohne Längenbeschränkung.
# schema.rb (von Migrationen generiert)
# models/note.rb
class Note < ApplicationRecord
belongs_to :user
has_and_belongs_to_many :tags
validates :title, presence: true
end
# migration
add_column :notes, :content, :text// schema.prisma — alles an einem Ort
model Note {
id Int @id @default(autoincrement())
title String
content String @db.Text
userId Int
user User @relation(fields: [userId], references: [id])
tags Tag[]
}
// Validierungen kommen separat (z.B. mit Zod)prisma migrate dev – Schema-first statt Migration-first
In Rails beginnst du eine Schemaänderung mit einer Migrationsdatei: rails generate migration AddPublishedToNotes published:boolean. Die Migration beschreibt die Änderung, und schema.rb spiegelt den aktuellen Stand wider – sie wird nie direkt bearbeitet.
Prisma dreht diesen Workflow um: Du änderst zuerst schema.prisma direkt, dann lässt du Prisma die passende Migration ableiten:
# 1. schema.prisma bearbeiten (z.B. neues Feld hinzufügen)
# 2. Migration erzeugen und anwenden
npx prisma migrate dev --name add_published_to_notes
# Prisma liest die Änderung, erzeugt SQL, wendet es an
# und regeneriert den TypeScript-Client automatischWas dabei im Hintergrund passiert: Prisma vergleicht dein aktuelles schema.prisma mit dem zuletzt angewendeten Migrationszustand, berechnet den Diff und generiert SQL-Code in prisma/migrations/<timestamp>_<name>/migration.sql. Dann wendet es die Migration an und ruft prisma generate auf, um den TypeScript-Client neu zu generieren.
# Für die Produktion: nur anwenden, nicht generieren
npx prisma migrate deploy
# Aktuellen Migrationsstatus prüfen
npx prisma migrate status
# Client regenerieren (nach manuellem Schema-Edit)
npx prisma generateDer Workflow für eine neue Schemaänderung sieht also so aus:
prisma/schema.prismaöffnen und das Feld oder Modell ergänzennpx prisma migrate dev --name <beschreibung>ausführen- TypeScript-Typen sind sofort aktualisiert – der Client-Code liegt unter
app/generated/prisma
# Rails: Migration-first
# Zuerst Migration schreiben
rails g migration AddPublishedToNotes
# ... Migration bearbeiten ...
rails db:migrate
# schema.rb wird automatisch aktualisiert// Prisma: Schema-first
// Zuerst schema.prisma bearbeiten:
// published Boolean @default(false)
// Dann Migration ableiten lassen:
// npx prisma migrate dev --name add_published_to_notesEin wichtiger Unterschied: In Rails ist schema.rb das Read-only-Ergebnis deiner Migrationen. In Prisma ist schema.prisma die Quelle der Wahrheit, die du aktiv bearbeitest. Die Migrationsdateien sind das Ergebnis – und sollten ebenfalls ins Git-Repository.
Prisma Client API – CRUD in der Praxis
Nach prisma migrate dev ist der Client automatisch aktualisiert und bereit zur Nutzung. Die API ist durchgängig Promise-basiert (du brauchst also await) und vollständig typsicher.
findMany – alle oder gefiltert
import { prisma } from "@/lib/prisma";
// Alle Notes eines Users, neueste zuerst, mit Tags
const notes = await prisma.note.findMany({
where: { userId: session.userId },
include: { tags: true },
orderBy: { createdAt: "desc" },
});
// Typ: Array<Note & { tags: Tag[] }>
// Mit Suche (ILIKE in PostgreSQL)
const results = await prisma.note.findMany({
where: {
userId: session.userId,
OR: [
{ title: { contains: query, mode: "insensitive" } },
{ content: { contains: query, mode: "insensitive" } },
],
},
orderBy: { updatedAt: "desc" },
});findUnique – einen Datensatz per ID oder unique field
// Per ID
const note = await prisma.note.findUnique({
where: { id: Number(params.id) },
include: {
user: { select: { name: true, email: true } }, // nur bestimmte Felder
tags: true,
},
});
// Gibt null zurück wenn nicht gefunden — kein throw wie bei AR
if (!note) {
notFound(); // Next.js 404
}create – neuen Datensatz anlegen
const newNote = await prisma.note.create({
data: {
title: "Meine erste Note",
content: "Inhalt hier...",
userId: session.userId,
tags: {
connectOrCreate: [
{
where: { name: "TypeScript" },
create: { name: "TypeScript" },
},
],
},
},
include: { tags: true }, // gibt die Note mit Tags zurück
});update – bestehenden Datensatz ändern
const updated = await prisma.note.update({
where: { id: noteId },
data: {
title: newTitle,
content: newContent,
updatedAt: new Date(), // @updatedAt macht das automatisch
},
});delete – Datensatz löschen
await prisma.note.delete({
where: { id: noteId },
});
// Kein Rückgabewert nötig — wirft bei nicht gefundener IDNote.all
Note.where(user_id: current_user.id)
Note.find(id)
Note.find_by(id: id)
Note.create!(title: ..., content: ...)
note.update!(title: ...)
note.destroyprisma.note.findMany()
prisma.note.findMany({ where: { userId } })
prisma.note.findUniqueOrThrow({ where: { id } })
prisma.note.findUnique({ where: { id } })
prisma.note.create({ data: { title, content, userId } })
prisma.note.update({ where: { id }, data: { title } })
prisma.note.delete({ where: { id } })Ein wichtiger Unterschied: Prisma unterscheidet zwischen findUnique (gibt null zurück) und findUniqueOrThrow (wirft bei nicht gefundenem Datensatz). In Rails macht find das Äquivalent zu findUniqueOrThrow – es wirft ActiveRecord::RecordNotFound. Für das null-Verhalten gibt es find_by.
Relationen – belongs_to, has_many und many-to-many
Prisma modelliert Beziehungen direkt im Schema. Die Syntax ist prägnant, aber an einer Stelle für Rails-Entwickler ungewohnt: Beide Seiten einer Beziehung werden im Schema deklariert – sowohl der Fremdschlüssel als auch die inverse Relation.
In unserem Schema sehen wir drei Arten von Beziehungen:
belongs_to (Note → User): user User @relation(fields: [userId], references: [id]) auf der Note-Seite, notes Note[] auf der User-Seite. Die Note hält den Fremdschlüssel userId.
has_many (User → Notes): Die Note[]-Liste auf dem User-Modell ist die virtuelle Rückseite der Relation. Sie speichert nichts in der Datenbank, ermöglicht aber Queries wie user.notes.
many-to-many (Note ↔ Tag): Wenn beide Modelle ein Array des jeweils anderen enthalten (tags Tag[] und notes Note[]), erzeugt Prisma automatisch eine implizite Join-Tabelle – kein has_and_belongs_to_many-Boilerplate nötig.
Eager Loading mit include
Das N+1-Problem kennst du aus Rails: Jede Note lädt ihren User separat. Die Lösung ist includes in Rails – bei Prisma heißt sie include:
// Ohne include: N+1, jeder Tag wird separat geladen
const notes = await prisma.note.findMany({ where: { userId } });
// Mit include: eine SQL-Query für alles
const notesWithTags = await prisma.note.findMany({
where: { userId },
include: {
tags: true,
user: {
select: { name: true }, // nur name, nicht password!
},
},
});# Rails eager loading
Note
.includes(:tags, :user)
.where(user_id: current_user.id)
.order(created_at: :desc)
# Nur bestimmte Felder laden
Note.includes(:user)
.select('notes.*, users.name as user_name')// Prisma eager loading
prisma.note.findMany({
where: { userId },
include: {
tags: true,
user: { select: { name: true } },
},
orderBy: { createdAt: 'desc' },
})Ein wesentlicher Unterschied zu Rails: Prisma unterstützt kein Lazy Loading. Es gibt keine Situation, in der ein fehlender include still ein N+1 erzeugt – du musst include bewusst angeben, oder du bekommst undefined statt des verknüpften Objekts. Das zwingt zu expliziterem Denken über Datenbankabfragen.
Many-to-many mit Tags – connectOrCreate
Die implizite Join-Tabelle zwischen Note und Tag ist komfortabel, aber das Verwalten von Tags beim Erstellen und Aktualisieren von Notes braucht ein wenig Aufmerksamkeit.
Das Muster connectOrCreate ist hier der Schlüssel: Es versucht, einen existierenden Tag per where-Bedingung zu finden, und legt ihn nur dann neu an, wenn er noch nicht existiert. So können mehrere Notes denselben Tag teilen, ohne dass Duplikate entstehen:
// Tags beim Erstellen einer Note setzen
const note = await prisma.note.create({
data: {
title,
content,
userId: session.userId,
tags: {
connectOrCreate: tagNames.map((name) => ({
where: { name },
create: { name },
})),
},
},
});
// Tags beim Aktualisieren neu setzen (alle alten ersetzen)
const updated = await prisma.note.update({
where: { id: noteId },
data: {
title,
content,
tags: {
// set: [] — alle alten Verbindungen löschen und neue setzen
set: [],
connectOrCreate: tagNames.map((name) => ({
where: { name },
create: { name },
})),
},
},
include: { tags: true },
});Das set: [] ist wichtig beim Aktualisieren: Es trennt zuerst alle bestehenden Tag-Verbindungen der Note, bevor die neuen gesetzt werden. Ohne set: [] würden neue Tags hinzugefügt, aber alte nicht entfernt.
Bei expliziten Many-to-many-Relationen (mit eigener Join-Tabelle und zusätzlichen Feldern) verwendet man stattdessen eigene Modelle für die Verbindungstabelle – das ist dann analog zu has_many :through in Rails.
Seeding – Testdaten für die Entwicklung
Prisma hat eine eingebaute Seeding-Unterstützung. Das Seed-Script liegt unter prisma/seed.ts und wird mit npx prisma db seed ausgeführt. Damit tsx als Interpreter verwendet wird, ist ein Eintrag in package.json nötig:
// package.json
{
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}Das Seed-Script folgt einem klaren Muster: zuerst alle bestehenden Daten löschen (in der richtigen Reihenfolge, um Foreign-Key-Konflikte zu vermeiden), dann Testdaten anlegen:
// prisma/seed.ts
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../app/generated/prisma";
import bcrypt from "bcryptjs";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
async function main() {
// Reihenfolge wichtig: zuerst die abhängigen Tabellen leeren
await prisma.tag.deleteMany();
await prisma.note.deleteMany();
await prisma.user.deleteMany();
const user = await prisma.user.create({
data: {
email: "demo@example.com",
password: await bcrypt.hash("password123", 12),
name: "Demo User",
},
});
// Tags anlegen (parallel — kein gegenseitiges Abhängigkeitsproblem)
const [tsTag, reactTag] = await Promise.all([
prisma.tag.create({ data: { name: "TypeScript" } }),
prisma.tag.create({ data: { name: "React" } }),
]);
// Note mit verknüpften Tags anlegen
await prisma.note.create({
data: {
title: "Server Components verstehen",
content: "React Server Components...",
userId: user.id,
tags: {
connect: [{ id: tsTag.id }, { id: reactTag.id }],
},
},
});
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());# db/seeds.rb
User.destroy_all
Tag.destroy_all
Note.destroy_all
user = User.create!(
email: 'demo@example.com',
password: 'password123'
)
ts_tag = Tag.create!(name: 'TypeScript')
Note.create!(
title: 'Server Components',
user: user,
tags: [ts_tag]
)// prisma/seed.ts
await prisma.tag.deleteMany()
await prisma.user.deleteMany()
const user = await prisma.user.create({
data: { email: 'demo@example.com', ... }
})
const tsTag = await prisma.tag.create({
data: { name: 'TypeScript' }
})
await prisma.note.create({
data: { ..., userId: user.id,
tags: { connect: [{ id: tsTag.id }] } }
})Prisma Studio – Datenbankinhalt im Browser
Für die Entwicklung bietet Prisma eine eingebaute Web-UI, die einen ähnlichen Zweck wie rails db (oder Rails Admin im weiteren Sinne) erfüllt:
npx prisma studio
# Öffnet http://localhost:5555Prisma Studio zeigt alle Tabellen mit ihren Daten in einer tabellarischen Ansicht. Du kannst Datensätze filtern, editieren, neue anlegen und löschen – ohne SQL oder Rails-Konsole. Das ist besonders nützlich, um nach einem prisma migrate dev schnell zu prüfen, ob die Schemaänderung korrekt angewendet wurde.
rails db öffnet die Datenbankshell (psql, mysql) und braucht SQL-Kenntnisse. rails console lädt die gesamte App und erlaubt Ruby-basierte Datenbankabfragen. Prisma Studio ist eine grafische Alternative zu beidem für Entwicklungsszenarien – kein SQL, keine Konsole, aber auch weniger mächtig als die Rails-Konsole für komplexe Queries.
Prisma 7 – Adapter-Pattern und prisma.config.ts
Prisma 7 hat die Art, wie die Datenbankverbindung konfiguriert wird, grundlegend geändert. Früher las Prisma die DATABASE_URL aus einer .env-Datei über das Schema selbst. Jetzt gibt es zwei neue Dateien, die zusammenarbeiten.
prisma.config.ts – Externe Konfigurationsdatei für die Prisma CLI:
// prisma.config.ts (Projektroot)
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});Diese Datei verwendet die CLI (für migrate dev, studio, generate). Die eigentliche Laufzeitverbindung läuft über einen Datenbank-Adapter im Code.
lib/prisma.ts – Der Singleton-Client mit pg-Adapter:
// lib/prisma.ts
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/app/generated/prisma";
// Verhindert mehrere PrismaClient-Instanzen beim HMR in der Entwicklung
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
return new PrismaClient({ adapter });
}
export const prisma: PrismaClient =
globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
export default prisma;Das Adapter-Pattern ist der Schlüssel: Statt dass Prisma intern eine Datenbankverbindung verwaltet, übergibst du einen externen Adapter – in diesem Fall PrismaPg aus @prisma/adapter-pg. Das ermöglicht Prisma, mit verschiedenen Connection-Poolern und Edge-Runtimes zu arbeiten.
Warum globalForPrisma? Next.js Hot Module Replacement (HMR) in der Entwicklung lädt Module bei jeder Änderung neu. Ohne den globalThis-Trick würde bei jedem gespeicherten File ein neuer PrismaClient angelegt, bis die Verbindungslimits der Datenbank erschöpft sind. globalThis überlebt HMR-Zyklen – in Produktion wird der Code nur einmal initialisiert, daher greift der Schutz nur in der Entwicklung.
Füge dem Note-Modell ein published-Feld hinzu und prüfe das Ergebnis in Prisma Studio.
-
Öffne
prisma/schema.prismaund ergänze das Feld inmodel Note:published Boolean @default(false)Das Feld kommt am besten nach
content, voruserId. -
Führe die Migration aus:
npx prisma migrate dev --name add_published_to_notesPrisma erzeugt eine neue Datei in
prisma/migrations/und regeneriert den Client automatisch. -
Starte Prisma Studio und prüfe, ob die neue Spalte in der
Note-Tabelle erscheint:npx prisma studioÖffne
http://localhost:5555und navigiere zuNote. Alle bestehenden Notes solltenpublished: falsezeigen. -
Bonus: Setze eine Note manuell auf
published: truein Prisma Studio und prüfe, ob der TypeScript-Typ aktualisiert wurde –note.publishedsollte jetztbooleansein.
Im Capstone: DevNotes
In der DevNotes-App steckt das Prisma-Setup direkt in lib/prisma.ts. Das Singleton-Muster dort ist kein optionales Detail – es ist die Lösung für ein konkretes Problem mit Next.js HMR.
Jede Server Action und jede Server Component, die Datenbankzugriff braucht, importiert prisma aus diesem Modul:
import { prisma } from "@/lib/prisma";Das Singleton-Pattern stellt sicher, dass in der gesamten App – egal wie viele Module prisma importieren – nur ein einziger PrismaClient existiert. In Rails ist das durch den Connection Pool von ActiveRecord automatisch geregelt. In Next.js mit HMR muss es explizit gebaut werden.
Die Relation zwischen Note, User und Tag im Schema ist so konzipiert, dass typische DevNotes-Queries – "alle Notes eines Users mit ihren Tags", "eine Note per ID mit User und Tags" – in einer einzigen Datenbankabfrage gelöst werden können. Sobald wir in Modul 6 Server Actions für das Erstellen und Bearbeiten von Notes bauen, werden die connectOrCreate-Muster für Tags zum Einsatz kommen.
Die onDelete: Cascade-Direktive auf der User-Note-Relation ist bewusst gesetzt: Wird ein User gelöscht, verschwinden alle seine Notes automatisch. Tags bleiben erhalten – sie sind global und gehören nicht einem einzelnen User. Das ist eine Designentscheidung, die in einem echten Produkt überdacht werden sollte, aber für das Tutorial die Datenbankoperationen einfach hält.