Flex Objects
Flex Objects are schema-driven custom data types that live outside the normal page tree. Where pages represent documents with routes and templates, Flex Objects represent structured records: product catalogues, team member lists, event schedules, FAQs — anything that doesn't map naturally to a URL hierarchy.
How it works
Each Flex Object type is defined by a YAML schema file and gets its own admin UI section, REST API endpoints, public URL routes, and collection query support — automatically. Records are stored as flat YAML files on disk.
flex-objects/
products.yaml ← schema definition
products/
a3f2c19d0e8b.yaml ← individual records
7b91e4f23c12.yaml
team.yaml
team/
...
Defining a schema
Create a YAML file in the flex-objects/ directory at your project root. The filename (without .yaml) becomes the type name used in the admin UI and API.
# flex-objects/products.yaml
title: Products
icon: 🛍️
description: Product catalogue entries
fields:
name:
type: text
label: Product Name
required: true
validate:
max: 120
price:
type: number
label: Price (CHF)
required: true
validate:
min: 0
description:
type: textarea
label: Description
category:
type: select
label: Category
options:
mugs: Mugs
prints: Prints
accessories: Accessories
published:
type: toggle
label: Published
default: true
tags:
type: list
label: Tags
Schema properties
| Property | Required | Description |
|---|---|---|
title |
Yes | Human-readable type name shown in the admin sidebar. |
icon |
No | Emoji or short string used as the sidebar icon. |
description |
No | Short description shown on the type list page. |
fields |
Yes | Map of field name → field definition. |
Field types
Flex Object fields use the same type system as Blueprint fields. Every type supports label, required, and default.
| Type | Stored as | Description |
|---|---|---|
text |
string | Single-line text input. |
textarea |
string | Multi-line text area. |
markdown |
string | Markdown editor with preview. |
number |
number | Numeric input. |
toggle |
boolean | On/off switch. |
date |
string (YYYY-MM-DD) | Date picker. |
select |
string | Dropdown — requires options map. |
list |
string[] | Ordered list of text values. |
file |
string | File path or URL. |
color |
string | Colour picker (#rrggbb or CSS value). |
Field options
All fields accept:
my_field:
type: text
label: My Field # displayed in the admin form
required: true # validation: must be non-empty on save
default: hello # pre-filled value for new records
select fields require an options map (value → label):
status:
type: select
label: Status
options:
draft: Draft
published: Published
archived: Archived
validate block for additional constraints:
price:
type: number
label: Price
validate:
min: 0 # minimum value (number) or minimum length (text/list)
max: 9999 # maximum value or maximum length
slug:
type: text
label: Slug
validate:
pattern: "^[a-z0-9-]+$" # regex the value must match
Admin UI
Once a schema file exists, a Flex Objects section appears in the admin sidebar (🗃️). Clicking it lists all defined types. From there you can:
- Browse records — a table auto-generated from the first few non-markdown fields.
- Create records — a form auto-generated from the schema fields.
- Edit records — same form, pre-populated with existing values.
- Delete records — with a confirmation prompt.
The admin UI requires authentication. editor and admin roles can create and edit records. The author role has read access only.
REST API
Flex Object records are exposed as read-only endpoints on the public REST API.
List all records
GET /api/flex/{type}
Returns all records for the type, sorted newest first (by creation time).
[
{
"_id": "a3f2c19d0e8b",
"_type": "products",
"_createdAt": 1741234567890,
"_updatedAt": 1741234567890,
"name": "Ceramic Mug",
"price": 24.00,
"category": "mugs",
"published": true,
"tags": ["handmade", "ceramic"]
}
]
Returns 404 if the type schema does not exist. Returns an empty array if the type exists but has no records.
Get a single record
GET /api/flex/{type}/{id}
Returns one record by its 12-character ID.
{
"_id": "a3f2c19d0e8b",
"_type": "products",
"_createdAt": 1741234567890,
"_updatedAt": 1741234567890,
"name": "Ceramic Mug",
"price": 24.00,
"category": "mugs",
"published": true,
"tags": ["handmade", "ceramic"]
}
Returns 404 if the type or record does not exist.
Using in collection queries
Flex Objects integrate with the standard collection system using the @flex source key. This lets any page render a list of flex records using exactly the same template patterns as page collections.
# In any page's frontmatter (e.g. content/products/default.md)
collection:
items:
"@flex": products
order:
by: date
dir: desc
limit: 12
In the theme template, access records through collection.items — each item exposes all user-defined fields through page.frontmatter.*:
// themes/default/templates/product-list.tsx
export default function ProductList({ page, collection }: TemplateProps) {
return (
<div class="product-grid">
{collection?.items.map((item) => (
<a key={item.frontmatter._id} href={`/flex/products/${item.frontmatter._id}`}>
<h2>{item.frontmatter.name}</h2>
<p>CHF {item.frontmatter.price}</p>
</a>
))}
</div>
);
}
The @flex source also supports the standard filter, order, limit, offset, and pagination modifiers. Flex records without an explicit published field are treated as published.
Public routes and theme templates
Every Flex Object type automatically gets two public-facing URLs that themes can style. No configuration required — they are active as soon as a schema file exists.
| URL | Purpose |
|---|---|
/flex/{type} |
List all records of a type |
/flex/{type}/{id} |
Show a single record |
Theme templates
Place TSX template files in your theme's templates/flex/ directory. The routing layer looks for type-specific templates first, then falls back to generic ones:
List view (/flex/products looks for, in order):
themes/{theme}/templates/flex/products-list.tsxthemes/{theme}/templates/flex/list.tsx- Built-in auto-generated table (always works, no template needed)
Detail view (/flex/products/{id} looks for, in order):
themes/{theme}/templates/flex/products.tsxthemes/{theme}/templates/flex/detail.tsx- Built-in auto-generated key/value page
Template props
List templates receive FlexListTemplateProps:
// themes/default/templates/flex/products-list.tsx
import type { FlexListTemplateProps } from "@dune/routing";
export default function ProductList({
type, // "products"
schema, // FlexSchema — title, icon, fields definition
records, // FlexRecord[] — all records, newest first
site,
config,
nav,
Layout,
t,
}: FlexListTemplateProps) {
return (
<Layout page={null} site={site} nav={nav} pageTitle={schema.title}>
<h1>{schema.icon} {schema.title}</h1>
{records.map((r) => (
<div key={r._id}>
<a href={`/flex/${type}/${r._id}`}>{String(r.name ?? r._id)}</a>
<span>CHF {String(r.price ?? "")}</span>
</div>
))}
</Layout>
);
}
Detail templates receive FlexDetailTemplateProps:
// themes/default/templates/flex/products.tsx
import type { FlexDetailTemplateProps } from "@dune/routing";
export default function ProductDetail({
type,
schema,
record, // FlexRecord — the single record
site,
nav,
Layout,
}: FlexDetailTemplateProps) {
return (
<Layout page={null} site={site} nav={nav} pageTitle={String(record.name ?? record._id)}>
<h1>{String(record.name)}</h1>
<p>{String(record.description ?? "")}</p>
<strong>CHF {String(record.price)}</strong>
</Layout>
);
}
Both prop types also include pathname (current URL path) and t (locale translation function).
Record format on disk
Each record is a YAML file named {id}.yaml. The _id, _createdAt, and _updatedAt fields are managed automatically — do not edit them by hand.
# flex-objects/products/a3f2c19d0e8b.yaml
_id: a3f2c19d0e8b
_createdAt: 1741234567890
_updatedAt: 1741234901234
category: mugs
description: A hand-thrown ceramic mug in matte white glaze.
name: Ceramic Mug
price: 24
published: true
tags:
- handmade
- ceramic
User-defined fields are stored alphabetically after the meta fields. The _type field is not stored — it is derived from the directory name at read time.
Example use cases
Product catalogue — fields: name, price, sku, category, published. Use @flex: products in a collection to render a paginated product listing page, and /flex/products/{id} for detail pages.
Team members — fields: name, role, bio, photo, linkedin. Add @flex: team to the About page frontmatter to embed team members inline, or create a dedicated team page template.
Events — fields: title, date, location, description, capacity, tickets_url. Order by date: asc in the collection definition to list upcoming events chronologically.
FAQs — fields: question, answer (markdown type), category. Query all FAQs with @flex: faq in a page collection, then group by category in the template.
Schema migrations
When a Flex Object schema evolves — a field is renamed, a type changes, or a required field is added — existing records on disk no longer match the new schema. Dune's migration system handles this without data loss.
Versioning your schema
Add a version integer to the schema file. Increment it whenever you make a breaking change:
# flex-objects/products.yaml
title: Products
version: 2 # ← increment this when making breaking changes
fields:
name:
type: text
required: true
category: # renamed from "tag" in v1
type: select
options:
mugs: Mugs
prints: Prints
Writing a migration
Create a TypeScript file in migrations/{type}/ named NNN-description.ts (zero-padded number, kebab description). Export a default object with from, to, and up:
// migrations/products/001-tag-to-category.ts
import type { FlexMigration } from "@dune/core";
export default {
from: 1,
to: 2,
up(record: Record<string, unknown>): Record<string, unknown> {
const { tag, ...rest } = record;
return { ...rest, category: tag ?? "mugs" };
},
} satisfies FlexMigration;
Dune applies all migrations whose from version is ≥ the record's _schemaVersion and ≤ the schema's version, in order.
How migration runs
Lazy (automatic): When a record is read (via the admin UI, API, or collection query) and its _schemaVersion is lower than the current schema version, Dune applies the pending migrations and writes the updated record back to disk. The migrated record is returned. No restart required.
Eager (CLI): Run dune migrate:flex to migrate all records of all types up front — useful before a deploy or after a bulk schema change:
dune migrate:flex # migrate all types
dune migrate:flex products # migrate one type
dune migrate:flex products --dry-run # preview without writing
The command prints a summary:
products: 48 records — 12 migrated (v1 → v2), 36 already current
team: 7 records — 0 migrated, 7 already current
Schema version on disk
Migrated records get a _schemaVersion field written alongside the standard meta fields:
_id: a3f2c19d0e8b
_schemaVersion: 2
_createdAt: 1741234567890
_updatedAt: 1741998234100
name: Ceramic Mug
category: mugs
Records written before versioning was introduced have no _schemaVersion and are treated as version 0. Migration from: 0 handles the initial transition.
Filtering and sorting
The /api/flex/{type} REST endpoint returns all records in creation order (newest first). For collection queries (@flex), the standard order and filter modifiers apply.
Filtering by specific field values (e.g. only published products, only events in a given category) is done in your template after loading the collection items, using the collection.filter() chainable method or plain JavaScript array methods. For complex filtering needs on large datasets, consider storing the data as pages instead to take advantage of the full taxonomy and frontmatter filter system.