Software Journal
Web Development Updated Aug 26, 2026 9 min read

Inside a Minimal Flask News Aggregator: RSS Parsing, Jinja2, and Bootstrap Without a Database

A code walkthrough of azario0/flask-news-aggregator — a lightweight Flask app that merges multiple RSS/Atom feeds into a single, filterable, dark-themed timeline with zero database dependency.

Portrait of Benmalek Zohir

Benmalek Zohir

Contributor

Share
Inside a Minimal Flask News Aggregator: RSS Parsing, Jinja2, and Bootstrap Without a Database

Inside a Minimal Flask News Aggregator: RSS Parsing, Jinja2, and Bootstrap Without a Database

Most “build a news aggregator” tutorials reach for a database, a background worker, and a caching layer before they’ve shown you a single headline. azario0/flask-news-aggregator takes the opposite approach: it’s a ~125-line Flask app that fetches RSS feeds live, on every request, and renders them with Jinja2 and Bootstrap 5. No models, no migrations, no ORM — just feedparser, a couple of helper functions, and a template.

That simplicity makes it a good specimen to dissect: every design decision is visible, and every trade-off is a deliberate omission rather than something buried under framework magic. Here’s how it’s put together.

Repository layout

flask-news-aggregator/
└── news-aggregator/
    ├── app.py
    ├── requirements.txt
    ├── static/
    │   └── css/style.css
    └── templates/
        ├── base.html
        └── index.html

Stack, per the project’s own README:

requirements.txt confirms the entire dependency surface:

Flask
feedparser
python-dotenv

Three packages. No SQLAlchemy, no Celery, no Redis. That’s the whole point of this design — the app is stateless and disposable, which trades runtime performance for essentially zero operational overhead.

Configuration: environment variables over hardcoding

At the top of app.py, feeds are loaded from an environment variable rather than a config file or database table:

load_dotenv()

DEFAULT_FEEDS = [
    'https://feeds.bbci.co.uk/news/rss.xml',
    'http://rss.slashdot.org/Slashdot/slashdotMain'
]
RSS_FEEDS_STR = os.getenv('RSS_FEEDS')
if RSS_FEEDS_STR:
    FEEDS = [feed.strip() for feed in RSS_FEEDS_STR.split(',')]
else:
    FEEDS = DEFAULT_FEEDS

python-dotenv’s load_dotenv() reads a .env file into os.environ at process start. RSS_FEEDS is expected as a single comma-separated string (e.g. RSS_FEEDS=url1,url2,url3), split and stripped into a Python list. If the variable is absent, it falls back to two hardcoded defaults (BBC News and Slashdot).

This is the classic twelve-factor app pattern: config lives in the environment, not in source. It’s a reasonable choice for a project this size, though it does mean feed lists can’t be changed without a restart — there’s no admin UI or hot-reload.

Pre-loading feed metadata for the filter dropdown

Before any request is handled, the app eagerly resolves human-readable titles for each configured feed:

def get_feed_details(url):
    try:
        feed = feedparser.parse(url)
        return {
            'url': url,
            'title': feed.feed.get('title', urlparse(url).netloc)
        }
    except Exception as e:
        print(f"Could not fetch details for {url}: {e}")
        return {'url': url, 'title': urlparse(url).netloc}

FEEDS_WITH_DETAILS = [get_feed_details(url) for url in FEEDS]

feedparser.parse(url) fetches and parses the feed, returning a FeedParserDict — a dict-like object with attribute access. feed.feed holds channel-level metadata (title, description, link), separate from feed.entries, which holds the individual articles. If the feed has no title field, or the request fails outright, the code falls back to the domain name via urlparse(url).netloc.

This runs once at import time — i.e., once per app process, not once per request — and the result (FEEDS_WITH_DETAILS) is cached in a module-level list for the lifetime of the process. It’s a reasonable micro-optimization, but it has a real consequence worth flagging: this network call happens synchronously at startup, so if a configured feed is slow or unreachable, flask run will hang or stall before the server starts accepting connections at all.

Fetching and merging articles

The core logic lives in fetch_articles():

