How to build an RSS reader

How to build an RSS reader with subscribe, a polite fetch, and unread tracking. Follow the prompts, check each step, then skip the AI summaries.

  • guide
  • vibe-coding
  • feedly

You can learn how to build an RSS reader as a subscribe form, a polite fetch, and an unread river. Feedly is that product. The catalog line is “The blogs you love, fetched into a bowl you own.”

You pay $7–$12 a month. That is $84–$144 a year. The verdict is “RSS never died”. The first estimate is 8 hours: subscribe, fetch, read and unread tracking.

There is no standalone teardown yet. The Readwise post already splits a pasted article from a feed that arrives on its own. The IFTTT post is the cousin that fires when an item appears. This guide is the walk. Prompts in order, then what to look at.

The audience is one reader following a few dozen blogs and newsletters. If you need a discovery algorithm, AI summaries, or a phone app, keep paying. The same v1 also replaces Inoreader and NewsBlur.

Readwise Reader is the read-later cousin, $10–$12.99 a month, about 2 days. You paste that URL. This app fetches the URL for you. A link you file by hand is the bookmark manager guide. A site that publishes the feed is Ghost Pro, $9–$35 a month, about 12 hours. The walk for that press is How to build a Ghost alternative. I already wrote the Ghost teardown.

How to build an RSS reader without a discovery algorithm

The Feedly page splits the work into three rows.

The first row is the one you want. Paste a homepage. Find the feed. Fetch it. Track unread. That is the 8-hour v1.

The second row is 3 days. Scheduled polling with folders, OPML, saved items. Do that after the river has stayed honest for a week.

The third row is 1 month and up. A discovery algorithm, AI summaries, mobile apps. Skip it.

If you tell an agent to “clone Feedly”, it aims at the third row. Pin every prompt to the first estimate.

The card’s search note says the same thing. Folders and OPML wait until that river works.

v1 does this. Flavio opens Feedbowl and pastes https://flaviocopes.com. The app finds /rss.xml, stores the subscription, and pulls recent posts into an unread river. He opens “How to host a SaaS clone on Cloudflare”, reads the sanitized HTML, and hits next. The item is read. A later hourly fetch sends If-None-Match. The feed answers 304. Unread flags stay put. Folders, OPML, saved items, and a “for you” ranking stay off the page.

The parts and where a fetch goes

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Feedly is Astro, Workers, and D1. This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. The Worker still has to fetch. A dashboard in React does not replace scheduled().

The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why the discovery algorithm waits.

Open Build your own Feedly. The guardrail is the useful sentence: fetch feeds politely with conditional requests, timeouts, and backoff, and sanitize all feed HTML before rendering. The non-goal sits next to it. No social sharing, a discovery algorithm, AI summaries, or newsletter hosting.

FLAVIO'S BROWSER River, item, feed manager login required ASTRO APP ON THE REQUEST Subscribe, mark read, refresh Never render raw feed HTML D1 HOLDS ROWS Feed, items, read flags ETag and last-modified per feed FETCH IN THE BACKGROUND Cloudflare Cron Trigger Timeout, size cap, then backoff REMOTE BLOGS If-None-Match, If-Modified-Since NOT IN V1 Folders, OPML, saved items Discovery algorithm, AI summaries Social share, newsletter hosting
Subscribe on the request. Fetch on a Worker. The item view only sees sanitized HTML.

Subscribe, mark read, and a manual refresh are ordinary HTTP. The hourly pass starts after scheduled(). If a remote blog is down, old items stay readable. If the plan invents a “similar blogs” ranker, send it back.

The data the river carries

The card lists Feed, Folder, Item, and SavedItem. Tonight you need Feed and Item. Folder and SavedItem wait for 3 days.

A feed has a homepage URL, the discovered feed URL, a title, last fetch time, health, and the validators from the last response. Health is ok, failing, or dead. Consecutive failures push the next attempt later. A 410 marks it dead. A 301 rewrites the stored feed URL once.

