HTMLRadar · Engineering
How I built HTMLRadar.
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. Existing document-tracking tools were built around PDF-first workflows. 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 broader than one founder's preference. Teams can now produce specs, design mocks, reports, dashboards, and internal briefs as HTML through hand-coded workflows and tools such as ChatGPT, Claude, v0, Lovable, and Claude Artifacts. HTML keeps responsive layouts, links, and interactive elements intact. The sending and analytics workflow is still largely designed around static documents. 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 see recipient sessions, section-level dwell, and exits in the sender dashboard. The dashboard refreshes every 30 seconds while its tab is visible. The repo is AGPL-3.0 at github.com/htmlradar/htmlradar.
The code splits into four 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.page/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 22 KB minified (8 KB gzipped) browser IIFE. The proxy injects a single <script> tag into every served document. The tracker creates the browser identifier, reads any email the proxy has already verified, and streams session metrics to Supabase after the warm-up.
packages/monitor is a Cloudflare cron Worker. It checks the hosted service every five minutes and replays first-party product events from Supabase to PostHog server-side.
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 22 KB minified
22 KB minified — 8 KB over the wire gzipped — 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 samples the visible viewport. It first uses configured selectors, then falls back to headings, slide or page containers, and finally plain-prose anchors. Each sample divides active time among the sections that cover enough of the viewport. A section begins qualifying only after at least half of it stays visible for one continuous second, and the public read signal uses a three-second dwell floor.
Flush runs every 15 seconds and on pagehide. The page-hide path requests one final best-effort update with keepalive: true. A single-flight guard prevents concurrent updates from racing.
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.
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 creates their first session for a document, a Postgres trigger fires pg_net directly at the Resend API. There is no application-owned queue, Lambda, or 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 schema is a sequence of idempotent SQL migrations under schema/. Apply every numbered file in order when self-hosting.
| 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 browser analytics SDK. Product events first land in Supabase. The monitor worker replays them to PostHog server-side for product analytics.
No Vercel. Cloudflare Pages runs Next.js on the edge. One fewer vendor in the dependency graph.
Takeaway
Four packages, a versioned SQL schema, and two core infrastructure 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.