---
title: Build an agenda
description: Store dated events in a collection and split them into upcoming and past, in chronological order, without writing a line of date logic in the template.
updated: 2026-07-25
---

An agenda is a collection whose entries carry a date, queried twice: once for what is coming, once for what is done. The split happens in the query, against server time.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Le Cinquième.</b> Every snippet on this page comes from <b>Le Cinquième</b>, the demo site of a 120-seat neighbourhood venue. Its <code>evenement</code> collection drives a single <code>agenda</code> block reused on the home page and the programme page.</div></div>

## 1. Use `calendar`, not `date`

The two are not interchangeable, and the choice decides what you can query.

| Field type | Stores | Use for |
|---|---|---|
| `date` | a plain day | a publication date, a release date, anything you only sort on |
| `calendar` | a date **and a time**, exposed as occurrences | anything you compare against "now": events, sessions, opening slots |

A `calendar` field exposes its occurrences to queries as `_occurrence.start`, which is what makes the upcoming/past split possible. A `date` field cannot do that.

```yaml
evenement:
  name: Events
  objectName: Event
  icon: ticket
  tabsets:
    main:
      name: Show
      fields:
        title:
          type: text
          label: Title
          searchable: true
        slug:
          type: slug
          label: Slug
          config:
            linkedField: title
        date:
          type: calendar
          label: Date & time
          required: true
        discipline:
          type: select
          label: Discipline
          config:
            options:
              theatre: Theatre
              concert: Concert
              lecture: Reading
        featured:
          type: checkbox
          label: Featured
          config:
            checkedValue: oui
            uncheckedValue: non
  displays:
    - type: datatable
      config:
        cols: [poster, title, discipline, date]
        order:
          column: date
          sort: asc
  filters:
    - discipline
    - featured
```

Mark the date `required: true`. An event with no date falls out of both queries and vanishes from the site with no visible error.

## 2. Split upcoming from past in the query

Two queries, same collection, opposite comparison against `{{now.datetime}}`, opposite sort. Upcoming reads soonest-first, past reads most-recent-first.

```yaml
        upcoming:
          type: query
          label: (auto) Upcoming
          config:
            itemSet: evenement
            limit: 100
            where:
              and:
                - '>=':
                    '_occurrence.start': '{{now.datetime}}'
            sort:
              - '_occurrence.start': asc
        past:
          type: query
          label: (auto) Past
          config:
            itemSet: evenement
            limit: 100
            where:
              and:
                - '<':
                    '_occurrence.start': '{{now.datetime}}'
            sort:
              - '_occurrence.start': desc
```

`{{now.datetime}}` is resolved server-side at render time, so the agenda rolls over on its own. Nothing expires by hand, and there is no scheduled job to run.

<div class="q-callout q-callout-tip"><i class="fa-solid fa-lightbulb lead"></i>
  <div><b>Label auto-filled queries as such.</b> Le Cinquième prefixes them <code>(auto)</code> so an editor opening the block sees at a glance which fields they fill and which the engine fills.</div></div>

## 3. Give the editor one block, not three

Rather than three content-types, declare one with a `mode` select and let the template pick which query to read. The same block then serves "what's on" on the home page and the full programme elsewhere.

```yaml
agenda:
  name: Agenda (events)
  icon: calendar
  template: elements/agenda.liquid
  tabsets:
    main:
      name: Content
      fields:
        title:
          type: text
          label: Title
        mode:
          type: select
          label: Which events?
          default: avenir
          config:
            options:
              avenir: Upcoming
              passes: Past
              alaune: Featured
        maxItems:
          type: number
          label: "Max count (0 = all)"
```

Add the three `query` fields from the previous step to the same tabset. All three resolve on every render; the template consumes one.

## 4. Render it

```liquid
{% assign c = content.custom %}
{% assign mode = c.mode %}{% if mode == blank %}{% assign mode = 'avenir' %}{% endif %}

{% if mode == 'avenir' %}{% assign list = c.upcoming %}
{% elsif mode == 'passes' %}{% assign list = c.past %}
{% else %}{% assign list = c.featured %}{% endif %}

{% assign lim = c.maxItems | times: 1 %}

{% if list != blank %}
  <div class="grid">
    {% for ev in list %}
      {% if lim == 0 or forloop.index <= lim %}
        {% include '_eventCard', ev: ev, past: isPast %}
      {% endif %}
    {% endfor %}
  </div>
{% else %}
  <p>{{ "Nothing coming up right now. Check back soon." | _ }}</p>
{% endif %}
```

