Vibe code your own Hotjar

Vibe code your own Hotjar as click counts and a page dashboard. Session replay, PII scrubbing, and the playback engine are the other product.

  • teardown
  • vibe-coding
  • scope

You can vibe code your own Hotjar. Start with click counts and a page dashboard. The catalog calls Hotjar that loop, not a replay studio.

You pay $39–$99 a month. The verdict is “Useful if privacy-first”.

Last time I wrote about how per-seat SaaS pricing works. That was a regular backlog post. This one is a teardown.

Vibe code your own Hotjar as click counts

The Hotjar page splits the work into three rows.

The first row is 3 days. Event script, click counts, basic page dashboard. That is a first-party fetch, a path, an x/y pair, and a table of pages. You can vibe-code this.

The second row is 2 weeks and up. Heatmap aggregation, scroll depth, rage clicks, filters. Still your own site. More screens, a bucket grid, a date range.

The third row is 3 months and up. Pixel-perfect session replay, funnels, cross-site identity. Skip that row.

If you tell an agent to “clone Hotjar”, it aims at the third row. The useful v1 is the first one.

Here is the shape of the data. Nothing else has to exist at 3 days.

const click = {
  id: 'clk-pricing-cta',
  siteId: 'flaviocopes.com',
  path: '/pricing/',
  x: 0.62,
  y: 0.41,
  occurredAt: '2026-10-26T09:14:00.000Z',
}

const page = {
  siteId: 'flaviocopes.com',
  path: '/pricing/',
  date: '2026-10-26',
  views: 1,
  clicks: 1,
}

A site, a click, and one day row. Scroll buckets can wait until Tuesday.

The audience line on the page is a product team improving one public marketing or onboarding flow. If that is you, a script and a page list are enough.

The same v1 also replaces Crazy Egg, Mouseflow, and FullStory if what you wanted was “where did people click on /pricing/”. Plausible is the pageview cousin at $9–$19 a month. I wrote about how Plausible works on October 23. DataFast is the revenue cousin at $9–$49. When the question is “what did they answer”, that is SurveyMonkey at $39–$75.

How does Hotjar work

Hotjar publishes the tracking code, the recorder’s public APIs, and the privacy rules. I am summarizing those help pages, not reconstructing today’s cluster.

The tracking code is a snippet you paste once. It loads the script. It stores hjid, the site id, so collected data lands on the right account. It stores hjsv, the snippet version, so they can warn you when an old snippet should go. It also queues hj() calls that fire before the script arrives.

A v1 needs the same queue. The page should not throw if the Worker is slow.

window.warmtrail = window.warmtrail || function () {
  (warmtrail.q = warmtrail.q || []).push(arguments)
}

warmtrail('event', 'pricing_cta')

Their performance note is the public map of the recorder. The snippet loads asynchronously from a CDN. Cursor position and scroll position are sampled every 100ms, ten times a second. Clicks store the pointer against the clicked element. Those events go to Hotjar through WebSocket frames, not a burst of XHR. For recordings, page changes go through the browser’s MutationObserver. They parse the mutation, then send it on the same socket.

Treat “every 2026 packet still uses that 100ms cadence” as an educated guess. The product rule is public: sample the pointer, observe the DOM, send a structured event.

They do not capture every visit. The coverage page drops sessions shorter than 30 seconds by default. HTTP pages are skipped. An IP on your block list is skipped. Sampling spreads a daily session cap across a moving 24-hour window, so the afternoon is not empty because the morning used the budget.

Data sits in AWS eu-west-1 in Ireland. Their security page says recordings are not backed up. Lose the cluster and those videos are gone. A v1 that stores click rows in D1 can afford a backup. A replay blob in R2 should be treated as disposable.

A 3-day v1 does not need a WebSocket. One Worker, one Analytics Engine dataset, one Astro dashboard. That is the stack on the card. Use TanStack Start if React is your weekday language.

LOAD THE SCRIPT Site id, then a queued hj() SAMPLE THE SESSION Daily cap, 30s floor, HTTPS RECORD THE CLICK Path, x, y, no keystrokes SEND THE EVENT Ack first, write later COUNT THE PAGE Views, clicks, top paths No form values on disk
The public path: load the script, sample the session, store a click, then count the page.

How Hotjar heatmaps work

A heatmap is a rollup of clicks, not a video.

Hotjar’s form article names the four overlays you will copy. A click map is where people click or tap. A scroll map is how far they got. A move map is where the desktop cursor hovered. Rage clicks are many clicks on the same spot in a short window. Engagement zones mix click, move, and scroll on one picture.

