zara-tracking

Drop-in Meta Pixel + Zaraz CAPI

Browser pixel + server-side Conversions API, deduplicated via a shared event_id. One config file. Works in Astro, Next.js, or any web framework.

What you get

Install

Pull straight from GitHub. The prepare script builds dist/ on install — no npm publish needed.

npm install github:rheav/zara-tracking

Quick start (Astro)

1. Create tracking.config.ts at the project root

import type { RuntimeConfig } from "zara-tracking";

const config: RuntimeConfig = {
  pixelIds: ["YOUR_PIXEL_ID"],
  debug: import.meta.env.DEV,

  defaults: { currency: "USD" },

  events: {
    pricingVisible: {
      on: "visible",
      selector: "#pricing",
      event: "ViewContent",
      threshold: 0.3,
      once: true,
    },
    heroCta: {
      on: "click",
      selector: "a[href='#get-started']",
      event: "Lead",
      data: { content_name: "Hero CTA" },
    },
  },
};

export default config;

2. Register the integration

// astro.config.mjs
import { defineConfig } from "astro/config";
import zaraTracking from "zara-tracking/astro-integration";

export default defineConfig({
  integrations: [zaraTracking()],
});

3. Add the Cloudflare Pages middleware

// functions/_middleware.ts
export { onRequest } from "zara-tracking/middleware";

That’s it. PageView fires automatically on every load and SPA navigation.

Three ways to add events

You don’t have to pick one. Mix and match.

A) HTML markup with data-track-event

The designer-friendly path. No JS, no config touch. Drop attributes onto the element:

<button
  data-track-event="InitiateCheckout"
  data-track-value="97"
  data-track-currency="USD"
  data-track-content-name="Premium Plan"
>
  Buy
</button>

That fires InitiateCheckout with { value: 97, currency: "USD", content_name: "Premium Plan" } on click. See the attribute reference below.

B) Declarative events: {} block

For triggers that don’t fit a single button — route changes, scroll depth, element visibility, form submits:

events: {
  thankYou: {
    on: "route",
    path: "/thank-you",
    event: "Purchase",
    data: ({ query }) => ({
      value: Number(query.v ?? 0),
      content_ids: [query.sku].filter(Boolean),
    }),
  },

  scroll75: {
    on: "scroll",
    percent: 75,
    event: "ScrollDepth",
  },

  leadForm: {
    on: "submit",
    selector: "form#lead",
    event: "Lead",
    data: ({ form }) => ({ content_name: form.get("plan") }),
  },
}

C) trackEvent() from anywhere

The escape hatch for programmatic events:

import { trackEvent } from "zara-tracking";

trackEvent("InitiateCheckout", { value: 47 });
trackEvent("CustomEvent", { foo: "bar" });

Both pixel and Zaraz fire with a shared event_id. Always.

Trigger types

onFires when
routeURL pathname matches path (string, prefix ending /, or RegExp)
clickClick bubbles up from an element matching selector
submitA <form> matching selector is submitted (form available in ctx)
visibleElement enters the viewport (IntersectionObserver, default 50% threshold)
scrollPage scrolled past percent
dwellAfter seconds of time on the page
video<video> matching selector plays or ends

Every entry takes an optional data resolver — either an object or a function (ctx) => payload. The ctx exposes query, pathname, el, form, and event depending on the trigger.

data-track-* reference

Any element with data-track-event="EventName" is auto-wired.

AttributePurpose
data-track-eventRequired. Meta event name.
data-track-onTrigger event. Default "click".
data-track-once"true" to fire only once per session.
data-track-valueParsed as Number.
data-track-currencyCurrency code (USD, BRL…).
data-track-content-nameMapped to Meta content_name.
data-track-content-idsCSV → array. Mapped to Meta content_ids.
data-track-num-itemsParsed as Number.
data-track-* (any other)Becomes a custom payload field (kebab → camel).

Earlier versions used data-track="EventName" (no -event suffix). Both attributes still work; if both are present, data-track-event wins. New markup should prefer data-track-event for readability.

Listeners are document-delegated, so this works for elements rendered after mount (SPA route changes, conditional renders, portals).

Debug logging

Set debug: true in tracking.config.ts and the console prints one styled line per event:

[META] InitiateCheckout (data-attr:click)  [✓ browser]  [✓ zaraz]   id=ab7  {value:97, content_name:'Premium'}
[META] PageView (boot)                     [✓ browser]  [✓ zaraz]   id=k0o
[META] Lead skipped                        once-guard (key=heroCta)

Each event line covers both legs at once. Status pills are filled green when fired, red on exception, gray when the path isn’t available (no fbq loaded, Zaraz not configured). The trigger label in parens (route, click, visible, scroll N%, dwell Ns, video:phase, data-attr:click, boot) tells you which rule fired without grepping the config.

A one-shot session line at boot prints the active pixel IDs + truncated identifiers so they don’t repeat on every event.

Verifying it’s working

  1. Open your site with ?fbclid=test123 appended (the fbclid makes the visit show up in Test Events reliably).
  2. Open Meta Events Manager → Pixels → your pixel → Test Events.
  3. You should see two rows for PageView within ~1 second of each other — one Browser, one Server — each with a Deduplicated badge and matching event_id.

If only Browser appears, the Zaraz tool isn’t firing. Check Cloudflare → Zaraz → Monitoring.

If both appear without the Deduplicated badge, the event_id mapping in your Zaraz tool is wrong. Make sure it maps to {{ client.event_id }} (not {{ client.eventID }} or {{ system.event.id }}).

Safari/ITP caps localStorage and 3rd-party cookies aggressively. The bundled middleware sets a meta_external_id cookie at the edge on first visit — same value also injected as window.__EXTERNAL_ID__ so the browser pixel and CAPI pick up the same identifier. Returning visitors keep the same id for up to a year, which materially improves match quality on the CAPI side.

License

MIT.