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.