Skip to content

Recipe: Plausible analytics

Plausible is a small, cookie-free analytics service. Its script exposes one function, window.plausible(event, { props, callback }), and that is enough to build a plugin that covers the three ways a goal gets recorded in a hypermedia app: a user clicks something, an expression decides something, or the server confirms something. All three share a name, track, because directives, functions and handlers live in separate namespaces.

plugin written as records
directive data-track:signup="{ plan: $plan }" a DOM event on an element
function @track('Checkout', { total: $total }) a step inside any expression
server-event handler event: sigmx-track a goal the server knows happened
All three, logged by a stand-in for Plausible's script

Ordered: total . @track resolved time(s).Nothing sent yet.

window.plausible calls
    <div class="ex plausible" data-signals="{ plan: 'growth', qty: 1, total: 0, ordered: false, sent: 0 }">
    <div class="grid">
    <label class="field-row">Plan
    <select class="field" data-bind:plan>
    <option value="starter">Starter</option>
    <option value="growth">Growth</option>
    <option value="team">Team</option>
    </select>
    </label>
    <label class="field-row">Quantity
    <input class="field" type="number" min="1" max="9" data-bind:qty>
    </label>
    </div>
    <div class="row">
    <button class="btn ghost" data-track:signup="{ plan: $plan }">Sign up (data-track)</button>
    <button class="btn ghost" data-on:click="@track('Plan Chosen', { plan: $plan }).then(() => $sent++)">Choose plan (@track)</button>
    <button class="btn" data-on:click="@post('/api/goal')">Order (server event)</button>
    </div>
    <p class="status muted">
    <span data-show="$ordered">Ordered: total <b class="num" data-text="$total"></b>. </span>
    <span data-show="$sent">@track resolved <b class="num" data-text="$sent"></b> time(s).</span>
    <span data-show="!$ordered && !$sent">Nothing sent yet.</span>
    </p>
    <div class="log">
    <div class="log-head">
    <span>window.plausible calls</span>
    <button class="btn ghost small" data-on:click="document.getElementById('plausible-log').replaceChildren()">Clear</button>
    </div>
    <ol id="plausible-log"></ol>
    </div>
    </div>
    <script>
    // A stand-in for Plausible's script: records each call on the page instead of sending it.
    type Call = { props?: Record<string, unknown>; callback?: () => void }
    const stub = (event: string, options: Call = {}) => {
    const li = document.createElement('li')
    li.innerHTML = `<b></b><code></code>`
    li.querySelector('b')!.textContent = event
    li.querySelector('code')!.textContent = options.props ? JSON.stringify(options.props) : ''
    document.getElementById('plausible-log')?.append(li)
    setTimeout(() => options.callback?.(), 150)
    }
    const w = window as any
    for (const args of w.plausible?.q ?? []) stub(...(args as [string, Call]))
    w.plausible = stub
    </script>
    <style is:global>
    .plausible { padding: 1rem; display: grid; gap: .8rem; background: var(--ex-surface); }
    .plausible .grid { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
    .plausible .field-row { display: grid; gap: .3rem; font-size: .8rem; color: var(--ex-muted); }
    .plausible .row { display: flex; gap: .6rem; flex-wrap: wrap; }
    .plausible .status { margin: 0; font-size: .85rem; }
    .plausible .log { border: 1px solid var(--ex-line); border-radius: .6rem; overflow: hidden; }
    .plausible .log-head { display: flex; justify-content: space-between; align-items: center; padding: .4rem .7rem; background: var(--ex-surface-2); font-family: var(--sl-font-mono); font-size: .72rem; color: var(--ex-muted); }
    .plausible .btn.small { padding: .2rem .6rem; font-size: .75rem; }
    .plausible ol { list-style: none; margin: 0; padding: 0; min-height: 2.2rem; font-family: var(--sl-font-mono); font-size: .8rem; }
    .plausible ol li { display: flex; gap: .8rem; padding: .4rem .7rem; border-top: 1px solid var(--ex-line); animation: msg-in .25s ease-out; }
    .plausible ol li b { color: var(--ex-accent); }
    .plausible ol li code { color: var(--ex-muted); background: none; padding: 0; }
    </style>

    The demo replaces window.plausible with a function that appends each call to the log, so it works without an account. Everything else on the page is the real plugin, registered on this site through auto mode as described below.

    One file, no dependencies beyond sigmx:

    src/plugins/plausible.ts
    // Plausible analytics for sigmx: a directive, a function and a server-event handler, all named
    // `track`, that talk to Plausible's script API (`window.plausible(event, { props, callback })`).
    // No runtime dependencies; the three plugins share one `send` helper.
    import { action, attribute, handler } from 'sigmx';
    export type Props = Record<string, string | number | boolean>;
    type Options = { props?: Props; callback?: () => void };
    type Plausible = ((event: string, options?: Options) => void) & { q?: unknown[][] };
    declare global {
    interface Window {
    plausible?: Plausible;
    }
    }
    /**
    * The script's global, or the same queue stub Plausible's snippet installs: calls made before the
    * script loads are replayed once it arrives, and are dropped harmlessly if it never does.
    */
    const plausible = (): Plausible => {
    if (!window.plausible) {
    const queue: Plausible = (...args) => {
    queue.q?.push(args);
    };
    queue.q = [];
    window.plausible = queue;
    }
    return window.plausible;
    };
    /**
    * Send one event. Resolves when Plausible acknowledges it, straight away when only the queue stub
    * is present, and after one second regardless so a blocked script never stalls an expression.
    */
    export const send = (name: string, props?: Props): Promise<void> =>
    new Promise((resolve) => {
    const p = plausible();
    if (p.q) return resolve();
    const timer = setTimeout(resolve, 1000);
    p(name, {
    props,
    callback: () => {
    clearTimeout(timer);
    resolve();
    },
    });
    });
    /**
    * `data-track:signup="{ plan: $plan }"` sends a `Signup` event on click. `__event.submit` picks
    * another DOM event, `__once` sends it once, `__case.kebab` keeps the key as written. The value is
    * an optional expression evaluated at send time, with `evt` in scope.
    */
    export const track = attribute({
    name: 'track',
    key: 'required',
    mount({ el, mods, cased, value, evaluate, listen, report }) {
    const name = cased('pascal');
    const type = mods.get('event')?.[0] ?? 'click';
    listen(el, type, (evt: Event) => send(name, value ? evaluate(evt) : undefined).catch(report), {
    once: mods.has('once'),
    });
    },
    });
    /** `@track('Checkout', { total: $total })` from any expression; returns the promise from `send`. */
    export const trackFunction = action({
    name: 'track',
    call: ({ error }, name: string, props?: Props) => {
    if (!name) throw error('needs an event name');
    return send(name, props);
    },
    });
    /**
    * Server event `sigmx-track`: `name Purchase` and an optional `props {"total": 42}` line, so the
    * server records a goal from the one place that knows it happened.
    */
    export const trackHandler = handler({
    name: 'track',
    handle: (_, { name, props }) => {
    if (name) send(name, props ? JSON.parse(props) : undefined);
    },
    });

    Three decisions are worth pointing out.

    Missing script. plausible() installs the same queue stub Plausible’s own snippet does when the script has not loaded. Calls are queued and replayed once it arrives, which covers a script loaded late or deferred; if the script is blocked they sit in the queue and nothing else changes. The page never throws because analytics are absent.

    Resolving the promise. send resolves when Plausible’s callback fires, immediately when only the queue stub is present (there is nothing to wait for), and after one second regardless, so @track('X').then(() => @post('/x')) can never stall because a script was blocked.

    Names from keys. HTML lowercases attribute names, so data-track:signup arrives as signup. cased('pascal') turns it into Signup and data-track:add-to-cart into AddToCart, which matches how Plausible goals are usually named. Authors who want the key as written add __case.kebab.

    <button data-track:signup="{ plan: $plan }">Sign up</button>
    <form data-track:contact__event.submit data-on:submit="@post('/contact')">…</form>
    <a href="/pricing" data-track:pricing__once>Pricing</a>
    part
    key required: the event name, recased to PascalCase
    value optional: an expression evaluated when the event fires, giving the props; evt is in scope
    __event.<name> listen for another DOM event; default click
    __once send at most once
    __case.<style> kebab, snake, camel or pascal for the event name

    The listener goes through listen, so it is removed when the attribute or element goes.

    <button data-on:click="@track('Checkout', { total: $total }); @post('/checkout')">Pay</button>
    <button data-on:click="@track('Download').then(() => location.href = '/file.zip')">Download</button>

    @track(name, props?) returns the promise from send. Fire and forget is the common case; chain when the next step navigates away, because a page unload can lose an event that is still being sent.

    The server is the one place that knows whether a purchase, a signup or an upload actually happened, so it records the goal itself, in the same stream that patches the page. On the wire:

    event: sigmx-patch-signals
    data: signals {"ordered": true, "total": 24}
    event: sigmx-track
    data: name Purchase
    data: props {"total": 24, "qty": 2}

    Any server can write those lines. name is the event, props an optional JSON object the handler parses. Because the event is emitted after the work, a failed request records nothing, and there is no client-side guess about what the server did.

    From a JavaScript runtime, sigmx/server writes the same thing:

    import { sseStream } from 'sigmx/server'
    sseStream((s) => {
    s.patchSignals({ ordered: true, total })
    s.send({ event: 'sigmx-track', lines: ['name Purchase', `props ${JSON.stringify({ total, qty })}`] })
    })

    The demo’s endpoint on this site is an Astro route (src/pages/api/goal.ts) that reads the signals with readSignals(request) and returns exactly that stream through @sigmx/astro/server.

    Plausible’s default script counts a pageview on load and hooks history.pushState, so navigations made with data-boost are counted without any help from the plugin.

    With the manual script variant nothing is automatic. Send the first pageview yourself and one after each boosted navigation, from the lifecycle event the request client dispatches:

    <body data-boost data-init="@track('pageview')"
    data-on:sigmx-fetch="evt.detail.type === 'finished' && evt.detail.el.closest('[data-boost]') && @track('pageview')">

    Or emit it from the server as event: sigmx-track / data: name pageview on responses that represent a new page.

    By hand, with the built-ins:

    import { createSigmx } from 'sigmx'
    import { essentials } from 'sigmx/presets/essentials'
    import { track, trackFunction, trackHandler } from './plugins/plausible'
    createSigmx({ plugins: [...essentials, track, trackFunction, trackHandler] })

    With auto mode, tell the scanner where the exports live, through the CLI, the Vite plugin or a framework integration, and it ships each one only when your templates use it: the directive when data-track appears, the function when @track( does, the handler when 'track' appears as a string, which the plugin file itself satisfies. Each export is one entry, so a file with three plugins is listed three times.

    Terminal window
    npx sigmx scan src --out src/sigmx-plugins.js \
    --custom track=src/plugins/plausible.ts,trackFunction=src/plugins/plausible.ts,trackHandler=src/plugins/plausible.ts
    vite.config.js
    import { sigmxAuto } from 'sigmx/vite'
    const custom = {
    track: 'src/plugins/plausible.ts',
    trackFunction: 'src/plugins/plausible.ts',
    trackHandler: 'src/plugins/plausible.ts',
    }
    export default { plugins: [sigmxAuto({ custom })] }
    // astro.config.mjs, which is how this site registers it
    sigmx({ plugins: 'auto', auto: { custom }, precompile: true })

    Expressions in data-track values are precompiled with everything else when the scanner knows the plugin, since it reads name and key from the source without running it.

    Plausible sets no cookies and stores no identifiers, and the plugin adds none. What you put in props is your responsibility: a plan name, a quantity or a category is fine; an email address, a user id or anything typed into a form is personal data and does not belong in an analytics event. Keep the expressions in data-track values to signals you would be happy to see in a public dashboard.

    Measured the same way as the bundle size tables (esbuild, minified, brotli), the three plugins together add 292 bytes on top of the 4,659 byte core, 751 bytes minified. Registering only the directive or only the function costs less, since send is the bulk of it and is shared.