How to build a Plausible alternative

How to build a Plausible alternative with a first-party script and five dashboard numbers. Follow the prompts, check each step, then skip the ingest platform.

  • guide
  • vibe-coding
  • plausible

You can learn how to build a Plausible alternative as a first-party pageview script and five numbers on a dashboard. Plausible is that product. The catalog line is “The five website numbers you look at, minus twelve menus.”

You pay $9–$19 a month. That is $108–$228 a year. The verdict is “Lovely focused build”. The first estimate is 8 hours: pageview script, visitor counts, top pages.

I already wrote the teardown, Vibe code your own Plausible. This guide is the walk. Prompts in order, then what to look at.

The audience is a site owner who wants first-party traffic numbers. If a company already lives in funnels and a public live demo people quote, keep paying. The same v1 also replaces Fathom Analytics, Simple Analytics, Umami, and Matomo.

DataFast is the revenue cousin at $9–$49 a month, about 10 hours. Hotjar is the click map at $39–$99. If the question is “did anyone see the launch tweet”, that is the Buffer guide.

How to build a Plausible alternative without ClickHouse

The Plausible page splits the work into three rows.

The first row is the one you want. A script on your origin. A daily visitor hash. Cards for visitors, views, bounce, duration, and top pages. That is the 8-hour v1.

The second row is 3 days. Goals, referrers, geo, device breakdown, CSV export. Still your own sites.

The third row is 1 month and up. Billions of events, funnels, imports, SaaS hardening. Skip it.

If you tell an agent to “clone Plausible”, it aims at the third row. Pin every prompt to the first one.

Here is the shape of the data. Nothing else has to exist at 8 hours.

const site = {
  id: 'site-flavio',
  domain: 'flaviocopes.com',
  timezone: 'Europe/Rome',
  allowlist: ['flaviocopes.com', 'www.flaviocopes.com'],
}

const pageview = {
  id: 'pv-home',
  siteId: 'site-flavio',
  path: '/blog/vibe-code-your-own-plausible/',
  referrerHost: 'news.ycombinator.com',
  name: 'pageview',
}

const day = {
  siteId: 'site-flavio',
  date: '2026-10-27',
  visitors: 1,
  views: 1,
}

A site, a pageview, and one day row. CustomEvent and Goal wait until Tuesday.

The card lists those two extra entities. The first estimate does not. Visitors, views, a bounce proxy, visit duration, and top pages are enough. Bounce is single-pageview visits over total visits. Duration is first event to last event inside a visit window.

sites.domain is unique. pageviews.path is scrubbed. days.date is the site timezone, not UTC.

SITE flaviocopes.com Europe/Rome, origin allowlist PAGEVIEW pv-home, one event id /blog/vibe-code-your-own-plausible/ 202 first, write later DAY ROW 2026-10-27, Europe/Rome visitors and views IP never lands on disk
One site. One accepted pageview. The day row is the card.

Pick the stack you already write

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Plausible is Workers, Analytics Engine, and Astro.

This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. The Worker is the collector. Analytics Engine holds the time series. D1 holds the site row and the day cards so localhost has numbers without a remote query.

A TanStack Start dashboard still needs that Worker ingest. Do not invent a second product to host the script.

Open Build your own Plausible. The guardrail is the useful sentence: do not set tracking cookies or store full IP addresses, user agents, or persistent identifiers. The non-goal sits next to it. No attribution models, ad integrations, user journeys, or raw event replay.

The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why funnels wait. Pick one stack and stay on it.

Prompt 1. Scaffold Plainstats and stop

Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it.

Ship the 8-hour row only. Pageview
script, visitor counts, top pages.

Project name: Plainstats.
Public URL is https://stats.flaviocopes.com.
Owner is Flavio. There are no team seats.

First site is flaviocopes.com.
Timezone Europe/Rome.
Allowlist flaviocopes.com and
www.flaviocopes.com.

Do not add goals, referrers, geo,
device charts, or CSV export yet.
Do not add funnels, GA imports,
attribution, or a public live demo.

