Open-source session analytics. No server required.
Heatmaps, scroll depth, rage clicks. One script tag. Events go straight to a backend you already have.
Quick start · Backends · Dashboard · What it tracks · Public API · vs. alternatives · poorjar.com
PoorJar is a behavioral analytics script. Paste one script tag, pick a backend you already have (Supabase, Google Sheets, any POST endpoint), and it starts tracking clicks, scroll depth, and rage clicks. Under 5KB, no dependencies.
The core decision: there is no PoorJar server. Events go from your visitor's browser directly to your backend. PoorJar.com is never in the loop.
This makes setup fast and keeps infrastructure minimal. It also means the data is yours from the start, not exported from someone else's platform later.
Paste this before </body>. Use the setup wizard to generate yours automatically.
<script
src="https://poorjar.com/poorjar.js"
data-endpoint="https://YOUR_PROJECT.supabase.co/rest/v1/poorjar_events"
data-site-id="your-site-id"
data-mode="supabase"
data-key="your-anon-key"
></script>No
asyncordefer. PoorJar readsdocument.currentScriptat parse time to find its config. Async loading breaks this. The setup wizard generates the correct tag automatically.
That is the entire installation. No npm. No build step. No account.
Pick one. The wizard walks you through whichever you choose.
| Backend | What you need | Data goes to |
|---|---|---|
| Supabase | Free project, one SQL command | Your Supabase project |
| Google Sheets | A published Apps Script web app | Your Google Sheet |
| Custom webhook | Any endpoint that accepts POST JSON | Wherever you want |
Create a free project at supabase.com, then run this in the SQL editor:
create table poorjar_events (
id bigint generated always as identity primary key,
site_id text,
session_id text,
type text,
x integer,
y integer,
vx integer,
vy integer,
vpw integer,
vph integer,
depth real,
scroll_y integer,
timestamp bigint,
url text
);
alter table poorjar_events enable row level security;
create policy "allow insert" on poorjar_events for insert with check (true);That's it. Your anon key goes in data-key, your project URL goes in data-endpoint.
The analytics dashboard lives at poorjar.com/dashboard.
What it shows:
- Click heatmap overlaid on a live preview of your actual site
- Dwell heatmap (where people stop and read)
- Scroll depth bar
- Session list with timestamps, URLs, and event breakdowns
- Rage click flags
- Page-by-page filtering
Everything runs in your browser. Your Supabase credentials go directly from you to Supabase. PoorJar.com is not in that loop.
Self-hosting: Download the dashboard as a single HTML file and host it anywhere, or just open it locally. No server, no dependencies. It's one file.
After connecting once, the setup wizard generates a pre-connected dashboard URL you can bookmark. One click, straight to your data.
| Event | When | Fields |
|---|---|---|
click |
Every click on the page | x, y, vx, vy, vpw, vph, url |
rage_click |
3+ clicks within 600ms and 60px radius | same as click |
scroll |
Once each at 25%, 50%, 75%, 100% depth | depth, scroll_y, url |
dwell |
Mouse pauses 500ms+ in the same 30px area | x, y, vx, vy, url |
Coordinate system: x/y are page-absolute (from the top of the document, so they're consistent across scroll positions). vx/vy are viewport-relative (where on the screen the event happened).
Flushing: Events are batched and sent every 5 seconds, and again on tab close / page navigation. If you close a tab immediately after clicking something, the flush fires via pagehide.
Each event is a flat JSON row (Supabase mode):
{
"site_id": "your-site-id",
"session_id": "abc123def456",
"type": "click",
"x": 540,
"y": 320,
"vx": 540,
"vy": 120,
"vpw": 1440,
"vph": 900,
"depth": null,
"scroll_y": null,
"timestamp": 1747000000000,
"url": "https://yoursite.com/page"
}All 13 fields are always present. Missing fields are null. This is required by PostgREST, which rejects batches with inconsistent keys.
After loading, PoorJar exposes a small API on window.PoorJar:
PoorJar.flush()
// Force an immediate send of queued events.
// Useful for single-page apps that navigate without a page unload.
PoorJar.getQueue()
// Returns a copy of events queued but not yet sent.
// [ { type: 'click', x: 540, y: 320, ... }, ... ]
PoorJar.stats()
// Returns { flushCount: 3, totalSent: 47, queued: 2 }PoorJar also fires two custom DOM events you can listen to:
document.addEventListener('poorjar:event', (e) => {
console.log('event queued:', e.detail);
});
document.addEventListener('poorjar:flush', (e) => {
console.log('flushed:', e.detail.count, 'events | total sent:', e.detail.totalSent);
});These are useful for building debug overlays, test consoles, or custom integrations.
PoorJar is designed to work even when the page fights it.
stopPropagation: All three event listeners (click, scroll, mousemove) attach with capture: true, so they run before any page-level handlers and can't be blocked by stopPropagation in bubbling phase.
Synthetic clicks: PoorJar tracks all clicks at the document level, including ones fired programmatically via .click() or dispatchEvent.
Dynamic DOM: PoorJar doesn't observe DOM mutations. It attaches once at the document level and stays there regardless of what the page renders or removes afterward.
Single-page apps: If your SPA navigates without a page unload, call PoorJar.flush() on route change so queued events include the correct URL.
If you're on React, Vue, Next.js, etc., flush on route change:
// React Router example
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function Analytics() {
const location = useLocation();
useEffect(() => {
if (window.PoorJar) window.PoorJar.flush();
}, [location]);
return null;
}poorjar.js (unminified) ~4.2 KB
poorjar.js (gzipped) ~1.8 KB
No external requests. No fonts. No images. No third-party anything.
PoorJar does not collect:
- IP addresses
- User agents
- Cookies or fingerprints
- Any personally identifiable information
It collects: where people clicked, how far they scrolled, and where they paused. That's it. You decide where that data goes and who can see it.
If you don't want to load the script from poorjar.com, serve it yourself:
<script
src="/your/path/to/poorjar.js"
data-endpoint="..."
data-site-id="..."
data-mode="supabase"
data-key="..."
></script>The script has no hardcoded URLs. It reads everything from data-* attributes.
Every behavioral analytics tool makes a choice about where data lives and who runs the infrastructure. Here is where PoorJar sits:
| Tool | Cost | Data goes to | Server to run? |
|---|---|---|---|
| Hotjar | $99+/month | Hotjar | No |
| Microsoft Clarity | Free | Microsoft | No |
| OpenReplay | Free (self-hosted) | Your server | Yes |
| PoorJar | Free | Your database | No |
Hotjar / FullStory: Full-featured, polished, expensive. Worth it if the budget is there.
Microsoft Clarity: Free and genuinely good. Microsoft uses your data to train models. That's the deal. Fine for many projects, not fine for some.
OpenReplay: Open source with session replay (PoorJar doesn't have this yet). Requires deploying and running a server. Good choice if you want more features and are willing to maintain infrastructure.
PoorJar: No session replay yet. No server on either end. Events go from the browser to a table in your Supabase project or a Google Sheet. That's the whole thing. Smaller scope, zero infrastructure.
If you need session replay today, use OpenReplay. If you want click heatmaps and scroll data without running anything, that's what PoorJar does.
MIT. Fork it, self-host it, white-label it. That's the point.
Built by Henry Ratterman in Bloomington, Indiana
Marketing major. Builds things anyway. Also built Phony and Arduous.