← Back to dashboard
plausiblemonitoringfresh

Plausible Analytics Integration Guide

Text
██████╗ ██╗      █████╗ ██╗   ██╗███████╗██╗██████╗ ██╗     ███████╗
██╔══██╗██║     ██╔══██╗██║   ██║██╔════╝██║██╔══██╗██║     ██╔════╝
██████╔╝██║     ███████║██║   ██║███████╗██║██████╔╝██║     █████╗
██╔═══╝ ██║     ██╔══██║██║   ██║╚════██║██║██╔══██╗██║     ██╔══╝
██║     ███████╗██║  ██║╚██████╔╝███████║██║██████╔╝███████╗███████╗
╚═╝     ╚══════╝╚═╝  ╚═╝ ╚═════╝ ╚══════╝╚═╝╚═════╝ ╚══════╝╚══════╝

Plausible Analytics Integration Guide

Focus: Privacy-first, cookieless web analytics for multi-tenant provider sites — script embedding (proxied), custom-event goals, and pulling the Stats API v2 into a Postgres rollup that powers the in-app /portal/analytics dashboard.

Overview

Plausible is an open-source, lightweight (~1 KB script), privacy-friendly analytics platform — a cookie-free alternative to Google Analytics. It collects no personal data and sets no cookies, so sites that embed it need no consent banner under GDPR/CCPA and it sidesteps most PII concerns under HIPAA — a direct fit for Florida healthcare provider sites where a tracking-cookie banner is both friction and a liability.

For the Motionstack Dashboard, Plausible is the data source behind the provider-facing analytics_daily rollup. Each provider site (/sites/[subdomain]) embeds a proxied Plausible script; a daily job reads the Stats API v2 per site and upserts visitors/pageviews/bounce-rate/lead-count into Neon, so the /portal/analytics surface renders from our own Postgres instead of a third-party iframe.

Official Documentation

No official MCP server. Plausible exposes a REST Stats API, not an MCP server — integrate it as a normal HTTPS data source (see the Stats API section) rather than via claude mcp add.


Script Setup

Classic script (data-domain)

The simplest embed. data-domain is the site ID in Plausible (it can be a subdomain, e.g. acme.providers.motionstack.app):

HTML
<script defer data-domain="acme.providers.motionstack.app"
        src="https://plausible.io/js/script.js"></script>

Opt into extensions by swapping the filename (combine with dots):

ExtensionFilenameUse
Custom events / goalsscript.tagged-events.jsTrack Lead, Signup, button clicks
Outbound linksscript.outbound-links.jsAuto-track external clicks
File downloadsscript.file-downloads.jsTrack PDF/intake-form downloads
Pageview propsscript.pageview-props.jsAttach metadata to pageviews
Revenuescript.revenue.jsAttach currency + amount to events
Manual / SPAscript.manual.jsFire pageview yourself on route change
HTML
<!-- combined: tagged events + outbound links + file downloads -->
<script defer data-domain="acme.providers.motionstack.app"
        src="https://plausible.io/js/script.tagged-events.outbound-links.file-downloads.js"></script>

The newer init-based script (<script async src="https://plausible.io/js/pa-XXXXX.js"></script> + plausible.init({ … })) is also supported; pick one style per site and stay consistent. next-plausible (below) abstracts this for you.

Proxying serves the script and event endpoint from your own domain (/pa/...), which defeats ad-blockers (they block plausible.io, not first-party paths) and keeps all traffic first-party — important when a provider's visitors run uBlock.

Bash
pnpm add next-plausible
TypeScript
// next.config.ts
import { withPlausibleProxy } from "next-plausible";

export default withPlausibleProxy({
  // omit customDomain for Plausible Cloud; set it for self-hosted CE:
  customDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_HOST, // e.g. https://stats.motionstack.app
})({
  reactStrictMode: true,
});
TSX
// app/sites/[subdomain]/layout.tsx — per-provider tenant site
import PlausibleProvider from "next-plausible";

