Build a server-side RSS digest

Point it at any RSS feed and it renders a clean digest page plus a JSON endpoint — built with rss-parser on the Node.js application, and honest about the one thing that makes or breaks it: reaching the feed.

Lots of sites still publish an RSS feed — a machine-readable list of their latest posts. This build fetches one on your server, parses it with the rss-parser library, and serves two things: a tidy HTML digest page and a JSON endpoint your own apps can consume. It's a great first taste of a server that talks to the outside world and reshapes what it gets back.

At a glance
You need A server running the Node.js application, and the URL of an RSS/Atom feed
Plan Any plan — free runs while your session timer has time left, premium runs 24/7
Time About fifteen minutes
No server yet? Create your first app server

For the Node basics this builds on — the index.js entry file and automatic npm install — see Node.js on Falix.

Step 1 — The files

package.json:

{
  "name": "rss-reader",
  "version": "1.0.0",
  "private": true,
  "main": "index.js",
  "dependencies": {
    "express": "^4.21.2",
    "rss-parser": "^3.13.0"
  }
}

index.js:

const express = require('express');
const Parser = require('rss-parser');

const parser = new Parser();
const FEED_URL = process.env.FEED_URL || 'https://hnrss.org/frontpage';

function esc(s) {
  return String(s || '').replace(/[&<>"']/g, (c) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

const app = express();

app.get('/', async (req, res) => {
  try {
    const feed = await parser.parseURL(FEED_URL);
    const items = feed.items.slice(0, 20).map((i) =>
      `<li><a href="${esc(i.link)}">${esc(i.title)}</a><br><small>${esc(i.pubDate || '')}</small></li>`
    ).join('\n');
    res.send(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(feed.title)}</title></head>
<body style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto;">
  <h1>${esc(feed.title)}</h1>
  <ul>${items}</ul>
</body>
</html>`);
  } catch (err) {
    res.status(502).send(`Could not load the feed: ${esc(err.message)}`);
  }
});

app.get('/api/digest', async (req, res) => {
  try {
    const feed = await parser.parseURL(FEED_URL);
    res.json({
      title: feed.title,
      items: feed.items.slice(0, 20).map((i) => ({ title: i.title, link: i.link, date: i.pubDate })),
    });
  } catch (err) {
    res.status(502).json({ error: err.message });
  }
});

const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));

Optionally add a .env file to set the feed without touching code:

FEED_URL=https://your-favourite-blog.example/feed.xml

(Reading .env needs the dotenv package and a require('dotenv').config() at the top — or just edit the FEED_URL default in the code. Either works.)

Step 2 — How it works

rss-parser does the hard part. parser.parseURL(FEED_URL) fetches the feed over the network and hands you back a normalized object: feed.title and feed.items, where each item has title, link, pubDate, and more — regardless of whether the source was RSS or Atom.

The two routes reshape that same data differently:

  • / builds an HTML list — the first 20 items as links. Every value goes through esc() so a title with special characters can't break the page.
  • /api/digest returns the same items as JSON, ready for another app to fetch.

Both wrap the fetch in try/catch and return 502 on failure, because the one thing that can go wrong here is out of your hands: the feed being unreachable.

🎯 Good to know: This server makes an outbound request every time someone loads the page. Falix app servers can reach the internet freely — the same way a Discord bot connects out — so no ports or firewall changes are needed for the fetch itself. SERVER_PORT is only for the traffic coming in to your digest page.

Step 3 — Start it and verify

Press Start. When the console shows Listening on port …, open your server's address (from the Network page):

  • The root page shows the feed's title and its latest items as clickable links.
  • /api/digest returns the same list as JSON.

If the feed is down or the URL is wrong, you get a readable 502 with the reason instead of a crash — which is exactly what you want a fetch-based app to do.

Fetch fresh vs. cache

Right now every page load re-fetches the feed. That's fine for personal use, but it hits the source site on each visit. Two easy improvements:

  • Cache in memory: fetch once, store feed in a variable with a timestamp, and only re-fetch when it's older than, say, five minutes. Fewer requests, faster pages.
  • Build a scheduled digest: instead of fetching on demand, pull the feed on a timer and save the result. A Schedule can restart or ping your app on an interval — pair it with a cached copy to send yourself a daily roundup. (Keeping the app up so it can fetch is the keeping apps online question.)

⚠️ Heads up: A cache lives in memory and a saved digest lives in a file — both are lost on a restart or reinstall, the file-based one permanently. Nothing here is precious (it's a mirror of a public feed), so that's usually fine; re-fetching rebuilds it.

Troubleshooting

  • Every request returns 502 — the server can't reach the feed, or the URL isn't a valid feed. Open FEED_URL in your own browser: if it downloads XML, it's a feed; if it shows a normal web page, that site doesn't expose RSS at that address.
  • Some feeds parse, one doesn't — a few sites block automated requests or serve malformed XML. rss-parser accepts a custom User-Agent and timeout in its options if a specific feed is fussy.
  • Cannot find module 'rss-parser' — install it from the Packages page (or check it's in package.json) and restart.
  • Page won't load at all — that's the inbound side: a port or bind problem, see I can't reach my app.

Feed quirks, custom fields, and timeouts are all covered by the library — see the rss-parser package docs for the full option list.


Next steps

Was this guide helpful?