FRL Overview

Frontbacked Rule Language (FRL) is the rule language for a theme. It answers four questions for every post type:

  1. What fields are allowed?
  2. How should incoming data be cleaned or computed?
  3. Who can create, read, edit, or delete this data?
  4. Should this action trigger other writes or async work?

FRL is stored in the theme's single rules file: backend/index.rules.

What FRL Unlocks

FRL is more than a validation file. It is the place where a theme describes its data powers:

FeatureWhat it lets your theme do
post blocksDefine records such as articles, products, comments, plans, tickets, reviews, portfolios, or lessons.
auth blockShape and validate custom signed-in user data.
schemaReject unknown fields and validate nested objects, arrays, files, prices, and immutable fields.
processTrim, normalize, compute slugs, set derived status, and prepare trusted fields before validation.
Action require[...]Decide who can create, read, edit, and delete each post type.
Action process hooksRun extra create/edit/delete/read-specific normalization.
after hooksCompute rule state after permission checks and before follow-up writes.
TriggersCreate, edit, or delete related posts after a successful action.
list policiesControl who can list a post type and set default/max list limits.
export blocksTurn filtered rows into CSV, text, or other downloadable formats.
Async resolutionModel delayed workflows that later resolve through callback-style events.
endpoint.* blocksAdd custom guarded JSON endpoints for workflows that are not plain CRUD.
idempotencyMake repeat endpoint requests return the same outcome instead of duplicating work.
$secret and $privateReference admin-filled private values without building custom settings forms.

A Minimal Rule File

post leads {
  process {
    $post.email = str.lower(str.trim($post.email));
  }

  schema {
    name: { type: "string", min: 1, max: 120, required: true }
    email: { type: "string", min: 3, max: 180, required: true }
    message: { type: "string", max: 1000 }
    createdAt: { type: "servertime" }
  }

  create require[
    $post.email
  ]

  read require[
    $access.read
  ]

  edit require[
    $access.edit
  ]

  delete require[
    $access.delete
  ]
}

This defines a leads post type. When a frontend page calls Frontbacked.uploadPost({ type: "leads", post }), Frontbacked finds post leads, runs the rule, and either stores the post or returns an error.

Execution Order

For create, edit, and delete actions, Frontbacked evaluates a post rule in this order:

  1. Build runtime context such as $post, $prev, $user, $access, $secret, $private, $page, and $currency.
  2. Run the shared process block if the post type has one.
  3. Run the action-specific process block, such as create process { ... }, when present.
  4. Validate the schema.
  5. Evaluate the action require[...] block.
  6. Run the matching after create/edit/delete/read block when present.
  7. Materialize any trigger writes for that action.

For async webhook resolution, Frontbacked first checks matching on resolved handlers. If one matches, the handler can update $post, then schema validation runs again.

Post Blocks

Every data record you want a theme to write or read is declared as a post.

post products {
  schema {
    title: { type: "string", min: 1, max: 140, required: true }
    price: { type: "price", required: true }
  }

  create require[ $access.edit ]
  read require[ $access.read ]
  edit require[ $access.edit ]
  delete require[ $access.delete ]
}

The post name is the type used by the frontend API:

await Frontbacked.uploadPost({
  type: "products",
  post: {
    title: "Starter Plan",
    "$fixedAmount.price": 5000
  }
});

type: "price" tells Frontbacked that the matching special keys, such as "$fixedAmount.price" or "$minAmount.price", are allowed. Frontbacked stores those values as protected price metadata and FQL can display them through $.$fixedAmount.price or $.$minAmount.price.

Auth Data Block

Use auth when you want to control what a signed-in user can write into their own data JSON. It uses the same process, schema, and require[...] shape as a post block, but it has no name because it always protects the current authenticated user.

auth {
  schema {
    phone: { type: "string", max: 40 }
    country: { type: "string", max: 80 }
    avatarUrl: { type: "string", max: 500 }
  }

  edit require[
    $user.id
  ]
}

