Skip to Content
FeaturesRecords

Records

A record is an instance of a model. It holds your actual data — the content fields, metadata, version history, and publishing state. All record types (pages, blog posts, products, bookings) are accessed through the same unified API.

Overview

Every record has:

  • Data — The content fields defined by the model’s schema (text, images, references, etc.)
  • Metadata — Non-versioned properties (tags, external IDs, custom attributes)
  • Natural key — An optional slug or handle for human-friendly lookups (e.g., about-us, homepage)
  • Version history — For models with versioning enabled, each edit creates a new version
  • Publishing state — For models with publishing enabled, records move through a draft/publish workflow

Versioning and Publishing Workflow

For models with versioning and publishing enabled:

  1. Create — A new record starts as version 1 in draft state
  2. Edit — Each save creates a new version (version 2, 3, etc.)
  3. Preview — View any version before publishing
  4. Publish — Make a specific version live; the public API returns the published version
  5. Roll back — Restore a previous version at any time

For models without publishing, changes take effect immediately. For models without versioning, updates replace the current data in place.

In the Admin

Creating a record

  1. Navigate to the content section for the model (e.g., Pages, Blog Posts)
  2. Click Create New
  3. Fill in the fields
  4. Click Save Draft or Publish (for models with publishing)

Editing a record

  1. Select the record from the list
  2. Make changes in the editor
  3. Save as a new draft or publish directly

Version history

For versioned models, click the History tab to view all versions, compare changes, and restore previous versions.

Via the CLI

The record subcommands take the model key and record identifier as positional arguments; field data is passed with -d, --data (inline JSON) or -f, --file (a JSON file).

List records for a model

foir records list blog-post foir records list blog-post --first 20 --after <cursor>

Get a record by ID or natural key

The second argument accepts either a record ID or a natural key:

foir records get blog-post rec_abc123 foir records get blog-post my-first-post

Add --resolved (published content), --preview (latest draft), or --locale <locale>.

Create a record

foir records create blog-post --data '{ "naturalKey": "my-first-post", "title": "My First Post", "slug": "my-first-post", "body": "Hello world." }'

Update a record

records update writes the record’s data column directly — it does not create a version or move the published pointer. For the admin “Save Draft” equivalent (atomic data + new version + current-version bump), use records save.

foir records update blog-post rec_abc123 --data '{ "title": "Updated Title" }'

Save a record (Save Draft equivalent)

Atomically replaces the data, writes a new immutable version, and advances the current version so preview and storefront see the new content without a separate publish step.

foir records save blog-post rec_abc123 \ --data '{ "title": "Updated Title" }' \ --message "Fixed the title"

Add --variant <key> to save into a specific variant.

Delete a record

foir records delete blog-post rec_abc123

Publish a version

Publishing is by version ID:

foir records publish ver_xyz789

Unpublish a record

foir records unpublish rec_abc123

Create a new version

foir records create-version rec_abc123 \ --data '{ "title": "New Draft Version" }' \ --message "Reworked the intro"

Duplicate a record

foir records duplicate blog-post rec_abc123 --natural-key my-first-post-copy

List versions

foir records versions rec_abc123

List variants

foir records variants rec_abc123

Create a variant

foir records create-variant rec_abc123 --key mobile --name "Mobile Version"

Via the API

Each public model has typed queries and mutations named after the model. The examples below use a page model; substitute your own model’s singular/plural names. Declared fields are returned at the top level of the record, alongside underscore-prefixed system fields (_id, _naturalKey, _modelKey, _createdAt, _updatedAt, _hasDraft). There is no data wrapper. See GraphQL → Querying Records for full detail.

Queries

Get a record by natural key

query { page(naturalKey: "about") { _id _naturalKey title body } }

Get a record by ID

query { page(id: "rec_abc123") { _id title } }

List records with filtering, sorting, and pagination

List queries return a Relay cursor connection. Use where to filter, orderBy to sort, and first/after to paginate:

query { products( where: { category: { eq: "electronics" }, price: { gte: 100 } } orderBy: [{ createdAt: DESC }] first: 20 ) { edges { node { _id _naturalKey title price } cursor } pageInfo { hasNextPage endCursor } totalCount } }

Content resolution

Resolution — variant selection, locale handling, reference resolution — happens automatically from the query arguments; the resolved values come back directly on the typed fields. There is no separate resolved sub-field.

query { page( naturalKey: "homepage" locale: "en-US" contexts: { device: MOBILE, market: "us" } ) { _id title hero { heading } } }