When the plan is done, stop and wait.
Do not write implementation code
until I say go.

The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.

You want pages like these:

  • /sites for the one domain
  • /sites/site-flavio for the snippet and rejected events
  • / for today’s five numbers
  • /pages for the path table

You want a sites table and a days table. pageviews can stay in Analytics Engine. goals should not appear. sessions as a stored profile should not appear.

If the plan includes a funnel builder, a Google Analytics import, or a cookie banner, send it back. Paste the non-goal again.

When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.

What to check after the scaffold

Start the app. Sign in as Flavio. You should see empty site, dashboard, and pages screens.

Open the migrations. sites.timezone is a string. days.date is a calendar date, not a UTC timestamp. There is no visitor_email column.

If the README is a placeholder, stop and make the agent write a real one before you add logic. The README has to say the collector sets no cookies, and a raw IP never lands in D1.

Prompt 2. Serve the script and accept a pageview

The collector is a guest on the page you measure. A slow 500 makes that page feel slower, which is why the teardown’s ingest always answers 202 first.

This site already loads Plausible from b.flaviocopes.com. That is their first-party proxy. Blocklists often miss a script on your own domain. They say a default plausible.io install can miss 5% to 25% of visits, depending on the audience. Serve https://stats.flaviocopes.com/js/script.js yourself.

Build ingest for Plainstats on
https://stats.flaviocopes.com.

GET /js/script.js serves a tiny
first-party tracker. No cookies.
No localStorage. No fingerprint
beyond the pageview POST.

The script POSTs text/plain JSON
to /api/event so the browser
skips a CORS preflight:

{
  id, name, domain, url, referrer
}

name is pageview tonight.
id is a UUID the script mints
before send. keepalive fetch.

POST /api/event always returns
202. Dropped events also return
202, plus x-plainstats-dropped: 1
so Flavio can debug.

Accept only when domain matches
a site row and Origin or
Referer host is on allowlist.
Unknown origin: 202, bump
sites.dropped, write nothing.

Scrub the path. Lowercase it.
Strip a trailing slash except
for /. Drop the query string
tonight. /Pricing?token=abc
and /pricing/ are one page.

Rate-limit 60 events per
minute per site. Over the cap:
202, bump dropped, write nothing.

Do not set Set-Cookie.
Do not store the raw body.

Seed site-flavio as above.

Done when I open
/blog/vibe-code-your-own-plausible/
with the snippet, the network
tab shows 202, D1 still has no
IP column, and a POST from
https://ads.notflaviocopes.com
returns 202 with
x-plainstats-dropped: 1.
function trackPageview() {
  fetch('https://stats.flaviocopes.com/api/event', {
    method: 'POST',
    headers: { 'content-type': 'text/plain' },
    body: JSON.stringify({
      id: crypto.randomUUID(),
      name: 'pageview',
      domain: 'flaviocopes.com',
      url: location.href,
      referrer: document.referrer,
    }),
    keepalive: true,
  })
}

text/plain is the preflight skip. application/json makes Chrome send OPTIONS first.

function scrubPath(url) {
  const parsed = new URL(url)
  let path = parsed.pathname.toLowerCase()
  if (path.length > 1 && path.endsWith('/')) {
    path = path.slice(0, -1)
  }
  return path
}

scrubPath('https://flaviocopes.com/Pricing?token=abc')
//'/pricing'

Keep ref and the utm_* keys on the 3-day row. Tonight the path is the page.

What to check after the first 202

Paste the snippet on a local page that claims flaviocopes.com. Open DevTools. Reload.

/js/script.js is a 200. /api/event is a 202. Response headers have no Set-Cookie.

Open D1. sites has flaviocopes.com. No request IP. No user agent string.

POST the same body from https://ads.notflaviocopes.com. Status is still 202. x-plainstats-dropped is 1. sites.dropped went up by one. The day row did not.

If the agent stored the full URL with ?token=abc, send prompt 2 back.

