HTMLRadar · Engineering
How I built HTMLRadar in three packages.
2026-05-14 · 5 min read · Engineering
I built HTMLRadar because I wanted to send investor updates as HTML, branded the way the rest of my company's site is branded, not flattened into a PDF. DocSend and the other tracking tools only accept PDFs. So I built a tracker for HTML decks for myself, then realised other founders had the same problem and open-sourced it.
The pattern is bigger than one founder's preference. Teams that use LLMs heavily ship more and more of their work as HTML — specs, design mocks, reports, dashboards, internal briefs. ChatGPT, Claude, v0, Lovable, and Anthropic Artifacts all produce HTML for the things that matter. PDFs are a pre-LLM artifact; the new deliverable is HTML. The analytics tooling stayed on PDFs. That's the gap HTMLRadar tries to fill.
HTMLRadar is open-source DocSend for HTML. You upload an HTML deck (or paste a URL), share a tracked link, and watch in real time who reads it, which sections they dwell on, when they bounce. The repo is AGPL-3.0 at github.com/htmlradar/htmlradar.
The code splits into three packages. Each runs in a different place. Each is small and does one thing.
The shape
packages/app is a Next.js 14 app on Cloudflare Pages. Dashboard, sign-in, upload, share creation — everything the sender does. The recipient never touches it.
packages/proxy is a Cloudflare Worker that owns the share URL. When someone opens htmlradar.com/r/swift-falcon-a3f2, the request hits this worker. It runs the email gate, fetches the HTML from R2 or an external URL, injects the tracker, streams the page back.
packages/tracker is a 14 KB browser IIFE. The proxy injects a single <script> tag into every served document. The tracker identifies the viewer, prompts for email if the share requires one, and streams session metrics to Supabase.
Splitting the proxy out keeps the recipient's bundle clean. The Worker has no React, no Supabase SDK, no Next runtime. It serves gated HTML with a tracker stitched in.
The tracker is 14 KB
14 KB is a tight budget. The tracker uses PostgREST directly rather than @supabase/supabase-js. The SDK is around 75 KB minified and brings a JWT lib, a fetch polyfill, and a realtime websocket client the tracker doesn't need.
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/start_session`, {
method: 'POST',
headers: { apikey: anonKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ p_share_slug, p_email, p_fingerprint }),
});The dwell tracker is a small state machine on the viewport. The tracker watches IntersectionObserver entries on h1[id], h2[id], h3[id] (the convention every static-site generator emits), treat the most recently-scrolled-past heading as the current section, and accumulate elapsed time on that section while the tab is visible.
Flush runs every 15 seconds and on pagehide with keepalive: true so the last second of analytics survives a close-tab. A single-flight mutex stops the 15-second timer racing the visibility-change flush during scroll-then-tab-away.
The proxy is where the security lives
Shares can require an email, a password, or both. The proxy enforces these before serving any HTML. The reader passes a gate, the proxy issues a cookie, subsequent requests skip the gate.
Gate sessions never touch the database. The cookie itself is the proof: an HMAC-SHA256 of slug + email + exp signed with one server-side secret.
const payload = `${slug}.${b64email}.${exp}`;
const mac = await hmacSha256(env.SESSION_SECRET, payload);
const cookie = `${payload}.${mac}`;No database round-trip per request. No session state to invalidate on revoke; the share's revoked_at column is the source of truth and the worker reads it on every request. Constant-time compare on verification. Unit tests cover round-trip and tamper-rejection.
This module is the one to read first if you're auditing security.
Recipient flow · /r/{slug}
- 01GET /r/{slug}recipient browser
- 02Gate checkHMAC cookie or email prompt
- 03Fetch HTMLR2 or external URL
- 04Inject <script>Cloudflare HTMLRewriter
- 05Stream responsefirst byte to recipient
- 06start_session RPCtracker boots, gets token
- 07Heartbeat every 15supdate_session, dwell + scroll
Database is the contract
Supabase Postgres is the only store. Two RPCs cover the entire writer surface for recipients: start_session and update_session. Both SECURITY DEFINER, both rate-limited, both return minimal data. The anon role has execute on these two functions and nothing else.
Rate limiting is a small rate_limits table keyed on slug + viewer identity rather than IP, because the tracker can't see the IP from the browser. Five attempts per 60s per identity. Forging viewers means fabricating distinct identities per request, which is a reasonable bar for v1.0.
Per-session bearer tokens live in sessions.token with a default of encode(gen_random_bytes(32), 'hex'). The tracker only sees the value through the RETURNING clause of start_session. Every subsequent update call must pass it back.
When a recipient session crosses the dwell threshold for the first time, a Postgres trigger fires pg_net directly at the Resend API. No queue, no Lambda, no Vercel function. The Resend API key lives in Supabase Vault and is decrypted at trigger time. Trade-off: pg_net is fire-and-forget so deliveries can fail silently; the trigger writes every dispatch to notifications_log for after-the-fact reconciliation.
The full schema is six SQL files at code/schema/001_* through 006_*. Each re-runs idempotently.
| Data | Lives at | Notes |
|---|---|---|
| Uploaded HTML | Cloudflare R2 | encrypted at rest |
| Owner accounts + shares | Supabase Postgres | RLS, owner-scoped |
| Recipient session + dwell | Supabase Postgres | sessions, section_events |
| Email-gate cookie | Recipient browser | HMAC, stateless, 24h |
| Tracker fingerprint | Recipient browser | localStorage UUID, anon |
| Resend API key | Supabase Vault | decrypted at trigger time |
What v1.0 doesn't do
No PDF. DocSend and Papermark already cover that surface. HTMLRadar is HTML-first by design.
No session replay. Section dwell, scroll depth, total active time. No mouse positions, no keystrokes, no DOM snapshots. Bundle stays small. Privacy bar stays higher.
No realtime websockets to the dashboard. Dashboard polls Supabase on page load; the trigger-driven email is the realtime channel. Supabase Realtime goes in when paying customers ask.
No third-party analytics. Events capture to a Supabase table that's schema-compatible with PostHog. Replay-able as a batch import if PostHog ever makes sense.
No Vercel. Cloudflare Pages runs Next.js on the edge. One fewer vendor in the dependency graph.
Takeaway
Three packages, six SQL files, two vendors (Cloudflare and Supabase). The whole thing is AGPL-3.0 at github.com/htmlradar/htmlradar. Issues and patches welcome.
Built an HTML deck of your own? See how to track the HTML deck you already built.
← Back to home