The frontend updates this data with Frontbacked.updateUserData({ data, merge }). With merge: true, the submitted object is merged into the existing data; with merge: false, it replaces the existing data after the auth rule passes.

Custom Endpoints

Use endpoint.get, endpoint.post, endpoint.put, or endpoint.delete when a theme needs a guarded JSON route that is not just storing one post.

endpoint.post estimateReturn("/investments/estimate") {
  verify $request.body.amount > 0
  idempotency `${$user.id || "guest"}:${$request.body.amount}:${$request.body.plan}`

  process {
    $context.rate = math.max($private.dailyRate || 0, 0);

    return {
      ok: true,
      dailyEstimate: $request.body.amount * $context.rate
    };
  }
}

The theme calls it at /fb-endpoints/investments/estimate. See Endpoints and Idempotency for request fields, repeat-request behavior, path params, and endpoint write helpers.

Runtime Context

FRL expressions use runtime variables. The most common are:

VariableMeaning
$idThe generated id for the current post. In auth rules, this is the signed-in user's id.
$postThe current post being created, edited, read, deleted, or resolved.
$prevThe previous stored value. Most useful during edit, read, and delete.
$userThe signed-in user, when available.
$accessAccess flags resolved by the platform, such as read, edit, and delete.
$secretSecret values referenced by the theme, such as $secret.PAYSTACK_SECRET. fb-admin creates fields for referenced secret keys automatically.
$privatePrivate server-side settings referenced by the theme, such as $private.minimumDepositAmount. fb-admin creates fields for referenced private settings automatically.
$pagePage settings from the site settings. These are edited through theme/page settings rather than the FRL admin forms.
$currencyPlatform currency config. $currency.value is a code such as USD or NGN; $currency.mode is currently fixed.
$contextScratch data you create in process and reuse later in the same rule run.
$resolvedWebhook payload data during on resolved handlers.

Admin Fields from $secret and $private

When a theme references $secret.someKey or $private.someKey in backend/index.rules, fb-admin automatically shows those keys on the Secret Keys or Private Settings pages for the site admin to fill.

create require[
  $secret.PAYSTACK_SECRET && $private.minimumDepositAmount && $user.id
]

Theme developers only need to reference the keys in FRL. They do not need to create custom admin form fields for those values.

Expressions

FRL expressions support paths, strings, numbers, arrays, objects, function calls, comparisons, logical operators, math operators, unary operators, and template strings.

$post.slug = str.slug($post.title);
$post.total = $post.price * $post.quantity;
$post.ownerCanEdit = $user.id == $prev.authorId || $access.edit;
$context.message = `New order ${$post.id} for ${$post.email}`;

Use semicolons inside process, function bodies, and resolved handlers.

Helper Functions

Built-in helpers include:

FunctionExamplePurpose
str.lower(value)str.lower($post.email)Lowercase a string.
str.upper(value)str.upper($post.code)Uppercase a string.
str.trim(value)str.trim($post.name)Trim whitespace.
str.includes(value, textOrArray)str.includes($post.title, "pro")Check string contents.
str.slug(...values)str.slug($post.title, $post.id)Build a URL-friendly slug.
str.id(length)str.id(12)Generate a random id.
arr.size(array)arr.size($post.tags)Count array items.
math.min(...)math.min($post.price, 100)Return the minimum value.
math.max(...)math.max($post.price, 0)Return the maximum value.

Theme rules can also declare local functions with fn.

fn normalizeEmail(email) {
  return str.lower(str.trim(email));
}

post signups {
  process {
    $post.email = normalizeEmail($post.email);
  }
}

Secure Defaults

If an action has no require[...] block, it is denied. This is intentional. A theme should explicitly say who can create, read, edit, and delete each post type.

For public form submissions, use a condition based on the submitted data instead of leaving the rule open by accident.

create require[
  $post.email && $post.acceptedTerms
]

For site-owner actions, prefer platform access flags.

edit require[
  $access.edit
]