JavaScript

Make a story feed interactive with JavaScript

Add search, category filters, and a saved-articles list to a server-rendered feed without breaking a single link.

Nov 6, 2026 · By Administrator
Make a story feed interactive with JavaScript

The home page of this site is a server-rendered list of article cards. Without JavaScript, it is still a complete, linkable page. With JavaScript, it gains instant search, category filters, and a reading list. This article walks through how that enhancement works and why it is built this way.

Start with HTML that already works

Each card is an ordinary article element with a real link. The extra data the script needs travels in data attributes:

article.post-card data-category="{{post.category}}" data-slug="{{post.slug}}" a.media href="/post/{{post.slug}}" h3 a href="/post/{{post.slug}}" "{{post.title}}"

If the script fails to load, readers still see every story and can open each one. Enhancement adds convenience. It never takes away access.

Build filters from the content

Instead of hard-coding category buttons, the script reads them from the cards. A new category in the CMS gets a filter tab automatically:

const cards = [...document.querySelectorAll('.post-card')]; for (const name of new Set(cards.map(card => card.dataset.category).filter(Boolean))) { const button = document.createElement('button'); button.type = 'button'; button.className = 'filter-tab'; button.dataset.filter = name; button.textContent = name; tabs.append(button); }

One function decides visibility

Search, category, and the saved-only toggle are combined in a single update function. A card is visible only when it passes all three tests. Keeping the rule in one place avoids the classic bug where two filters fight each other.

card.hidden = !( (category === 'all' || card.dataset.category === category) && (!onlySaved || saved.has(card.dataset.slug)) && card.textContent.toLowerCase().includes(query) );

Using the hidden attribute removes cards from both the layout and the accessibility tree, so screen reader users hear the same filtered list that sighted users see.

A laptop displaying an interactive analytics dashboard

Tell people what happened

Filtering should never leave a mystery. The script updates a status line such as "03 stories to explore". When nothing matches, it shows an empty state that explains why, instead of a blank grid. Buttons also expose their state with aria-pressed, so assistive technology announces which filter is active.

Bookmarks with localStorage, done defensively

Saved articles are stored as a list of slugs in localStorage. Storage can be disabled, full, or corrupted, so every read and write is wrapped:

const read = (key, fallback) => { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } }; const write = (key, value) => { try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* Storage may be disabled. */ } };

The stored value is also validated, so only strings are kept, before it becomes a Set. Treat anything read from storage as untrusted input, even if your own code wrote it.

Know the limits

localStorage belongs to one browser on one device. A reading list built this way does not sync between devices and disappears when site data is cleared. That is fine for a convenience feature, but not for anything a reader would be upset to lose.

Checklist for your own feed

  • The page works and every link opens with JavaScript disabled.
  • Filters are derived from the content, not duplicated in the script.
  • A single function combines all filters.
  • The status text and empty state explain the result.
  • Button state is exposed with aria-pressed.
  • Storage access is wrapped in try/catch and validated.

Comments

Leave a comment