export default async function ProviderSiteLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ subdomain: string }>;
}) {
  const { subdomain } = await params;
  const domain = `${subdomain}.providers.motionstack.app`; // the Plausible site ID

  return (
    <html lang="en">
      <head>
        <PlausibleProvider
          domain={domain}
          customDomain={process.env.NEXT_PUBLIC_PLAUSIBLE_HOST}
          selfHosted={Boolean(process.env.NEXT_PUBLIC_PLAUSIBLE_HOST)}
          trackOutboundLinks
          taggedEvents
          enabled={process.env.NODE_ENV === "production"}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

Custom Events & Goals

A custom event only counts once you create a matching Goal in Plausible (Site Settings → Goals → "Custom event"). The flagship goal is Lead — fired when a provider's contact form is submitted, so analytics_daily.leads_count reconciles against Plausible.

TSX
// components/sites/LeadForm.tsx
"use client";
import { usePlausible } from "next-plausible";

type Events = {
  Lead: { provider: string; source: string };
  Download: { file: string };
};

export function LeadForm({ providerSlug }: { providerSlug: string }) {
  const plausible = usePlausible<Events>();

  async function onSubmit(formData: FormData) {
    await fetch("/api/leads", { method: "POST", body: formData });
    // goal conversion — props power the Plausible breakdown view
    plausible("Lead", { props: { provider: providerSlug, source: "site-form" } });
  }

  return <form action={onSubmit}>{/* … */}</form>;
}

Server-side conversions (Events API)

For conversions that happen off the page (e.g. a Stripe webhook confirming an upsell), record them server-side. You must forward the visitor's User-Agent and IP via X-Forwarded-For or Plausible cannot attribute the event:

TypeScript
// lib/plausible/event.ts
export async function trackServerEvent(opts: {
  name: string;
  domain: string;          // the site ID
  url: string;             // canonical page URL
  userAgent: string;
  ip: string;
  props?: Record<string, string | number | boolean>;
  revenue?: { currency: string; amount: number };
}): Promise<void> {
  const host = process.env.PLAUSIBLE_HOST ?? "https://plausible.io";
  const res = await fetch(`${host}/api/event`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "User-Agent": opts.userAgent,
      "X-Forwarded-For": opts.ip,
    },
    body: JSON.stringify({
      name: opts.name,
      url: opts.url,
      domain: opts.domain,
      props: opts.props,
      revenue: opts.revenue,
    }),
  });
  if (!res.ok) throw new Error(`Plausible event failed: ${res.status}`);
}

Stats API v2 → analytics_daily rollup

This is the core integration. The Stats API v2 is a single POST /api/v2/query endpoint, authenticated with a Bearer API key (Plausible account → Settings → API Keys).

Bash
curl https://plausible.io/api/v2/query \
  --request POST \
  --header "Authorization: Bearer $PLAUSIBLE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "site_id": "acme.providers.motionstack.app",
    "metrics": ["visitors", "pageviews", "bounce_rate"],
    "date_range": "7d",
    "dimensions": ["time:day"]
  }'

Daily job that fans out over every active provider and upserts the rollup (run it on an Upstash QStash schedule — see the upstash guide — so it survives serverless cold starts and retries):

TypeScript
// app/api/cron/plausible-rollup/route.ts
import { db } from "@/lib/db";
import { providers, analyticsDaily } from "@/lib/db/schema";
import { eq, sql } from "drizzle-orm";

const PLAUSIBLE = process.env.PLAUSIBLE_HOST ?? "https://plausible.io";

type Row = { date: string; visitors: number; pageviews: number; bounce_rate: number };

async function queryDay(siteId: string): Promise<Row[]> {
  const res = await fetch(`${PLAUSIBLE}/api/v2/query`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PLAUSIBLE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      site_id: siteId,
      metrics: ["visitors", "pageviews", "bounce_rate"],
      date_range: "yesterday",
      dimensions: ["time:day"],
    }),
  });
  if (!res.ok) throw new Error(`Stats API ${res.status} for ${siteId}`);
  const json = (await res.json()) as {
    results: { dimensions: string[]; metrics: number[] }[];
  };
  return json.results.map((r) => ({
    date: r.dimensions[0],
    visitors: r.metrics[0],
    pageviews: r.metrics[1],
    bounce_rate: r.metrics[2],
  }));
}

export async function POST(): Promise<Response> {
  const active = await db
    .select({ id: providers.id, subdomain: providers.subdomain })
    .from(providers)
    .where(eq(providers.status, "active"));

  for (const p of active) {
    const siteId = `${p.subdomain}.providers.motionstack.app`;
    try {
      for (const row of await queryDay(siteId)) {
        await db
          .insert(analyticsDaily)
          .values({
            providerId: p.id,
            date: row.date,
            visitors: row.visitors,
            pageviews: row.pageviews,
            bounceRate: String(row.bounce_rate),
          })
          .onConflictDoUpdate({
            target: [analyticsDaily.providerId, analyticsDaily.date],
            set: {
              visitors: row.visitors,
              pageviews: row.pageviews,
              bounceRate: String(row.bounce_rate),
            },
          });
      }
    } catch (err) {
      // surface to Sentry — see the `sentry` guide — don't fail the whole batch
      console.error(`rollup failed for ${siteId}`, err);
    }
  }
  return Response.json({ ok: true, providers: active.length });
}

