Building a blog with collections
A collection turns a directory of pages into a sorted, optionally paginated list β blog posts,
changelog entries, docs. Each direct subdirectory of your markup in can be a collection; every
file inside it (except index.*) becomes an item.
Declaring a collection
Option A β front matter on the directory's index file:
---
title: Blog
collection: true
paginate: 10
sort: date
---
collection: true uses the directory name; a string names it explicitly. Option B β config,
listing collections by name (must match a subdirectory of in):
{
"markup": {
"in": "src/markup",
"out": "dist",
"options": {
"collections": [
"changelog",
{ "name": "blog", "paginate": 5, "sort": { "by": "date", "order": "desc" } }
]
}
}
}
Writing a post
A post is a normal Markdown file with front matter:
---
layout: post
title: Hello world
date: 2026-07-09
description: My first post built with Poops.
tags: [poops, static-site]
published: true
---
Welcome to the blog.
Warning
Always set a real date in front matter. Undated posts fall back to the file's modification
time β meaningless on CI, where a fresh git clone resets mtimes, so posts would reshuffle
between deploys. A post with published: false is excluded and its page isn't built.
Listing posts
Every collection is a global named after it. Loop its items:
{% for post in blog.items %}
<article>
<h2><a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a></h2>
<time>{{ post.date | date("MMMM D, YYYY") }}</time>
<p>{{ post.description }}</p>
</article>
{% endfor %}
Each item carries its front matter plus url, title, date, wordcount, fileName,
filePath and collection.
Pagination
With paginate: N, the collection's index renders once per page: page 1 β blog/index.html,
page 2 β blog/2/index.html, and so on. Inside the index, the collection object carries page
state β pageItems, pageNumber, totalPages, pageUrl, nextPageUrl, prevPageUrl:
Set paginate: N on the collection index front matter (or the collection entry in markup.collections);
without it, there is only one page and the pagination globals stay at their single-page defaults.
{% for post in blog.pageItems %}
<h2><a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a></h2>
{% endfor %}
{% pagination blog %}
{% pagination blog %} works in both Nunjucks and Liquid.
Note
{% pagination blog %} is just a convenience tag. The generated globals
are always available, so you can render pagination manually when you need custom markup.
{% if blog.totalPages > 1 %}
<nav aria-label="Pagination">
{% if blog.prevPageUrl %}<a href="{{ relativePathPrefix }}{{ blog.prevPageUrl }}">Previous</a>{% endif %}
<span data-page="{{ blog.pageNumber }}" data-total-pages="{{ blog.totalPages }}">
Page {{ blog.pageNumber }} of {{ blog.totalPages }}
</span>
{% if blog.nextPageUrl %}<a href="{{ relativePathPrefix }}{{ blog.nextPageUrl }}">Next</a>{% endif %}
</nav>
{% endif %}
Pages 2..N automatically get a distinct <title> β Blog β Page 2 β so the paginated pages don't
all share the landing page's title (and its og/jsonld metadata). Page 1 keeps its own title.
Localizing the labels
The β Page N title suffix and the {% pagination %} tag's wording default to
English. Override them site-wide under site.pagination (the same term-page titles and breadcrumbs
localize too β see Tags & categories):
site:
pagination:
title: "{title} β Seite {n}" # {title}, {n}, {total} tokens; used on pages 2..N
prev: ZurΓΌck
next: Weiter
of: von # the "{n} von {total}" separator
Grouping posts by year
The groupby filter groups any array of objects by a field, with optional date-part extraction.
Groups keep insertion order, so sort descending and years come out newest-first:
{% for group in blog.items | groupby("date", "year") %}
<h2>{{ group.key }}</h2>
{% for post in group.items %}
<p><a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a></p>
{% endfor %}
{% endfor %}
Tags & categories (taxonomies)
Grouping lists terms on one page. A taxonomy goes further: it gives every term its own
paginated landing page β changelog/tag/feature/, changelog/category/release/ β crawlable and
shareable. Declare which front-matter fields become taxonomies on the collection, alongside
paginate/sort:
---
title: Changelog
collection: true
paginate: 10
taxonomies:
- name: tags # front-matter field to group on
path: tag # URL segment (defaults to name); use "tag" for a singular URL
paginate: 5 # per-term page size (defaults to the collection's paginate)
---
Shorthand: a bare string (taxonomies: [tags, category]) uses the field name as the URL segment
and inherits the collection's paginate. Array-valued fields split per element β a post with
tags: [js, css] lands under both tag/js/ and tag/css/. Terms are slugified for the URL
(Static Site β static-site).
Pages render with the collection's own index template β no extra file. On a term page the
collection object carries the term context; branch on activeTerm to render a term view:
{% if changelog.activeTerm %}
<h1>Tagged {{ changelog.activeTerm | humanize }}</h1>
{% for post in changelog.pageItems %}
<p><a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a></p>
{% endfor %}
{% pagination changelog %}
{% endif %}
On a term page items/pageItems are scoped to that term (so pagination and groupby narrow to
it too); activeTaxonomy holds the URL segment and activeTermSlug the slug. Build tag links
anywhere from collection.taxonomies:
{% for tax in changelog.taxonomies %}
{% for term in tax.terms %}
<a href="{{ relativePathPrefix }}{{ term.url }}">{{ term.term | humanize }} ({{ term.count }})</a>
{% endfor %}
{% endfor %}
Each term exposes term, slug, url, count and totalPages. The slugify and humanize
filters (inverses of each other) are handy for building and displaying terms. Each term page also
gets a distinct <title> and og/jsonld metadata β Tag: Feature (paged: Tag: Feature β Page 2) β instead of the shared landing title. The breadcrumb and jsonld filters resolve term pages
to a Home βΊ Collection βΊ Tag: Term trail automatically (the last crumb carries the same
taxonomy label, and the non-page tag/category URL segment is skipped), so nothing extra is
needed there.
Note
Term pages are treated like pagination pages: listed in the sitemap (crawlable) but kept out of the search index, llms.txt and nav, so those point at posts, not term listings.
Sorting
sort is a field shorthand ("sort": "title") or an object { "by": "field", "order": "asc" | "desc" }.
Sorting by date compares dates (default desc); any other field compares alphabetically
(default asc).
RSS / Atom feed
Point the feed option at the collection and Poops writes a subscription feed β no hand-authored
XML template. Items are the posts newest-first by date, channel metadata comes from your site
data:
{
"markup": {
"options": {
"feed": { "collection": "blog", "out": "blog/feed.rss" }
}
}
}
type: "atom" switches format, limit caps the item count (default 20), and omitting collection
emits a feed for every collection. Then advertise it in your layout <head>:
<link rel="alternate" type="application/rss+xml" href="{{ site.url }}/blog/feed.rss">
Full option table in the config reference.
Info
Collection index and pagination pages are included in the sitemap but excluded from the search index, so search results point at posts, not list pages.
Next: React components.