# Click tracking

> Counting outbound clicks without touching the link — ping or redirect, what is and is not counted, where the numbers show, and the quotas that cap them.

You are reading one page of the ZeroDirs documentation. ZeroDirs is an Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with eighteen hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/grow/clicks/

---

`features.clicks` decides how a click on a listing's **Visit** button is counted: `'ping'` adds a
background beacon to the real link, `'redirect'` routes the click through `/out/<id>/`, `'off'`
leaves a plain link. Counting needs a listing with a D1 row id, so it only works on a site built
with `LISTINGS_SOURCE=d1`.

## Setup

**ping**

1. Switch it on. The site must already build from the database
   ([Listings from D1](https://zerodirs.com/docs/deploy/listings-from-d1/)).

   ```ts title="site.config.ts"
   features: { clicks: 'ping' },
   ```

2. Deploy.

   ```sh title="Terminal"
   pnpm deploy
   ```

3. Read one listing page's link.

   ```sh title="Terminal"
   curl -s https://<your domain>/<listingBase>/<slug>/ | grep -o 'ping="/out/[^"]*"'
   ```

   `ping="/out/<id>/"`. The `href` is still the listing's own URL.

**redirect**

1. Switch it on.

   ```ts title="site.config.ts"
   features: { clicks: 'redirect' },
   ```

2. Deploy with `pnpm deploy`.

3. Read the link and the robots file.

   ```sh title="Terminal"
   curl -s https://<your domain>/<listingBase>/<slug>/ | grep -o 'href="/out/[^"]*"'
   curl -s https://<your domain>/robots.txt | grep out
   ```

   `href="/out/<id>/"` and `Disallow: /out/`.

## Verify

| What to check | Where | You should see |
| --- | --- | --- |
| The endpoint | `curl -sI https://<your domain>/out/<id>/ \| head -1` | `HTTP/2 405` in ping mode (a beacon is a POST); `HTTP/2 302` in redirect mode — and this HEAD is not counted |
| A counted click | click **Visit** in a browser, then `pnpm exec wrangler d1 execute DB --remote --command "SELECT listing_id, day, count FROM clicks ORDER BY day DESC LIMIT 5"` | one row per listing per UTC day, `count` incremented |
| The stats | `/admin/stats/` → **Top outbound clicks, last 30 days** | the listing and its count |
| Popularity | after the next build, `/search/?sort=popularity` or any grid | clicked listings sort first |

## How it works

**`ping`** keeps the anchor exactly as built — `href` the listing's URL, `rel` from the tier —
and adds `ping="/out/<id>/"`. On a click the browser POSTs `Content-Type: text/ping` in the
background; the navigation is untouched, no JavaScript is involved, and a broken database costs
a count, not a click. The endpoint accepts only a POST with that content type and a `Ping-From`
(or `Origin`) matching the request's origin or `site.url`, answers 204, and records after the
response through `waitUntil()`. Firefox ships pings disabled, so roughly 3% of clicks go
uncounted: popularity is a ranking signal, not analytics. No analytics provider is embedded
anywhere; add whatever you use.

**`redirect`** sets `href="/out/<id>/"` and answers 302 to the listing's URL. Complete counts, at
the cost of the link: nothing dofollow reaches the listed site, and `robots.txt` gains
`Disallow: /out/`. A failed lookup is a 503 here, because there is no target to send the visitor
to — one more reason `ping` is the default.

**What a click stores.** One upsert into `clicks(listing_id, day, count)`, keyed by listing and
UTC day: `INSERT … ON CONFLICT DO UPDATE SET count = count + 1`. No IP, no user agent, no
per-click row, and nothing on a page view. Two statements per counted click — the lookup and
the upsert — and about two rows written per listing per day, which is what fits 8,000 clicks a
day inside the free plan. Every `/out/` response is `no-store` and `X-Robots-Tag: noindex`.

**What is not counted.** A `HEAD` request (a link checker); a user agent matching
`bot|crawl|spider|slurp|preview|facebookexternalhit|curl|wget`; an id that is not an approved
listing (404); a beacon with the wrong content type (400) or from another site (403). The
filter is deliberately short: a miss inflates one count, a false positive erases a real visitor.

**A Markdown listing has no row.** `listingId` is `null` for a file-loaded entry, so the button
is a plain direct link whatever the flag says. `/out/` still answers, but nothing links to it.

**Where the numbers show.** `/admin/stats/` reads the last 30 days live. At build time the D1
loader sums the same window into each listing's `popularity` — its log line says
`30-day clicks for <n>` — which drives `listing.defaultSort` (`'featured'` puts featured
listings first, then popularity; `'popular'` starts with popularity) and the **Most popular**
sort on `/search/`. Counts reach the site at the next build, not the next click.

## When it is off or degraded

| State | Visitor sees | `/admin/` reports | Fix |
| --- | --- | --- | --- |
| `'off'` (the default) | a plain link; `/out/<id>/` answers 404 | stats: `features.clicks is "off", so nothing is counted.` | set the mode |
| `LISTINGS_SOURCE=files` | a plain link in every mode | stats: `No clicks recorded yet in this window.` | build from D1 |
| Firefox in ping mode | the link works; the beacon is not sent | nothing | accept it, or `redirect` |
| D1 write fails, or the free-plan quota is spent | ping: 204 and the visitor arrives; the count is lost silently | `clicks: recording a click for listing <id> failed` in the Worker log | [Cost and limits](https://zerodirs.com/docs/run/cost-and-limits/) |
| Redirect mode, the lookup fails | 503 "Try again shortly." | the lookup's own log line | retry; consider `ping` |

Past the request quota, tracking calls fail and nothing alerts you, so treat popularity as
approximate near the limit. The report on `/admin/` has no clicks check; the stats page is the
signal.

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `features.clicks` | `'off'` | `'ping'`, `'redirect'` or `'off'`: the shape of the outbound anchor and what `/out/<id>/` answers |
| `listing.defaultSort` | `'featured'` | `'featured'` sorts featured first, then by `popularity`; `'popular'` by `popularity` first; `'newest'` by approval date |
| `listing.outboundRel` | `'nofollow'` | the `rel` on free-tier links; paid and featured tiers are always `sponsored` |

See [`features`](https://zerodirs.com/docs/configure/reference/#features) and [`listing`](https://zerodirs.com/docs/configure/reference/#listing).

## Files

- src/
  - server/clicks.ts `handleOutbound()`, the guards, the upsert
  - pages/out/\[id\].ts the route, every method
  - components/listing/
    - OutLink.astro the anchor in each mode
    - Detail.astro where it renders
  - lib/
    - seo.ts `robotsDisallow()` adds `/out/`
    - listing.ts `outboundRel()`, the sort comparators
    - search.ts the popularity sort on `/search/`
  - loaders/d1.ts the 30-day aggregate at build time
  - db/schema.ts `clicks`
  - templates/admin/StatsPage.astro the top-clicks list

## Related

- [Search](https://zerodirs.com/docs/configure/search/) — The popularity sort order and the URL that carries it.
- [Cost and limits](https://zerodirs.com/docs/run/cost-and-limits/) — How many clicks a day the free plan's D1 writes allow.
- [Listings from D1](https://zerodirs.com/docs/deploy/listings-from-d1/) — The build variables that give every listing a row id.
- [SEO](https://zerodirs.com/docs/configure/seo/) — Dofollow, sponsored and robots.txt.
