Dyrected
Deployment & OperationsServer RuntimeHooks

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:

  • beforeRead
  • afterRead
  • beforeChange
  • afterChange
  • beforeDelete
  • afterDelete

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:

  • beforeRead
  • afterRead
  • beforeChange
  • afterChange

There are no delete hooks because globals are updated in place rather than removed.

Fields

Field hooks stay close to one value:

  • beforeChange
  • afterRead

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.onChange
  • admin.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.

  • 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, or beforeDelete stops the operation.
  • afterChange and afterDelete are for side effects after persistence. Their return values are ignored, and their errors are isolated so a successful write is not turned into a 500.
  • Server hooks receive a database adapter, but only afterChange and afterDelete get a writable one.

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;
}
OptionDescription
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;
}
OptionDescription
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>;
}
OptionDescription
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;
}
OptionDescription
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;
}
OptionDescription
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

See: CollectionBeforeReadHook

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;
}
OptionDescription
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.

On this page

Dyrected| Cloud

Get your backend ready in minutes

Use a managed database, storage, APIs, and admin dashboard without setting up the infrastructure yourself.

Set Up My Backend