Frontbacked Docs

Before, After, and Related Writes

FRL lets a theme shape data before it is saved, then run tidy follow-up work after a write is accepted. This is how a frontend-first theme gets clean slugs, trusted status fields, counters, audit records, notifications, and other production behavior without a custom server project.

Before Hooks

Use before when incoming post data needs to be cleaned or completed before schema validation and permission checks.

post articles {
  before create, edit {
    $post.pending.data.title = str.trim($post.pending.data.title);
    $post.pending.data.slug = $post.pending.data.slug || str.slug($post.pending.data.title);
  }

  before create {
    $post.pending.data.createdAt = time.now();
  }

  schema {
    title: { type: "string", min: 1, max: 140, required: true }
    slug: { type: "string", pattern: "^[a-z0-9-]+$", required: true }
    status: { type: "enum", enums: ["draft", "published"], default: "draft" }
    createdAt: { type: "datetime" }
  }

  allow create: $actor.id
  allow edit: $admin.canEdit("$this")
}

before only works for write actions: create, edit, delete, and write. write means create, edit, and delete together. Read actions such as get, list, and read do not have before hooks.

In before create and before edit, write to $post.pending.data. That is the data Frontbacked will validate and save. Use $post.current.data only when you need the previously saved value.

Ordering

When multiple before blocks match, Frontbacked runs them in the order they appear in the file.

post products {
  before write {
    $context.actorId = $actor.id;
  }

  before create, edit {
    $post.pending.data.name = str.trim($post.pending.data.name);
  }

  before create {
    $post.pending.data.searchText = str.lower($post.pending.data.name);
  }
}

For a create, the example runs before write, then before create, edit, then before create. The same $context moves through the whole rule run.

Action Metadata

Use $action when a shared block needs to know which operation is running.

before write {
  if ($action.isDelete) {
    $context.auditAction = "deleted";
  } else {
    $post.pending.data.updatedAt = time.now();
  }
}

For post and profile rules, $action.isWrite is true for create, edit, and delete. $action.isRead is true for get and list.

After Hooks

Use after for follow-up work that should run only after validation and permissions pass.

post products {
  after create, edit {
    post.createAsSystem("audit_logs", {
      action: "product.saved",
      productId: $post.pending.id,
      actorUserId: $actor.id,
      summary: "Saved " + $post.pending.data.name
    });
  }

  after delete {
    post.createAsSystem("audit_logs", {
      action: "product.deleted",
      productId: $post.current.id,
      actorUserId: $actor.id,
      summary: "Deleted " + $post.current.data.name
    });
  }
}

after uses the same write-action list as before: after create, after edit, after delete, after write, or comma-separated groups such as after create, edit. It does not run for get, list, or read.

Related Writes

Inside after blocks, endpoint bodies, and payment events, use post.* helpers to write related posts.

HelperUse it when
post.createAsActor(type, data)The related post should belong to the signed-in visitor/admin who caused the action. It fails if no signed-in actor exists.
post.createAsSystem(type, data)The related post is automatic site work such as logs, webhook results, counters, or imported records.
post.edit(id, data)A successful rule should update another post by id.
post.delete(id)A successful rule should remove another post by id.
post.increment(id, data)A successful rule should atomically increment numeric fields on another post.
payment confirmed {
  if ($payment.metadata.purpose == "product_purchase" && $payment.metadata.productId) {
    post.increment($payment.metadata.productId, {
      completedOrderCount: 1
    });
  }
}

Every post.* target must be a declared post type. Related creates and edits require a declared schema and are checked against it before they are saved. Increments also require a declared schema and are checked against the target schema after the increment is applied. Deletes only require the target post type to be declared, because no new data is being saved.

Related writes do not run the target post type's before, after, or allow rules. The current rule has already accepted the action, so post.* helpers apply the target schema and then write directly. If a required field has type: "post" or type: "user", pass the referenced post id or user id just like a normal save.

Use the exact string "$id" inside post.createAsActor or post.createAsSystem data when the related post needs its own generated id copied into a normal field.

after create {
  post.createAsActor("notifications", {
    notificationId: "$id",
    title: `New lead from ${$post.pending.data.email}`,
    lead: $post.pending.id
  });
}

Allow Still Decides Access

before and after blocks are optional, but allow is still the gate. If a concrete action has no matching allow rule, Frontbacked denies it.

allow create: $post.pending.data.email && $post.pending.data.acceptedTerms
allow get: $post.current.data.status == "published" || $admin.canRead("$this")
allow list(limit=20, max=100): true
allow edit: $admin.canEdit("$this")
allow delete: $admin.canDelete("$this")

Matching allow rules intersect. If a rule file declares allow read, write: ..., allow read: ..., and allow get: ..., a get request must pass every matching rule.

allow read: and allow list: are request-level gates for list requests, so they cannot use post-scoped values such as $post, $row, or $rowMeta, even through helper functions. Use FQL where or filter to choose list rows, and use allow get: for single-post checks.

Best Practices

Use before for deterministic changes such as trimming, lowercasing, slug creation, derived statuses, timestamps, and fields that should be validated before saving.

Use after for work that should happen only after a write is truly accepted, such as audit logs, notifications, counter updates, or syncing a related record.

Prefer post.createAsSystem for automatic records. Use post.createAsActor only when the created record should genuinely belong to the signed-in actor.

Handle a small submitted array

Use a bounded for block when one action includes several entries, such as the lines of a shopping cart:

for ($context.line in $request.body.items) {
  must($context.line.quantity >= 1, "Choose a quantity.");
  $context.product = posts.findOne({
    type: "products",
    where: { id: $context.line.productId, visibility: "published" }
  });
  must($context.product, "This product is no longer available.");
}

The source must be an array with at most 100 entries. Each entry is available through the named $context field during its block. Nested loops are not supported. Use database filters and queries for growing record lists; this block is for small, bounded inputs to an action.

arr.concat(first, second) joins two arrays into a new array, which is useful for building validated line items before creating one payment.