Seed Protocol
Guides

Serve published items as an RSS, Atom or JSON feed

Turn published Seed items into RSS 2.0, Atom and JSON Feed routes with @seedprotocol/feed, and distribute the same feeds peer to peer with @seedprotocol/feed-hyper and the seed-feed command.

Feed readers, podcast apps and other sites understand RSS, Atom and JSON Feed, not EAS attestations. @seedprotocol/feed turns the published items of a model into those formats, with paging, monthly archives and HTTP caching handled for you. This guide serves /posts/rss, /posts/atom and /posts/json from a Node or Bun server, then shows how to distribute the same feeds over Hyperswarm with @seedprotocol/feed-hyper.

You'll need:

  • Node 20.18.1 or later, or Bun. The feed package uses process.env and the file system, so it runs on a server, not in the browser.
  • Published items of a model. The examples serve post items from OP Sepolia; see Publishing your first item to publish your own.

The feed package reads its items with @seedprotocol/query, so everything in Read published data applies: it reads every app's post items, from EAS, with revoked items left out.

Install

npm install @seedprotocol/feed

Set the channel details

Every feed has channel metadata: a title, a home page and its own URL. Set them once at startup with setSiteConfig:

import { setSiteConfig } from '@seedprotocol/feed'

setSiteConfig({
  title: 'My Blog',
  description: 'Posts from my Seed app',
  siteUrl: 'https://blog.example.com',
  feedUrl: 'https://feeds.example.com',
  author: { name: 'My Blog', email: 'editor@example.com', link: 'https://blog.example.com' },
})

Every field you leave out keeps Seed Protocol's default, and that includes each field of author: setting only author.name still publishes info@seedprotocol.io as the email. Set every field you don't want to inherit. FeedConfig fields lists the defaults and where each field appears.

setSiteConfig applies to the whole process. The handlers also take a site config as their last argument, but with the cache on, don't use that to serve several sites from one process: the sites share one cache entry per feed page, so they keep replacing each other's document, and when generation fails one site can be served the other's. Run one process per site, or turn the cache off.

Add the feed routes

handleFeedRequest does the whole request: it reads the items, builds the document and returns a Web Response with Content-Type, ETag and Cache-Control set. The collection is the plural of the schema name (posts serves post items), and the format is rss, atom or json. Any other format gets a 400.

On Bun, or in a framework whose route handlers return a Response (Hono, or a Next.js route handler on the Node runtime), return the Response as it is. On Node's node:http, copy it onto the server's response, as the complete example below does.

The handlers register the Node EAS and Arweave clients themselves when none are registered, and keep any that are, including a running SeedClient's. Media and rich-text bodies come from arweave.net by default. To use a different Arweave gateway, set ARWEAVE_HOST (for example ARWEAVE_HOST=ar.seedprotocol.io for Seed's gateway), or call initializeFeedPlatform({ arweaveDomain }) before the first request (only the first call applies).

Paging and archives

Each feed page has FEED_PAGE_SIZE items (default 25), newest first. When there are more items than fit on a page, the feed links to the next one with ?page=2 (rel="next"), so pass the page query parameter through as the routes above do.

The first page also links to last month's archive, <feedUrl>/posts/archive/<year>/<month>/<format> (RFC 5005). Serve those with handleArchiveFeedRequest. An archive holds every item published that month, up to 1,000, and links to the months before and after it.

Each item's link and guid point to its attestation on the EAS explorer by default, for example https://optimism-sepolia.easscan.org/attestation/view/<seedUid>. An item with its own storage transaction, such as an Image item, links to its file on the public Arweave gateway instead, and its guid is ar://<txId>. siteUrl doesn't change either. To link to pages on your own site, set two environment variables:

FEED_ITEM_URL_BASE=https://blog.example.com
FEED_ITEM_URL_PATH=posts
# Item links become https://blog.example.com/posts/<seedUid>

What goes into each item

