CASE · 012024 — ONGOINGSOLOREALTIME · MULTILINGUAL
◦ AZADIFEED · azadifeed.com

A crisis feed. In three languages. In seconds.

Curated, AI-translated real-time news for the Iranian diaspora. RSS in, Telegram out, full RTL, under €50/month.

§ 01Problem · motivation

Why this exists.

No central, multilingual hub for verified breaking news during protests, execution waves, and internet shutdowns.

For the Iranian diaspora — and people inside Iran itself — during crisis events there is no central, multilingual hub that bundles verified news in real time. Relevant reports are scattered across dozens of Persian Telegram channels, Twitter accounts, and Western outlets that lag behind.

Existing news portals are either Farsi-only (unreadable for the second diaspora generation), English/German-only (without Persian context), or pure aggregators without crisis classification and push. AzadiFeed curates RSS sources automatically, uses AI to translate into EN / DE / FA, and pushes breaking news within seconds to Telegram subscribers with city-level filtering.

§ 02Constraints · operating box

The box it had to fit in.

Every architecture decision had to be checked against these constraints.
C/01 · OPS
Solo dev, side project — everything has to be maintainable without an ops team.
C/02 · BUDGET
Hard cap: < €50/month — hosting + OpenAI + self-hosted MongoDB.
C/03 · LATENCY
RSS item → Telegram push in < 2 minutes, including triple translation + crisis scoring.
C/04 · LANGUAGES
EN / DE / FA with full RTL layout — Vazir font, mixed LTR in quotes/URLs.
C/05 · MODERATION
Auto-publish only above the crisis threshold. Everything below goes to the manual queue — false positives would be politically sensitive.
C/06 · TRAFFIC
Spiky — 95 % of the time <100 visits/h, during crisis events 50–100× spikes within minutes.
C/07 · PRIVACY
IP hashing, 24h retention for crisis reports, GDPR-compliant for the German diaspora.
C/08 · OFFLINE
PWA with service worker — users inside Iran on unstable connections keep reading cached items.
§ 03Architecture · system topology

How it runs.

Coolify + Docker on a self-hosted VPS. MongoDB co-hosted locally. OpenAI as translation/classification layer. Socket.IO and Telegram as sinks.
pipeline.live·tick 00:00·interval 5–10m
published/24h 0·p50 latency
SOURCE · 01
RSS · N sources
persian + intl media
fetched0
CRON · 02
rss-sync · 5–10m
setInterval · self-hosted
LOCK · 03
cron-lock · atomic
prevents double-translate
held2.4s
QUEUE · 04
pending_feeds
MongoDB · in-flight
in-flight0
TRANSLATE · 05
OpenAI · fa→en/de
nano · mini · 4.1
nanomini4.1
CLASSIFY · 06
Crisis-relevance
auto-publish gate
IMG · 07
cover scraper
og:image · twitter:image · fallback
cached82%
STORE · 08
published_feeds
MongoDB · indexed
total0
MODERATE · 09
manual queue
below crisis threshold
pending12
SOCKET · 10
Socket.IO
web clients · realtime
connected0
CITY · 11
city filter
TG channel routing
channels14
TG · 12
Telegram bot
city-targeted push
pushed0
EVENT LOG · /api/cron · /api/publish · MongoDB writes
12:04:18crontick · interval 7m · lock acquired 2.4s
12:04:14translateopenai mini · fa→en · 0.74s · 312 tokens
12:04:12classifycrisis-score 0.87 · auto-publish ✓
12:04:09imgcover · og:image · 280×180 · cached
12:04:07tgpush · #city-tehran · 2,180 subs · deduped
12:04:04socket.iobroadcast · 142 connected · room:home
12:04:01classifycrisis-score 0.34 · → moderate queue
12:03:57rssfetched · BBC Persian · 18 new items
12:03:52cityfilter · 14 channels · 8 matches
12:04:18crontick · interval 7m · lock acquired 2.4s
12:04:14translateopenai mini · fa→en · 0.74s · 312 tokens
12:04:12classifycrisis-score 0.87 · auto-publish ✓
12:04:09imgcover · og:image · 280×180 · cached
12:04:07tgpush · #city-tehran · 2,180 subs · deduped
12:04:04socket.iobroadcast · 142 connected · room:home
12:04:01classifycrisis-score 0.34 · → moderate queue
12:03:57rssfetched · BBC Persian · 18 new items
12:03:52cityfilter · 14 channels · 8 matches
§ 04Decisions · trade-offs

Four deliberate choices.

Per decision: what was chosen, instead of what, why.
D/01

Coolify + Docker instead of Vercel.

chosen
Self-hosted Coolify on VPS · internal setInterval-scheduler in src/lib/scheduler.ts
instead of
Vercel with vercel.json cron jobs
reason
Vercel cron is limited to 1×/day on the free tier, and Pro costs €20/month. A self-hosted VPS allows 5–10-minute intervals + Socket.IO without function timeouts + no cold starts during traffic spikes.
D/02

