Hook types
Hooks are categorized by when they fire in the lifecycle:- Before hooks - Fire before an operation (can prevent the operation)
- After hooks - Fire after a successful operation
- Error hooks - Fire when an operation fails
Record hooks
Record lifecycle
// Before record is validated
onRecordValidate((e) => {
console.log("Validating record in", e.collection.name)
return e.next()
})
// Before record is created (after validation)
onRecordCreate((e) => {
console.log("Creating record:", e.record.id)
return e.next()
})
// During record creation (database transaction)
onRecordCreateExecute((e) => {
console.log("Executing create for:", e.record.id)
return e.next()
})
// After successful creation
onRecordAfterCreateSuccess((e) => {
console.log("Record created successfully:", e.record.id)
return e.next()
})
// After creation error
onRecordAfterCreateError((e) => {
console.log("Failed to create record:", e.error)
return e.next()
})
Filtering by collection
You can filter hooks to specific collections using tags:// Only for "posts" collection
onRecordCreate((e) => {
const record = e.record
console.log("Creating post:", record.getString("title"))
return e.next()
}, "posts")
// Multiple collections
onRecordCreate((e) => {
console.log("Creating record in:", e.collection.name)
return e.next()
}, "posts", "articles", "pages")
Record create hooks
// Auto-set author to current user
onRecordCreate((e) => {
if (e.collection.name === "posts") {
const authRecord = e.requestInfo()?.auth
if (authRecord) {
e.record.set("author", authRecord.id)
}
}
return e.next()
}, "posts")
// Generate slug from title
onRecordCreate((e) => {
if (e.collection.name === "posts") {
const title = e.record.getString("title")
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
e.record.set("slug", slug)
}
return e.next()
}, "posts")
// Send notification after creation
onRecordAfterCreateSuccess((e) => {
if (e.collection.name === "posts") {
const record = e.record
const author = $app.findRecordById("users", record.getString("author"))
const message = new MailerMessage({
from: {
address: $app.settings().meta.senderAddress,
name: $app.settings().meta.senderName,
},
to: [{address: author.getString("email")}],
subject: "Post Published",
html: `<p>Your post "${record.getString("title")}" has been published!</p>`,
})
$app.newMailClient().send(message)
}
return e.next()
}, "posts")
Record update hooks
// Prevent updating published posts
onRecordUpdate((e) => {
if (e.collection.name === "posts") {
const currentStatus = e.record.getString("status")
const originalStatus = e.record.originalCopy().getString("status")
if (originalStatus === "published" && currentStatus !== "published") {
throw new BadRequestError("Cannot unpublish a post")
}
}
return e.next()
}, "posts")
// Track changes
onRecordUpdate((e) => {
if (e.collection.name === "posts") {
const original = e.record.originalCopy()
const current = e.record
if (original.getString("title") !== current.getString("title")) {
console.log("Title changed from:", original.getString("title"))
console.log("To:", current.getString("title"))
}
}
return e.next()
}, "posts")
// Update timestamp
onRecordUpdate((e) => {
if (e.collection.name === "posts") {
e.record.set("updated_at", new DateTime().string())
}
return e.next()
}, "posts")
Record delete hooks
// Prevent deletion of published posts
onRecordDelete((e) => {
if (e.collection.name === "posts") {
if (e.record.getString("status") === "published") {
throw new ForbiddenError("Cannot delete published posts")
}
}
return e.next()
}, "posts")
// Cleanup related data
onRecordAfterDeleteSuccess((e) => {
if (e.collection.name === "posts") {
const postId = e.record.id
// Delete all comments for this post
const comments = arrayOf(new Record())
$app.findAllRecordsByData("comments", "post", postId, comments)
for (let comment of comments) {
$app.delete(comment)
}
}
return e.next()
}, "posts")
Record validation hooks
onRecordValidate((e) => {
if (e.collection.name === "posts") {
const title = e.record.getString("title")
// Check for spam
const spamWords = ["viagra", "casino", "lottery"]
for (let word of spamWords) {
if (title.toLowerCase().includes(word)) {
throw new BadRequestError("Content flagged as spam")
}
}
// Check for duplicate titles
const existing = $app.findFirstRecordByData("posts", "title", title)
if (existing && existing.id !== e.record.id) {
throw new BadRequestError("A post with this title already exists")
}
}
return e.next()
}, "posts")
API request hooks
Record request hooks
// Before record create request
onRecordCreateRequest((e) => {
console.log("Create request for:", e.collection.name)
console.log("Request data:", e.requestInfo().data)
return e.next()
})
// Before record update request
onRecordUpdateRequest((e) => {
// Only allow updating own records
const authRecord = e.requestInfo().auth
if (!authRecord) {
throw new UnauthorizedError()
}
if (e.record.getString("author") !== authRecord.id) {
throw new ForbiddenError("You can only update your own records")
}
return e.next()
}, "posts")
// Before record delete request
onRecordDeleteRequest((e) => {
// Admins only
const authRecord = e.requestInfo().auth
if (!authRecord || authRecord.getString("role") !== "admin") {
throw new ForbiddenError("Only admins can delete records")
}
return e.next()
}, "posts")
// Before record view request
onRecordViewRequest((e) => {
// Track views
const record = e.record
record.set("view_count", record.getInt("view_count") + 1)
$app.save(record)
return e.next()
}, "posts")
// Before records list request
onRecordsListRequest((e) => {
console.log("Listing records from:", e.collection.name)
console.log("Filters:", e.request.url.query.get("filter"))
return e.next()
})
Authentication hooks
// Before auth request
onRecordAuthRequest((e) => {
console.log("Auth request for collection:", e.collection.name)
return e.next()
})
// After successful password auth
onRecordAuthWithPasswordRequest((e) => {
const record = e.record
// Update last login timestamp
record.set("last_login", new DateTime().string())
$app.save(record)
// Log login
console.log("User logged in:", record.getString("email"))
return e.next()
}, "users")
// After OAuth2 auth
onRecordAuthWithOAuth2Request((e) => {
const record = e.record
const provider = e.providerName
console.log("OAuth2 login:", record.getString("email"), "via", provider)
return e.next()
}, "users")
// After OTP auth
onRecordAuthWithOTPRequest((e) => {
const record = e.record
console.log("OTP login:", record.getString("email"))
return e.next()
}, "users")
Email hooks
// Before verification request
onRecordRequestVerificationRequest((e) => {
const record = e.record
console.log("Verification requested for:", record.getString("email"))
return e.next()
}, "users")
// After verification confirmed
onRecordConfirmVerificationRequest((e) => {
const record = e.record
// Send welcome email
const message = new MailerMessage({
from: {
address: $app.settings().meta.senderAddress,
name: $app.settings().meta.senderName,
},
to: [{address: record.getString("email")}],
subject: "Welcome!",
html: "<p>Thanks for verifying your email!</p>",
})
$app.newMailClient().send(message)
return e.next()
}, "users")
// Before password reset request
onRecordRequestPasswordResetRequest((e) => {
console.log("Password reset requested for:", e.record.getString("email"))
return e.next()
}, "users")
// Before email change request
onRecordRequestEmailChangeRequest((e) => {
const record = e.record
const newEmail = e.requestInfo().data.newEmail
console.log("Email change:", record.getString("email"), "->", newEmail)
return e.next()
}, "users")
Collection hooks
// Before collection create
onCollectionCreate((e) => {
console.log("Creating collection:", e.collection.name)
return e.next()
})
// After collection created
onCollectionAfterCreateSuccess((e) => {
console.log("Collection created:", e.collection.name)
return e.next()
})
// Before collection update
onCollectionUpdate((e) => {
const original = e.collection.originalCopy()
const current = e.collection
if (original.name !== current.name) {
console.log("Renaming collection:", original.name, "->", current.name)
}
return e.next()
})
// Before collection delete
onCollectionDelete((e) => {
console.log("Deleting collection:", e.collection.name)
return e.next()
})
Application hooks
Serve hook
// Runs when the server starts
onServe((e) => {
console.log("Server starting...")
// Add custom routes
e.router.GET("/health", (e) => {
return e.json(200, {status: "ok"})
})
return e.next()
})
Settings hooks
// Before settings update
onSettingsUpdateRequest((e) => {
console.log("Settings being updated")
return e.next()
})
// After settings reload
onSettingsReload((e) => {
console.log("Settings reloaded")
return e.next()
})
Batch hooks
// Before batch request
onBatchRequest((e) => {
console.log("Batch request with", e.batch.requests.length, "requests")
return e.next()
})
File hooks
// Before file download
onFileDownloadRequest((e) => {
console.log("Downloading file:", e.servedName)
console.log("From record:", e.record.id)
return e.next()
})
// Modify file token generation
onFileBeforeTokenRequest((e) => {
// Add custom validation before generating file token
const authRecord = e.requestInfo().auth
if (!authRecord) {
throw new UnauthorizedError("Authentication required")
}
return e.next()
})
Realtime hooks
// Before realtime connect
onRealtimeConnectRequest((e) => {
console.log("Realtime connection from:", e.realIP())
return e.next()
})
// Before realtime disconnect
onRealtimeDisconnectRequest((e) => {
console.log("Realtime disconnection")
return e.next()
})
// Before subscribe
onRealtimeSubscribeRequest((e) => {
const subscriptions = e.subscriptions
console.log("Subscribing to:", subscriptions)
return e.next()
})
Hook execution order
For a record create operation, hooks fire in this order:onRecordCreateRequest- API request startsonRecordValidate- Validation runsonRecordCreate- Before database operationonRecordCreateExecute- During database transactiononRecordAfterCreateSuccess- After successful creation
- Later hooks in the chain won’t execute
onRecordAfterCreateErrorwill fire instead of success hooks- Database changes are rolled back
Best practices
Keep hooks focused
// Good - one responsibility
onRecordCreate((e) => {
if (e.collection.name === "posts") {
e.record.set("slug", generateSlug(e.record.getString("title")))
}
return e.next()
}, "posts")
// Separate hook for different responsibility
onRecordAfterCreateSuccess((e) => {
if (e.collection.name === "posts") {
sendNotification(e.record)
}
return e.next()
}, "posts")
Handle errors gracefully
onRecordAfterCreateSuccess((e) => {
if (e.collection.name === "posts") {
try {
// Send notification (non-critical)
sendNotification(e.record)
} catch (err) {
// Log but don't fail the request
console.log("Failed to send notification:", err.message)
}
}
return e.next()
}, "posts")
Use collection tags
// Good - specific collections only
onRecordCreate((e) => {
// Logic here
return e.next()
}, "posts", "articles")
// Bad - checks collection name inside
onRecordCreate((e) => {
if (e.collection.name === "posts" || e.collection.name === "articles") {
// Logic here
}
return e.next()
})