Seed Protocol
@seedprotocol/feed

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

NameTypeDefaultDescription
collectionSegmentstringrequiredThe collection from the URL, e.g. posts. See Collections and schemas
formatSegmentstringrequiredrss, atom or json, any case. Anything else returns a 400
ifNoneMatchstring | nullnoneThe request's If-None-Match header. Comma-separated lists and * are understood, and a weak validator (W/"…") matches like a strong one
cacheBuststringnoneAppended 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
pagenumber1The page, from ?page=. Values below 1 become 1. Pass a number: NaN isn't caught
siteConfigOverridesSiteConfigOverridesnoneChannel 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

StatusWhenBody
200The document was served from cache or generatedThe feed document
304The cache is on, a cached copy exists, and ifNoneMatch matches its ETagEmpty
400The format isn't rss, atom or json{"error":"Invalid feed format: <format>"}
500Reading 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

HeaderValue
Content-Typeapplication/rss+xml; charset=utf-8, application/atom+xml; charset=utf-8 or application/feed+json; charset=utf-8
Cache-ControlPage 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-ModifiedAlways 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-FormatThe schema name and format served, e.g. post and rss
X-CacheHIT 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.

feed-routes.ts
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

NameTypeDefaultDescription
collectionSegmentstringrequiredAs for handleFeedRequest
yearnumberrequired1970 to 2100
monthnumberrequired1 to 12. Number('09') works, so zero-padded URLs are fine
formatSegmentstringrequiredrss, atom or json
ifNoneMatch, cacheBust, siteConfigOverridesnoneAs 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:

  • 400 also 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 same 400.
  • Cache-Control is public, max-age=86400, s-maxage=86400, must-revalidate, and the cache TTL is CACHE_ARCHIVE_TTL.
  • Image metadata and the stale fallback work as for handleFeedRequest. A cached archive is served until CACHE_ARCHIVE_TTL runs out, with no check for new items.

The handlers add RFC 5005 paging and archive links built from feedUrl in the site config:

DocumentLinks
Page 1next (?page=2) when there are more items; prev-archive to last month's archive (UTC)
Page 2 and laternext when there are more items; previous; first (?page=1)
Archive for a monthprev-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 no rel="self" link. Archives also get <fh:archive/>.
  • Atom: as <link rel="…">, plus rel="self" (<feedUrl>/<collection>/atom) and an alternate link to siteUrl.
  • 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. data becomes datum, for example. If a model's schema name changes when singularized, build its feed with getFeedItemsBySchemaName and createFeed instead.

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

#NameTypeDefaultDescription
1itemsFeedItem[]requiredThe items, in the order they should appear. Each needs id and title by type; see Field mapping
2schemaNamestringrequiredUsed for the collection in titles and self links (post → posts) and for media:category
3formatFeedFormatrequired'rss', 'atom' or 'json'. Any other value produces RSS
4cacheBuststringnoneAppended as ?v=<cacheBust> to the self link (Atom id and self link, JSON feed_url)
5paginationFeedPaginationOptionsnoneAdds next, previous and first links. See below
6archiveLinksFeedArchiveLink[]noneExtra links added as given, e.g. prev-archive. Ignored by JSON Feed
7isArchivebooleanfalseRSS only: marks the document as an archive with <fh:archive/>
8transformOverridesPartial<TransformOptions>noneOnly richTextDataUriImages is read: 'include_items' keeps data:image items for this call
9siteConfigOverridesSiteConfigOverridesnoneChannel 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

FieldTypeDescription
pagenumberThe current page
pageSizenumberItems per page. Not used to build links
hasNextbooleanWhether to add a next link
baseUrlstringThe 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.

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:

my-feed.ts
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>[]>
OptionDefaultDescription
limit100Items to read
skip0Items 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 (and Title): the item's title, or its Seed UID when there is none.
  • link and guid: see Field mapping.
  • pubDate: the Seed's timeCreated as an RFC 822 string, unless the item has one.
  • seedUid, attester and 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 fieldComes from
Titletitle, 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 guidguid 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 idThe 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 are Title, Link, Guid, PubDate and SeedUid when 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 gets dc:identifier with the Seed UID and storage transaction, and dc:source with import_url. Items with an image also get media:title, media:description, media:keywords, media:category (the schema name), media:copyright, and media:credit from the item's authors or the site author.
  • Atom: standard Atom fields only. createFeed spreads every other property into the entry, but the feed writer (feedsmith) drops keys Atom doesn't define. An item's own title, updated and links replace the defaults, except that null keeps the default. An empty string in updated gives an entry with no <updated>, and links: [] an entry with no link.
  • JSON Feed: every other property spread into the item object, then id set as in the table.
  • String properties whose name contains date, time, published, created or updated and 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 createFeed call: { 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(): void

Drops 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.

On this page