def fetch_articles(selected_feed_url=None):
    articles = []
    feeds_to_process = [selected_feed_url] if selected_feed_url else FEEDS

    for url in feeds_to_process:
        try:
            feed = feedparser.parse(url)
            source_title = feed.feed.get('title', 'Unknown Source')
            source_icon = get_favicon_url(url)

            for entry in feed.entries:
                articles.append({
                    'title': entry.get('title', 'No Title'),
                    'link': entry.get('link', '#'),
                    'summary': entry.get('summary', ''),
                    'published_parsed': entry.get('published_parsed', time.gmtime()),
                    'source_title': source_title,
                    'source_icon': source_icon
                })
        except Exception as e:
            print(f"Error fetching or parsing feed {url}: {e}")

    articles.sort(key=lambda x: x['published_parsed'], reverse=True)
    return articles

A few things worth calling out here as an experienced reader:

  • Normalization. Every feed entry, regardless of source format (RSS 2.0, Atom, RDF — feedparser abstracts over all of them), gets flattened into a uniform dict with title, link, summary, published_parsed, plus the source’s display name and favicon. This is the app’s only real “data model,” and it’s implicit rather than declared as a class or TypedDict.
  • Sorting key. published_parsed is a time.struct_time (already parsed by feedparser from whatever date format the feed used — RFC 822, ISO 8601, etc.), which sorts correctly with a plain tuple comparison. Using time.gmtime() as a fallback for entries missing a publish date means undated entries sort as if published “now,” pushing them to the top rather than the bottom — a subtle default that could surprise you if a feed frequently omits dates.
  • Failure isolation. Each feed is fetched inside its own try/except, so one broken or slow feed doesn’t take down the whole page — the loop just moves to the next URL and logs to stdout. There’s no timeout configured on feedparser.parse, though, so a feed that hangs (rather than erroring) will still stall the request.
  • No caching. This function runs fully, over the network, on every single page load. It’s the most consequential simplification in the whole app — see “Trade-offs” below.

Favicons via a third-party service

def get_favicon_url(feed_url):
    domain = urlparse(feed_url).netloc
    return f"https://www.google.com/s2/favicons?domain={domain}&sz=32"

Rather than fetching and hosting favicons itself, the app delegates to Google’s public favicon service, requesting a 32px icon for the feed’s domain. This is a clever shortcut — zero local logic or storage — but it does introduce a hard dependency on a third-party, undocumented Google endpoint that could change behavior or rate-limit without notice.

Turning struct_time into readable text: a Jinja2 custom filter

@app.template_filter('datetimeformat')
def format_datetime(value):
    if not value:
        return ""
    dt_object = datetime.fromtimestamp(time.mktime(value))
    return dt_object.strftime('%a, %d %b %Y %H:%M')

@app.template_filter registers this function as a Jinja2 filter usable inside templates via the pipe syntax. time.mktime() converts the struct_time into a Unix timestamp, which datetime.fromtimestamp() turns into a datetime object, formatted with strftime. It’s invoked in index.html as:

{{ article.published_parsed | datetimeformat }}

This is the idiomatic Flask way to keep presentation logic (date formatting) out of both the route handler and the raw template — it’s a small, testable, reusable unit.

The single route

@app.route('/')
def index():
    selected_feed = request.args.get('feed')

    if selected_feed and selected_feed not in FEEDS:
        selected_feed = None

    articles = fetch_articles(selected_feed)

    page_title = "All News"
    if selected_feed:
        for feed_info in FEEDS_WITH_DETAILS:
            if feed_info['url'] == selected_feed:
                page_title = feed_info['title']
                break

    return render_template(
        'index.html',
        articles=articles,
        feeds=FEEDS_WITH_DETAILS,
        selected_feed=selected_feed,
        page_title=page_title
    )

The entire app is one route. Filtering by source is done via a query parameter — /?feed=<url> — rather than a path segment or POST form, which keeps the filtered view bookmarkable and shareable. Note the validation step: selected_feed is checked against the known FEEDS list before use, which prevents a client from passing an arbitrary URL and turning this endpoint into an open SSRF proxy that fetches whatever feed URL they supply. That’s a small but important defensive detail easy to miss on first read.

