ALL WRITING
White Paper · Systems & Full-Stack

Single Origin: A Production Civic-Reporting Platform on a Free-Tier Budget

India आसपास turns a photo of a pothole into a shareable, crawler-friendly civic report. The hard parts were never the map — they were making a JavaScript app describe itself to WhatsApp, trusting strangers just enough to let them post, and hardening an upload path that assumes every phone photo carries the sender's home GPS. Here is how it all fits in one Node process running for about US$3 a month.

Node 22 / Express PostgreSQL SSR meta shell React + Leaflet Cloudflare R2 Zod · JWT

Most "report a civic issue" apps die quietly. Not because the map is hard, but because a report that cannot travel is a report nobody acts on. The whole design of India आसपास — भारत के लिए एक सामुदायिक समस्या रिपोर्टिंग प्लेटफॉर्म — is bent toward one goal: a resident photographs a pothole, garbage dump, or broken streetlight, and the resulting link renders as a rich preview card the moment it lands in a WhatsApp group.

01A civic problem that is really a distribution problem

Residents report the ordinary failures of a shared city: potholes, garbage dumping, illegal construction, traffic, water and drainage, dead street lighting. Anyone can report anonymously and a moderator publishes it; a verified resident publishes instantly. That part is table stakes.

The part that decides whether the platform matters is what happens after a report is published. Civic pressure is a numbers game — a report shared to a neighbourhood WhatsApp group, to X, to a councillor's inbox, is worth a hundred reports sitting in a database. So every published report has to be shareable to WhatsApp, X, Facebook, Telegram and LinkedIn with a real preview card, not a bare grey URL. That single requirement drove almost every architectural decision below.

6
civic issue categories on one public map
67
backend tests against a real Postgres engine
1,200
seed reports across 12 Indian cities
~$3/mo
production hosting, Mumbai region

02One process, one origin

The most consequential choice was to serve everything — the JSON API, the metadata shell for crawlers, the generated share cards, and the built single-page app — from one Node 22 / Express process on one origin. It sits behind Cloudflare's free CDN for TLS, caching and WAF, and talks to a managed PostgreSQL and an S3-compatible object store.

