FRL Overview
A Frontbacked theme is the reusable website package you ship: pages, styles, scripts, assets, optional skins, and optional rules for the data and actions the site supports.
Frontbacked Rule Language (FRL) gives that theme a protected data layer without making every theme become a server project. It answers four questions for every post type:
- What fields are allowed?
- How should incoming data be cleaned or computed?
- Who can create, get, list, edit, or delete this data?
- Should this action create related writes or async work?
FRL lives 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:
| Feature | What it lets your theme do |
|---|---|
version 2; | Declare the current FRL grammar. Put it before any other statement. |
post blocks | Define records such as articles, products, comments, plans, tickets, reviews, portfolios, or lessons. |
profile block | Shape and validate custom signed-in user data. |
schema | Reject unknown fields and validate nested objects, arrays, enums, files, prices, defaults, and immutable fields. |
before hooks | Trim, normalize, compute slugs, set derived status, and prepare trusted fields before validation. |
allow rules | Decide who can create, get, list, edit, delete, read, or write each post type. |
after hooks | Run follow-up work after validation and permissions pass. |
| Related writes | Create, edit, delete, or increment related posts after a successful action. |
allow list(...) policies | Control who can list a post type and set default/max list limits. |
endpoint.* blocks | Add custom guarded JSON endpoints for workflows that are not plain CRUD. |
http.* helpers | Contact approved outside services from FRL with host review and loop protection. |
must(...) and mustNot(...) | Stop a hook or endpoint when a required assertion fails. |
dedupe | Make repeat endpoint requests return the same outcome instead of duplicating work. |
$secret and $private | Reference admin-filled private values without building custom settings forms. |
as aliases | Give admin UIs friendlier labels for post types, fields, endpoints, secret keys, and private settings without renaming the stored data. |
A Minimal Rule File
version 2;
post leads {
before create {
$post.pending.data.email = str.lower(str.trim($post.pending.data.email));
}
schema {
name: { type: "string", min: 1, max: 120, required: true }
email: { type: "email", required: true }
message: { type: "string", max: 1000 }
createdAt: { type: "serverTime" }
}
allow create: $post.pending.data.email
allow get: $admin.canRead("$this")
allow list(limit=20, max=100): $admin.canRead("$this")
allow edit: $admin.canEdit("$this")
allow delete: $admin.canDelete("$this")
}
This defines a leads post type. When a frontend page calls Frontbacked.createPost({ type: "leads", post }), Frontbacked applies the rule and either stores a clean, valid post or returns a useful error.
Rule Flow
For create, edit, and delete actions, Frontbacked evaluates a post rule in this order:
- Prepare rule values such as
$post,$actor,$action,$admin,$secret,$private,$page,$currency, and$this. - Fill server-generated fields, then schema defaults for missing or
nullfields during create. - Run every matching
beforehook in source order.before writeruns for create, edit, and delete, and comma groups such asbefore create, editshare setup across selected actions. - Validate the
schema. - Evaluate every matching
allowrule. All of them must pass. - Run matching
afterhooks for write actions when present. - Materialize any related writes queued by
post.*helpers.
get and list do not run before or after hooks. get checks single-post permissions, while list checks whether the visitor may request the list and what size the list may be. Use FQL where or filter to choose which rows appear in a list. Because allow read: also applies to list requests, allow read: and allow list: cannot use post-scoped values such as $post, $row, or $rowMeta, even through helper functions. If an action has no matching allow rule, Frontbacked denies it.
For webhook callbacks and delayed external work, use endpoint.* with dedupe and post.* helpers. That keeps callback handling in the same clear endpoint model as every other custom route.
Post State
FRL separates saved data from the data being written, so rules stay easy to reason about.
| Path | Meaning |
|---|---|
$post.current.data | The saved data before this request. Use this for ownership, status, and previous-value checks. |
$post.current | Post metadata for the saved post, such as id, type, author id, and status. |
$post.pending.data | The data this request wants to save. before create and before edit hooks write here. |
$post.pending | Post metadata for the pending write, including the generated id during create. |
| Action | Current state | Pending state |
|---|---|---|
create | Empty. | The new post data and generated metadata. |
edit | The saved post before the edit. | The merged post data that will be saved. |
delete | The saved post being deleted. | Empty. |
get | The saved post being fetched. | Empty. |
list | Not available in list permissions because list checks run before rows are loaded. | Empty. |
Post Blocks
Every data record you want a theme to write or read is declared as a post.
version 2;
post products {
schema {
title: { type: "string", min: 1, max: 140, required: true }
status: { type: "enum", enums: ["draft", "published"], default: "draft", required: true }
price: { type: "money", min: 0.01, required: true }
}
allow create: $admin.canCreate("products")
allow get: $post.current.data.status == "published" || $admin.canRead("products")
allow list(limit=24, max=100): true
allow edit: $admin.canEdit("products")
allow delete: $admin.canDelete("products")
}
The post name is the type used by the frontend API:
await Frontbacked.createPost({
type: "products",
post: {
title: "Starter Plan",
price: { amount: "5000", currency: "USD" }
}
});
type: "money" stores an exact amount and currency. FQL returns a rich money object with safe methods such as $.price.getAmount(), $.price.getCurrency(), and $.price.format().
User Data And Lifecycle
Use user to validate custom user data and connect other records to the user lifecycle. The site admin decides whether registration, profile changes, and profile-photo changes are available, so a theme cannot grant those capabilities from FRL.
user {
schema {
phone: { type: "phone" }
country: { type: "string", max: 80 }
}
after create {
post.createAsUser("customer_accounts", {
owner: $user.id
});
}
}
$user.id always identifies the affected user. $actor.id identifies the person who initiated the action, which can be different when an admin deletes a user. $user.current, $user.pending, and $user.changes let hooks inspect safe identity fields and custom data without exposing passwords, hashes, or tokens.
The user schema validates data being created or edited; delete and sign-in hooks do not revalidate older stored data. Every account creation path—including email signup, provider signup, and accounts created by a site admin—runs after create. If required user data is missing or a related write fails, the account is not partially created. Use after create, after edit, and after delete for related writes. after signIn runs after successful authentication and cannot turn valid credentials into a failed sign-in. The older profile spelling remains available while themes migrate, but user is the current form.
The frontend updates custom data with Frontbacked.updateUserData({ data, merge }). With merge: true, the submitted object is merged into existing data; with merge: false, it replaces existing data after schema validation.
Custom Endpoints
Use endpoint.get, endpoint.post, endpoint.put, endpoint.patch, or endpoint.delete when a theme needs a guarded JSON route that is not just storing one post.
endpoint.post estimateReturn("/investments/estimate") as "Estimate Return" {
must($request.body.amount > 0, "Amount is required", 422);
dedupe `${$actor.id || "guest"}:${$request.body.amount}:${$request.body.plan}`;
$context.rate = math.max($private.dailyRate || 0, 0);
return {
ok: true,
dailyEstimate: $request.body.amount * $context.rate
};
}
The theme calls it by name with Frontbacked.endpoints.call("estimateReturn", { body }). Direct HTTP callers can use /fb-endpoints/investments/estimate. See Endpoints and Dedupe for request fields, repeat-request behavior, path params, cancellation, and endpoint write helpers.
Payment Events
Use payment created, payment confirmed, and payment failed when a theme needs to react to payment lifecycle events.
payment confirmed {
if ($payment.metadata.purpose == "product_purchase" && $payment.metadata.productId) {
post.increment($payment.metadata.productId, {
completedOrderCount: 1
});
}
}
Use confirmed, not successful, in FRL event names. Stored transaction rows can still expose successful to FQL, but $payment.status is normalized to confirmed inside FRL payment events.
Rule Context
FRL expressions use rule variables. The most common are:
| Variable | Meaning |
|---|---|
$post.current.id / $post.pending.id | The generated id for the current post. In a user hook, use $user.id. |
$post.current.data | Saved post data for get, edit, and delete checks. Empty during create. |
$post.current | Post metadata for the saved post. |
$post.pending.data | New or edited data that will be saved. Write this in before create and before edit. |
$post.pending | Post metadata for the pending write, including the generated id during create. |
$payment | The current payment during payment created, payment confirmed, and payment failed events. |
$user | The affected user inside user hooks. Read $user.id, safe $user.current and $user.pending state, and $user.changes during edits. |
$action | Metadata for the current operation, including requested read filters at $action.where, the current payment event at $action.payment, and booleans such as isCreate, isGet, isRead, isList, isEdit, isDelete, isWrite, and isSignIn. |
$actor | The requester causing the rule run. Use $actor.id, $actor.email, $actor.data, $actor.wallet, $actor.isGuest, $actor.isSignedIn, $actor.isAdmin, $actor.isEndpoint, and $actor.endpoint. |
$admin | Content permission helpers such as $admin.canEdit("products") or $admin.canEdit("$this.name") for FRL-managed posts. |
$secret | Secret values referenced by the theme, such as $secret.PAYMENT_SECRET. fb-admin creates fields for referenced secret keys automatically. |
$private | Private rule settings referenced by the theme, such as $private.minimumDepositAmount. fb-admin creates fields for referenced private settings automatically. |
$page | Page settings from the site settings. These are edited through theme/page settings rather than the FRL admin forms. |
$currency | Platform currency config. $currency.value is a code such as USD or NGN; $currency.mode is currently fixed. |
$this | Current post type metadata, including field labels, defaults, and enum helpers such as $this.fields.status.enums.published. |
$context | Scratch data for the current rule run. Write it in hooks, endpoint bodies, and event blocks; read it in allow rules and helpers. |
$local | Scratch data inside one helper function call. |
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.
allow create: $secret.PAYSTACK_SECRET && $private.minimumDepositAmount && $actor.id
Theme developers only need to reference the keys in FRL. They do not need to create custom admin form fields for those values.
Use as to give generated admin UIs a clearer label:
$secret.PAYMENT_SECRET as "Payment provider secret key";
$private.notificationEmail as "Notification recipient email";
post reviews as "Product Reviews" {
schema {
product as "Product": { type: "post", postType: "products", required: true }
reviewer as "Reviewer": { type: "user", required: true }
body as "Review text": { type: "string", max: 600 }
}
}
endpoint.post createLead("/leads/submit") as "Create Lead" {
must($request.body.email, "Email is required");
return { ok: true };
}
Aliases do not rename the underlying post type, field, endpoint, route, secret key, private key, query path, or permission path. The schema keyword itself cannot be aliased.
Expressions
FRL expressions support paths, strings, numbers, arrays, objects, function calls, comparisons, logical operators, math operators, unary operators, and template strings.
$post.pending.data.slug = str.slug($post.pending.data.title);
$post.pending.data.total = $post.pending.data.price * $post.pending.data.quantity;
$post.pending.data.ownerCanEdit = $actor.id == $post.current.authorId || $admin.canEdit("articles");
$context.message = `New order ${$post.pending.id} for ${$post.pending.data.email}`;
Use semicolons inside hooks, endpoint bodies, and function bodies.
Helper Functions
Built-in helpers include:
| Function | Example | Purpose |
|---|---|---|
str.lower(value) | str.lower($post.pending.data.email) | Lowercase a string. |
str.upper(value) | str.upper($post.pending.data.code) | Uppercase a string. |
str.trim(value) | str.trim($post.pending.data.name) | Trim whitespace. |
str.includes(value, textOrArray) | str.includes($post.pending.data.title, "pro") | Check string contents. |
str.slice(value, start, end?) | str.slice("Frontbacked", -6) | Return part of a string; negative positions count backward from the end. |
str.slug(...values) | str.slug($post.pending.data.title) | Build a URL-friendly slug with a six-character random suffix to reduce collisions. |
random.id(length) | random.id(18) | Generate a secure alphanumeric id. |
random.string(lengthOrOptions?, options?) | random.string(8, { alphabet: "numbers" }) | Generate invite codes, PINs, and custom tokens. |
arr.size(array) | arr.size($post.pending.data.tags) | Count array items. |
math.min(...) | math.min($post.pending.data.price, 100) | Return the minimum value. |
math.max(...) | math.max($post.pending.data.price, 0) | Return the maximum value. |
crypto.verifyHmac(options) | crypto.verifyHmac({ secret, payload, signature }) | Verify signed webhook requests. |
Theme rules can also declare local functions with fn.
fn normalizeEmail(email) {
return str.lower(str.trim(email));
}
fn isSubscribed() {
$local.matches = posts.count({
type: "subscriptions",
where: {
user: $actor.id,
status: "active"
}
});
return matches > 0;
}
post signups {
before create {
$post.pending.data.email = normalizeEmail($post.pending.data.email);
}
}
Helper functions can be used inside allow rules, hooks, endpoint bodies, and other helpers. They can read the current $context, $actor, $admin, and resource helpers such as posts.count(...). Helpers called from endpoint blocks can also read $request. Use $local for temporary values inside the helper, then return the result.
Secure Defaults
If a concrete post action has no matching allow rule, it is denied. This is intentional. A theme should explicitly say who can create, get, edit, and delete each protected post type.
For public form submissions, use a condition based on the submitted data instead of leaving the rule open by accident.
allow create: $post.pending.data.email && $post.pending.data.acceptedTerms
For site-owner actions, prefer $admin permission helpers scoped to the content being managed.
allow edit: $admin.canEdit("products")
Named declarations follow the FRL naming rules. The system, system_…, and system.… names are reserved for Frontbacked, case-insensitively. Ordinary data fields are unaffected.