vb_hooks
Handlers that run because a record changed, whoever changed it. A route answers a call your own code makes; a hook fires for the SDK, for someone clicking in the admin panel, and for your own routes alike. That is the difference, and it is the reason this directory exists at all.
One hook per file
Any file in vb_hooks/ whose default export is a defineHook is registered once, when the app mounts, in filename order. The first argument is the event, the last are the collections it applies to.
// vb_hooks/slug.ts
import { defineHook } from "@voidbase-cloud/voidbase/adapter";
export default defineHook("onRecordCreateRequest", (e) => {
e.record.set("slug", String(e.record.get("title")).toLowerCase().replaceAll(" ", "-"));
e.next();
}, "posts");It is TypeScript in the project, so it imports what everything else imports: the modules your routes use, the helpers in src/, your own types.
// vb_hooks/notify.ts
import { defineHook } from "@voidbase-cloud/voidbase/adapter";
import { sendDigest } from "@/shared/mail"; // the same module routes/ imports
export default defineHook("onRecordAfterCreateSuccess", async (e) => {
await e.next();
await sendDigest(e.record.get("author") as string);
}, "posts");How a handler behaves
e.next() runs the remaining handlers and then the action itself, so work before that call happens first and work after it happens once the write has gone through. Throwing instead refuses the request with that error, and the error classes come from pb:
import { defineHook, pb } from "@voidbase-cloud/voidbase/adapter";
export default defineHook("onRecordUpdateRequest", (e) => {
if (e.record.get("locked") && !e.auth?.isSuperuser()) throw new pb.ForbiddenError("locked");
e.next();
}, "posts");The events are the ones a project's pb_hooks has, with the same names and the same event objects: records as models and as requests, collections, auth, files, realtime, settings, mail and batches. That page lists them, and everything it says about $app, $apis and the rest is true here too, reached as pb.$app, pb.$apis, pb.$os and pb.Record.
The same data from a route
There is no hooks directory to write endpoints in: routes/ is where the API goes, and a route that reads or writes collections imports PocketBase's API from the adapter. authOf(c) is the authenticated record, exactly as a hook's e.auth; requireAuth and requireSuperuser are the Void-shaped guards.
// routes/api/posts/[id].ts -> GET /api/posts/:id
import { defineHandler } from "void";
import { authOf, pb, requireAuth } from "@voidbase-cloud/voidbase/adapter";
export const GET = defineHandler(requireAuth("users"), async (c) => {
const post = await pb.$app.findRecordById("posts", c.req.param("id"));
if (!post) throw new pb.NotFoundError("No such post.");
if (post.getString("owner") !== authOf(c)?.id) throw new pb.ForbiddenError();
return { post: post.publicExport() };
});routerUse, PocketBase's global request middleware, is a hook like any other and can be written here, but Void's middleware/ means the same thing and already has a directory: every request, in file order, PocketBase's endpoints and the panel included. Guard on the path when a middleware is meant for your own routes only, because one that throws takes the whole backend with it.
Three mistakes the build refuses
| What you wrote | What it says |
|---|---|
A file in vb_hooks/ that exports no hook | That the file is attached to nothing, rather than silently never running. |
| An event name that is not one of PocketBase's | The nearest real ones, because it is almost always a typo. |
A defineHook left in middleware/ | That middleware is Void's and is called with (c, next), and a hook belongs here. |
All three are build errors rather than warnings, because each one is a handler that would otherwise never fire and give no sign of it.
Where they end up
The build compiles this directory, along with routes/, middleware/, crons/ and queues/, into the generated instance's single hook bundle, pb_hooks/void-app.js, with everything they import inlined. There is nothing to register and no order to maintain beyond the filenames. Reading pb.$app outside a request, a cron tick or a hook throws, so a module under src/ may register something at import time but not read.
Fix it yourself. The link below opens this file in GitHub's editor and forks the repository for you if you need one, and your change becomes a pull request without leaving the browser.