Their heatmap limitations tell you how the picture is made. They expand the page in a large virtual viewport and take one screenshot of the HTML. Nested scrollers, scroll-lock, and parallax cut that screenshot short. Iframes show as a black box. HTML over 10MB is dropped. Pages taller than 50,000 pixels can time out.

A v1 does not take that screenshot. Store clicks as fractions of the page box. Paint a grid later.

function clickBucket(click, cols, rows) {
  const col = Math.min(cols - 1, Math.floor(click.x * cols))
  const row = Math.min(rows - 1, Math.floor(click.y * rows))
  return `${click.path}:${col}x${row}`
}

function rageClick(recent, now, limitMs, count) {
  return recent.filter((item) => now - item.at < limitMs).length >= count
}

Twenty columns by twelve rows is enough to see that the footer got the clicks and the hero did not. Rage is “three clicks inside 800ms on the same bucket”. Put those numbers in one module so the card, the chart, and the CSV agree.

The catalog’s second row is this aggregation. Filters by page, device class, and date come with it. Targeting by a custom event is their Events API: hj('event', 'checkout_started'). Events are a name only. They refuse emails, IPs, and numbers with 9 or more digits on that API.

Plausible already has the pageview half. If you only need visitors and top pages, build that instead. This post is the click half.

Session replay and PII scrubbing

The skip row is Hotjar the platform.

Their performance page says a recording watches MutationObserver and sends those changes, then playback rebuilds a DOM and applies the event log. Open-source libraries such as rrweb show the same shape: a full snapshot, then incremental mutations, pointer moves, scrolls, and inputs. Treat “Hotjar’s 2026 wire format is rrweb” as an educated guess. The hard part is the same either way. You must reconstruct a page the visitor saw, after the live CSS and images have moved on.

Contentsquare, Hotjar’s parent, publishes the scrape. When a session is collected, the tag sends static resource URLs. A scraper fetches CSS, images, and fonts if they were not stored in the last six hours. Logged-in profile photos fail that fetch. A v1 that stores only {path, x, y} never has this problem.

Privacy is why the catalog’s guardrail exists. Hotjar suppresses user input by default. Text fields become ***. Dates become 01/01/1979. Numbers become 111. Numbers with 9 or more digits are always stripped, including hyphens and colons, so a card number never leaves the browser. After a session is sent, you cannot suppress it later.

The allow list is opt-in and narrow. data-hj-allow does not recurse. Fields named email, password, address, or ccnumber stay suppressed even if you allow them. Their personal data page is blunt about the other leak: a profile page that prints a name in the HTML will appear in the recording unless you suppress that node.

function scrubInput(el) {
  const name = (el.name || el.id || '').toLowerCase().replace(/[-_]/g, '')
  const blocked = ['email', 'password', 'phone', 'address', 'ccnumber']
  if (blocked.includes(name) || el.type === 'password') return '***'
  if (/^\d[\d-:]{8,}$/.test(el.value)) return '111'
  return '***'
}

A v1 should not keep the value at all. The card already says that: no form values, no keystrokes, no page text, no exact IP, no unsampled sensitive routes. Login, checkout, and /account stay off the script until you write an allowlist.

Funnels, surveys, and the Identify API sit on the same skip row. Identify attaches your user id to the recording. That is cross-site identity. The catalog’s non-goal already names it. SurveyMonkey is the form if you wanted answers, not a widget on top of a replay.

3-DAY V1 First-party event script Click counts per path A page dashboard SKIP THIS HALF Pixel-perfect session replay PII scrubbing of the live DOM Playback and asset scraping
Ship the script and the click table. Leave replay, scrubbing, and playback on the skip row.

A Hotjar alternative for one page

I would rebuild Hotjar for one public flow I already ship, where the current “heatmap” is me clicking around after a launch. One site row, a first-party script on the same origin, sampled clicks, and a dashboard that lists /pricing/ before /blog/.

Scroll depth comes after the click table looks true for a week. Rage clicks come when a button stops working and I want that in a filter. I would not store the DOM.

Host it next to the product, on Astro, Workers, and Analytics Engine, because that is the stack on the card. Use TanStack Start if React is your weekday language. Add a date filter once the page list is boring.

If the rent is “how many people saw the post”, that is Plausible, 8 hours for a pageview script and visitor counts. If the rent is “which click paid”, that is DataFast, 10 hours for UTM and a payment webhook.

Start with the first row

Go to Build your own Hotjar. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate only: event script, click counts, basic page dashboard. Three days.

If the company already lives in session replay and a privacy review of every recording, keep paying.