PocketBase ist ein kompaktes Open-Source-Backend in einer einzigen Datei: Datenbank, Authentifizierung, Dateispeicher und Admin-Oberfläche inklusive. Richtig spannend wird es aber erst, wenn du eigene Logik einbaust. Genau dafür gibt es PocketBase JavaScript Hooks. Du legst einfach eine Datei in den Ordner pb_hooks, und PocketBase führt deinen Code bei bestimmten Ereignissen aus, ganz ohne eigenen Go-Build.
In diesem Beitrag bekommst du 10 praxisnahe Beispiele, die du direkt übernehmen und anpassen kannst. Alle Snippets basieren auf der aktuellen Hook-API ab PocketBase v0.23.
So funktionieren PocketBase Hooks
Jede Datei mit der Endung .pb.js im Ordner pb_hooks (neben der PocketBase-Binary) wird beim Start geladen. Änderungen erkennt PocketBase automatisch und startet neu. Innerhalb der Dateien registrierst du Handler wie onRecordCreate(), routerAdd() oder cronAdd().
Zwei Regeln solltest du dir merken:
- Rufe
e.next()auf. Hooks bilden eine Kette. Ohnee.next()wird die eigentliche Aktion (z. B. das Speichern) nicht ausgeführt. - Handler laufen isoliert. Variablen außerhalb eines Handlers sind darin nicht verfügbar. Gemeinsamen Code lädst du per
require()innerhalb des Handlers (siehe Beispiel 10).
Die folgende Grafik zeigt, in welcher Reihenfolge die wichtigsten Record-Hooks bei einer Anfrage über die API greifen:
Beispiel 1: Slug automatisch erzeugen
Du willst für Blogposts eine saubere URL, ohne dass jemand den Slug von Hand eintippen muss? Mit onRecordCreate setzt du das Feld vor dem Speichern, egal ob der Record über die API, das Dashboard oder einen anderen Hook entsteht.
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// pb_hooks/slug.pb.js onRecordCreate((e) => { if (!e.record.get("slug")) { const slug = e.record.get("title") .toLowerCase() .replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss") .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); e.record.set("slug", slug); } e.next(); }, "posts"); |
Beispiel 2: Eigene Validierung
Die eingebauten Feldregeln decken viel ab, aber nicht alles. Mit onRecordCreateRequest prüfst du eingehende API-Daten und brichst mit einer verständlichen Fehlermeldung ab.
|
1 2 3 4 5 6 7 8 9 10 |
// pb_hooks/validate.pb.js onRecordCreateRequest((e) => { const start = new Date(e.record.get("start_date")); const end = new Date(e.record.get("end_date")); if (end <= start) { throw new BadRequestError("Das Enddatum muss nach dem Startdatum liegen."); } e.next(); }, "events"); |
Beispiel 3: Besitzer automatisch setzen
Ein Klassiker: Der eingeloggte User soll automatisch als Autor eingetragen werden. So verhinderst du auch, dass jemand im Request einen fremden Autor mitschickt.
|
1 2 3 4 5 6 7 8 |
// pb_hooks/owner.pb.js onRecordCreateRequest((e) => { if (!e.auth) { throw new UnauthorizedError("Bitte melde dich an."); } e.record.set("author", e.auth.id); e.next(); }, "posts"); |
Beispiel 4: Willkommens-Mail nach der Registrierung
Sobald ein neuer User erfolgreich gespeichert wurde, verschickst du eine persönliche Begrüßung. Der Hook onRecordAfterCreateSuccess läuft erst, wenn der Datensatz wirklich in der Datenbank steht. Die SMTP-Einstellungen kommen aus dem Dashboard.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// pb_hooks/welcome.pb.js onRecordAfterCreateSuccess((e) => { const meta = e.app.settings().meta; const message = new MailerMessage({ from: { address: meta.senderAddress, name: meta.senderName }, to: [{ address: e.record.email() }], subject: "Willkommen bei " + meta.appName, html: "<p>Hallo " + (e.record.get("name") || "") + ", schön, dass du da bist!</p>", }); e.app.newMailClient().send(message); e.next(); }, "users"); |
Beispiel 5: Eigene API-Route
Nicht jede Anfrage passt in das Standard-CRUD-Schema. Mit routerAdd() baust du eigene Endpunkte. Die Middleware $apis.requireAuth() sorgt dafür, dass nur eingeloggte User Zugriff haben.
|
1 2 3 4 5 6 7 |
// pb_hooks/stats.pb.js routerAdd("GET", "/api/stats", (e) => { const posts = $app.countRecords("posts"); const users = $app.countRecords("users"); return e.json(200, { posts: posts, users: users }); }, $apis.requireAuth()); |
Auch Pfadparameter sind möglich, zum Beispiel /api/hello/{name}. Den Wert liest du dann mit e.request.pathValue("name") aus.
Beispiel 6: Cronjob zum Aufräumen
Alte Sessions, abgelaufene Einladungen oder temporäre Uploads sammeln sich schnell an. Mit cronAdd() planst du wiederkehrende Aufgaben direkt in PocketBase, ganz ohne externen Scheduler.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// pb_hooks/cleanup.pb.js // Jeden Tag um 3:00 Uhr cronAdd("cleanup_invites", "0 3 * * *", () => { const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) .toISOString() .replace("T", " "); const records = $app.findRecordsByFilter( "invites", "created < {:cutoff}", "-created", 500, 0, { cutoff: cutoff } ); records.forEach((r) => $app.delete(r)); $app.logger().info("Einladungen gelöscht", "count", records.length); }); |
Vielleicht fragst du dich, ob der Cronjob bei jedem Start oder jeder Änderung an der Datei erneut hinzugefügt wird und am Ende mehrfach läuft. Die Antwort ist nein, und zwar aus zwei Gründen:
- Die ID ist eindeutig: Der erste Parameter von
cronAdd()(hier"cleanup_invites") identifiziert den Job. Existiert bereits ein Job mit dieser ID, ersetzt PocketBase ihn, statt einen zweiten anzulegen. - Hooks werden nur beim Start geladen: Die Dateien in
pb_hookslaufen einmal beim Start der App. Änderst du eine Datei, startet PocketBase den Prozess automatisch neu, und der Scheduler beginnt wieder mit einer leeren Liste.
Wichtig ist also nur, dass jeder Job eine eigene ID bekommt. Verwendest du dieselbe ID in zwei Dateien, überschreibt der zuletzt geladene Job den anderen. Brauchst du einen Job nicht mehr, entfernst du ihn mit cronRemove("cleanup_invites"). Alle registrierten Jobs siehst du im Dashboard unter Settings > Crons. Mehr dazu findest du in der offiziellen Doku zum Jobs Scheduling.
Beispiel 7: Webhook an einen externen Dienst
Wenn ein Beitrag veröffentlicht wird, soll zum Beispiel ein Slack-Channel oder ein Static-Site-Build benachrichtigt werden. Dafür gibt es $http.send(). Packe den Aufruf in ein try/catch, damit ein ausgefallener Fremddienst nicht deine App blockiert.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// pb_hooks/webhook.pb.js onRecordAfterUpdateSuccess((e) => { if (e.record.get("status") === "published") { try { $http.send({ url: "https://hooks.example.com/deploy", method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: e.record.id, title: e.record.get("title") }), timeout: 10, }); } catch (err) { e.app.logger().error("Webhook fehlgeschlagen", "error", String(err)); } } e.next(); }, "posts"); |
Beispiel 8: Felder in der Antwort ausblenden
Manche Felder brauchst du intern, sie sollen aber nie beim Client landen. Mit onRecordEnrich passt du jede Record-Antwort an, egal ob sie aus einer Liste, einer Einzelabfrage oder einem Realtime-Event stammt.
|
1 2 3 4 5 6 7 8 |
// pb_hooks/hide.pb.js onRecordEnrich((e) => { // Nur Superuser sehen die interne Notiz if (!e.requestInfo.auth || !e.requestInfo.auth.isSuperuser()) { e.record.hide("internal_note"); } e.next(); }, "orders"); |
Beispiel 9: Audit-Log für Änderungen
Wer hat wann den Status einer Bestellung geändert? Ein einfacher Audit-Log beantwortet das. Lege dafür eine Collection audit_logs mit den Feldern collection, record_id, old_status und new_status an. Im Model-Hook onRecordUpdate hast du über original() noch Zugriff auf den alten Wert.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// pb_hooks/audit.pb.js onRecordUpdate((e) => { const oldStatus = e.record.original().get("status"); const newStatus = e.record.get("status"); e.next(); // erst speichern if (oldStatus !== newStatus) { const col = e.app.findCollectionByNameOrId("audit_logs"); const log = new Record(col); log.set("collection", e.record.collection().name); log.set("record_id", e.record.id); log.set("old_status", oldStatus); log.set("new_status", newStatus); e.app.save(log); } }, "orders"); |
Weil hier e.app statt $app verwendet wird, läuft das Speichern des Logs in derselben Transaktion wie die eigentliche Änderung, falls eine aktiv ist.
Beispiel 10: Gemeinsamen Code auslagern
Wenn mehrere Hooks dieselbe Hilfsfunktion brauchen, lagerst du sie in ein Modul aus. Wichtig: Die Datei darf nicht auf .pb.js enden, sonst lädt PocketBase sie als Hook. Und der require()-Aufruf gehört in den Handler, weil Handler isoliert laufen.
|
1 2 3 4 5 6 7 |
// pb_hooks/utils.js module.exports = { slugify: (text) => text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""), }; |
|
1 2 3 4 5 6 |
// pb_hooks/categories.pb.js onRecordCreate((e) => { const utils = require(`${__hooks}/utils.js`); e.record.set("slug", utils.slugify(e.record.get("name"))); e.next(); }, "categories"); |
Tipps für den Alltag mit PocketBase Hooks
- Typen nutzen: PocketBase legt beim Start die Datei
pb_data/types.d.tsan. Verweise am Anfang deiner Hook-Datei mit/// <reference path="../pb_data/types.d.ts" />darauf und dein Editor liefert Autovervollständigung. - Loggen statt raten: Mit
$app.logger().info()landen deine Meldungen in den Logs im Dashboard. - Request oder Model? Request-Hooks (
...Request) greifen nur bei API-Aufrufen und kennen den eingeloggten User. Model-Hooks wieonRecordCreategreifen bei jedem Speichern, auch aus Cronjobs oder dem Dashboard. - Kein Node.js: Die Hooks laufen in der eingebetteten JS-Engine goja. npm-Pakete mit Node-APIs wie
fsfunktionieren dort nicht.
Alle verfügbaren Hooks und Hilfsfunktionen findest du in der offiziellen PocketBase-Dokumentation zur JavaScript-Erweiterung.
FAQ zu PocketBase JavaScript Hooks
Im Ordner Nein. PocketBase überwacht den Ordner Meist fehlt der Aufruf von Nur eingeschränkt. Reine JavaScript-Bibliotheken ohne Node-spezifische APIs kannst du per Ja, etwas. Für typische Aufgaben wie Validierung, Mails oder Webhooks fällt das kaum ins Gewicht. Bei sehr rechenintensiver Logik lohnt sich eine Erweiterung in Go.
Wo lege ich meine Hook-Dateien ab?
pb_hooks direkt neben der PocketBase-Binary. Jede Datei muss auf .pb.js enden, damit sie geladen wird.Muss ich PocketBase nach einer Änderung neu starten?
pb_hooks und startet bei Änderungen automatisch neu. Unter Windows kann das je nach Setup eingeschränkt sein.Warum wird mein Record nicht gespeichert?
e.next(). Ohne ihn bricht die Hook-Kette ab und die eigentliche Aktion findet nicht statt.Kann ich npm-Pakete in Hooks verwenden?
require() laden. Pakete, die auf fs, http oder Ähnliches angewiesen sind, laufen nicht.Sind JavaScript Hooks langsamer als Go-Erweiterungen?
