createFeed and handlers
Reference for the HTTP handlers and generation functions in @seedprotocol/feed: routes, query parameters, status codes and headers, createFeed's nine arguments, how item fields are mapped, and the exported types.
@seedprotocol/feed gives you two levels. The handlers, handleFeedRequest and handleArchiveFeedRequest, read a schema's published items, build the document, cache it, and return a Web Response. createFeed takes items you've already chosen and returns the document as a string, with no reading, caching or headers. Install and configuration are on the overview.
import {
handleFeedRequest,
handleArchiveFeedRequest,
createFeed,
getFeedItemsBySchemaName,
} from '@seedprotocol/feed'handleFeedRequest
Serves one page of a schema's feed, newest items first.
function handleFeedRequest(
collectionSegment: string,
formatSegment: string,
ifNoneMatch?: string | null,
cacheBust?: string,
page?: number,
siteConfigOverrides?: SiteConfigOverrides,
): Promise<Response>Parameters
| Name | Type | Default | Description |
|---|---|---|---|
collectionSegment | string | required | The collection from the URL, e.g. posts. See Collections and schemas |
formatSegment | string | required | rss, atom or json, any case. Anything else returns a 400 |
ifNoneMatch | string | null | none | The request's If-None-Match header. Comma-separated lists and * are understood, and a weak validator (W/"…") matches like a strong one |
cacheBust | string | none | Appended to the feed's self link as ?v=<cacheBust>. Not part of the cache key, but a cached document is served only for the cacheBust value it was built with |
page | number | 1 | The page, from ?page=. Values below 1 become 1. Pass a number: NaN isn't caught |
siteConfigOverrides | SiteConfigOverrides | none | Channel metadata for this call, merged onto the process site config. See the multi-site warning |
Each page has FEED_PAGE_SIZE items (default 25), read with skip = (page - 1) * FEED_PAGE_SIZE, plus one more to tell whether there's a next page. Items with data:image HTML are dropped after paging, so a page can hold fewer items than the page size.
Responses
| Status | When | Body |
|---|---|---|
200 | The document was served from cache or generated | The feed document |
304 | The cache is on, a cached copy exists, and ifNoneMatch matches its ETag | Empty |
400 | The format isn't rss, atom or json | {"error":"Invalid feed format: <format>"} |
500 | Reading or generating failed, or an indexer request got no answer within 30 seconds, and no unexpired cached copy exists (with one, it's served with X-Cache: STALE) | {"error":"Failed to generate feed","message":"<error message>"} |
An unknown collection isn't an error: it's a schema with no items, so you get a 200 with an empty feed.
Response headers
| Header | Value |
|---|---|
Content-Type | application/rss+xml; charset=utf-8, application/atom+xml; charset=utf-8 or application/feed+json; charset=utf-8 |
Cache-Control | Page 1: public, max-age=<CACHE_MAX_AGE>, s-maxage=<CACHE_MAX_AGE>, must-revalidate (CACHE_MAX_AGE defaults to 0). Later pages: public, max-age=3600, s-maxage=3600, must-revalidate. The same with or without the cache |
ETag, Last-Modified | Always set. Page 1's ETag changes only when query's collection, the site config or cacheBust does; later pages' when the document does. With the cache off, a matching If-None-Match still gets a 200 |
X-Feed-Schema, X-Feed-Format | The schema name and format served, e.g. post and rss |
X-Cache | HIT or MISS when the cache is on; STALE, with Warning: 110 - "Response is stale", when generation failed and a cached copy was served. See the overview |
Error responses have Content-Type: application/json only. 304 responses carry ETag, Last-Modified and Cache-Control.
With DEBUG=seedFeed set, each call logs cache hits and misses, item counts and image probes.
Example
A fetch-style router works as is in Bun, Hono, Deno or a Next.js route handler on the Node runtime. For node:http, copy the Response onto the ServerResponse, as the guide's complete example does.
import { handleFeedRequest, handleArchiveFeedRequest } from '@seedprotocol/feed'
export async function feedRoutes(req: Request): Promise<Response> {
const url = new URL(req.url)
const parts = url.pathname.split('/').filter(Boolean)
const ifNoneMatch = req.headers.get('if-none-match')
// /posts/rss, /posts/atom, /posts/json, with ?page=2 for older items
if (parts.length === 2) {
const page = Number(url.searchParams.get('page') ?? 1) || 1
return handleFeedRequest(parts[0]!, parts[1]!, ifNoneMatch, undefined, page)
}
// /posts/archive/2026/9/rss
if (parts.length === 5 && parts[1] === 'archive') {
return handleArchiveFeedRequest(parts[0]!, Number(parts[2]), Number(parts[3]), parts[4]!, ifNoneMatch)
}
return new Response('Not found', { status: 404 })
}handleArchiveFeedRequest
Serves every item of a schema created in one calendar month (RFC 5005 archived feed), newest first.
function handleArchiveFeedRequest(
collectionSegment: string,
year: number,
month: number,
formatSegment: string,
ifNoneMatch?: string | null,
cacheBust?: string,
siteConfigOverrides?: SiteConfigOverrides,
): Promise<Response>Parameters
| Name | Type | Default | Description |
|---|---|---|---|
collectionSegment | string | required | As for handleFeedRequest |
year | number | required | 1970 to 2100 |
month | number | required | 1 to 12. Number('09') works, so zero-padded URLs are fine |
formatSegment | string | required | rss, atom or json |
ifNoneMatch, cacheBust, siteConfigOverrides | none | As for handleFeedRequest |
The month runs from the 1st at 00:00 UTC up to the 1st of the next month, by the Seed's attestation time, whatever the server's time zone. An archive holds up to 1,000 items and isn't paged.
Responses are the same as handleFeedRequest's, with these differences:
400also for a year or month out of range:{"error":"Invalid year: <year>"},{"error":"Invalid month: <month>"}. These are checked before the format, and a year or month that isn't an integer (NaN,2026.5) gets the same400.Cache-Controlispublic, max-age=86400, s-maxage=86400, must-revalidate, and the cache TTL isCACHE_ARCHIVE_TTL.- Image metadata and the stale fallback work as for
handleFeedRequest. A cached archive is served untilCACHE_ARCHIVE_TTLruns out, with no check for new items.
Links in each document
The handlers add RFC 5005 paging and archive links built from feedUrl in the site config:
| Document | Links |
|---|---|
| Page 1 | next (?page=2) when there are more items; prev-archive to last month's archive (UTC) |
| Page 2 and later | next when there are more items; previous; first (?page=1) |
| Archive for a month | prev-archive (the month before); current (the main feed); next-archive (the month after), unless that month is in the future in UTC |
Where they appear in each format:
- RSS: as
<atom:link rel="…">elements. RSS documents have norel="self"link. Archives also get<fh:archive/>. - Atom: as
<link rel="…">, plusrel="self"(<feedUrl>/<collection>/atom) and an alternate link tositeUrl. - JSON Feed: only
next_url. JSON Feed has no field for the others.
The prev-archive link is added whether or not that month has items, so following it backwards eventually reaches empty archives.
Collections and schemas
The handlers take the collection from the URL and turn it into a schema name: lowercase it, then singularize it with pluralize. posts and post both serve schema post; blog_posts serves blog_post; identities serves identity.
- Schema names are matched exactly against EAS, so only lowercase schemas can be served this way.
- Words that pluralize oddly don't round-trip.
databecomesdatum, for example. If a model's schema name changes when singularized, build its feed withgetFeedItemsBySchemaNameandcreateFeedinstead.
The collection in titles and links is the plural of the schema name, capitalized in the title: My Blog - Posts.
createFeed
Builds one feed document from items you pass in. It doesn't read, cache or set headers. The handlers call it, and so does @seedprotocol/feed-hyper.
function createFeed(
items: FeedItem[],
schemaName: string,
format: FeedFormat,
cacheBust?: string,
pagination?: FeedPaginationOptions,
archiveLinks?: FeedArchiveLink[],
isArchive?: boolean,
transformOverrides?: Partial<TransformOptions>,
siteConfigOverrides?: SiteConfigOverrides,
): Promise<string>Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | items | FeedItem[] | required | The items, in the order they should appear. Each needs id and title by type; see Field mapping |
| 2 | schemaName | string | required | Used for the collection in titles and self links (post → posts) and for media:category |
| 3 | format | FeedFormat | required | 'rss', 'atom' or 'json'. Any other value produces RSS |
| 4 | cacheBust | string | none | Appended as ?v=<cacheBust> to the self link (Atom id and self link, JSON feed_url) |
| 5 | pagination | FeedPaginationOptions | none | Adds next, previous and first links. See below |
| 6 | archiveLinks | FeedArchiveLink[] | none | Extra links added as given, e.g. prev-archive. Ignored by JSON Feed |
| 7 | isArchive | boolean | false | RSS only: marks the document as an archive with <fh:archive/> |
| 8 | transformOverrides | Partial<TransformOptions> | none | Only richTextDataUriImages is read: 'include_items' keeps data:image items for this call |
| 9 | siteConfigOverrides | SiteConfigOverrides | none | Channel metadata for this call, merged onto the process site config |
transformOverrides is the 8th argument and siteConfigOverrides the 9th. TransformOptions also declares itemUrlBase and itemUrlPath, but createFeed ignores those overrides: item links come from the items and the FEED_ITEM_URL_* variables.
FeedPaginationOptions
| Field | Type | Description |
|---|---|---|
page | number | The current page |
pageSize | number | Items per page. Not used to build links |
hasNext | boolean | Whether to add a next link |
baseUrl | string | The page URL without a query string, e.g. https://feeds.example.com/posts/rss. Any query string is stripped |
next is <baseUrl>?page=<page + 1>. When page > 1, previous (page - 1) and first (page=1) are added too.
FeedArchiveLink
interface FeedArchiveLink {
rel: string // e.g. 'prev-archive', 'next-archive', 'current'
href: string
}Returns
The document as a string: RSS 2.0 XML, Atom 1.0 XML, or JSON Feed 1.1 JSON.
Example
Your own items, with a page of paging links and your own site details:
import { createFeed, type FeedItem } from '@seedprotocol/feed'
import { queryBySchema } from '@seedprotocol/query'
const { items: records } = await queryBySchema('post', { limit: 25 })
const items: FeedItem[] = records
.filter((post) => typeof post.data.title === 'string')
.map((post) => ({
...structuredClone(post.data),
id: post.seedUid,
title: String(post.data.title),
link: `https://blog.example.com/posts/${post.seedUid}`,
timeCreated: post.timeCreated,
}))
const atom = await createFeed(
items,
'post',
'atom',
undefined,
{ page: 1, pageSize: 25, hasNext: records.length === 25, baseUrl: 'https://blog.example.com/feeds/posts/atom' },
undefined,
false,
{ richTextDataUriImages: 'include_items' },
{ title: 'My Blog', siteUrl: 'https://blog.example.com', feedUrl: 'https://blog.example.com/feeds' },
)getFeedItemsBySchemaName
Reads a schema's published items the way the handlers do, ready for createFeed.
function getFeedItemsBySchemaName(
schemaName: string,
options?: { limit?: number; skip?: number },
): Promise<Record<string, unknown>[]>
function fetchFeedItemsBySchemaName(
schemaName: string,
options?: { limit?: number; skip?: number },
): Promise<{ items: Record<string, unknown>[]; etag?: string }>
function getFeedItemsBySchemaNameForMonth(
schemaName: string,
year: number,
month: number,
): Promise<Record<string, unknown>[]>| Option | Default | Description |
|---|---|---|
limit | 100 | Items to read |
skip | 0 | Items to skip, newest first |
fetchFeedItemsBySchemaName returns the same items with query's collection ETag, which changes whenever a listed Seed or anything it was assembled from changes. etag is set only when query's collection cache answered: the cache is on and skip is 0. The handlers use it to decide whether page 1 needs rebuilding.
They call queryBySchema (or queryBySchemaForMonth) from @seedprotocol/query with hydrateStorage: true and relations: 'expand' (see @seedprotocol/query), then fill in feed defaults on a copy of each item's data, so query's cached records aren't touched:
title(andTitle): the item's title, or its Seed UID when there is none.linkandguid: see Field mapping.pubDate: the Seed'stimeCreatedas an RFC 822 string, unless the item has one.seedUid,attesterand their capitalized twins (SeedUid,Attester).
Expanded relations get the same defaults. Expanded Image relations get a gateway link to the image and no explorer link. Every Arweave link in the result, in a link field or inside Html, is then moved onto the public gateway (initializeFeedPlatform says which host that is), so a feed never carries a local sidecar address.
Cast the result to FeedItem[] to pass it to createFeed. The schema name is matched exactly, so pass it lowercase.
Field mapping
How an item's properties become feed fields, in priority order. "Handlers" means handleFeedRequest, handleArchiveFeedRequest and feed-hyper's publishFeed (which seed-feed publish runs), which read items through getFeedItemsBySchemaName.
| Feed field | Comes from |
|---|---|
| Title | title, then Title. Handlers: the Seed UID when both are empty. createFeed with your own items: Untitled |
Link (RSS link, Atom link, JSON url) | The item's link or Link property. Then, for items read by the handlers: a /raw/ URL on the public gateway if the item has its own storage transaction (an Image item, https://arweave.net/raw/<txId> by default), otherwise <FEED_ITEM_URL_BASE>/<FEED_ITEM_URL_PATH>/<seedUid> (the EAS explorer by default). For your own items in createFeed: import_url or importUrl, then the same explorer URL with id |
RSS guid | guid or Guid, then the link. Marked isPermaLink="true" when it starts with http:// or https://. Handlers fill in guid when the item has none: ar://<txId> for an item with its own storage transaction and no link of its own (an Image item), marked isPermaLink="false", otherwise the link. The ar:// guid doesn't change with the public gateway host. Before 0.7.13 such an item's guid was its gateway URL, so subscribers see those items once more as new; a cached document keeps the old guids until it's rebuilt |
Atom id, JSON id | The RSS guid, since 0.7.12, so all three formats identify an item the same way (ar://<txId> for an Image item). The item's own id doesn't override it |
Full content (RSS content:encoded, Atom content, JSON content_html) | A rich-body field query marked as stored in an Html, File or Json item, in that priority, then by property name and list position. Otherwise the first non-empty of html, Html, body, Body, content, Content; then summary, Summary, description, Description, text, Text. When the item has an image whose URL isn't in the content, an <img> for it is put first |
Summary (RSS description, Atom summary, JSON summary) | The first non-empty of summary, Summary, description, Description, text, Text |
Date (RSS pubDate and dc:date, Atom updated, JSON date_published) | For Atom updated, the item's own updated first, unless it's null. Then pubDate or PubDate, then timeCreated (Unix seconds), then publishedAt, createdAt or updatedAt, then the current time |
Image (RSS enclosure, media:content, media:thumbnail, media:group; Atom enclosure link; JSON image and attachments) | Image metadata probed by handleFeedRequest and handleArchiveFeedRequest, else the first of feature_image, featureImage, image, images, cover_image, coverImage, thumbnail, thumbnail_image, thumbnailImage that resolves to an image URL (a URL, an Arweave transaction ID or an expanded Image relation; for arrays, the first that resolves). RSS only, except probed images, which Atom and JSON Feed get too. The type defaults to image/jpeg when unknown |
Because the handlers fill in link before createFeed sees the item, an item's import_url never becomes its link when served by a handler. Set FEED_ITEM_URL_BASE to link to your own site.
Other things each format carries:
- RSS: every other property of the item as an extra element, sorted by name (readers ignore elements they don't know). Keys starting with
_are skipped, and so areTitle,Link,Guid,PubDateandSeedUidwhen the lowercase key is present. Expanded relations become nested elements in a namespace per relation,https://seedprotocol.io/ns/relations/<name>/1.0. Each item also getsdc:identifierwith the Seed UID and storage transaction, anddc:sourcewithimport_url. Items with an image also getmedia:title,media:description,media:keywords,media:category(the schema name),media:copyright, andmedia:creditfrom the item'sauthorsor the site author. - Atom: standard Atom fields only.
createFeedspreads every other property into the entry, but the feed writer (feedsmith) drops keys Atom doesn't define. An item's owntitle,updatedandlinksreplace the defaults, except thatnullkeeps the default. An empty string inupdatedgives an entry with no<updated>, andlinks: []an entry with no link. - JSON Feed: every other property spread into the item object, then
idset as in the table. - String properties whose name contains
date,time,published,createdorupdatedand that parse as a date are converted to dates.
Feed output isn't sanitized. The HTML is exactly what was published, by whichever app published it. Sanitize it wherever you render it.
data:image items
By default, items whose HTML embeds images as data:image/...;base64,... URIs are left out, because they make documents huge. An item is dropped when any rich-body field marked by query, or any of html, Html, body, Body, content, Content, summary, Summary, description, Description, text or Text, contains both data:image/ and ;base64,.
To keep them:
- for every feed:
FEED_INCLUDE_DATA_URI_HTML_ITEMS=true - for one
createFeedcall:{ richTextDataUriImages: 'include_items' }as the 8th argument
The handlers have no per-call switch.
Rich text helpers
The selection rules above, exported for your own rendering:
function pickFeedItemContent(item: Record<string, unknown>): string
function pickFeedItemDescription(item: Record<string, unknown>): string
function feedItemRichTextContainsDataUriImage(item: Record<string, unknown>): boolean
function filterItemsByRichTextDataUriImagePolicy<T extends Record<string, unknown>>(
items: readonly T[],
richTextDataUriImages?: 'omit_items' | 'include_items', // default 'omit_items'
): T[]pickFeedItemContent and pickFeedItemDescription return '' when nothing matches. The rich-body markers are the _feedFieldStorageModels and _feedListElementStorageModels keys that query adds to data. The storage schemas that count as rich bodies are query's RICH_BODY_STORAGE_SCHEMAS (html, file, json), with isRichBodyStorageSchema to test a name.
parseRssString
Parses an RSS or Atom document into plain objects, the input @seedprotocol/mapping expects for imports.
function parseRssString(xml: string): Promise<ParsedRssChannel>
type ParsedRssChannel = {
title?: string
link?: string
description?: string
items: Record<string, unknown>[] // plain JSON: dates are ISO strings
}It uses rss-parser and also keeps the item fields featureImage, feature_image, content:encoded, seedUid and SeedUid. It rejects on malformed XML.
import { parseRssString } from '@seedprotocol/feed'
const xml = await (await fetch('https://blog.example.com/feeds/posts/rss')).text()
const channel = await parseRssString(xml)
for (const item of channel.items) console.log(item.title, item.link)Import a feed with mapping shows it in use.
resetCacheManager
function resetCacheManager(): voidDrops the feed's in-memory cache and its settings, so the next request re-reads CACHE_* and starts with an empty memory cache. Files under CACHE_DIR stay and are used again. Mostly for tests.
Types
type FeedFormat = 'rss' | 'atom' | 'json'
interface FeedConfig {
title: string
description: string
siteUrl: string
feedUrl: string
language?: string
copyright?: string
author?: { name: string; email?: string; link?: string }
}
type SiteConfigOverrides = Partial<Omit<FeedConfig, 'author'>> & {
author?: Partial<NonNullable<FeedConfig['author']>>
}
interface TransformOptions {
schemaName: string
itemUrlBase: string
itemUrlPath: string
richTextDataUriImages?: 'omit_items' | 'include_items' // default 'omit_items'
}
interface FeedItem {
id: string
title: string
summary?: string
html?: string
body?: string
content?: string
description?: string
text?: string
createdAt?: string
updatedAt?: string
publishedAt?: string
authors?: Array<{ name: string; displayName?: string; profile?: string }>
featureImage?: { src: string; alt?: string }
[key: string]: unknown // plus capitalized variants: Html, Body, Content, Description, Text
}FeedItem is an assembled Seed record's data with the feed defaults applied. Any object with id and title and your own properties fits. FeedPaginationOptions and FeedArchiveLink are above.
Related
- Serve published items as a feed: the walkthrough
- @seedprotocol/feed overview: install, site config, environment variables and caching
- Hyper distribution: publishing these documents over Hyperswarm
- @seedprotocol/query:
queryBySchemaand theSeedRecordshape - @seedprotocol/mapping: importing parsed RSS into Seed items
Overview
What @seedprotocol/feed and @seedprotocol/feed-hyper do, how to install them, the site config, the environment variables they read, and how feed caching works.
Hyper distribution (feed-hyper)
Reference for @seedprotocol/feed-hyper: publishFeed, serveFeed, openFeed, seedFeed and watchFeed, the seed-feed command and its flags, the drive layout, and its known limits.