Collections
The one kind of output that is not a translation of some input.
A blog index exists because a set of posts exists, not because someone wrote index.org. A [[collections]] block declares one: a source directory in, an output file out, through a template.
Keeping it declarative means an RSS feed is the same mechanism with an XML template rather than a second feature.
A blog index
[[collections]]
source = "blog" # directory to list; empty means every page
output = "blog/index.html" # where to write it
template = "list.html" # template file name
title = "Blog"
sort = "date" # date | title | path
order = "desc" # desc | asc
nav = true # put this page in the site nav
The template receives the collection's entries as pages, already sorted, plus the usual site, nav and root:
{% extends "base.html" %}
{% block content %}
{% for post in pages %}
{{ post.date_iso }}
{{ post.title }}
{{ post.excerpt | truncate(180) }}
{% endfor %}
{% endblock %}
Sorting
sort is date (default), title or path; order is desc (default) or asc.
Date sorting uses page.date_iso, the YYYY-MM-DD extracted from #+DATE: whatever org syntax it was written in — [2025-09-05 Fri 10:21:00], <2024-05-01 Wed> or a bare 2024-05-01 all work.
Pages with no parseable date sort last in either direction, so an undated draft never leads a dated archive.
Grouping a listing by year
An archive usually wants year headings, and that is a template decision rather than a config one — the entries are already in the right order, they just need breaking up. page.year exists for exactly this, because minijinja's groupby takes an attribute name and cannot slice a date itself:
{% for year, posts in pages | groupby("year") | reverse %}
{{ year if year else "undated" }}
{% for entry in posts %}
{{ entry.date_iso }}
{{ entry.title }}
{% endfor %}
{% endfor %}
groupby sorts its groups ascending, so | reverse puts the newest year first — matching the order = "desc" the entries themselves already use, and leaving undated pages in a group of their own at the end.
Name that group in the template rather than with groupby's default= argument, which covers an attribute that is missing and not one that is null — an undated page has a year, and it is none.
Full-content feeds
A feed usually carries whole posts, and a subscriber handed excerpts instead has lost something. include_content gives the template each entry's rendered HTML as entry.content:
[[collections]]
source = "blog"
output = "feed.xml"
template = "feed.xml"
include_content = true
Off by default, because it costs a render of every listed page each time the listing is rebuilt. That cost is only paid when the listing is not cached, and the listing's cache key covers its entries' content — so a body edit reaches the feed, and an unchanged site pays nothing.
Everywhere else entry.content is none, since carrying every page's body in every listing context would be most of a site's memory for nothing.
nav = true
The listing page joins the site navigation. This is how a section landing page — /blog/, /notes/ — gets into a nav built from top-level pages, and it points at the right thing: the section, not any one post in it.
Tag pages
Add group_by and the collection emits one page per group instead of one page total, plus an optional index of the groups:
[[collections]]
source = "blog"
group_by = "tags" # "tags", or any #+KEYWORD: name
output = "tags/{tag}.html" # {tag} becomes each group's slug
template = "tag.html"
title = "Tagged: {tag}"
index_output = "tags/index.html"
index_template = "tags.html"
index_title = "Tags"
nav = true # adds the *index*, not every tag
A group page receives its own posts as pages and itself as group:
{{ group.name }} ({{ group.count }})
{% for post in pages %}{{ post.title }}{% endfor %}
The index receives groups, sorted by name:
{% for tag in groups %}
{{ tag.name }} ({{ tag.count }})
{% endfor %}
Grouping by anything
group_by = "tags" is multi-valued: a post appears under every tag it carries. Any other value names a single-valued #+KEYWORD:, so group_by = "category" buckets pages by #+CATEGORY: with no extra machinery.
Two tags that would collide are an error
web_dev and web@dev both slugify to web-dev, so one page would silently overwrite the other. That is a build error naming both values.
Pagination
[[collections]]
source = "blog"
output = "blog/index.html"
paginate = 10
paginate_output = "blog/page/{n}.html" # {n} is the 1-based page number
Page 1 stays at output, so a section's canonical URL never moves as its page count changes. Only pages 2..N are named by paginate_output.
The template gets a paginator:
{% if paginator and paginator.total > 1 %}
{% if paginator.prev_url %}Newer{% endif %}
{% for pg in paginator.pages %}
{{ pg.number }}
{% endfor %}
{% if paginator.next_url %}Older{% endif %}
{% endif %}
| Field | Meaning |
|---|---|
current, total | This page's number, and how many there are. |
per_page, total_entries | As configured, and across the whole listing. |
prev_url, next_url | none at the ends. |
first_url, last_url | Always present. |
pages | [{number, url, current}] for a numbered strip. |
Every URL is relative to the page carrying it, so links work from page 1 (page/2.html) and from page 5 (../index.html, 6.html) without the template knowing where it sits. An unpaginated collection has no paginator at all, so {% if paginator %} is a reliable test in a shared template.
Grouping and pagination compose: each group paginates independently, which is why paginate_output needs {tag} as well as {n} on a grouped collection.
An RSS feed
A feed is a listing page with an XML template. Templates load by full filename and any extension, so:
[[collections]]
source = "blog"
output = "feed.xml"
template = "feed.xml"
title = "Feed"
{{ site.title }}
{{ "index.html" | absolute }}
{% for post in pages %}
{{ post.title }}
{{ post.url | absolute }}
{{ post.url | absolute }}
{{ post.date_iso | rfc822 }}
{% endfor %}
This needs site.base_url, because a feed with relative links is invalid everywhere it is read. orgo init writes this template and leaves the collection commented out until there is a base URL to make absolute links from.
Every setting
| Key | Default | Meaning |
|---|---|---|
source | ="" | Directory to list. Empty means every page. |
output | "index.html" | Where to write. Needs {tag} when grouped. |
template | "list.html" | Template file name. |
title | "Index" | {{ page.title }}. {tag} is substituted when grouped. |
group_by | ="" | "tags", or a #+KEYWORD: name. Empty means one page. |
index_output | ="" | Where to write the group index. Empty means none. |
index_template | "tags.html" | Template for the group index. |
index_title | "Tags" | Title for the group index. |
sort | "date" | date, title or path. |
order | "desc" | desc or asc. |
paginate | 0 | Entries per page. 0 means no pagination. |
paginate_output | ="" | Where pages 2..N go. Needs {n}. |
nav | false | Add this page — or its index, when grouped — to the nav. |
Incremental behaviour
A listing page is cached on the entries it lists, so:
- Adding a post re-renders that post, its section index, its tag pages and the tag index whose counts changed. Nothing else.
- Editing a post's body changes no listing metadata, so the index is not touched at all.
- Retitling a post does re-render the listings that display the title.
A tag page depends on its own posts and not on the other groups, which is why groups is given to the index and not to every group page: a page that can see every group would depend on every group, and one new post would re-render every tag page.
When a collection shrinks below a page boundary, the pages that no longer exist are deleted rather than left serving stale content.