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.
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.
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.
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.
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.
| Role | Report | Comment | Moderate | Manage 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.
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.| Table | Carries |
|---|---|
issues | Reports, moderation state, denormalised counters, a generated tsvector |
users | Accounts, roles, verification state, civic points |
refresh_tokens | Hashed, rotating, individually revocable sessions |
share_events | Which platform, which report, when — the data behind "what actually spreads" |
audit_log | Every 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.
| Concern | Measure |
|---|---|
| Credential theft via XSS | Access token in memory only; refresh token in an HttpOnly cookie |
| Stolen refresh token | Rotation on every use, with family-wide revocation on replay |
| Account enumeration | Identical responses and timing for unknown-user vs wrong-password |
| Mass assignment | Zod parses and replaces the request body; unknown keys are dropped before a handler sees them |
| Data leakage | Allow-list serialisers — a newly added column cannot start appearing in public responses by accident |
| Location leakage | EXIF 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 thesimpledictionary 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.