MongoDB as the queue, instead of Redis.

chosen
Two collections (pending_feedspublished_feeds) with an aiProcessed: false flag as the work queue, distributed lock via a Mongo document
instead of
Redis + BullMQ or SQS
reason
MongoDB was already running. Another infrastructure component would have blown the budget and doubled the failure surface. For 100–500 items/day, polling + lock is enough.
D/03

OpenAI multi-model routing at runtime.

chosen
Model selection via admin setting (gpt-5-nano / mini / 4.1); config in src/lib/openai-config.ts
instead of
Hardcoded gpt-4o for all items
reason
80 % of RSS items are routine (weather, business, sports) — nano is enough for classification + translation and costs ~1/30 of 4.1. During crisis escalations the admin switches live to 4.1 — no redeploy.
D/04

Socket.IO instead of SSE or Pusher.

chosen
Socket.IO mounted via the Pages Router (src/pages/api/socket.ts) on Next.js's built-in server — same host as the API, optional Redis adapter for horizontal scaling
instead of
Server-Sent Events or Pusher / Ably
reason
Pusher starts at $49/month for >100 concurrent connections. Socket.IO needs no third system, has built-in reconnect logic, and later allows bidirectional admin moderation events without a rewrite.
§ 05Highlights · interesting bits

Things that were not obvious.

Edge cases and details that only became clear while building.

RTL with mixed LTR segments

H/01
Persian articles regularly contain Latin proper names, URLs, hashtags (#MahsaAmini), and numerals. Naive RTL setups break the layout because browsers apply the bidi algorithm inconsistently to mixed strings.

Current state: <html dir="rtl"> per locale, Vazirmatn as a variable font with a unicode-range fallback to Inter for Latin glyphs — the browser's bidi algorithm (UAX #9) handles the rest. Carries mainstream cases. On the roadmap: unicode-bidi: plaintext plus explicit Unicode wrapping \u202B…\u202C of the OpenAI outputs — bidi belongs in the content pipeline, not the stylesheet.

Distributed lock via a MongoDB document

H/02
During deploys the Coolify scheduler runs briefly in two containers in parallel (rollout). A classic "single instance only" design leads to doubled OpenAI spending — every item translated twice = 2× cost + 2× Telegram push.

Solution in src/lib/cron-lock.ts: an atomic findOneAndUpdate on a locks document plus an expireAfterSeconds: 0 TTL index on expiresAt; lock duration is per-job (300–900 s). The process that loses the lock returns early. The TTL also catches crashes — no stuck process after an OOM kill.

Content enrichment fallback chain

H/03
RSS often only delivers a title + 200-character summary, sometimes without an OG image. Pure RSS pipelines produce broken cards.

Three-stage chain wired up in src/app/api/cron/rss-sync/route.ts: (1) JSDOM-scrape the article URL via src/lib/content-fetcher.ts for og:image / twitter:image / link rel=image_src, (2) fall back to the RSS item's image fields in priority order: media:content (highest resolution) > <enclosure> > media:thumbnail > first <img> inside the content HTML, (3) if both stages turn up nothing, <NewsPlaceholder> renders a gradient card with a category- and crisis-type-aware Lucide icon — never a broken image. Each network fetch is wrapped in a 10–15 s AbortController timeout so a dead source doesn't block the pipeline.

Crisis relevance as the auto-publish gate

H/04
The OpenAI response returns not just translations but JSON with crisisRelevanceScore: 0–100 (persisted as crisisRelevance). An admin-configurable threshold (minCrisisRelevanceThreshold) decides autoPost — anything below lands in the moderation queue.

Solves two problems at once: (a) weather news doesn't get pushed as "breaking", (b) the admin can temporarily lower the threshold during acute situations to let more through.
§ 06Stack · in production

What's running.

Working toolchain in production — nothing theoretical.
Next.js 16React 19TypeScriptTailwindMongoDB (self-hosted)Socket.IOOpenAI (nano · mini · 4.1)Cheerioisomorphic-dompurifyCoolifyDockerHetzner VPSTelegram Bot APIPWA · Service WorkerVazir (Farsi)
§ 07Reflection · takeaways

What I learned.

Project is live. These are the things I'm carrying into the next ones.

Budget as an architectural constraint.

The €50/month cap forced more good decisions than it prevented. MongoDB-as-queue, multi-model routing, a self-hosted socket server — all decisions I wouldn't have followed through on so rigorously without the cap. Constraints are features.

Bidi isn't a CSS property, it's a product problem.

RTL with interspersed LTR content needs a content-pipeline decision, not a stylesheet property. Push it onto the CSS layer and you'll fail. On the next multilingual project I'll plan bidi normalization as a translation-output format from day one.

◦ NEXT CASE · 02 / 11
AI Builds
← all projects