Working with Records

Records let you manage repeating content — blog posts, team members, products, case studies, bibliographic references — as a set of files (markdown, YAML, JSON, or BibTeX). You write each one in its own file (or, for BibTeX, drop in the file your reference manager already exports) in your site's records/ folder, and the framework delivers them to your components as structured data.

This guide covers everything you need to know as a content author. No coding required.


What Records Are

Most of your content lives in pages/ — one folder per page, with markdown files for each section. That's your site's static content: a fixed composition of sections on a fixed set of pages.

Records are different. They're a small database. The framework loads them through the data-fetching pipeline and delivers them to components as content.data. The same pipeline handles remote APIs, so from a component's point of view a locally-authored record and a backend-served one look identical. Whether the data lives in files or behind an endpoint is a transport concern.

Three separate things, and keeping them separate is what makes the rest simple:

site/
├── pages/               ← your site's pages
│   ├── home/
│   ├── about/
│   └── blog/
├── records/             ← 1. YOUR RECORDS — every file in a schema folder is one
│   ├── article/
│   │   ├── getting-started.md
│   │   ├── design-tips.md
│   │   └── our-roadmap.md
│   ├── person/
│   │   ├── alice.md
│   │   ├── bob.md
│   │   └── carol.md
│   └── folder.yml       ← 3. OPTIONAL — folders a query can read one of
├── queries.yml          ← 2. HOW THEY ARE REACHED
└── site.yml

1. records/{schema}/ — your records. Putting a file here is what makes it a record; nothing else lists it. The folder it sits in names its data schema:

on diskschema
records/article/…@/article — your foundation's own
records/std/person/…@std/person — the shared standard set
records/acme/project/…@acme/project — an organization's

Keep those folders flat. records/article/design-tips.md works; records/article/2025/design-tips.md is read as the 2025 schema of an article organization, which is not what you meant. A file whose name starts with _ is not a record — somewhere to keep work in progress.

2. queries.yml — how content is reached. A page never walks the records; it asks a named query for a set of them. A query names a schema, and the site's records of that schema are its rows:

# queries.yml
recent:
  schema: '@/article'
  sort: date desc
  limit: 10

team:
  schema: '@/person'
  sort: order asc

3. records/folder.yml — folders, when a query needs a slice. Most sites have none. It sits in records/ beside the schema folders, the one file there that is not a record, and sorts records into folders so a query can read one of them — see Organizing Records into Folders.

The file getting-started.md becomes the article "Getting Started." The file alice.md becomes the team member "Alice." Neither reaches a page until a query asks for it.

With a backend, uniweb push sends every record in records/, and they are served once the site is published. A site with no records/ folder leaves the backend's records alone; an empty records/ folder removes them — the CLI asks before it does that.


Choosing a File Format

A record can be markdown, YAML, JSON, or BibTeX. Pick the format that matches the content:

FormatBest forWhy
Markdown (.md)Items with prose — articles, blog posts, case studiesFrontmatter holds structured fields (title, date, tags); the body below holds text the author edits in a familiar writing environment
YAML (.yml / .yaml)Structured records with no prose body — team members, products, datasetsCleaner than squeezing structured data into a markdown frontmatter with an empty body
JSON (.json)Same as YAMLPick it if the data is exported from another tool or you prefer JSON syntax
BibTeX (.bib)Bibliographic references — academic citations, reading lists, archival metadataDrop the file your reference manager already exports; each @entry{key, ...} becomes one record, with the cite key as the lookup id

All four produce the same shape at runtime — the foundation components see records, not files.

One file per record, or one file with many

For the three pure-data formats (YAML, JSON, BibTeX), the same authoring choice applies:

  • One record per file — write a single mapping at the top of the file. The file's name is the record's name: team/alice.yml is alice, and so is its URL on a [slug] page. To rename a record, rename its file — a slug: key inside it stops the build, and so does a file holding a list of records.
  • BibTeX — a .bib file holds many entries, each named by its cite key. This is the typical pattern for an exported bibliography (a Zotero .bib).

Within a single schema folder you can mix formats, and the framework merges everything into one list. So a bibliography/ folder can hold an exported refs.bib next to a hand-written extras.yml; a team/ folder can hold a YAML file per member alongside an alice.md markdown bio for someone who needs a long prose introduction.