An item belongs to that feed. It carries a stable identity, title, source link, published time, sanitized HTML, and a read flag. Identity is scoped to that feed. Two blogs can reuse the same guid.

FEED flaviocopes.com · /rss.xml ITEM guid or link · unread until opened IDENTITY scoped to this feed READ FLAG edits do not reset it FOLDER 3-day row SAVED ITEM 3-day row NO RANKING SCORE newest unread first
One feed, many items, one read flag. Identity never leaves that feed. Folders wait.

Every owner query is scoped to Flavio. A guessed item id from a signed-out window returns nothing useful.

Use guid or Atom id when the feed sent one. Fall back to the link. Last resort is a hash of title plus published date. If a feed regenerates guids, match the known link instead of flooding the river.

The screens you ship

Three surfaces. Keep them boxes and labels. If the agent starts matching Feedly’s live UI, send it back.

The reading list is Flavio’s home. An all-items river. Unread count. Excerpt, source, and published time on each row. Unread rows look different from read ones. Keyboard next and previous. A subscribe action.

WIREFRAME · RIVER SUBSCRIBE ALL · 2 UNREAD Host a SaaS clone · UNREAD flaviocopes.com · 31 Oct Kit alternative · UNREAD flaviocopes.com · 31 Oct Cron job monitor · read
Unread sits on the row. Subscribe is the only create path. No folder tree yet.

The item view is the sanitized post. Title, source link, published time. Next and previous. Mark unread if he opened it by mistake. No share button. No “summarize this”.

WIREFRAME · ITEM How to host a SaaS clone flaviocopes.com · 31 Oct The adapter, then the bindings, then the custom domain. Sanitized HTML. No script tags. PREV NEXT MARK UNREAD
Source link stays. Scripts do not. Next is a key, not a recommendation.

The feed manager is the plumbing. Title, feed URL, last fetch, health. Refresh one feed. Unsubscribe is a confirm. Discovery lives on the subscribe form, not as a browse catalog.

WIREFRAME · FEEDS flaviocopes.com · OK /rss.xml · fetched 14:00 REFRESH UNSUBSCRIBE vibecodeityourself.com · FAILING timeout · next try in 6h old items still readable
Health is visible. A failing feed keeps its history. Unsubscribe is a confirm.

Subscribe, fetch, and mark a post read

The first journey on the card is this walk. Paste a site. Land on unread items. Open one.

PASTE A HOMEPAGE DID DISCOVERY FIND IT? no: say so, do not subscribe FIRST FETCH FILLS ITEMS OPEN AN ITEM sanitized HTML, then read LATER CRON FETCH If-None-Match on the wire 304, NEW, OR FAIL? 304 leaves flags alone NEW IDENTITY IS UNREAD same identity never duplicates FAIL KEEPS OLD ITEMS health fails, fetch backs off EDITED CONTENT UPDATES read flag stays read
Discovery first. Fetch second. Unread only changes when Flavio reads, or a new identity arrives.

A pasted article you clean by hand is a different job. That is the Readwise post. IFTTT is $2.99–$14.99 a month, about 8 hours, when the new item should send mail instead of sit in a river.

Prompt 1. Scaffold Feedbowl and stop

Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it. If you picked TanStack Start, keep the same routes and rules. Only the adapters change.

Ship the 8-hour row only.
Subscribe, fetch, read and
unread tracking.

Project name: Feedbowl.
Public URL is
https://feeds.flaviocopes.com.
Owner is Flavio.

Seed feeds after subscribe
works: https://flaviocopes.com
and https://vibecodeityourself.com.

Do not add folders, OPML,
or saved items.
Do not add a discovery
algorithm, AI summaries,
social sharing, or
newsletter hosting.

When the plan is done, stop.
Do not write code until I say go.

The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.

You want / for the river, /items/:id for the reader, /feeds for the manager, and a subscribe action. Tables for feeds and items. No folders. No saved_items. No recommendations.

If the plan includes OpenAI, a “for you” score, or a public follow graph, send it back.

When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.

What to check after the scaffold