Prompt 3. Hash the visitor and forget the IP

Unique visitors without a cookie is a daily hash, then amnesia. The catalog’s first hard rule matches Plausible’s data policy: no cookies, no reusable id on Thursday.

The mini-prompt on the card is “Counting visitors you can’t recognize tomorrow”. Steal that salt rule tonight.

Build cookieless unique visitors
for Plainstats.

Hash once per accepted event:
SHA-256 of salt + domain +
cf-connecting-ip + user-agent.

Salt rotates at midnight
Europe/Rome. Store the current
salt and the previous salt in
KV. Delete salts older than
48 hours.

Use the hash only to decide
"seen today". Put
seen:site-flavio:2026-10-27:{hash}
in KV with a 36-hour TTL.
Increment days.visitors on first
sight. Increment days.views
every time.

Day buckets use the site
timezone. A pageview at
2026-10-27T23:30:00.000Z lands
on 2026-10-28 in Europe/Rome
(CET is UTC+1 after 25 Oct).

Read cf-connecting-ip for the
hash, then drop it. Never write
IP, user-agent, or the hash
into D1 or Analytics Engine.

Idempotency: if pageview.id was
already processed in the last
48 hours, skip the increment.
Replay five times, counts move
once.

Analytics Engine write is
writeDataPoint, not awaited.
blobs: siteId, path, event id.
index: siteId.
Do not put the hash in a blob.

Done when three same-day
pageviews from one browser
show 1 visitor and 3 views,
yesterday's salt cannot rebuild
today's key, and D1 has no IP.
async function visitorId(site, request, salt) {
  const ip = request.headers.get('cf-connecting-ip') || ''
  const userAgent = request.headers.get('user-agent') || ''
  const raw = [salt, site.domain, ip, userAgent].join('|')
  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(raw),
  )
  return [...new Uint8Array(digest)]
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('')
}

Hash, then throw the IP away. Persist the “seen today” key in KV. Three same-day views are one visitor and three pageviews.

function siteDay(iso, timeZone) {
  return new Intl.DateTimeFormat('en-CA', {
    timeZone,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  }).format(new Date(iso))
}

siteDay('2026-10-27T23:30:00.000Z', 'Europe/Rome')
//'2026-10-28'

Store the calendar date the owner lives on. A UTC date('now') in D1 will split Tuesday’s evening traffic onto Wednesday.

What to check after the hash lands

Reload the seed page three times in one browser. The days row must say 1 visitor and 3 views for 27 Oct 2026.

Open KV. You see seen:site-flavio:2026-10-27: plus a hex string. You do not see the IP.

Open D1 days. visitors is 1. views is 3. Replay the same id. Both stay put.

Advance the salt to the next Rome day. The same browser is a new visitor. That is the honest tradeoff in Plausible’s metrics docs.

If the agent wrote the hash into Analytics Engine, delete that blob. A stored hash is a profile.

Prompt 4. Show the five numbers

The cards are the product. Put every formula in one module so the chart and the table cannot drift. The other mini-prompt on the card is “One definition of bounce, everywhere”.

Build the Plainstats dashboard
for site-flavio.

Five numbers, one module:
- visitors: distinct seen-today
  keys that day
- views: accepted pageviews
- bounce proxy: visits with
  one pageview, over visits
- duration: first event to last
  event inside a 30-minute
  silence window, then average
- top pages: path, views, sorted

A visit is a run of pageviews
from one daily hash with no
gap longer than 30 minutes.

Read days for visitors and
views. Derive bounce and
duration from the same
accepted events. Top pages
group by scrubbed path.

Range defaults to today in
Europe/Rome. A 7-day toggle
is fine. Comparison deltas
can wait for the 3-day row.

/ shows the five cards and a
daily views bar. /pages lists
paths. Empty state says
"Waiting for the first
pageview" and repeats the
snippet.

Do not add a country table.
Do not add a public /share
URL.

