Frontbacked Docs

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:

VisibilityVisitor or guestSigned-in non-authorPost authorFRL rules/endpoints
publicVisible if the post itself is readable.Visible if the post itself is readable.Visible if the post itself is readable.Visible.
authorHidden.Hidden.Visible.Visible.
backendHidden.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

TypeAcceptsTypical use
stringJavaScript string valuesNames, emails, titles, slugs, statuses.
emailEmail-like string valuesAccount or contact email fields.
phonePhone number strings with a country codeContact numbers, account phone fields, support phone fields.
enumOne string from a declared setPublish states, plan tiers, workflow steps.
userGenerated user IDsOwner, assignee, member, or customer references.
numberJavaScript number valuesCounts, measurements, percentages.
moneyAn amount whose currency follows the site's currency settingsAccount balances, credits, debits, ledger entries.
booleantrue or falseCheckboxes, toggles, feature flags.
datetimeDate strings or Date valuesEvent dates, expiry dates, scheduled times.
serverTimeFrontbacked-managed timestamp valuescreatedAt, updatedAt.
postExisting post IDs from another post typeCategory, author, parent, or related-record references.
objectPlain objectsStructured settings, address fields, nested metadata.
arrayArraysTags, gallery images, feature lists.
fileUploaded file metadata objectsImages, videos, PDFs, audio, documents.
anyOfOne of several field schema objectsFlexible 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" }
}
RuleMeaning
requiredValue must not be undefined or null.
minMinimum string length.
maxMaximum string length.
patternA 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:

RuleMeaning
requiredValue must be present.
enumsAllowed string values.
defaultValue 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 }
}
RuleMeaning
requiredValue must be present.
minMinimum numeric value.
maxMaximum 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:

RuleMeaning
requiredObject must be present.
minKeysMinimum number of object keys.
maxKeysMaximum number of object keys.
maxDepthMaximum nested object depth.
maxCharsMaximum 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:

RuleMeaning
requiredArray must be present.
minMinimum array length.
maxMaximum array length.
uniqueItems 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:

RuleMeaning
requiredFile must be present.
maxSizeMaximum 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.
mimeTypesAllowed MIME types.
maxDurationSecondsMaximum 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.