Start the app. Sign in as Flavio. You should see an empty river and a subscribe action.

Open the migrations. Feeds store a feed URL, validators, last fetch, and health. Items store an identity unique per feed, plus a read flag. There is no score column.

If the README is a placeholder, stop. It has to name discovery, the polite fetch, sanitization, and the unread rule.

Prompt 2. Discover a feed and subscribe

The first mini-prompt on the card is the useful one. People paste a homepage. They do not paste /rss.xml.

Build Feedbowl subscribe
and feed discovery.

Flavio pastes a site URL.
Fetch the page. Read link
tags with rel alternate and
an RSS or Atom type.
Resolve relative hrefs
against the page, including
a base tag.

If the head is empty, probe
/feed, /rss, /atom.xml,
/index.xml, /feed.xml, and
the same paths under /blog.
Parse each candidate as a
real feed before trusting
it. An HTML 404 with status
200 is not a feed.

If several feeds appear,
pick the site-wide one and
show the others as a choice.
If nothing parses, say so.
Do not create a broken row.

Store homepage URL, feed
URL, and title. Then run
the first fetch from
prompt 3 once that lands.

Login required for every
owner page. A guessed feed
id from a signed-out window
returns nothing useful.

Do not import OPML yet.
Do not invent a catalog of
blogs to follow.

Done when pasting
https://flaviocopes.com
stores /rss.xml, pasting a
feedless site explains the
miss, and a second paste of
the same feed is refused.

What to check after subscribe exists

Paste https://flaviocopes.com. The manager shows that title and a feed path. It is not still the homepage.

Paste https://example.com if that host has no feed, or any page you know is feedless. You get a clear miss. No row.

Subscribe twice to the same feed. One row.

Open /feeds with no cookie. Login wall. Guess an id from a private window. Nothing useful.

Prompt 3. Fetch items and keep unread honest

The fetcher is the product. The second and third mini-prompts on the card sit here. Conditional requests, then a stable identity so a lying feed cannot flood the river.

Build Feedbowl fetch for
subscribe and for a Worker
cron.

On each fetch send
If-None-Match and
If-Modified-Since from the
stored validators. A 304
updates last-checked only.
Timeout and cap the body.
Never let one slow feed
block the rest of the batch.

Identity is guid or Atom id,
else the link, else a hash
of title plus published
date. Scope it to the feed.
If a new guid appears with
a known link, it is the
same item.

New identities insert as
unread. A later edit updates
sanitized HTML and leaves
the read flag alone.
Refetching ten times adds
zero duplicates.

Sanitize before store.
Allow paragraphs, headings,
lists, links, blockquotes,
code, and images. Strip
script and style. Drop
event attributes. Reject
javascript: and data: URLs.
Resolve relative links
against the feed. Drop 1x1
trackers. Add rel noopener
and target blank on
outbound links.

Health: consecutive failures
back off. One success
resets. A 301 updates the
stored feed URL once. A 410
marks the feed dead and
stops fetching. Old items
stay readable.

wrangler.jsonc has a Cron
Trigger every hour.
scheduled() walks live
feeds. A refresh button on
/feeds runs the same path
for one feed.

Do not summarize items.
Do not fetch pages that
are not the feed.

Done when the first
flaviocopes.com fetch fills
unread items, a second
fetch creates none, a
forced 304 changes no
flags, and a dead feed
keeps its history.

The hourly trigger looks like this.

[triggers]
crons = ["0 * * * *"]

Cron Triggers map that expression to scheduled(). They run on UTC. Cloudflare says changes can take several minutes to spread, so do not debug a missing fetch by redeploying every thirty seconds.

What to check after the first fetch

Subscribe to https://flaviocopes.com. The river has recent titles. All unread.

Hit refresh. The count does not jump. Open one item. Refresh again. That item stays read.

Trigger scheduled() twice. Still no duplicates.

If the agent stored raw content:encoded as HTML, view source on the item. A script from the feed must not run.

Prompt 4. Read the river and mark items

The list is only useful if unread is a state you trust.