Done when three reloads on
/blog/vibe-code-your-own-plausible/
and one reload on / show
visitors 1, views 4, bounce
0%, duration above zero, and
two rows on /pages.
function bounceProxy(visits) {
  if (visits.length === 0) return 0
  const bounced = visits.filter((visit) => visit.views === 1).length
  return bounced / visits.length
}

function visitDuration(events) {
  const first = events[0].at
  const last = events[events.length - 1].at
  return last - first
}

One visit with four paths is a 0% bounce. Duration is the span, not a guess. The same functions feed the card and the 7-day bar.

What to check after the cards light up

Seed four accepted events from one browser: three on the teardown path, one on /. Date them on 27 Oct 2026, Europe/Rome.

/ says 1 visitor, 4 views, 0% bounce, duration above zero. /pages lists /blog/vibe-code-your-own-plausible/ at 3 and / at 1.

Send a fifth event with the same id. The cards stay put.

Open a private window. Load / once. Visitors become 2. Views become 5. Bounce is no longer 0%, because that second visit is one page.

If the agent built a world map, stop. That is the 3-day row.

Failures the agent ships anyway

A third-party script on a rented hostname

A collector on a brand-new domain gets blocked the same way plausible.io does. Put the script on a hostname you already own. stats.flaviocopes.com next to flaviocopes.com is the point.

Cookies “just for uniqueness”

localStorage and a first-party cookie both survive Thursday. The guardrail forbids them. After the salt dies, yesterday’s visitor cannot be recognized.

The full URL in the path table

/login?token=abc will leak into /pages the first time a reset link loads the snippet. Scrub before you write. The DocSend guide has the same rule for a gated file: never put the secret in the address bar you later print.

A 500 from /api/event

The page you measure waited on your collector. Keep the status 202 and drop on the header plus the dropped counter.

Analytics Engine as the only store

writeDataPoint is not awaited, and local wrangler often has nothing to query. Keep the days row in D1 so the cards work on localhost. Analytics Engine is the time series, not the seed.

Funnels on Saturday

Funnels, user journeys, and a public dashboard people paste into launch threads are the skip row. The teardown already walked why Plausible moved that work to ClickHouse. DataFast is the card if the rent is “which click paid”. Hotjar is the card if the rent is “where did they click on /pricing/”.

If the agent starts an attribution model or a session replay, paste the non-goal and delete it.

Deploy the collector

The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.

Plainstats wants Workers, Analytics Engine, and Astro. Put it on a subdomain you already own, stats.flaviocopes.com. D1 holds sites and days. KV holds salts and “seen today”. The Worker is /js/script.js and /api/event.

The hosting post is that path: the adapter, then the bindings, then the custom domain. This card adds Analytics Engine, so wrangler.jsonc needs an analytics_engine_datasets binding named EVENTS.

Attach the domain on the Worker. Paste the snippet on one real page. Reload. Confirm /api/event is 202 from the public hostname. Confirm / shows one visitor that is you.

Take a D1 backup before you delete the paid account. The day rows are the history you will miss, so treat that backup like a password.

Use it as the real dashboard for a week, not as a demo. If you miss referrers or a CSV, those are the 3-day row and the other mini-prompts on the card. Add them after a week of numbers you trust.

Then cancel Plausible.

What to skip on purpose

Leave out ingest at billions of events, funnels, imports, and a public dashboard people trust.

8-HOUR V1 First-party pageview script Daily visitor hash Five dashboard numbers SKIP THIS HALF Ingest at billions of events Funnels and GA imports A public dashboard people trust
Ship the script and the five numbers. Leave scale ingest on the skip row.

Goals come after the counts look true for a week. Geo can wait until a country list would change a decision. I would not build a funnel builder.

A missed cron is a different job. That is Cronitor, $10–$49 a month, about 6 hours, if a heartbeat stops. This collector has no cron. The page posts, the Worker answers.

More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.

Start now

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

Tell the agent the first estimate only: pageview script, visitor counts, top pages. Eight hours.

Paste the snippet on one real page tonight. If the cards move once, you can cancel.

Related articles

Read all articles →