Templates: inheritance and the safe filter

base.html defines the shared shell — Bootstrap CDN links, dark theme (data-bs-theme="dark"), navbar, footer — with a single {% block content %} for child templates to fill:

<html lang="en" data-bs-theme="dark">
  ...
  <main class="container my-4">
    {% block content %}{% endblock %}
  </main>
  ...
</html>

index.html extends it ({% extends "base.html" %}) and renders the article list inside a Bootstrap dropdown/card layout. The most notable line is:

{{ article.summary | safe }}

Feed summaries frequently contain embedded HTML (<p>, <img>, <a> tags from the publisher). Jinja2 autoescapes variables by default, so without | safe this markup would render as literal text. Marking it safe disables that escaping and renders the HTML as-is.

This is worth pausing on if you’re evaluating the code for production use. Rendering unescaped HTML sourced from external, third-party feeds is a textbook stored/reflected XSS vector if any configured feed (or a compromised one) includes malicious markup or script-bearing tags in its summary field. For a personal read-it-yourself dashboard against trusted feeds like BBC or Slashdot, the risk is low. For anything with untrusted feed sources or multiple users, this line needs a sanitization pass (e.g. bleach or nh3) before shipping to production.

The dropdown menu iterates feeds (the pre-loaded FEEDS_WITH_DETAILS) and builds filter links with url_for('index', feed=feed.url), letting Flask handle URL encoding automatically — this is why the template comment calls out urlencode as “crucial,” though in this case it’s implicit via url_for rather than an explicit filter.

Styling

static/css/style.css is a small set of enhancement rules layered on top of Bootstrap’s dark theme: a hover transform and shadow on .article-card for a bit of interactivity, fixed 24px rounded favicons, and a rule ensuring images embedded in feed summaries stay responsive (max-width: 100%) instead of overflowing their card. Nothing framework-specific — it’s standard CSS transitions and box-shadow work riding on top of Bootstrap 5’s utility classes.

How it all fits together, end to end

  1. On process start: .env is loaded, FEEDS is resolved, and FEEDS_WITH_DETAILS is populated by synchronously hitting every feed once.
  2. On each GET /: the optional feed query param is validated against known feeds.
  3. fetch_articles() re-fetches the relevant feed(s) over the network, normalizes entries into plain dicts, and sorts by date.
  4. render_template hands articles, feeds, selected_feed, and page_title to index.html, which extends base.html.
  5. Jinja2 renders the card list, applying the datetimeformat filter for timestamps and safe for embedded HTML summaries.
  6. Bootstrap 5 (via CDN) and the local style.css handle all layout and visual polish; no custom JavaScript is shipped.

Trade-offs worth naming explicitly

The project’s own README is candid about this being a starting point, listing exactly the gaps you’d expect:

  • No caching — every page load re-fetches every feed over the network. Under any real traffic this is slow and unfriendly to the upstream feed servers.
  • No background fetching — a scheduler (APScheduler) or task queue (Celery) would decouple fetching from request/response and make page loads instant.
  • No persistence — nothing is stored, so there’s no read/unread state, no search, no history beyond what each feed currently exposes.
  • No sanitization on feed HTML — flagged above, the | safe filter is the one line most worth revisiting before any public deployment.

None of these are bugs — they’re the intentional scope boundary of a “simple, clean, and modern” reference implementation, and the README lists them itself as the natural next steps. As a teaching example, that scoping is exactly what makes the code readable in one sitting: every mechanism (feed parsing, template filters, query-param filtering, Jinja2 inheritance) is exposed rather than abstracted behind a framework layer, which is precisely what makes it a useful codebase to read end to end.

Source

Full code: github.com/azario0/flask-news-aggregator

Share
Portrait of Benmalek Zohir

Written by

Benmalek Zohir

Founder, AI Engineer & Full Stack Developer

Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.

The Software Journal Dispatch

One excellent engineering read, every week.

A concise digest of our best new essays on architecture, tooling, databases, and the craft of software. No spam, no noise — unsubscribe anytime.