Useful query knobs: metrics (visitors, pageviews, bounce_rate, visit_duration, events, conversion_rate); date_range presets ("day", "7d", "30d", "month", "6mo", "12mo") or ["2026-01-01","2026-06-20"]; dimensions (time:day, event:page, event:goal, visit:country, visit:source); filters (e.g. [["is","visit:country",["KE","US"]]]). To reconcile leads_count, query with dimensions: ["event:goal"] filtered to the Lead goal.


Self-Hosting (Community Edition)

The dashboard can run against Plausible Cloud or a self-hosted Community Edition (CE) instance at stats.motionstack.app (data sovereignty + flat cost). CE bundles PostgreSQL and ClickHouse via Docker Compose:

Bash
git clone https://github.com/plausible/community-edition plausible-ce
cd plausible-ce

# Configure the instance
cat > plausible-conf.env <<'ENV'
BASE_URL=https://stats.motionstack.app
SECRET_KEY_BASE=<openssl rand -base64 48>
TOTP_VAULT_KEY=<openssl rand -base64 32>
ENV

docker compose up -d   # starts plausible + postgres + clickhouse + caddy

When self-hosted, set selfHosted / customDomain in next-plausible and point PLAUSIBLE_HOST at the instance. Everything else (script, Events API, Stats API v2) is identical to Cloud.


Environment Variables

Bash
# Stats API v2 (server-side only — never expose in the browser)
PLAUSIBLE_API_KEY=plausible-...

# Host: omit/leave default for Cloud; set for self-hosted CE
PLAUSIBLE_HOST=https://plausible.io
NEXT_PUBLIC_PLAUSIBLE_HOST=               # set to https://stats.motionstack.app if self-hosting

Add these to ENV_MASTER.md and each project's .env.example. The API key is server-only — it must never reach the client bundle.


Automation Workflows

QStash schedule (daily rollup)

Bash
# Register the cron once (03:15 UTC daily). See the `upstash` guide for QStash setup.
curl -X POST "https://qstash.upstash.io/v2/schedules/https://app.motionstack.app/api/cron/plausible-rollup" \
  -H "Authorization: Bearer $QSTASH_TOKEN" \
  -H "Upstash-Cron: 15 3 * * *"

Claude Code slash command: analytics snapshot

.claude/commands/analytics.md:

Markdown
Summarize Plausible analytics for provider: $ARGUMENTS

1. Read PLAUSIBLE_API_KEY from the environment (do not print it).
2. POST to /api/v2/query for site "$ARGUMENTS.providers.motionstack.app" with
   metrics ["visitors","pageviews","bounce_rate","visit_duration"], date_range "30d",
   dimensions ["time:day"].
3. Also query dimensions ["event:goal"] to pull the "Lead" goal conversions.
4. Compare against the analytics_daily rows in Neon for the same window and flag any drift.
5. Output a short trend summary (WoW change) and any anomalies.

Common Use Cases

Use CaseApproach
Per-provider site analyticsProxied PlausibleProvider per /sites/[subdomain], data-domain = site ID
Daily rollup into PostgresPOST /api/v2/query per provider → upsert analytics_daily (QStash cron)
Lead conversion trackingLead custom-event goal + plausible('Lead', …) on form submit
Off-page conversionsServer-side Events API (POST /api/event) from Stripe webhook
Ad-blocker resistancewithPlausibleProxy so the script is served first-party at /pa/...
No cookie banner (HIPAA/GDPR)Cookieless by design — nothing to consent to
Data sovereigntySelf-hosted Community Edition at stats.motionstack.app

Troubleshooting

IssueFix
No data appearingConfirm data-domain exactly matches the site ID in Plausible (no https://, no trailing slash)
Events blocked by ad-blockersUse withPlausibleProxy (first-party /pa/... path) instead of the raw plausible.io script
Custom event not countingCreate the matching Goal in Site Settings → Goals; use the tagged-events script (or next-plausible taggedEvents)
Stats API 401API key missing/expired, or site_id not owned by the key's account
Stats API 400Invalid metric/dimension name or malformed date_range — check spelling against the docs
Server event not attributedForward the visitor User-Agent and X-Forwarded-For; without them Plausible drops the event
Localhost shows no dataProduction-only by default — set enabled/captureOnLocalhost (or use script.local.js) for dev testing
Self-hosted script 404Verify BASE_URL in plausible-conf.env and that customDomain/PLAUSIBLE_HOST point at the CE instance