Format-specific niceties: markdown items get auto-generated excerpts and first-image extraction. BibTeX entries are normalized to CSL-JSON fields (author, title, issued, container-title, DOI, …); LaTeX accents (\"u, \'e) are converted to Unicode automatically.


Organizing Records into Folders

Most sites need none of this. The records are usually flat, and queries do the organizing — a sort:, a where:, a limit: is how you show one slice of them.

Folders exist for one thing: so a query can ask for a slice of the site's records rather than all of them. They are declared in records/folder.yml, not as directories. Every record sits at the top unless folder.yml places it in a folder, so a record that belongs at the top needs no line:

# records/folder.yml — news/announcement.md sits at the top
- folder: 2024
  label: 2024                   # a folder's display text
  records:
    - news/spring.md            # in 2024
    - folder: q1
      records:
        - news/report.md        # in 2024/q1

- folder: 2023
  records:
    - news/retrospective.md     # in 2023

They all stay records of the site — a query over @/news still reaches all of them. Folders do not split anything, and a folder is never part of a record's URL.

A record's entry can also say something about the record: write it as { path, tags, label } rather than a path.

- folder: 2024
  records:
    - path: news/spring.md
      tags: [featured]          # the entry's own labels, in the order written
      label: Spring update      # its display text

A component receives them beside the record as $tags and $label, and a query can ask for them: where: { $tags: featured }. A record that sits at the top can be listed at the top level for this, and only for this.

On a site in several languages, a label — a record's or a folder's — can give one text per language, and each page shows the one for its language:

    - path: news/spring.md
      label: { en: Spring update, fr: Nouvelles du printemps }

To ask for one branch, give the query a scope: — it belongs to the query, so a fetch: that names the query can't carry one:

queries:
  news2024:
    schema: '@/news'
    scope: '2024'                      # 2024 and everything inside it
fetch:
  query: news
  where: { path: '2024' }              # only records directly in 2024

See Predicates for scope: and the rest of the query language.

Three things to know.

  • The question is never "does this want sub-pages?" It is "will a query ever ask for a slice?" If none will, do not make the folder.
  • A record belongs to one folder. Listing the same file twice is an error, and the build names both entries. If you want a computed subset — "everything from this year", "the five most recent" — that is a query, not a second placement.
  • Slugs must stay unique within a schema. A slug is what a record's page matches on, so two records with the same slug — a repeated slug: field, or the same entry in two data files — both claim /news/notes, and only one can have it. The build warns and names both; rename one.

When to Use Records

Records and items-in-a-section can both show repeating content. Here's how to choose:

Items in a sectionRecords
Where content livesAll in one .md fileEach item in its own file
Best forA few items that belong together (3–6 features, a short FAQ)Many items that grow over time (blog posts, team members)
Individual pagesNoYes — each item can have its own URL
Sorting and filteringNoYes — by date, tags, or any field
ExampleFeature cards on a landing pageBlog articles with their own pages

Rule of thumb: If you'll keep adding items over weeks and months, use records. If it's a fixed set that belongs to one section, use items in a single markdown file.


Writing a Record

A markdown item has two parts: frontmatter (the metadata at the top) and body content (the text below). A YAML or JSON item is a single structured record — no frontmatter/body split, just the fields.

The Frontmatter

Frontmatter is the block between --- lines at the top of the file. It holds structured information about the item — things like the title, date, and tags.

FieldWhat it doesExample
titleThe item's display nametitle: Getting Started
dateWhen it was publisheddate: 2025-03-15
tagsCategories or labelstags: [tutorial, beginner]
imageA thumbnail or hero imageimage: ./hero.jpg
descriptionA short summarydescription: Learn the basics
draftKeep it off the live site (default: no)draft: true
authorWho wrote itauthor: Sarah Chen

You can add any other fields you need — price, role, location, order — whatever makes sense for your content. The site will pass them through.

The Body Content

Below the frontmatter, write standard markdown — the same kind you use for page sections. Headings, paragraphs, images, links, lists — it all works.

A Complete Example

Here's a blog article:

---
title: Design Tips for Small Teams
date: 2025-06-10
author: Alice Park
tags: [design, teams]
image: ./design-tips-cover.jpg
description: Practical advice for teams without a dedicated designer.
---

# Design Tips for Small Teams

You don't need a full design team to ship something that looks good.

## Start with Constraints

Pick one font, two colors, and a consistent spacing scale. Constraints make decisions easier.

## Borrow Patterns, Not Pixels

Look at sites you admire. Notice how they handle navigation, cards, and whitespace — then adapt those patterns to your own content.

![Spacing example](./spacing-diagram.svg)

## Ship and Iterate

Don't wait for perfection. Ship something simple, get feedback, and improve.

And here's a team member, first as markdown:

---
title: Alice Park
role: Lead Designer
image: ./alice.jpg
order: 1
---

Alice leads the design team. She specializes in design systems and accessibility.

Previously at Figma and Google. Speaker at Config and SmashingConf.

For a team member, the bio is short and there's no long-form prose — YAML is often cleaner:

# records/person/alice-park.yml
title: Alice Park
role: Lead Designer
image: ./alice.jpg
order: 1
bio: Alice leads the design team. She specializes in design systems and accessibility. Previously at Figma and Google. Speaker at Config and SmashingConf.

Notice the differences: the article has date, tags, and author; the team member has role and order. Each schema uses whatever fields make sense for its content.


Declaring Queries

queries.yml — how pages ask for records

A query names a schema, and the site's records of that schema are its rows:

recent:
  schema: '@/article'

If the query's name matches the schema's, you can leave the schema out — articles: alone means @/articles. You can also write the whole map under queries: in site.yml instead of a separate file, if you prefer one file.

With options

recent:
  schema: '@/article'
  sort: date desc
  where:
    tags: featured
  limit: 100
OptionWhat it doesExample
schemaWhich records this query is over'@/article', '@std/person'
sortOrder records by a fielddate desc (newest first)
whereInclude only matching records (predicate){ tags: featured }
limitHow many records the query selects — the first N in its sort. Only those get pages100
scopeOne folder of records, and everything inside itarchive
excerptHow a markdown record's summary is made{ maxLength: 200 }
queryableFields a foundation can offer for filtering UI (see below)(object)
urlA public JSON endpoint instead of the site's recordshttps://api.example.com/articles

Every key, in full: Queries.

Sorting: Add asc (A→Z, oldest first) or desc (Z→A, newest first) after the field name. For example, sort: date desc shows newest articles first. sort: title asc sorts alphabetically.

Filtering: Narrow with a where: predicate. Common shapes:

Goalwhere:
Tagged "featured"{ tags: featured }
Written by one author{ author: 'Sarah Chen' }
From 2025 onward{ date: { gte: '2025-01-01' } }

To read one folder of records and everything inside it, the query takes a scope: archive beside its where:, not a predicate.

See Predicates for the full operator reference.

Several queries, and several over one schema

A site can have as many queries as it needs — and more than one over the same records, which is the usual way to show the same set two ways:

recent:
  schema: '@/article'
  sort: date desc
  limit: 5

everything:
  schema: '@/article'
  sort: date desc

team:
  schema: '@/person'
  sort: order asc

Lean lists

A list carries each record's brief — the card: its title, date, excerpt. When a record's schema keeps its heavy parts in a section of their own — @std/article keeps its body in body — every list leaves them out, on every site, and a page that shows one article in full gets it whole. Nothing to configure. A schema that keeps a heavy field in its brief — a fields: schema, which is all brief — ships it in every list; the developer moves it to a section of its own to leave it out.

External queries. The above describes the site's own records — the build writes each one's own file at /data/<name>/<name of the record>.json automatically. An external query — url: instead of a schema: — reads records the site doesn't hold. When its list carries less than a whole record, name the request for one with record::

queries:
  articles:
    url: https://api.example.com/articles             # an external query
    where: { slug: :slug }                             # a [slug] page names each article by its slug
    record:
      url: https://api.example.com/articles/{slug}    # how to fetch one full record

where: { slug: :slug } says which field the page's URL names — the site's own records are named by their files, and an API's are named by nothing until the query says so. Both the [slug] page and useWholeRecord use record: when it's set. See Data Fetching → External queries.

Removed: detailUrl: — its case is record: { url }.

Filterable surfaces with queryable:

For sites where readers compose their own filtered views — a department dropdown, a "show only featured" toggle, a date-range slider — you declare which fields are filterable, with their type and any type-specific metadata:

queries:
  members:
    schema: '@/member'
    queryable:
      department:
        type: enum
        label: Department
        options: [biology, physics, chemistry, geology]
      tenured:
        type: boolean
        label: Tenured
      start_year:
        type: range
        label: Start year
        min: 1800
        max: 2025

You declare the surface — what the foundation can offer. The foundation reads the metadata and renders matching controls (dropdown, toggle, slider). When the reader picks values, the foundation composes a predicate and fetches the matching records. No extra wiring on your side.

See Predicates for the full pattern, including saved views.


Displaying Records on Pages

Once a query is declared, you can show its records on any page.

The query: shorthand

The simplest way is the query: line in page.yml:

# pages/blog/page.yml
title: Blog
query: articles

This tells the page to run the articles query. The page's section types then display those articles — as a grid of cards, a list, or however the site's design presents them.

Showing a few items on another page

Want to show the latest three articles on your homepage? Use fetch: in a section's frontmatter:

---
type: ArticleTeaser
fetch:
  query: articles
  limit: 3
  sort: date desc
---

# Latest from the Blog

This pulls just three articles, sorted newest first, for a teaser section. The full blog page still shows everything.

A fetch: like this narrows the query for one section: its where: keeps the records that also match, its sort: puts them in another order, and its limit: takes the first few. It can never add a record the query leaves out — a limit: larger than the query's still shows only what the query selects — and it cannot change the query's scope:: for another folder branch, declare another query.


Individual Pages for Records

Records become even more useful when each one gets its own page — like /blog/design-tips for a blog article or /team/alice for a team member.

The [slug] folder

A folder with square brackets in its name is a parametric page — one page with a URL for each record (other tools call these dynamic routes). Create it inside the page that names the query:

pages/
└── blog/
    ├── page.yml          ← The blog page: names the query
    ├── list.md
    └── [slug]/           ← Creates a page for each article
        ├── page.yml
        └── article.md
# pages/blog/page.yml
title: Blog
query: articles

The [slug] folder tells the site: "For each record the query returns, create a page." The article at records/article/design-tips.md becomes the page /blog/design-tips. The one at records/article/getting-started.md becomes /blog/getting-started.

The query decides which records get a page. A query with limit: 100 gives the 100 records it selects a page each, and no other; a where: on the query does the same. A limit: or where: on a section's fetch: only changes what that section shows.

The section inside [slug]/ receives the individual item's content automatically. You don't need to do anything special in the markdown file — just set the section type:

<!-- pages/blog/[slug]/article.md -->
---
type: Article
---

These generated pages don't appear in navigation menus. They're meant to be reached through the list or direct links — and every list of the query links each record to its page, wherever the list appears.

More from the same query, beside a record

A record's page often shows a few of its siblings — "more articles", "related posts". Add a section to the [slug] folder that names the same query and leaves out the record the page is about:

<!-- pages/blog/[slug]/more.md -->
---
type: ArticleTeaser
fetch:
  query: articles
  current: exclude   # every article except this page's
  limit: 3
---

# More from the blog

current: exclude works on a section inside a [slug] folder. current: include shows all of them, this one included — for a previous / next pager.

For the full blog recipe with step-by-step setup, see Recipes. For everything about these pages — nested folders, [...path], what the URL matches — see Parametric Pages.


Drafts

To keep a record off your live site without deleting it, set draft: true in its frontmatter:

---
title: Upcoming Feature Announcement
date: 2025-07-01
draft: true
---

This article won't appear on the site until it's no longer a draft.

A draft is still a record — it stays in records/, and pnpm dev shows it, so you can preview it where it will appear. It's left out of what the site delivers: a build for hosting leaves it out of every query. Records without draft: are delivered.

This is useful for:

  • Drafts you're still writing
  • Scheduled content you've prepared ahead of time
  • Archived items you want to keep but take off the site

When it's ready, remove the line (or set draft: false).

To keep a file out of the site's records altogether — work that isn't a record yet — start its name with _: _new-idea.md is never read.


Keeping Images with Your Content

You can store images and other files right next to your markdown files. This keeps everything for one item in the same place.

records/article/
├── design-tips.md
├── design-tips-cover.jpg     ← Cover image for the article
├── spacing-diagram.svg       ← Diagram used in the article
├── getting-started.md
└── getting-started-hero.jpg

Reference these files with ./ in your markdown:

---
title: Design Tips for Small Teams
image: ./design-tips-cover.jpg
---

![Spacing example](./spacing-diagram.svg)

The ./ means "in the same folder as this file." The build processes these references automatically — you don't need to worry about where the files end up in the final site.

Tip: Name your images to match the markdown file they belong to. design-tips-cover.jpg clearly belongs to design-tips.md. This keeps things organized as your records grow.


Beyond Blogs

Records work for any repeating content, not just articles. Here are a few common patterns.

Team directory

records/person/
├── alice-park.md
├── bob-silva.md
└── carol-wu.md
---
title: Alice Park
role: Lead Designer
image: ./alice-park.jpg
order: 1
---

Alice leads the design team with a focus on accessibility and design systems.
# site.yml
queries:
  team:
    schema: '@/person'
    sort: order asc

Product catalog

records/product/
├── starter-plan.md
├── pro-plan.md
└── enterprise-plan.md
---
title: Pro Plan
price: $49/month
features: [Unlimited projects, Priority support, Custom domains]
image: ./pro-plan-icon.svg
order: 2
---

Everything you need to grow. Includes all Starter features plus priority support and custom domain mapping.
# site.yml
queries:
  products:
    schema: '@/product'
    sort: order asc

Case studies

records/case/
├── acme-corp.md
├── globex.md
└── initech.md
---
title: Acme Corp
industry: Manufacturing
image: ./acme-logo.svg
date: 2025-04-20
tags: [enterprise, manufacturing]
---

## The Challenge

Acme needed to consolidate 12 regional websites into a single platform.

## The Solution

We built a multilingual site with dynamic routing for each region.

## Results

- 60% reduction in maintenance costs
- 3x faster content updates
# site.yml
queries:
  cases:
    schema: '@/case'
    sort: date desc

Excerpts

When records are displayed as a list — blog cards, product summaries, search results — each item needs a short preview. These are called excerpts.

Automatic excerpts: If you don't do anything special, the site generates an excerpt from the first ~160 characters of your content body. This works fine in most cases.

Explicit description: For more control, add a description field to your frontmatter:

---
title: Design Tips for Small Teams
description: Practical advice for teams without a dedicated designer — constraints, borrowed patterns, and the art of shipping early.
---

When a description is present, it's used as the excerpt instead of the auto-generated one. This lets you write a polished summary rather than relying on whatever your first paragraph happens to say.

You can also configure excerpt behavior in site.yml:

queries:
  articles:
    schema: '@/article'
    sort: date desc
    excerpt:
      maxLength: 200          # Characters (default: 160)
      field: description      # Prefer this frontmatter field

Tips

  • Start with two or three items. You can always add more later. Starting small lets you settle on the right frontmatter fields before writing dozens of files.

  • Filenames become URLs. The file design-tips.md creates the slug design-tips, which becomes part of the URL (/blog/design-tips). Use lowercase, hyphen-separated names.

  • Keep schema folders flat. Put every file directly in its schema folder. records/article/design-tips.md works; records/article/2025/design-tips.md is read as the 2025 schema of an article organization, which is not what you meant. Group in records/folder.yml instead.

  • Items vs. records — a rule of thumb. If you're writing content that fits naturally in one section (a few feature cards, a short FAQ), use items in a single markdown file. If the content is a growing catalog (blog posts, team members, products), use records.

  • Use consistent frontmatter. If your blog articles use date, author, and tags, add those fields to every article — even if some are optional. Consistency makes your content predictable and easier to maintain.

  • Preview with pnpm dev. Records update automatically during development: add or edit a file in records/, or change records/folder.yml or queries.yml, and the site refreshes.


Quick Reference

What you want to doHow to do it
Add recordsPut files in records/<schema>/ — every file there is a record
Keep a file outStart its name with _
Organize them into folders- folder: archive in records/folder.yml (optional)
Reach themAdd a query to queries.yml — recent: { schema: '@/article' }
Sort themsort: date desc or sort: title asc on the query
Filter themwhere: { tags: featured } on the query
Show on a pagequery: articles in page.yml
Show a subsetfetch: { query: articles, limit: 3 } in section frontmatter
Give each record a pageAdd a [slug]/ folder inside the page that names the query
Show more beside a recordfetch: { query: articles, current: exclude, limit: 3 } in a section of the [slug]/ folder
Keep a record off the live sitedraft: true in its frontmatter
Add an imageStore next to the .md file, reference with ./
Write an excerptAdd description: to item frontmatter

What's Next?

For technical details, see Records and Queries.