Pass preview: true to get the latest draft instead of the published version (requires a secret key with the drafts:read scope).

List versions

query { recordVersions(parentId: "rec_abc123", first: 10) { edges { node { id versionNumber changeDescription createdAt } } pageInfo { hasNextPage endCursor } totalCount } }

Mutations

Create a record

create<Model> takes the model’s declared fields flattened at the top level, plus the optional naturalKey and _metadata system keys.

mutation { createBlogPost(input: { naturalKey: "my-first-post" title: "My First Post" slug: "my-first-post" body: "Hello world." }) { _id _naturalKey title } }

Update a record

update<Model> identifies the target with where (must match exactly one record) and applies a typed operator tree in data. For versioned models, updates create a new version automatically.

mutation { updateBlogPost( where: { id: { eq: "rec_abc123" } } data: { title: { set: "Updated Title" } } changeDescription: "Fixed the title" ) { _id _updatedAt title } }

Update many records

updateMany<Model> applies the same typed update to every record matching the predicate, returning { count, ids }. Always scope where — omitting it matches every record in the model.

mutation { updateManyProduct( where: { category: { eq: "electronics" } } data: { price: { multiply: 0.9 } } ) { count ids } }

Publish a version

mutation { publishBlogPostVersion(versionId: "ver_xyz789") }

Unpublish a record

mutation { unpublishBlogPost(id: "rec_abc123") }

Delete a record

mutation { deleteBlogPost(id: "rec_abc123") }

Returns true on success.

Batch Operations

Process multiple create, update, and delete operations across any models in a single request. Each operation declares its type and the fields that type needs:

mutation { batchRecordOperations(input: { operations: [ { type: CREATE modelKey: "product" naturalKey: "blue-widget" data: { name: "Blue Widget", price: 29.99 } } { type: UPDATE modelKey: "product" id: "rec_existing123" data: { price: 24.99 } } { type: DELETE id: "rec_old456" } ] }) { created updated deleted errors { type index message } } }

Atomic Field Operations

Atomic field updates are expressed through the update operator tree, evaluated server-side without a read-modify-write round trip:

mutation { updateArticle( where: { id: { eq: "rec_abc123" } } data: { viewCount: { increment: 1 } tags: { push: ["featured"] } lastViewed: { set: "2026-02-23T12:00:00Z" } } ) { _id viewCount tags } }
OperatorApplies toEffect
setany fieldReplace the value
setNullany fieldClear the value (set to null)
increment / decrement / multiplynumberAdjust the numeric value in place
pushlistAppend items
pulllistRemove matching items
splicelistInsert/remove at an index (start, deleteCount, insert)
removeWherelistRemove items matching the item’s where shape
connect / disconnectreferencePoint at / clear a referenced record

Filtering

Each model has a generated <Model>WhereInput. Filter on declared fields by key, plus the always-available direct columns (id, naturalKey, createdAt, updatedAt, ownerId, createdBy, updatedBy). Operators available depend on the field type. Common text operators:

OperatorDescriptionExample
eqEquals{ status: { eq: "active" } }
neNot equals{ status: { ne: "cancelled" } }
lt / gt / lte / gteComparison{ price: { gte: 50 } }
contains / startsWith / endsWithSubstring match (text){ title: { contains: "shirt" } }
in / ninIn / not in array{ category: { in: ["a", "b"] } }
isNull / isNotNullNull check{ publishedAt: { isNotNull: true } }

Compose with AND, OR, and NOT. See GraphQL → Filtering for operators by field type and list/reference filtering.

Advanced write patterns

For high-concurrency or bulk write scenarios, the GraphQL API exposes patterns that go beyond a plain field replacement:

  • Atomic field operationsincrement, multiply, push, pull, splice, removeWhere, etc. on a single field, evaluated server-side without read-modify-write. See GraphQL → Atomic Field Operations.
  • Update many — apply one typed update to every record matching a predicate in a single mutation. See GraphQL → Update Many Records.
  • Batch operations — group create/update/delete calls across models into a single batchRecordOperations mutation that runs as one round trip. See GraphQL → Batch Operations.

Best Practices

  • Use natural keys for records that need human-friendly URLs (pages, blog posts). They make API queries and CLI lookups much simpler.
  • Write meaningful changeDescription values on each update so your version history is useful.
  • Use atomic field operations (increment, push, etc.) for counters and array fields to avoid race conditions.
  • For bulk updates across many records, use update many with a scoped where predicate.
  • For bulk imports or migrations, use batch operations to reduce round trips.
Last updated on