A news aggregator collects articles from many publishers and shows them in one place. Building one yourself usually means finding feeds, parsing them, removing duplicates and tagging topics. With a news API, all of that becomes a single request.

This tutorial builds a small, searchable news site in two files, using Node.js 18 or later and no dependencies. By the end you'll have:

  • a grid of top stories with images
  • keyword search and a category menu
  • "Load more" pagination
  • your API key kept safely on the server, with a 5-minute cache in front of it

I'm using DynamiRSS, the news API I build, but the server/browser split and the caching apply to any news API.

1. Get an API key

Sign up for DynamiRSS and copy your key from the dashboard. The free plan gives you 500 requests a month and up to 20 articles per request, which is plenty for this tutorial. Then make a first request to see what comes back:

curl -X POST https://api.rss.dynamiapps.com/scan \ -H "Authorization: Bearer $DYNAMIRSS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"category": "General", "has_image": true, "limit": 3}' 

You get JSON with an articles list. Each article has a title, url, source, summary, image, published_at and a few more fields, all described in the API reference. "General" is the category for top stories from major outlets; the sources page lists every category and the sources behind it.

2. Keep the key on a small server

Never call a news API with your key straight from the browser: anyone could copy it from the page and use up your quota. Instead, the browser asks your own server, and your server asks the API.

Create a folder with a file named server.mjs:

import http from "node:http"; import { readFile } from "node:fs/promises"; const API_URL = process.env.DYNAMIRSS_API_URL ?? "https://api.rss.dynamiapps.com"; const API_KEY = process.env.DYNAMIRSS_API_KEY; const PORT = process.env.PORT ?? 3000; const CACHE_MS = 5 * 60 * 1000; const cache = new Map(); async function searchNews({ query, category, offset }) { const body = { limit: 12, offset, has_image: true }; if (query) body.query = query; if (category) body.category = category; if (!query && !category) body.category = "General"; const cacheKey = JSON.stringify(body); const cached = cache.get(cacheKey); if (cached && Date.now() - cached.time < CACHE_MS) return cached.data; const res = await fetch(`${API_URL}/scan`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { const error = new Error(data.error ?? `DynamiRSS returned ${res.status}`); error.status = res.status; throw error; } if (cache.size > 500) cache.clear(); cache.set(cacheKey, { time: Date.now(), data }); return data; } function sendJson(res, status, value) { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(value)); } http .createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); if (url.pathname === "/api/news") { try { const data = await searchNews({ query: url.searchParams.get("q")?.trim(), category: url.searchParams.get("category"), offset: Number(url.searchParams.get("offset")) || 0, }); sendJson(res, 200, { articles: data.articles, has_more: data.has_more }); } catch (error) { sendJson(res, error.status === 429 ? 429 : 502, { error: error.message }); } return; } if (url.pathname === "/") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(await readFile(new URL("./index.html", import.meta.url))); return; } res.writeHead(404); res.end(); }) .listen(PORT, () => console.log(`News aggregator running at http://localhost:${PORT}`)); 

A few details worth knowing:

  • has_image: true skips articles without a picture, so every card looks complete.
  • With no search and no category, it shows "General" top stories.
  • Results are cached for five minutes. Ten visitors loading the front page cost one request, not ten, which keeps you well inside a monthly quota.
  • Only articles and has_more are passed to the browser, and a used-up quota comes back as a readable error (status 429) instead of a crash.

3. Show the articles

Next to it, create index.html. It has a search box and a category menu, renders each article as a card, and loads more when you click the button:

<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>My News</title> <style> body { font-family: system-ui, sans-serif; max-width: 960px; margin: 0 auto; padding: 16px; } form { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; } #articles { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } .card { border: 1px solid #ddd; border-radius: 8px; overflow: hidden; } .card img { width: 100%; height: 150px; object-fit: cover; } .card div { padding: 12px; } .card small { color: #666; } </style> </head> <body> <h1>My News</h1> <form id="search"> <input name="q" placeholder="Search, e.g. climate"> <select name="category"> <option value="">Top stories</option> <option>Business &amp; Markets</option> <option>Artificial Intelligence</option> <option>Sports</option> <option>Science</option> </select> <button>Search</button> </form> <p id="status"></p> <div id="articles"></div> <button id="more" hidden>Load more</button> <script> const form = document.getElementById("search"); const list = document.getElementById("articles"); const more = document.getElementById("more"); const status = document.getElementById("status"); let offset = 0; function card(article) { const el = document.createElement("article"); el.className = "card"; el.dir = article.is_rtl ? "rtl" : "ltr"; if (article.image) { const img = document.createElement("img"); img.src = article.image; img.alt = ""; img.loading = "lazy"; el.append(img); } const body = document.createElement("div"); const link = document.createElement("a"); link.href = article.url; link.target = "_blank"; link.rel = "noopener"; link.textContent = article.title; const meta = document.createElement("small"); const date = article.published_at ? new Date(article.published_at).toLocaleString() : ""; meta.textContent = [article.source, date].filter(Boolean).join(" · "); const summary = document.createElement("p"); summary.textContent = article.summary; body.append(link, document.createElement("br"), meta, summary); el.append(body); return el; } async function load(reset) { if (reset) { offset = 0; list.replaceChildren(); } status.textContent = "Loading…"; const params = new URLSearchParams(new FormData(form)); params.set("offset", offset); const res = await fetch(`/api/news?${params}`); const data = await res.json(); if (!res.ok) { status.textContent = data.error; return; } list.append(...data.articles.map(card)); offset += data.articles.length; more.hidden = !data.has_more; status.textContent = offset === 0 ? "No articles found." : ""; } form.addEventListener("submit", (e) => { e.preventDefault(); load(true); }); more.addEventListener("click", () => load(false)); load(true); </script> </body> </html> 

Articles are added with textContent rather than innerHTML, so a headline can never inject markup into your page. Cards for Arabic and Hebrew sources are set to right-to-left using the is_rtl field. Each card links to the original article and names its source, which is also what publishers expect when you show their headlines.

4. Run it

DYNAMIRSS_API_KEY=your_key node server.mjs 

On Windows PowerShell, set the key first:

$env:DYNAMIRSS_API_KEY="your_key" node server.mjs 

Open http://localhost:3000. You should see a grid of top stories. Search for a topic, pick a category, and use Load more to page through results.

5. Where to go from here

  • More filters. Add language for non-English news, source to follow specific outlets, or date_from for recent stories only. See narrowing results.
  • A full category menu. GET /categories needs no key and returns every category name, so you can build the menu from it instead of hard-coding a few.
  • Newest first. The default order shows one story from each source before repeating any. Send sort: "newest" for a plain timeline.
  • On WordPress? The DynamiRSS Blocks plugin does all of this without code. See the WordPress docs.

If you build something with this, I'd love to see it in the comments. You can get a free API key (no credit card) or try the live demo first.