Schemas and Validation
An FRL schema is the contract for one post type. It lists every field Frontbacked will accept and rejects extra fields. This is one of the main protections that keeps published sites from accepting unexpected data.
post products {
schema {
title: { type: "string", min: 1, max: 140, required: true }
price: { type: "number", min: 0, required: true }
status: { type: "enum", enums: ["draft", "published", "archived"], default: "draft", required: true }
}
}
If the frontend sends a field that is not in the schema, validation fails.
Field Syntax
Each field is an object:
fieldName: { type: "string", ruleName: value }
fieldName as "Friendly field label": { type: "string" }
Examples:
title: { type: "string", min: 1, max: 140, required: true }
price: { type: "number", min: 0, max: 1000000 }
published: { type: "boolean" }
expiresAt: { type: "datetime" }
createdAt: { type: "serverTime" }
status: { type: "enum", enums: ["draft", "published"], default: "draft" }
Boolean rules use true, such as required: true, unique: true, or immutable: true.
The optional as label gives admin screens a friendlier label. It does not rename the stored field or change the FQL path.
default fills a missing or null literal field during create. Use it with string, email, phone, enum, number, boolean, and datetime fields. Defaults are available to before create and before write hooks, then the final value is validated like normal input. Edits do not auto-fill omitted fields.
generated: true marks a field as server-managed. Forms and direct API writes do not supply it. Set it from FRL hooks, endpoints, payment events, or related server-side writes, and control who can read it separately with visibility.
Searchable Fields
In FRL version 2, mark the values visitors should be able to find with searchable: true:
version 2;
post products {
schema {
name: { type: "string", required: true, searchable: true }
description: { type: "string", searchable: true }
tags: { type: "array", of: { type: "string" }, searchable: true }
price: { type: "number" }
}
}
Then use a list selector such as { $list: "products", $search: "curly blonde" }. Each search word must match, and the words can appear in different searchable fields.
You can mark up to 10 top-level fields per post schema. Supported types are string, enum, number, and arrays of those types. Searchable fields must have public visibility. Objects, nested fields, files, and user or post references are not supported; related record names are not copied into search.
Frontbacked keeps search content current when records are created or edited, including partial edits and writes made through admin forms or FRL. Values are lowercased, surrounding whitespace is removed, and repeated whitespace becomes a single space. Arrays contribute their values in order. Empty or missing values contribute nothing. Search covers the first 16,000 characters of the combined text, in schema declaration order.
There is no search-text field to fill in. Changes to the searchable declarations refresh existing records when the updated rules are next used. Theme previews and selected versions search their own declared fields.
Omit $fields to use these declarations. An explicit $fields list still searches those fields. Schemas without any searchable declarations keep the existing default search behavior.
Query Indexes
Declare fields you plan to filter, sort, or search. Frontbacked uses the schema and your theme queries to keep large lists fast.
Reference fields can be filtered by their saved ID. The typed suffix is optional, but can be useful when you want the selector to be explicit:
await Frontbacked.get({
posts: [
{
type: "sessions",
where: { mentor: selectedUserId }
}
]
});
Post Summaries in Admin Pickers
When one post references another, includeInPostSummary: true tells Frontbacked which values should identify the referenced post in admin forms. This keeps selectors useful for people who recognize an account, customer, or product but should not need to recognize an internal post ID.
post bank_accounts {
schema {
owner as "Account holder": {
type: "user",
required: true,
immutable: true,
includeInPostSummary: true
}
accountNumber as "Account number": {
type: "string",
required: true,
unique: true,
immutable: true,
includeInPostSummary: true
}
}
}
The declaration order is the display order. A user summary field shows the user's email when available, followed by the other marked values. You can mark up to four user, email, phone, string, enum, number, datetime, or boolean fields. Backend-only fields and complex values such as files, arrays, and objects cannot be included.
If you do not mark any fields, Frontbacked chooses a short fallback from the field types and constraints already in the schema. Explicit declarations are best when a particular combination matters to the people managing the site.
Field Visibility
In FRL version 2, schema fields can declare visibility when a value should not be returned to normal theme pages.
version 2;
post orders {
schema {
title: { type: "string", required: true }
deliveryAddress: { type: "string", visibility: "author" }
paymentReference: { type: "string", visibility: "backend" }
}
}
Visibility values by requester:
| Visibility | Visitor or guest | Signed-in non-author | Post author | FRL rules/endpoints |
|---|---|---|---|---|
public | Visible if the post itself is readable. | Visible if the post itself is readable. | Visible if the post itself is readable. | Visible. |
author | Hidden. | Hidden. | Visible. | Visible. |
backend | Hidden. | Hidden. | Hidden. | Visible. |
public is the default when visibility is not declared. Admin status does not automatically bypass field visibility in normal theme page data; an admin viewing through the site frontend still receives the same field visibility as their requester identity. Use FRL rules or endpoint handlers for values that must remain server-only.
Visibility inherits downward. If an object field is author, its nested fields are also author-only unless a nested field is stricter, such as backend. Array visibility applies to every item in the array; numeric array indexes in field paths are ignored.
When a post type has restricted fields, search only checks fields the current reader is allowed to see. Hidden values cannot match a visitor search or filter.
Supported Types
| Type | Accepts | Typical use |
|---|---|---|
string | JavaScript string values | Names, emails, titles, slugs, statuses. |
email | Email-like string values | Account or contact email fields. |
phone | Phone number strings with a country code | Contact numbers, account phone fields, support phone fields. |
enum | One string from a declared set | Publish states, plan tiers, workflow steps. |
user | Generated user IDs | Owner, assignee, member, or customer references. |
number | JavaScript number values | Counts, measurements, percentages. |
money | An amount whose currency follows the site's currency settings | Account balances, credits, debits, ledger entries. |
boolean | true or false | Checkboxes, toggles, feature flags. |
datetime | Date strings or Date values | Event dates, expiry dates, scheduled times. |
serverTime | Frontbacked-managed timestamp values | createdAt, updatedAt. |
post | Existing post IDs from another post type | Category, author, parent, or related-record references. |
object | Plain objects | Structured settings, address fields, nested metadata. |
array | Arrays | Tags, gallery images, feature lists. |
file | Uploaded file metadata objects | Images, videos, PDFs, audio, documents. |
anyOf | One of several field schema objects | Flexible values such as string or number. |
Reference Fields
Use type: "post" for a field that points to another post, and type: "user" for a field that points to a site user.
post sessions {
schema {
title: { type: "string", required: true }
category: { type: "post", postType: "categories", required: true }
mentor: { type: "user", required: true }
}
}
Send IDs from the frontend:
await Frontbacked.createPost({
type: "sessions",
post: {
title: "Lace install basics",
category: selectedCategory.id,
mentor: selectedUser.id
}
});
Post references are saved as post IDs, and user references are saved as user IDs. Do not save copied post or user objects into reference fields.
When a theme page asks for a visible reference field, Frontbacked expands it in place. A field named category can be read as category.name, and mentor can be read as mentor.name. Each expanded reference also includes id.
Nested references can be read through the same field names. For example, if reviews.product points to a product, and products.category points to a category, a theme can read review.product.category.name. Frontbacked expands only the requested reference chain, up to five reference hops.
Use visibility on the reference field when those user details should only be returned to the author or kept for FRL rules and endpoint handlers.
customer: { type: "user", required: true, visibility: "author" }
reviewer: { type: "user", required: true }
Related creates and edits from post.* helpers follow the same schema rules as normal saves. If a required reference field is missing from the helper data, the write fails with the normal schema error.
String Rules
schema {
title: { type: "string", min: 1, max: 140, required: true }
sku: { type: "string", pattern: "^[a-zA-Z0-9-]{3,120}$" }
expires as "Card expiry": {
type: "string",
pattern: {
segments: [
{ name: "Month", placeholder: "MM", pattern: "^[0-9]{2}$" },
{ name: "Year", placeholder: "YY", pattern: "^[0-9]{2}$" }
],
separator: "/"
}
}
phone: { type: "phone" }
}
| Rule | Meaning |
|---|---|
required | Value must not be undefined or null. |
min | Minimum string length. |
max | Maximum string length. |
pattern | A regular expression string, or a segmented pattern object for structured values. |
Use a regex string when a value belongs in one input. Use { segments, separator } when the value has meaningful parts, such as a card expiry, serial number, or grouped identifier. Each segment requires its own pattern and can include a user-facing name and placeholder. Frontbacked validates every segment and the literal separator, and admin forms can present the parts as dedicated inputs without guessing what a regex means.
Segment patterns validate one complete segment. The separator stays outside the segment patterns and is stored as part of the final string, so the example above stores 12/30.
Use type: "phone" for phone numbers. Frontbacked accepts common formatting such as spaces, dashes, and parentheses, normalizes the value, and requires a valid international number with a country code.
Enum Fields
Use type: "enum" when a field must be one of a few named options.
schema {
status: { type: "enum", enums: ["draft", "published", "archived"], default: "draft", required: true }
}
Enum values are saved as strings, so FQL reads them normally:
<article f-show="$.status == 'published'">
Inside FRL, $this.fields exposes the enum values without repeating string literals:
allow get: $post.current.data.status == $this.fields.status.enums.published || $admin.canRead("$this")
Use hasEnum(value) when a rule needs to check whether a value belongs to that field's enum list:
allow create: $this.fields.status.hasEnum($post.pending.data.status)
Enum rules:
| Rule | Meaning |
|---|---|
required | Value must be present. |
enums | Allowed string values. |
default | Value to use on create when the field is missing or null. |
Number Rules
schema {
amount: { type: "number", min: 1, max: 10000000, required: true }
discount: { type: "number", min: 0, max: 100 }
}
| Rule | Meaning |
|---|---|
required | Value must be present. |
min | Minimum numeric value. |
max | Maximum numeric value. |
Object Fields
Use type: "object" with fields for nested structured values.
schema {
address: {
type: "object",
required: true,
fields: {
line1: { type: "string", min: 1, max: 180, required: true }
city: { type: "string", min: 1, max: 80, required: true }
country: { type: "string", min: 2, max: 80, required: true }
}
}
}
When an object has nested fields, extra keys inside that object are also rejected.
Object-level rules:
| Rule | Meaning |
|---|---|
required | Object must be present. |
minKeys | Minimum number of object keys. |
maxKeys | Maximum number of object keys. |
maxDepth | Maximum nested object depth. |
maxChars | Maximum JSON.stringify size. |
Example:
metadata: { type: "object", minKeys: 0, maxKeys: 20, maxDepth: 2, maxChars: 2000 }
Arrays
Use type: "array" with of or items for the element schema.
schema {
tags: {
type: "array",
of: { type: "string", min: 1, max: 40 },
min: 1,
max: 10,
unique: true
}
}
Array rules:
| Rule | Meaning |
|---|---|
required | Array must be present. |
min | Minimum array length. |
max | Maximum array length. |
unique | Items must be unique after JSON serialization. |
Arrays can contain objects or files:
gallery: {
type: "array",
max: 8,
of: {
type: "object",
fields: {
alt: { type: "string", max: 140 }
image: {
type: "file",
maxSize: 7000000,
mimeTypes: ["image/jpeg", "image/png"],
required: true
}
}
}
}
Union Values
Use anyOf when a value may be one of several allowed schema objects.
schema {
answer: {
anyOf: [
{ type: "string", max: 200 },
{ type: "number", min: 1, max: 10 }
]
}
}
The value passes validation if it matches any option.
Money Fields
Use type: "money" for balances and recorded monetary amounts. A theme declares the amount rules; the site admin chooses the currency in fb-admin.
post accounts {
schema {
owner: { type: "user", required: true, unique: true }
balance: { type: "money", min: 0, default: 0, required: true }
}
}
On a single-currency theme, forms only need an amount. On a multi-currency theme, submit an amount and one of the currencies enabled for that site:
await Frontbacked.createPost({
type: "ledger_entries",
post: {
amount: { amount: "125.50", currency: "NGN" }
}
})
Frontbacked rejects currencies that are not enabled for the site. If no supported-currency list has been saved yet, only the site's base currency is accepted. min and max apply to the ordinary amount a person sees, not to minor units.
FRL can read a money value with money.getAmount(value), money.getCurrency(value), and money.format(value). Money arithmetic is exact, and weighted allocation returns an array of money values. Balance changes stay concise and atomic:
must(money.compare($post.current.data.balance, $request.body.amount) >= 0, "The balance is too low.");
post.increment($post.current.id, {
balance: "-" + money.getAmount($request.body.amount)
});
In FQL, theme developers can define .moneyAmount(), .moneyCurrency(), or .moneyFormat() and use the platform money helper inside those functions:
<strong f="true" f-text="$.balance.moneyFormat()">USD 0.00</strong>
Use money for every monetary value, including product prices, package minimums, balances, credits, debits, and ledger amounts. Monetary settings do not belong in $settings; use a money field on an FRL post so fb-admin can validate and edit the amount with the site's currency contract.
Monetary Fields
Use type: "money" for product prices and every other monetary value. Payment creation is handled by a guarded FRL endpoint.
post products {
schema {
name: { type: "string", min: 1, max: 140, required: true }
price: { type: "money", min: 0.01, required: true }
}
}
The frontend submits an exact money value:
await Frontbacked.createPost({
type: "products",
post: {
name: "Silk lace front",
price: { amount: "250", currency: "USD" }
}
})
Use separate money fields when a value has a minimum and maximum, for example minInvestment and maxInvestment. A payment endpoint validates the chosen amount and calls payment.create({ amount, metadata }).
Post References
Use type: "post" when a field should store the generated ID of another post. The referenced post type is declared with postType.
post categories {
schema {
name: { type: "string", min: 1, max: 120, required: true }
}
}
post products {
schema {
name: { type: "string", min: 1, max: 140, required: true }
category: { type: "post", postType: "categories", required: true }
}
}
The saved value is the referenced post's stable generated ID, such as the value available in FQL as $.id. It is not a slug, because slugs are optional theme fields and may change. During writes, Frontbacked verifies that the referenced post exists on the same site and has the declared postType. Set exists: false only when a theme intentionally wants to accept an ID-shaped value without an existence check.
Post references are indexed like string fields for filtering and ordering:
{
"$list": "products",
"$where": { "category": selectedCategoryId },
"$order": { "createdOn": "desc" }
}
The typed field suffix also works when a selector needs to be explicit: { "category@post": selectedCategoryId }. The shorter { category: selectedCategoryId } is also valid.
File Fields
File fields validate the stored file metadata generated by Frontbacked upload handling.
schema {
avatar: {
type: "file",
maxSize: 3000000,
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
required: true
}
}
For video fields, add maxDurationSeconds when the theme should keep playback to a specific length:
schema {
demoVideo: {
type: "file",
maxSize: 500000000,
maxDurationSeconds: 300,
mimeTypes: ["video/mp4", "video/webm"],
required: true
}
}
Supported file rules:
| Rule | Meaning |
|---|---|
required | File must be present. |
maxSize | Maximum file size in bytes. Frontbacked also has a default upload cap of 1GB, so use FRL to set a stricter field-level limit when your theme needs one. |
mimeTypes | Allowed MIME types. |
maxDurationSeconds | Maximum prepared playback duration in seconds. Longer videos keep the first allowed portion and ignore the ending portion. This only applies to video file fields. |
The stored file metadata must contain a valid url, size, mimeType, fileName, and uploadTime. Extra stored file metadata properties are rejected.
When video files are read back through FQL, Frontbacked may add a read-only media object with processing status and playback URLs. That object is for display and playback only, and is ignored on incoming writes before schema validation.
Server Time Fields
Use type: "serverTime" for timestamps that should be set by Frontbacked.
schema {
createdAt: { type: "serverTime" }
updatedAt: { type: "serverTime" }
}
Frontbacked fills these timestamps before defaults and hooks run, and serverTime fields do not accept default. This keeps frontend clients from deciding trusted timestamps.
Generated Fields
Use generated: true for values supplied by trusted FRL logic rather than by forms or ordinary API payloads. Frontbacked removes caller-supplied values before before hooks run. On edit, the saved generated value is preserved unless trusted FRL logic replaces it. The final value must still satisfy its type, required, pattern, and other schema rules.
post cards {
schema {
cardNumber: { type: "string", min: 10, max: 19, pattern: "^[0-9]{10,19}$", required: true, visibility: "backend" }
last4: { type: "string", min: 4, max: 4, pattern: "^[0-9]{4}$", required: true, generated: true }
}
before create, edit {
$post.pending.data.last4 = str.slice($post.pending.data.cardNumber, -4);
}
allow create, edit: true
}
generated does not make a field public. Use visibility: "author" or visibility: "backend" whenever the generated value needs restricted read access. Endpoints, payment events, and related post.* writes can also set generated fields explicitly.
Immutable Fields
Use immutable rules to protect fields during updates. Immutable validation runs on edit actions and compares $post.pending.data against $post.current.data.
schema {
id: { type: "string", required: true, immutable: true }
authorId: { type: "string", required: true, immutable: true }
}
Use editableIf when a field can change only under a specific guard.
schema {
slug: {
type: "string",
min: 3,
max: 160,
required: true,
editableIf: $post.current.data.status == "draft" && $actor.id == $post.current.data.authorId
}
status: {
type: "enum",
enums: ["draft", "published", "archived"],
default: "draft",
required: true,
editableIf: $admin.canEdit("articles.status")
}
}
The guard can use the same rule context available to permission expressions.
Admin-Supplied Rule Values
When a theme references $secret.someKey or $private.someKey in backend/index.rules, fb-admin automatically detects those keys and pre-populates fields for the site admin. Add an alias when the admin UI should display a friendlier label.
$secret.PAYMENT_SECRET as "Payment provider secret key";
$private.minimumDepositAmount as "Minimum deposit amount";
allow create: $secret.PAYMENT_SECRET && $private.minimumDepositAmount && $actor.id
The admin fills those values in Secret Keys or Private Settings. Theme developers do not need to build a separate settings form for them.
Use $secret for sensitive credentials such as API secrets and webhook signing keys. Use $private for private rule settings that are not secrets, such as review email addresses or numeric limits.
A Complete Schema Example
post articles {
schema {
id: { type: "string", min: 1, max: 120, required: true, immutable: true }
authorId: { type: "string", min: 1, max: 120, required: true, immutable: true }
title: { type: "string", min: 1, max: 140, required: true }
slug: {
type: "string",
pattern: "^[a-z0-9-]+$",
min: 3,
max: 160,
required: true,
editableIf: $post.current.data.status == "draft"
}
summary: { type: "string", max: 300 }
body: { type: "string", min: 1, max: 50000, required: true }
status: { type: "enum", enums: ["draft", "published", "archived"], default: "draft", required: true }
cover: {
type: "file",
maxSize: 7000000,
mimeTypes: ["image/jpeg", "image/png", "image/webp"]
}
tags: {
type: "array",
of: { type: "string", min: 1, max: 40 },
max: 12,
unique: true
}
seo: {
type: "object",
maxChars: 600,
fields: {
title: { type: "string", max: 80 }
description: { type: "string", max: 180 }
}
}
createdAt: { type: "serverTime" }
updatedAt: { type: "serverTime" }
}
}
Named declarations follow the FRL naming rules. The system, system_…, and system.… names are reserved for Frontbacked, case-insensitively. Ordinary data fields are unaffected.