Clients Browser WhatsApp / Googlebot Cloudflare TLS · cache · WAF free tier Node 22 · Express 4 /api/* — JSON API /issue/* — SSR meta shell /og/* — generated share cards /* — built React SPA PostgreSQL Supabase free Object store Cloudflare R2
One process answers the API, the crawler shell, the share cards and the SPA — no cross-origin surface to police.

The alternative — a static SPA host plus a separate API — is the default that quietly kills the sharing feature. It introduces CORS preflights, cross-site cookie restrictions, and the exact class of bug where a social crawler hits the static host and never reaches the code that knows how to describe the page. Collapsing everything onto one origin removes that entire category of problem. (A single flag, SERVE_FRONTEND=false, splits them again if scale ever demands it.)

03The part nobody sees: server-rendered social previews

Here is the uncomfortable fact every SPA sharing feature runs into: WhatsApp, Facebook, X, Telegram, LinkedIn and Google all fetch a URL with a plain HTTP client and read the raw HTML. None of them execute JavaScript. A Vite SPA therefore serves every crawler the same empty index.html, and every shared link renders as a bare grey URL. The map works perfectly for humans and is invisible to the machines that carry links between them.

The fix did not require a framework migration to Next.js. A single module, ssr/metaRenderer.js, reads the built index.html once at boot, then for a request to /issue/:slug or /user/:username it strips the placeholder <title> and description, looks up the entity, and streams head + generated tags + tail. React then hydrates over the top exactly as before.

WhatsApp crawler no JavaScript Human browser runs React metaRenderer.js head + generated OG/Twitter tags + embedded JSON + tail Rich preview card title · image · text Instant paint, then React hydrates
Same URL, same module — the crawler gets described metadata, the human gets a page that paints from embedded JSON before hydration.

The report is also embedded as JSON in that response, so a link opened from WhatsApp paints immediately instead of showing a spinner while the API round-trips. And crucially, sharing is gated on moderation in three independent places — the serializer, the share endpoint, and the metadata renderer, which marks unapproved reports noindex and never exposes their text to a crawler. An un-approved report simply cannot leak through the preview surface.

The reusable lessonYou don't need SSR for your whole app. You need it for the handful of routes that strangers' crawlers will fetch. Isolating that to one streaming module kept a fast client-rendered map and still made every link describe itself.

04Trust in two tiers

Civic platforms live or die on a single tension: friction keeps out spam, but friction also keeps out the exhausted resident who just wants to report a problem once. India आसपास resolves it with a two-tier trust model where email verification is the only thing that unlocks instant publishing.

RoleReportCommentModerateManage users
Visitor (anonymous)✅ → queued
Registered (unverified)✅ → queued
Registered (verified)✅ → instant
Moderator✅ instant
Admin✅ instant

Anyone can join and anyone can report; only a confirmed address skips the moderation queue. This means the barrier to raising a complaint is zero, while the barrier to publishing unreviewed to a public map is a single click in an inbox. The role a request runs under is re-read from the database on every request, so revoking someone's access takes effect immediately rather than at their next login.

05A schema that does real work

Nine tables back the whole thing, in raw parameterised SQL against PostgreSQL rather than an ORM — because the interesting behaviour lives in the database: triggers, generated columns, full-text search and percentile queries. An ORM would hide exactly the parts that carry their weight.

The sharpest example is counters. The map endpoint reads every visible row on every load, and aggregate subqueries there — COUNT-ing votes and comments per report — are the first thing to become a bottleneck. So upvotes, comment_count and share_count are denormalised columns maintained by triggers as votes and comments change, never recomputed per request.

A detail that matters in IndiaFull-text search uses PostgreSQL's simple dictionary, which applies no stemming. That is what makes it behave correctly for Devanagari as well as Latin script — and it needs no extension a managed free tier might not expose. A search feature that only worked in English would have quietly excluded most of the intended users.
TableCarries
issuesReports, moderation state, denormalised counters, a generated tsvector
usersAccounts, roles, verification state, civic points
refresh_tokensHashed, rotating, individually revocable sessions
share_eventsWhich platform, which report, when — the data behind "what actually spreads"
audit_logEvery moderation and account action, with salted-hashed IPs

06Security when the input is a stranger's phone photo

An anonymous, public upload endpoint is one of the most hostile surfaces you can expose. The threat model here starts from an unusual but correct assumption: the photo itself is dangerous. A phone photo of a pothole outside someone's house carries EXIF GPS pointing at that house. So every upload is decoded with sharp, stripped of EXIF, and re-encoded to three WebP derivatives — and the image format is detected from the decoded bytes, not the client's MIME header, so a renamed executable never gets treated as an image.

ConcernMeasure
Credential theft via XSSAccess token in memory only; refresh token in an HttpOnly cookie
Stolen refresh tokenRotation on every use, with family-wide revocation on replay
Account enumerationIdentical responses and timing for unknown-user vs wrong-password
Mass assignmentZod parses and replaces the request body; unknown keys are dropped before a handler sees them
Data leakageAllow-list serialisers — a newly added column cannot start appearing in public responses by accident
Location leakageEXIF stripped from every upload; only server-derived coordinates are stored

The theme across all of it is defaults that fail safe: input is replaced rather than merged, output is allow-listed rather than blocked, and privileges are re-checked rather than cached. Each one turns a whole class of "someone forgot to…" bug into a structural impossibility.

07Shipping it for the price of a coffee

None of this assumes a budget. The production target is a single fly.toml app in the Mumbai region at roughly US$3/month, fronted by Cloudflare's free tier, with PostgreSQL on a managed free plan and images on Cloudflare R2 — chosen specifically because R2 has zero egress cost, and every shared link pulls a preview image. A blueprint for Render's free tier ships alongside it.

Testing stays honest without a cloud bill too: the 67-test suite runs against PGlite — PostgreSQL compiled to WebAssembly — so tests exercise the real engine, triggers and generated columns included, with no Docker daemon and no service container in CI. The same triggers that maintain the counters in production are the ones the tests assert against.

Key takeaways

  • Design for the crawler, not just the browser. For a sharing-driven product, server-rendered metadata on a few routes is the feature — a client-only SPA makes every link invisible.
  • One origin removes a category of bugs. Collapsing API, SSR shell, share cards and SPA into one process erased CORS, cross-site-cookie and "crawler-never-reached-the-code" failures.
  • Push behaviour into PostgreSQL. Triggers, generated tsvectors and denormalised counters did the work an ORM would have hidden — and the simple dictionary made search work in Devanagari.
  • Assume the input is hostile — even the photo. Fail-safe defaults (replace, allow-list, re-check) beat remembering to sanitise.
  • Free-tier is a real constraint, not a demo. Zero-egress storage, a WASM Postgres for tests, and a $3 VM shaped the architecture as much as any feature did.
Launch Live App Read the source More writing
Nirmit K. Tripathii
Nirmit K. Tripathii
AI/ML Engineer & Researcher. Building production AI systems, agentic pipelines and civic technology. Currently AI/ML Software Engineer at Chetu India.