Server Hooks
Run unrestricted TypeScript hook functions inside a self-hosted Dyrected backend.
Hooks are where content changes get cleaned up, shaped, or reacted to.
The important first decision is runtime: Cloud hooks are content rules Dyrected can serialize and run on the hosted content backend, while self-hosted Dyrected can run arbitrary TypeScript functions inside your server.
By the end of this page, you should know which hook shape fits your runtime and where to put the behavior you need.
Start with the runtime boundary
Use Cloud-safe hooks when the behavior should survive sync:schema and run in Dyrected Cloud. In practice, that means hooks defined as serializable Jexl-style content rules, not arbitrary functions.
Use function hooks when the behavior needs arbitrary server code. Function hooks are self-hosted runtime behavior. Reach for them when the logic needs async work, external packages, database writes after persistence, custom integrations, or side effects that belong inside your application server.
If you are building for Cloud and need asynchronous side effects, do not assume a function hook will run on the hosted backend. Cloud content events and webhooks are the intended model for that work, but they are still coming soon.
The hook surfaces
Dyrected has four hook surfaces:
- Collection hooks for document-level logic on repeatable content.
- Global hooks for document-level logic on singleton data such as site settings.
- Field hooks for logic that belongs to one field value.
- Admin field hooks for reactive form behavior in the dashboard.
Admin hooks improve the editing experience, but they are not a security boundary. If a rule must hold on every API path, enforce it with access control, a server hook, or a Cloud-safe rule that runs on the backend.
Self-hosted function hooks
Self-hosted Dyrected can run hook functions because the backend is your Node.js runtime. That means your hook can call services, use packages, read request context, perform side effects, or coordinate with application-specific backend logic.
Use function hooks for self-hosted behavior such as:
- normalizing data with custom TypeScript
- validating a write against several fields
- reshaping API responses
- triggering cache revalidation after a write
- calling internal services
- coordinating with database-backed application records
That power is intentionally not the Cloud default. Cloud keeps the content backend managed and predictable; self-hosted gives you the unrestricted runtime.
Where each family fits
Collections
Collection hooks wrap the full lifecycle of a document in a collection:
beforeReadafterReadbeforeChangeafterChangebeforeDeleteafterDelete
Reach for them when the logic depends on several fields, the whole document, or the surrounding operation.
Globals
Global hooks work the same way, but globals only support the phases that make sense for a singleton document:
beforeReadafterReadbeforeChangeafterChange
There are no delete hooks because globals are updated in place rather than removed.
Fields
Field hooks stay close to one value:
beforeChangeafterRead
They are a good fit for value-level normalization, masking, formatting, and recursive behavior inside nested fields.
Admin field hooks
Admin field hooks run in the form UI:
admin.hooks.onChangeadmin.hooks.options
Use them to derive a slug, recalculate a total, or update select options as editors change related fields.
admin.hooks.onChange also has a declarative string form, which makes it the recommended option when you want the same form behavior to survive Cloud schema sync.
The recommended decision tree
- Use a Cloud-safe hook when the behavior is a simple content rule that must run in Cloud.
- Use a field hook when one value can be transformed on its own.
- Use a collection or global hook when the logic depends on several fields or the surrounding operation.
- Use an admin hook when the goal is immediate editor feedback in the dashboard.
- Use access control when the question is who can read or write.
- Use a self-hosted function hook when the behavior needs arbitrary TypeScript or server-side side effects.
A self-hosted function-hook example
This collection uses both sides of the lifecycle: beforeChange prepares the data that should be stored, and afterChange reacts only after the write has succeeded. Because the example includes an async side effect, treat it as self-hosted runtime code.
import { defineCollection, defineTextField } from '@dyrected/core'
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
async function revalidateTag(tag: string) {
await fetch(`https://example.com/api/revalidate?tag=${tag}`, { method: 'POST' })
}
export const Posts = defineCollection({
slug: 'posts',
fields: [
defineTextField({ name: 'title', label: 'Title', required: true }),
defineTextField({ name: 'slug', label: 'Slug' }),
],
hooks: {
beforeChange: [
({ data }) => {
if (!data.title || typeof data.title !== 'string') return data
return { ...data, slug: slugify(data.title) }
},
],
afterChange: [
async ({ doc }) => {
await revalidateTag(`post-${doc.id}`)
},
],
},
})What this gives you:
- The slug is derived on the server, so every write path gets the same result.
- The page revalidation only happens after the document is committed.
- A failed revalidation does not undo the saved document.
For a Cloud-safe slug transform, use a hook defined as a content rule instead. Keep the revalidation step outside Cloud hooks until Cloud content events and webhooks are public.
Runtime rules worth remembering
- Hooks in an array run in order.
- When a
before*hook returns a value, that value becomes the input to the next hook in the chain. - Declarative string hooks run synchronously.
- Mixed arrays of functions and strings still chain in order.
- Throwing inside
beforeRead,beforeChange, orbeforeDeletestops the operation. afterChangeandafterDeleteare for side effects after persistence. Their return values are ignored, and their errors are isolated so a successful write is not turned into a500.- Server hooks receive a database adapter, but only
afterChangeandafterDeleteget a writable one.
What to read next
- Collection hooks for the full document lifecycle on collections.
- Cloud-safe hooks for the Cloud-specific compatibility rules.
- Lifecycle events for content events, webhooks, and self-hosted handlers.
- Global hooks for singleton settings and site-wide config.
- Field hooks for value-level transforms and admin form reactivity.
- Hook context for the
req,user, anddbvalues hooks receive.
Generated reference
The contracts below are generated from the public @dyrected/core exports by @dyrected/knowledge, so every hook signature stays in sync with the package. Use them when you need the exact argument and return types behind the guidance above.
AuthenticatedUser
Base shape of an authenticated user as decoded from the JWT.
The actual shape will include every field on your auth collection — this interface only guarantees the properties that Dyrected always stamps on the token. Extend it in your own codebase for stronger typing:
export interface AuthenticatedUser {
/** The user's document ID in the database. */
sub: string;
/** The user's email address. */
email?: string;
/** Slug of the collection this user was authenticated against. */
collection: string;
/** Array of role strings, if your auth collection has a `roles` field. */
roles?: string[];
/** Any additional fields from the auth collection document. */
[key: string]: unknown;
}| Option | Description |
|---|---|
sub (required) | The user's document ID in the database. |
email (optional) | The user's email address. |
collection (required) | Slug of the collection this user was authenticated against. |
roles (optional) | Array of role strings, if your auth collection has a `roles` field. |
CollectionAfterChangeHook
Runs after a document is created or updated in the database.
export type CollectionAfterChangeHook<TDoc extends object = Record<string, unknown>> = (args: {
doc: TDoc;
previousDoc?: TDoc;
req: HookRequestContext;
user?: AuthenticatedUser;
operation: "create" | "update";
db: DatabaseAdapter;
}) => void | Promise<void>;CollectionAfterDeleteHook
Runs after a document has been deleted from the database.
export type CollectionAfterDeleteHook<TDoc extends object = Record<string, unknown>> = (args: {
id: string;
doc: TDoc;
req: HookRequestContext;
user?: AuthenticatedUser;
db: DatabaseAdapter;
}) => void | Promise<void>;CollectionAfterReadHook
Runs after a document (or list of documents) is fetched from the database, before the response is sent to the client.
export type CollectionAfterReadHook<TDoc extends object = Record<string, unknown>> = (args: {
doc: TDoc;
req: HookRequestContext;
user?: AuthenticatedUser;
db: ReadonlyDatabaseAdapter;
}) => TDoc | Promise<TDoc>;CollectionAfterReadHookEntry
Exported type from @dyrected/core.
export type CollectionAfterReadHookEntry<
TDoc extends object = Record<string, unknown>,
> = CollectionAfterReadHook<TDoc> | DeclarativeHookExpression;CollectionBeforeChangeHook
Runs before a document is created or updated in the database.
export type CollectionBeforeChangeHook<TDoc extends object = Record<string, unknown>> = (args: {
data: Partial<TDoc>;
doc?: TDoc;
req: HookRequestContext;
user?: AuthenticatedUser;
operation: "create" | "update";
db: ReadonlyDatabaseAdapter;
}) => Partial<TDoc> | void | Promise<Partial<TDoc> | void>;CollectionBeforeChangeHookEntry
Exported type from @dyrected/core.
export type CollectionBeforeChangeHookEntry<
TDoc extends object = Record<string, unknown>,
> = CollectionBeforeChangeHook<TDoc> | DeclarativeHookExpression;CollectionBeforeDeleteHook
Runs before a document is deleted from the database.
export type CollectionBeforeDeleteHook<TDoc extends object = Record<string, unknown>> = (args: {
id: string;
doc: TDoc;
req: HookRequestContext;
user?: AuthenticatedUser;
db: ReadonlyDatabaseAdapter;
}) => void | Promise<void>;CollectionBeforeReadHook
Runs before Dyrected queries the database for a list or single-document fetch.
Return a new where query object to override or extend the current filter.
Return undefined (or nothing) to leave the query unchanged.
export type CollectionBeforeReadHook = (args: {
req: HookRequestContext;
query?: Record<string, unknown>;
user?: AuthenticatedUser;
db: ReadonlyDatabaseAdapter;
}) => Record<string, unknown> | void | Promise<Record<string, unknown> | void>;CollectionBeforeReadHookEntry
Exported type from @dyrected/core.
export type CollectionBeforeReadHookEntry =
| CollectionBeforeReadHook
| DeclarativeHookExpression;DeclarativeHookExpression
Exported type from @dyrected/core.
export type DeclarativeHookExpression = string;FieldAdminHooks
Exported type from @dyrected/core.
export type FieldAdminHooks<TValue> = {
admin?: {
hooks?: {
onChange?: FieldAdminOnChangeHook<TValue> | DeclarativeHookExpression;
};
};
};FieldAdminOnChangeHook
Exported type from @dyrected/core.
export type FieldAdminOnChangeHook<TValue = unknown> = (
args: FieldAdminOnChangeHookArgs<TValue>,
) => unknown;FieldAdminOnChangeHookArgs
Exported interface from @dyrected/core.
export interface FieldAdminOnChangeHookArgs<TValue = unknown> {
/** Current field value in the form state. */
value: TValue;
/** Current values for sibling fields at the same nesting level. */
siblingData: Record<string, unknown>;
/** Current values for the entire form. */
data: Record<string, unknown>;
/** Imperative setter for async or derived updates. */
setValue: (value: unknown) => void;
}| Option | Description |
|---|---|
value (required) | Current field value in the form state. |
siblingData (required) | Current values for sibling fields at the same nesting level. |
data (required) | Current values for the entire form. |
setValue (required) | Imperative setter for async or derived updates. |
FieldAdminOptionsHook
Exported type from @dyrected/core.
export type FieldAdminOptionsHook = (
args: FieldAdminOptionsHookArgs,
) => FieldAdminOptionsHookResult | Promise<FieldAdminOptionsHookResult>;FieldAdminOptionsHookArgs
Exported interface from @dyrected/core.
export interface FieldAdminOptionsHookArgs {
/** Current values for sibling fields at the same nesting level. */
siblingData: Record<string, unknown>;
/** Current values for the entire form. */
data: Record<string, unknown>;
}| Option | Description |
|---|---|
siblingData (required) | Current values for sibling fields at the same nesting level. |
data (required) | Current values for the entire form. |
FieldAdminOptionsHookResult
Exported type from @dyrected/core.
export type FieldAdminOptionsHookResult = Array<
string | { label: string; value: unknown }
>;FieldAfterReadHook
Exported type from @dyrected/core.
export type FieldAfterReadHook<
TValue = unknown,
TDoc extends object = Record<string, unknown>,
> = (args: FieldAfterReadHookArgs<TValue, TDoc>) => unknown;FieldAfterReadHookArgs
Exported interface from @dyrected/core.
export interface FieldAfterReadHookArgs<
TValue = unknown,
TDoc extends object = Record<string, unknown>,
> {
/** Raw stored field value before this hook transforms it. */
value: TValue;
/** Full document currently being returned to the caller. */
doc: TDoc;
/** Authenticated user requesting the document, if any. */
user?: AuthenticatedUser;
/** Read-only database adapter for related lookups. */
db: ReadonlyDatabaseAdapter;
}| Option | Description |
|---|---|
value (required) | Raw stored field value before this hook transforms it. |
doc (required) | Full document currently being returned to the caller. |
user (optional) | Authenticated user requesting the document, if any. |
db (required) | Read-only database adapter for related lookups. |
FieldBeforeChangeHook
Exported type from @dyrected/core.
export type FieldBeforeChangeHook<
TValue = unknown,
TDoc extends object = Record<string, unknown>,
> = (args: FieldBeforeChangeHookArgs<TValue, TDoc>) => unknown;FieldBeforeChangeHookArgs
Exported interface from @dyrected/core.
export interface FieldBeforeChangeHookArgs<
TValue = unknown,
TDoc extends object = Record<string, unknown>,
> {
/** Current field value after previous hooks in the chain. */
value: TValue;
/** Existing stored document before the write, if this is an update. */
originalDoc?: TDoc;
/** Full incoming payload being written. */
data: Record<string, unknown>;
/** Authenticated user performing the write, if any. */
user?: AuthenticatedUser;
/** Read-only database adapter for related lookups. */
db: ReadonlyDatabaseAdapter;
}| Option | Description |
|---|---|
value (required) | Current field value after previous hooks in the chain. |
originalDoc (optional) | Existing stored document before the write, if this is an update. |
data (required) | Full incoming payload being written. |
user (optional) | Authenticated user performing the write, if any. |
db (required) | Read-only database adapter for related lookups. |
FieldHook
Exported type from @dyrected/core.
export type FieldHook<TDoc extends object = Record<string, unknown>, TValue = unknown> = FieldBeforeChangeHook<
TValue,
TDoc
>;FieldHooks
Exported type from @dyrected/core.
export type FieldHooks<TValue> = {
hooks?: {
beforeChange?: Array<FieldBeforeChangeHook<TValue> | DeclarativeHookExpression>;
afterRead?: Array<FieldAfterReadHook<TValue>>;
};
};GlobalAfterChangeHook
Runs after the global document is updated. Side-effects only.
export type GlobalAfterChangeHook<TDoc extends object = Record<string, unknown>> = (args: {
doc: TDoc;
previousDoc?: TDoc;
req: HookRequestContext;
user?: AuthenticatedUser;
operation: "update";
db: DatabaseAdapter;
}) => void | Promise<void>;GlobalAfterReadHook
Runs after the global document is fetched, before the response is sent.
export type GlobalAfterReadHook<TDoc extends object = Record<string, unknown>> = (args: {
doc: TDoc;
req: HookRequestContext;
user?: AuthenticatedUser;
db: ReadonlyDatabaseAdapter;
}) => TDoc | Promise<TDoc>;GlobalAfterReadHookEntry
Exported type from @dyrected/core.
export type GlobalAfterReadHookEntry<
TDoc extends object = Record<string, unknown>,
> = GlobalAfterReadHook<TDoc> | DeclarativeHookExpression;GlobalBeforeChangeHook
Runs before the global document is updated.
Operation is always 'update' (globals cannot be created or deleted).
export type GlobalBeforeChangeHook<TDoc extends object = Record<string, unknown>> = (args: {
data: Partial<TDoc>;
doc?: TDoc;
req: HookRequestContext;
user?: AuthenticatedUser;
operation: "update";
db: ReadonlyDatabaseAdapter;
}) => Partial<TDoc> | void | Promise<Partial<TDoc> | void>;GlobalBeforeChangeHookEntry
Exported type from @dyrected/core.
export type GlobalBeforeChangeHookEntry<
TDoc extends object = Record<string, unknown>,
> = GlobalBeforeChangeHook<TDoc> | DeclarativeHookExpression;GlobalBeforeReadHook
export type GlobalBeforeReadHook = CollectionBeforeReadHook;GlobalBeforeReadHookEntry
Exported type from @dyrected/core.
export type GlobalBeforeReadHookEntry = CollectionBeforeReadHookEntry;HookFunction
Exported type from @dyrected/core.
export type HookFunction<TDoc extends object = Record<string, unknown>> = (args: {
data?: Partial<TDoc>;
doc?: TDoc;
user?: AuthenticatedUser;
req?: HookRequestContext;
operation?: "create" | "update" | "delete";
db?: DatabaseAdapter;
[key: string]: unknown;
}) => unknown | Promise<unknown>;HookRequestContext
Minimum HTTP request context passed to every server-side hook and resolver.
The full Web Standard Request is available as raw when you need it, but
most hooks only need query (URL search parameters).
export interface HookRequestContext {
/** Parsed URL query-string parameters, e.g. `{ page: '2', search: 'hello' }`. */
query: Record<string, string>;
/** Incoming HTTP headers, lowercased. */
headers: Record<string, string>;
/** The raw Web Standard `Request` object. Useful for streaming or advanced header inspection. */
raw?: Request;
}| Option | Description |
|---|---|
query (required) | Parsed URL query-string parameters, e.g. `{ page: '2', search: 'hello' }`. |
headers (required) | Incoming HTTP headers, lowercased. |
raw (optional) | The raw Web Standard `Request` object. Useful for streaming or advanced header inspection. |