Skip to content

Feeds

@plumix/plugin-feeds is syndication: the XML a reader subscribes to and an aggregator polls. Installing it and configuring nothing serves RSS and Atom for the front page, for each entry type’s archive, for each taxonomy term, for each author and for each date period, and puts a <link rel="alternate"> in the head of every page that has one. A feed is its archive: it carries the entries the archive’s page lists, from the same query.

The plugin exports a factory named feeds. It takes no options, adds no database tables and ships no admin screens, so installing it is a package install and one array entry.

Feeds are not a search-engine concern, which is why they are their own package rather than part of @plumix/plugin-seo. A crawler does not need them; a reader does.

Nothing serves /feed until you install it. That is the boundary Plumix draws between core and a plugin: core owns what would be wrong without a plugin installed, and a plugin owns what would merely be absent. A site with no feeds is a site with no feeds — it is not a broken one.

Every path below is claimed in the plugin’s afterSetup, once every archive is registered: core lists each route an archive’s page is served at, and the plugin claims <route>/feed beside each one. Nothing is guessed at from the shape of a request, which is what leaves a page slugged feed under some other prefix still rendering as content.

Each row is RSS 2.0 at the path shown and Atom at that path plus /atom.

Archive Path Contents
Front page /feed Published entries of every non-hierarchical public type
Entry type /<archive slug>/feed Published entries of that type
Taxonomy term /<taxonomy>/<term>/feed Published entries of the taxonomy’s public types on that term
Author /authors/<slug>/feed That author’s published entries of non-hierarchical types
Date /YYYY[/MM[/DD]]/feed Published entries of non-hierarchical types in the period
Plugin archive <archive route>/feed The archive’s own entries

Each row is the archive page’s own listing: the feed and the page read one entry query, so they cannot disagree about what the archive contains. A standalone page — an entry of a hierarchical type — is in its type’s feed and its terms’ feeds, and in none of the front page, author or date feeds, because it is on none of those pages.

<archive slug> is where the type’s archive page is: its hasArchive slug, or its rewrite.slug when hasArchive is true. A type registered as post with hasArchive: "articles" syndicates at /articles/feed, and /post/feed is nothing. A type with no archive page — hasArchive: false, which is how @plumix/plugin-blog registers post, its posts listed on the front page instead — has no feed of its own; its entries are in /feed.

Twenty items, newest publish time first, whatever order the archive’s page lists them in — that is what a subscriber’s reader assumes. An archive that resolves to nothing — a term slug nothing matches, an author nobody is, an impossible date such as 2026/02/30 — is a 404 rather than an empty feed, as its page is.

A term feed sits directly under the term’s own archive, wherever that is: a hierarchical taxonomy addresses a nested term at its full path (/region/europe/france/feed), a flat one at its slug alone.

An entry type with an access policy has feeds like any other type, and its entries are in the front page, term, author and date feeds too. The policy is asked per entry, for the reader requesting the feed, through the same read rule the archive’s page uses:

  • A request with no session gets what an anonymous visitor may list. An entry whose policy redirects or hard-challenges that visitor is left out. That anonymous feed is the one the CDN stores, and a write to any entry of the type purges it, a change of the entry’s access choice included.
  • A signed-in request gets the entries its reader may list. That feed is rendered for that reader alone. It is never read from or written to the CDN, and it leaves as private, no-store. Most feed readers send no session, so they get the anonymous feed.
  • An entry whose policy lists it but holds its content back, a soft challenge, is a teaser. Its item carries the title, link, dates and excerpt. A feed item never carries an entry’s body, so nothing gated rides out in one.

A feed whose entries are all gated is still served. An anonymous reader gets a valid feed with no items, as the archive’s page lists nothing.

Install the package with whichever package manager the project uses:

Terminal window
pnpm add @plumix/plugin-feeds

Then:

  1. Add feeds() to the plugins array. Its position there does not matter — see below.

    import { plumix } from "plumix";
    import { blog } from "@plumix/plugin-blog";
    import { feeds } from "@plumix/plugin-feeds";
    import { theme } from "./theme";
    export default plumix({
    plugins: [blog(), feeds()],
    theme,
    });
  2. Restart the dev server.

    Terminal window
    pnpm dev
  3. Read one. /feed is the front page’s posts, /category/<term>/feed is one category’s, and /feed/atom is the same items as Atom.

Array order does not decide what gets a feed. The plugin reads the registry once, in afterSetup, which runs after every plugin’s setup — so it sees blog’s archives wherever blog() sits. The one thing it cannot see is an archive another plugin registers from its own afterSetup when that plugin is listed later, which is a reason to register in setup.

