When I started building CrisisPulse, I had one constraint: it had to work without a backend database, a framework, or a build pipeline. The result is crisispulse.org — a live global conflict monitor + emergency supply calculator, shipped as a single HTML file.
Here's how the architecture works.
The Stack
Frontend: Pure HTML/CSS/JS with D3.js for the world map. No React, no build step. The entire app ships as one file (~99KB). Zero dependencies to install, zero build times.
Daily news updates: A Netlify Scheduled Function runs @daily, fetching Bing RSS feeds for 25+ conflict zones. It parses article counts to calculate intensity scores and deltas, translates headlines to Chinese via the Google Translate gtx endpoint, and stores everything to Netlify Blobs.
Persistence without a database: Netlify Blobs is a built-in KV store included with Netlify. Visitor counts by country, conflict data, subscriber emails — all stored there. No Postgres, no Redis, no external API keys for storage.
The Tricky Part — Bing RSS URL Decoding
Bing's RSS feeds double-encode their redirect URLs. The <link> tags look like this:
https://www.bing.com/news/apiclick.aspx?url=https%3A%2F%2F...&ref=...
The & breaks new URL() parsing. The fix:
function extractRealUrl(bingUrl) {
let clean = bingUrl.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
try {
if (clean.includes('apiclick.aspx') || clean.includes('bing.com/news')) {
const u = new URL(clean);
const real = u.searchParams.get('url');
if (real) return decodeURIComponent(real);
}
if (clean.startsWith('http')) return clean;
} catch (_) {}
return clean;
}
Decode HTML entities before parsing the URL params. Simple fix, non-obvious bug.
Bilingual Support Without a Translation API Key
A static CONFLICT_ZH map covers all 25 conflict names, types, and descriptions. Dynamic news descriptions get batch-translated via the free Google Translate gtx endpoint (no API key required). Language switching re-runs the risk calculation rather than serving stale cached strings — a subtle bug I hit early on where the cache stored already-translated strings.
Visitor Tracking by Country
Each page load hits a Netlify Function that:
- Reads the visitor's country from
context.geo(Netlify's built-in geolocation) - Falls back to GPS if available
- Increments a per-country counter in Netlify Blobs
- Returns the updated counts for the left-side visitor panel
No external analytics, no cookies, no tracking scripts.
What I'd Do Differently
The single-file constraint was a feature, not a limitation. It forced every line to justify its existence. But I'd reconsider using the free Google Translate gtx endpoint in production — it's undocumented and could break without notice.
Live site: crisispulse.org
Free, no signup, supports English and Chinese.
Top comments (1)
Things I'd change in v2 — AMA if you're curious 👇
A few trade-offs I made that I'm still not 100% sure about:
Single HTML file vs. SSR/ISR. Shipping one ~100KB file means zero build step and instant edge cache hits, but the map data + 25 conflicts + i18n strings all load upfront. For first paint on slow connections that's a real cost. Would splitting critical-path JSON from lazy-loaded map assets be worth the complexity? I went with "ship it all" because Brotli on Netlify makes the gzipped payload surprisingly small (~32KB), but the jury's out.
Bing RSS + Google Translate gtx over a real news API. It's free, multilingual, and needs zero API keys — but Bing sometimes returns stale entries and gtx has no SLA. The news freshness SLO is basically "best effort." A paid NewsAPI would fix this at ~$50/mo. For a side project I chose the zero-cost path. Wrong call for a production OSINT tool? Probably.
Netlify Blobs instead of Postgres/SQLite-on-the-edge. Blobs is basically a KV store with no query engine. Fine for visitor counters and RSS snapshot caches, painful the moment you want analytics. I'll hit this wall within a month.
Happy to go deeper on any of these — the code is ~1200 lines of vanilla JS, nothing is hidden. Fire away 🙋