Two details worth copying. `c.maxItems | times: 1` coerces the number field to an integer so the comparison is numeric. And an empty agenda gets a real sentence: for a venue between seasons, that is the normal state of the page, not an error.

## 5. Show the date in the reader's language

```liquid
<time datetime="{{ ev.date }}">{{ ev.date | date: 'D MMMM YYYY' }}</time>
```

The `date` filter formats according to the current locale, so the same template prints `12 mars 2027` and `12 March 2027` with no branching.

## Pitfalls

- **`limit` in the query is not the editor's cap.** The query's `limit` bounds how much data is fetched (100 here); the block's `maxItems` field is an editorial choice applied while rendering. Keep both.
- **Filtering on `now` in the template does not work.** You would have to fetch everything and discard most of it, and pagination would be meaningless. The comparison belongs in the query.
- **A `date` field cannot be split this way.** If you started with `date` and now need an upcoming/past split, change the field type to `calendar` before writing template logic around it.

## Serve the agenda as a JSON feed

A template can answer with something other than HTML, and it takes one key. A page-type declares its response `headers`, and the template writes the body:

```yaml
agendaFeed:
  name: Feed agenda (JSON)
  template: 'agendaFeed.liquid'
  headers:
    content-type: 'application/json; charset=utf-8'
  sections:
    main:
  tabsets:
    config:
      name: Source
      fields:
        upcoming:
          type: query
          label: (auto) Upcoming
          config:
            itemSet: evenement
            limit: 100
            where:
              and:
                - '>=':
                    '_occurrence.start': '{{now.datetime}}'
            sort:
              - '_occurrence.start': asc
```

Then one hidden page on that type — `state: active`, `visible: false` — and a template with **no `{% layout %}`**: a single line of chrome would break the document.

```liquid
{%- assign origine = 'https://' | append: request.host -%}
{
  "site": {{ globals.siteName | default: '' | json }},
  "generated": {{ now.datetime | default: '' | json }},
  "count": {{ page.custom.upcoming.size | default: 0 }},
  "events": [
{%- for ev in page.custom.upcoming %}
    {
      "title": {{ ev.title | default: '' | json }},
      "url": {{ 'evenement' | url: evenement: ev | prepend: origine | json }},
      "start": {{ ev.date.start | default: '' | json }},
      "end": {{ ev.date.end | default: '' | json }},
      "summary": {{ ev.excerpt | default: '' | json }}
    }{%- unless forloop.last -%},{%- endunless -%}
{%- endfor %}
  ]
}
```

Two rules hold the whole file together.

**Every value goes through `| json`.** The filter writes the quotes *and* escapes the content, so never type `"{{ ev.title }}"` by hand — one apostrophe in a title and the document is broken. Verified on Le Cinquième: a summary containing `s'invite` round-trips correctly.

**Every value that might be absent goes through `| default:` first.** This is the sharp edge. A missing value renders as *nothing at all*, so `"key": {{ absent | json }}` emits `"key": ,` — invalid JSON, served with a 200 and an `application/json` content type. Nothing warns you; the consumer's parser is what finally complains.

### Three constraints worth knowing before you start

**`sections` is required, even with no HTML.** A page-type without it fails validation. Declare one and let the template ignore it.

**A slug cannot contain a dot,** so the feed cannot live at `/agenda.json` — that request is rejected at page creation with a slug validation error. Ours is at `/agenda-feed`. The `content-type` header is what identifies the response, not an extension, and every consumer that matters reads the header. It does mean a URL that looks less like a feed than people expect.

**There is no site-origin variable.** `site.url` is empty, so an absolute URL is built from `request.host`, which is what the snippet above does. Feeds need absolute URLs — a relative one is meaningless the moment the document leaves the site.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>The feed is per-locale, like every other page.</b> On Le Cinquième, <code>/fr/agenda-feed</code> and <code>/en/agenda-feed</code> return the same events with their translated titles — "Piano sous la lune" and "Piano Under the Moon". Two feeds, one template, nothing to declare. Publish the one that matches the audience, or both.</div></div>

Everything here generalises: the same `headers` key serves an RSS feed, a sitemap, a `.ics` calendar or a CSV export. Only the body changes.

## Related

<div class="q-cards">
  <a class="q-card reco" href="/en/docs/custom-fields"><span class="k">Recommended</span><span class="t">Custom fields</span><span class="d"><code>calendar</code>, <code>query</code>, and the <code>where</code> operators.</span></a>
  <a class="q-card" href="/en/docs/blog"><span class="t">Build a blog</span><span class="d">Detail routes, listings and front-end filtering, in full.</span></a>
</div>