The plugin subscribes to render.document and appends the page’s own feed, both formats:

<link
rel="alternate"
type="application/rss+xml"
href="https://example.com/feed"
/>
<link
rel="alternate"
type="application/atom+xml"
href="https://example.com/feed/atom"
/>

A page advertises the feed of the archive that owns it — the archive the router resolved the page to — and only where the route that answers the feed URL is that archive’s feed, so the head points at exactly the feed that serves those entries. A later page advertises the feed of the listing it paginates: /category/news/page/2 points a reader at /category/news/feed.

A single entry, the search page and an error page belong to no archive, so they advertise nothing.

It gap-fills. A theme or another plugin that already set an alternate of a given type keeps it, and only the missing format is added.

The package root exports the plugin’s one filter in a namespace, feed. feed.items is an async filter over the collected list, applied before serialization, whose input is the scope the items were collected for. Add, drop or re-order:

import { definePlugin } from "plumix/plugin";
import { feed } from "@plumix/plugin-feeds";
export const featured = definePlugin("featured", {
setup: (ctx) => {
ctx.filters.add(feed.items, (items, { archive }) =>
archive.kind === "frontPage" ? items.slice(0, 5) : items,
);
},
});

archive names the archive as core’s lookup does — frontPage, entryType (with its entryType), term (with its taxonomy), author, date, or archiveType (with the plugin archive’s name) — and params holds what its route captured.

feed.items shapes one feed’s list and leaves the archive’s page alone. To change which entries an archive holds — on its page and in its feed alike — narrow its query with core’s render.archiveEntries filter instead.

registerArchiveType takes an optional feed, and that field is this plugin’s augmentation rather than a core one — declare it and it type-checks once @plumix/plugin-feeds is a dependency. It is only accepted beside entries: the feed is the archive’s own entry query, so an archive with no entries has nothing a feed could read, and the types refuse it.

import { definePlugin } from "plumix/plugin";
import "@plumix/plugin-feeds";
export const events = definePlugin("events", {
setup: (ctx) => {
ctx.registerArchiveType("event-series", {
routes: ["/events/:series"],
entries: (q, params) =>
q.ofTypes("event").inTerm("event-series", params.series ?? ""),
title: (params) => `Series: ${params.series ?? ""}`,
feed: true,
});
},
});

feed is true, or an object reserved for feed-only options, of which there are none yet. There is nothing else to declare: the entries are the archive’s, and a feed that needs a different set is a different archive.

The feed carries what the page lists, newest first and capped at twenty, whatever order entries gives the page. The query entries receives already holds only public entries, and the feed applies that rule again when it compiles the query, so an archive that builds a query from scratch still cannot syndicate a draft.

The feed paths follow from the archive’s routes, so there is nothing to keep in step. Each route gains a feed at <route>/feed and Atom at <route>/feed/atom: /events/summer syndicates at /events/summer/feed. The /page/:page form core derives for each route is a later page of a listing rather than a listing of its own, so it gets no feed, and its pages advertise the feed of the route they paginate.

Params entries answers null for are a 404 on the page and the feed together, and the page advertises nothing. Because entries records intent rather than resolving it, a page asking whether it has a feed costs no query.

An archive declaring an access policy gets no feed at all. A feed is a registered public route, which core answers ahead of the access gate, so nothing would check the archive’s policy, and serving one would hand the archive’s entries to anyone who asked. An entry type’s access is a different matter, which Gated entries covers.

Two archives can match one URL. A feed is served only where the feed route that answers it belongs to the archive whose page is there: a /:section archive outranking a type archive at /news owns the page at /news, but the type’s literal /news/feed route answers that feed URL, so it is a 404 and the page advertises nothing rather than a feed of different entries.

The plugin owns its paths through registerPublicRoute, the seam Routing documents — including why a registered route always answers and what that means for a path you did not claim. Permalinks and Slugs covers the URL shapes the feed paths hang off, and Statuses and Publishing covers which entries are in a feed at all.

The site title and tagline a feed’s channel carries come from the settings in Configuration, and Overview covers installing a plugin and the version track this one is on. Its feed option on an archive type reaches a consumer through declare module "plumix", the one specifier every registration option in Content Modelling uses, while feed.items is a value imported from the package.

Read Blog if the site has no dated entry type yet: a feed of nothing is still a valid feed, but not an interesting one.

OG Cards is the other half of how a link travels — the image an unfurl shows, where this plugin is the XML an aggregator reads.