Plausible Analytics Integration Guide
██████╗ ██╗ █████╗ ██╗ ██╗███████╗██╗██████╗ ██╗ ███████╗
██╔══██╗██║ ██╔══██╗██║ ██║██╔════╝██║██╔══██╗██║ ██╔════╝
██████╔╝██║ ███████║██║ ██║███████╗██║██████╔╝██║ █████╗
██╔═══╝ ██║ ██╔══██║██║ ██║╚════██║██║██╔══██╗██║ ██╔══╝
██║ ███████╗██║ ██║╚██████╔╝███████║██║██████╔╝███████╗███████╗
╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝╚═════╝ ╚══════╝╚══════╝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/analyticsdashboard.
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
| Resource | URL |
|---|---|
| Plausible Docs | https://plausible.io/docs |
| Stats API v2 (read) | https://plausible.io/docs/stats-api |
| Events API (server-side) | https://plausible.io/docs/events-api |
| Custom Event Goals | https://plausible.io/docs/custom-event-goals |
Script Extensions / plausible.init() | https://plausible.io/docs/script-extensions |
| Script Proxy | https://plausible.io/docs/proxy/introduction |
| Community Edition (self-host) | https://github.com/plausible/community-edition |
Official NPM tracker (@plausible-analytics/tracker) | https://www.npmjs.com/package/@plausible-analytics/tracker |
next-plausible | https://github.com/4lejandrito/next-plausible |
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
The snippet (init-based script)
Plausible ships one lightweight script (~1 KB). When you add a site, the dashboard generates a site-specific snippet (its pa-XXXXX ID encodes your site / data-domain) that goes in <head>:
<script defer src="https://plausible.io/js/pa-XXXXX.js"></script>Enhanced measurements — outbound links, file downloads, form submissions — are toggled in Site Settings → General → Tracking and take effect without editing the snippet. Advanced behavior is configured through plausible.init():
plausible.init() option | Type | Default | Use |
|---|---|---|---|
outboundLinks | boolean | false | Track external link clicks |
fileDownloads | boolean | { fileExtensions } | false | Track PDF/intake-form downloads |
formSubmissions | boolean | false | Track form submissions |
customProperties | object | (eventName) => object | {} | Attach global props to every event |
hashBasedRouting | boolean | false | SPA hash routing |
autoCapturePageviews | boolean | true | Set false to fire pageviews manually |
captureOnLocalhost | boolean | false | Enable dev/localhost tracking |
endpoint | string | https://plausible.io/api/event | Point at a proxy / self-hosted host |
Legacy note: older installs used filename-based extensions (
script.tagged-events.js,script.outbound-links.js,script.manual.js,script.local.js, …). Those still resolve, but new sites get the init-basedpa-XXXXX.jsscript above — prefer it. The official NPM build is@plausible-analytics/tracker(init()/track()); the old communityplausible-trackerpackage is deprecated.next-plausible(below) wraps all of this for Next.js.
Next.js (App Router) with next-plausible@4 + proxy — recommended
next-plausible@4 tracks Plausible's init-based script and is the cleanest path for the dashboard. 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.
pnpm add next-plausible// next.config.ts
import { withPlausibleProxy } from "next-plausible";
export default withPlausibleProxy({
// your site-specific script URL from the Plausible dashboard.
// Self-hosted CE: use the instance URL, e.g. https://stats.motionstack.app/js/pa-XXXXX.js
src: process.env.NEXT_PUBLIC_PLAUSIBLE_SRC!,
// keep the first-party paths the rest of this guide references
// (defaults are /js/script.js and /api/event):
scriptPath: "/pa/js/script.js",
apiPath: "/pa/api/event",
})({
reactStrictMode: true, // a config object is mandatory, even if empty
});// app/sites/[subdomain]/layout.tsx — per-provider tenant site
import PlausibleProvider from "next-plausible";
export default async function ProviderSiteLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
{/* v4 mounts the provider inside <body>, not <head>.
`src` is omitted here because withPlausibleProxy wires it up automatically. */}
<body>
<PlausibleProvider
init={{ outboundLinks: true }}
enabled={process.env.NODE_ENV === "production"}
>
{children}
</PlausibleProvider>
</body>
</html>
);
}v4 breaking change: the old
domain/customDomain/selfHosted/trackOutboundLinks/taggedEventsprops are gone. Enhanced measurements now live in Site Settings or theinitobject; self-hosting is just a differentsrc. For true per-provider site IDs, pass a per-subdomainsrc={tenant.paScriptUrl}(that tenant'spa-XXXXX.js) instead of the shared proxy, or run one Plausible site with subdomain/hostname filtering. See next-plausible'sMIGRATION.mdwhen upgrading from v3.
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.
// 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:
// 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).
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):
// 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 };
// Stats API v2 has NO "yesterday" shortcut — a single day is a custom [date, date] range.
function yesterdayISO(): string {
return new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); // YYYY-MM-DD
}
async function queryDay(siteId: string): Promise<Row[]> {
const day = yesterdayISO();
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: [day, day], // single day; v2 has no "yesterday" preset
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, visits, pageviews, bounce_rate, visit_duration, events, conversion_rate, total_revenue, …); date_range presets ("day", "24h", "7d", "28d", "30d", "91d", "month", "6mo", "12mo", "year", "all") — there is no "yesterday" shortcut, so a single day is a custom ["2026-08-22","2026-08-22"] array; dimensions (time:day, event:page, event:goal, visit:country, visit:source); filters (e.g. [["is","visit:country",["KE","US"]]]). The Stats API is rate-limited to 600 requests/hour per key, so fan out the rollup with that budget in mind. 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. Pin the release tag (current: v3.2.1) and configure a .env file — CE reads configuration from .env, not the old plausible-conf.env:
git clone -b v3.2.1 --single-branch \
https://github.com/plausible/community-edition plausible-ce
cd plausible-ce
# Configure the instance (SECRET_KEY_BASE must be at least a 64-byte string)
echo "BASE_URL=https://stats.motionstack.app" >> .env
echo "SECRET_KEY_BASE=$(openssl rand -base64 48)" >> .env
# HTTP_PORT=80 + HTTPS_PORT=443 enable automatic Let's Encrypt TLS (no Caddy needed);
# expose them via a compose override so the container can bind them.
echo "HTTP_PORT=80" >> .env
echo "HTTPS_PORT=443" >> .env
cat > compose.override.yml <<'YML'
services:
plausible:
ports:
- 80:80
- 443:443
YML
docker compose up -d # starts plausible + postgres + clickhouse (TLS is built-in)
TOTP_VAULT_KEYis no longer required — current CE derives it automatically; onlyBASE_URLandSECRET_KEY_BASEare mandatory. TLS is handled by the app's built-in automatic Let's Encrypt (there is no bundled Caddy reverse proxy anymore).
When self-hosted, point next-plausible's src at your instance's pa-XXXXX.js (e.g. https://stats.motionstack.app/js/pa-XXXXX.js) and set PLAUSIBLE_HOST for the server-side Events/Stats API calls. Everything else (script, Events API, Stats API v2) is identical to Cloud.
Environment Variables
# Stats API v2 + Events API (server-side only — never expose in the browser)
PLAUSIBLE_API_KEY=... # Bearer key from Plausible → Settings → API Keys
# Server-side API host: leave default for Cloud; set for self-hosted CE
PLAUSIBLE_HOST=https://plausible.io
# Site-specific script URL used by next-plausible v4 `src` (public — it is the snippet).
# Cloud: https://plausible.io/js/pa-XXXXX.js | CE: https://stats.motionstack.app/js/pa-XXXXX.js
NEXT_PUBLIC_PLAUSIBLE_SRC=https://plausible.io/js/pa-XXXXX.jsAdd these to
ENV_MASTER.mdand each project's.env.example. The API key is server-only — it must never reach the client bundle.
Automation Workflows
QStash schedule (daily rollup)
# 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:
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 Case | Approach |
|---|---|
| Per-provider site analytics | Proxied PlausibleProvider per /sites/[subdomain], per-tenant src = that site's pa-XXXXX.js |
| Daily rollup into Postgres | POST /api/v2/query per provider → upsert analytics_daily (QStash cron) |
| Lead conversion tracking | Lead custom-event goal + plausible('Lead', …) on form submit |
| Off-page conversions | Server-side Events API (POST /api/event) from Stripe webhook |
| Ad-blocker resistance | withPlausibleProxy so the script is served first-party at /pa/... |
| No cookie banner (HIPAA/GDPR) | Cookieless by design — nothing to consent to |
| Data sovereignty | Self-hosted Community Edition at stats.motionstack.app |
Troubleshooting
| Issue | Fix |
|---|---|
| No data appearing | Confirm the site's domain in Plausible exactly matches (no https://, no trailing slash), and that the correct site's pa-XXXXX.js / src is loading |
| Events blocked by ad-blockers | Use withPlausibleProxy (first-party /pa/... path) instead of the raw plausible.io script |
| Custom event not counting | Create the matching Goal in Site Settings → Goals → + Add goal → Custom event (name must match exactly, char-for-char); goals are not backfilled, so fire the event again after creating it |
Stats API 401 | API key missing/expired, or site_id not owned by the key's account |
Stats API 400 | Invalid metric/dimension name or malformed date_range (e.g. the removed "yesterday" preset) — check spelling against the docs |
Stats API 429 | Over the 600 requests/hour per-key limit — back off / batch the rollup fan-out |
Server event dropped (x-plausible-dropped: 1) | Forward the visitor User-Agent and X-Forwarded-For; events from localhost/staging domains not added to the account are rejected by bot filtering |
| Localhost shows no data | Production-only by default — set enabled (next-plausible) or init={{ captureOnLocalhost: true }} for dev testing |
| Self-hosted script 404 | Verify BASE_URL in .env and that next-plausible's src / PLAUSIBLE_HOST point at the CE instance's pa-XXXXX.js |