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:
- Create — A new record starts as version 1 in draft state
- Edit — Each save creates a new version (version 2, 3, etc.)
- Preview — View any version before publishing
- Publish — Make a specific version live; the public API returns the published version
- 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
- Navigate to the content section for the model (e.g., Pages, Blog Posts)
- Click Create New
- Fill in the fields
- Click Save Draft or Publish (for models with publishing)
Editing a record
- Select the record from the list
- Make changes in the editor
- 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-postAdd --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_abc123Publish a version
Publishing is by version ID:
foir records publish ver_xyz789Unpublish a record
foir records unpublish rec_abc123Create 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-copyList versions
foir records versions rec_abc123List variants
foir records variants rec_abc123Create 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
}
}| Operator | Applies to | Effect |
|---|---|---|
set | any field | Replace the value |
setNull | any field | Clear the value (set to null) |
increment / decrement / multiply | number | Adjust the numeric value in place |
push | list | Append items |
pull | list | Remove matching items |
splice | list | Insert/remove at an index (start, deleteCount, insert) |
removeWhere | list | Remove items matching the item’s where shape |
connect / disconnect | reference | Point 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:
| Operator | Description | Example |
|---|---|---|
eq | Equals | { status: { eq: "active" } } |
ne | Not equals | { status: { ne: "cancelled" } } |
lt / gt / lte / gte | Comparison | { price: { gte: 50 } } |
contains / startsWith / endsWith | Substring match (text) | { title: { contains: "shirt" } } |
in / nin | In / not in array | { category: { in: ["a", "b"] } } |
isNull / isNotNull | Null 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 operations —
increment,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
batchRecordOperationsmutation 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
changeDescriptionvalues 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
wherepredicate. - For bulk imports or migrations, use batch operations to reduce round trips.