An item's title comes from its title property (the Seed UID when there's none), its full content from an Html relation or an html, body or content property, its summary from summary, description or text, and its date from pubDate or the Seed's timeCreated. So a Post with a summary and an Html body gets a short description and the full article. The RSS guid is the item's link (ar://<txId> for an item with its own storage transaction), and Atom and JSON Feed use the same value as the entry's id, so all three formats identify an item the same way. Field mapping has the full priority order, including images.

Feed output isn't sanitized. The HTML in a feed is exactly what was published, by whichever app published it. If you render feed content in a page, sanitize it there.

Items whose HTML embeds images as data:image/...;base64 URIs are left out by default, because they make feeds huge. Set FEED_INCLUDE_DATA_URI_HTML_ITEMS=true to keep them.

Caching

The cache is on unless NODE_ENV is development or CACHE_ENABLED=false. With it on, each request for the first page asks the indexer whether the feed's items changed, and the document is rebuilt only when they did, so a new post appears on the next request. Requests with a matching If-None-Match get a 304. The first page's Cache-Control max-age is CACHE_MAX_AGE (default 0); raising it lets browsers and CDNs keep the page, and the feed skip the indexer check, for that many seconds. Later pages and archives are kept for their own TTLs without a check. Caching covers the TTLs and the cache directory.

Build a feed from your own items

The handlers always read every published item of a schema. To choose the items yourself (one publisher's posts, with queryBySchema's attesters option, or items with a link on your site), read them with @seedprotocol/query and pass them to createFeed, which returns the document as a string:

my-feed.ts
import { createFeed, type FeedItem } from '@seedprotocol/feed'
import { queryBySchema } from '@seedprotocol/query'

const { items: records } = await queryBySchema('post', { limit: 25 })

// Keep only posts with a title, and give each one a link on your site
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 rss = await createFeed(items, 'post', 'rss', undefined, undefined, undefined, false, undefined, {
  title: 'My Blog',
  siteUrl: 'https://blog.example.com',
  feedUrl: 'https://blog.example.com/feeds',
})

createFeed doesn't cache or set headers; that's up to you. Of its nine arguments, transformOverrides is the 8th and the site config the 9th; createFeed lists them all. An item you pass without a title is titled Untitled.

Distribute the feed over Hyperswarm

@seedprotocol/feed-hyper writes the same feeds into a Hyperdrive, a peer-to-peer file system, and announces it on Hyperswarm. Other machines replicate it by its public key, and anyone can serve it to ordinary feed readers over localhost HTTP. The feed's identity is the drive key, not a hostname, so it keeps working without your server.

There's no official Seed feed on Hyperswarm yet (DEFAULT_SEED_FEED_HYPER_KEY is empty), so everything below works with drives you publish yourself. feed-hyper runs on Node 20.18.1 or later only. It depends on Holepunch native modules, which may crash under Bun.

npm install @seedprotocol/feed-hyper

The seed-feed command

The package installs a seed-feed command with three subcommands:

# Generate the feeds and write them into a drive; prints the drive key
npx seed-feed publish --schema post --site-url https://blog.example.com

# Replicate someone's drive's metadata (see the note below: this doesn't copy file contents)
npx seed-feed seed <key>

# Replicate a drive and serve it to feed readers at http://127.0.0.1:8080
npx seed-feed serve <key>

The seed-feed command lists every flag, including --store, --page-size, --port and --no-announce.

publish prints the drive key (z32) and a hyper:// URL. Then it keeps announcing the drive until you press Ctrl+C, unless you pass --no-announce, in which case it writes the drive and exits. seed and serve also run until stopped.

The drive holds:

/registry.json            schemas, formats, paths and last update
/posts/rss.xml
/posts/atom.xml
/posts/feed.json

serve answers both the drive paths (/posts/rss.xml) and the HTTP-style paths the feed package uses (/posts/rss, /posts/json).

Things to know before you run it for real:

  • Publishing is a snapshot. publish generates the feeds once. To update the drive, run publish again with the same --store and --drive-name: the key stays the same and peers pick up the new version. Run it on a schedule if you want a live feed.
  • Back up the store. The drive's private key lives in the --store directory. Lose it and you can't update that feed again; Hyperdrive keys can't be rotated, only replaced with a new drive under a new key.
  • Keep at least one peer online. A drive is only reachable while some machine announces it. Leave publish running on an always-on host. seed-feed seed doesn't download file contents, so a seeding peer can't serve the feed once the publisher goes offline; to mirror a drive from code, open it and call drive.download('/') (see seedFeed).

A drive is append-only, so peers keep versions with items you've since revoked, and only one process can use a store at a time. The drive covers both.

From code

The same operations are exported as functions, for example to republish from a scheduled job:

publish-hyper.ts
import { publishFeed, serveFeed, localFeedUrl } from '@seedprotocol/feed-hyper'

const session = await publishFeed({
  schemas: ['post'],
  formats: ['rss', 'atom', 'json'],
  storePath: '.seed/feed-store',
  siteUrl: 'https://blog.example.com',
  announce: false, // write the drive and close; true keeps announcing until session.close()
})
console.log(session.key, session.paths)

const gateway = await serveFeed({
  key: session.key,
  storePath: '.seed/feed-store',
  port: 8080,
  announce: false,
})
console.log(localFeedUrl(gateway.baseUrl, 'post', 'rss'))
// later: await gateway.close()

openFeed({ key, storePath }) reads a drive from Node (feed.get('/posts/rss.xml'), feed.getRegistry()), waiting up to 15 seconds for peers when the drive is new to this store. seedFeed replicates the drive's metadata without serving (not file contents), and watchFeed(drive, { onUpdate }) calls you back when a new version arrives. publishFeed also takes includeArchives: true, which the command doesn't offer.

Complete example

A Node server for the feed routes, the archive routes and conditional requests:

feed-server.ts
import { createServer } from 'node:http'
import {
  initializeFeedPlatform,
  setSiteConfig,
  handleFeedRequest,
  handleArchiveFeedRequest,
} from '@seedprotocol/feed'

await initializeFeedPlatform()

setSiteConfig({
  title: 'My Blog',
  description: 'Posts from my Seed app',
  siteUrl: 'https://blog.example.com',
  feedUrl: 'https://feeds.example.com',
  author: { name: 'My Blog', email: 'editor@example.com', link: 'https://blog.example.com' },
})

async function route(url: URL, ifNoneMatch: string | null): Promise<Response> {
  const parts = url.pathname.split('/').filter(Boolean)

  // /posts/rss, /posts/atom, /posts/json (?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 })
}

createServer(async (req, res) => {
  const url = new URL(req.url ?? '/', 'http://localhost')
  const response = await route(url, req.headers['if-none-match'] ?? null)

  const headers: Record<string, string> = {}
  response.headers.forEach((value, key) => {
    headers[key] = value
  })
  res.writeHead(response.status, headers)
  res.end(Buffer.from(await response.arrayBuffer()))
}).listen(3000, () => console.log('http://localhost:3000/posts/rss'))

Set feedUrl to the public address the server is reachable at, so the self and paging links in each feed resolve.

Troubleshooting

  • The feed is empty. Check the collection: /posts/rss reads schema post. A model called BlogPost is published as blog_post, so its collection is blog_posts. Also check that EAS_CHAIN_ID isn't pointing at a chain without Seed data.
  • A new post doesn't appear. The feed itself shows it on the next request for the first page (or after CACHE_MAX_AGE seconds, if you set it). If you serve the feed through a CDN or proxy, check that it isn't keeping the page for longer than the Cache-Control the handler sent, and that CACHE_MAX_AGE isn't higher than you want. Later pages follow CACHE_PAGE_TTL. See Caching.
  • You want to see what the handlers do. Set DEBUG=seedFeed to log cache hits and misses, item counts and image probes for each request.
  • A Hyper feed's rel="next" link goes nowhere. seed-feed publish writes only the first page of each feed, but when that page is full it still links to ?page=2. Raise --page-size if your readers need more items.
  • seed-feed crashes under Bun. Run it with Node 20.18.1 or later.
  • TypeScript loses some types. Under "moduleResolution": "nodenext" with skipLibCheck on, some exports of @seedprotocol/feed and @seedprotocol/feed-hyper silently become any. Type-check with "moduleResolution": "bundler"; see Install.

On this page