Build the Feedbowl river
and the item view.

/ lists items newest first.
Unread rows are visually
distinct and also marked
in text, not by color
alone. Show title, source,
published time, and a
short excerpt. Keyboard
j and k move. Enter opens.

Opening an item marks it
read. Mark-unread puts it
back. Next and previous
respect the current list
order. A mark-all-read
action on the river is
fine. There are no folders,
so it means every item.

Sanitize again on render.
Never ship a raw feed
body to the browser.

Empty river explains how
to subscribe. A failing
feed still lists its old
items and shows health on
/feeds.

Do not add save-for-later.
Do not add share.
Do not add a weekly digest.

Done when I can open the
hosting post, see sanitized
body, hit k to the next
item, and reload with that
first item still read.

What to check after the river works

Open the hosting post. The unread badge drops by one. Reload. Still read.

Hit k. You land on the next item, not a recommended one.

View source. rel="noopener" is on outbound links. No onerror. No javascript: href.

Mark all read. The badge is zero. A later fetch of the same items does not bring them back.

Unsubscribe from the second seed feed. Its items leave the river. The first feed is untouched.

Failures the agent ships anyway

Fetching on the subscribe request only

A reader that never polls is a one-shot importer. The capability line is Workers and Cloudflare Cron Triggers. The hosting post is the binding, then the cron, then the domain. You notice the next morning when yesterday’s river is still the whole product.

A refetch that resets unread

Identity plus a uniqueness rule on (feed_id, identity) is the lock. Without it, the 14:00 pass reinserts this morning’s posts. Replay refresh twice. The badge must not climb.

Guid churn

Some feeds mint a new guid every request. Match the link. You notice when one blog adds twelve copies of the same title.

Rendering the feed as HTML

Every item is untrusted HTML from a blog you do not control. If the view runs a script, you built a browser for strangers. Cap it. Allowlist it. The IFTTT post is allowed to call out. This view is not.

Fetching the article page because the excerpt was short

That is Readwise Reader. The Readwise teardown already walks URL capture. If documents.body appears in the plan, you left Feedly.

A ranking column

Newest unread first. A “for you” score is the third row. You notice when the hosting post sinks under a generated digest.

A link you file on purpose is a different job. That is Raindrop.io, $2.33–$3 a month, about 3 hours. I walked that as How to build a bookmark manager.

Deploy the reader

Feedbowl wants Astro, Workers, and D1. Put it on a subdomain you already own, feeds.flaviocopes.com. The Cron Trigger is the hourly fetch.

The hosting post is that path: the adapter, then the bindings, then the custom domain. Wrangler exposes /cdn-cgi/local/scheduled during wrangler dev. Hit that before you wait for a real hour on the network.

Attach the domain on the Worker. Add the hourly cron.

Point the first subscription at a feed you already read. Confirm the river filled. Confirm a second fetch added nothing. Confirm one item stays read after the next pass.

Take a D1 backup before you delete the paid account. The unread flags are the history you will miss.

Use it as the real reader for a week. If you miss folders, OPML, or a saved pile, those are the 3-day row. Add them after the river has been honest.

Then cancel Feedly, or Inoreader, or NewsBlur, whichever one you pay for.

What to skip on purpose

Leave out folders, OPML, saved items, a discovery algorithm, AI summaries, social sharing, and newsletter hosting.

8-HOUR V1 Subscribe and discover a feed Polite fetch, honest unread River and sanitized item view SKIP THIS HALF Discovery algorithm AI summaries Mobile apps, social, newsletters
Ship the river and one honest unread flag. Leave the recommendation engine on the skip row.

Folders, OPML, and saved items are the 3-day row. Useful after you have more feeds than one screen.

A queue that publishes later is a different cron. That is the Buffer guide. A Worker that waits for silence is the cron job monitor.

More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.

Start now

Go to Build your own Feedly. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate only: subscribe, fetch, read and unread tracking. Eight hours.

Paste https://flaviocopes.com and wait for one real morning fetch.