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 Proxy | https://plausible.io/docs/proxy/introduction |
| Community Edition (self-host) | https://github.com/plausible/community-edition |
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
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):
<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):
| Extension | Filename | Use |
|---|---|---|
| Custom events / goals | script.tagged-events.js | Track Lead, Signup, button clicks |
| Outbound links | script.outbound-links.js | Auto-track external clicks |
| File downloads | script.file-downloads.js | Track PDF/intake-form downloads |
| Pageview props | script.pageview-props.js | Attach metadata to pageviews |
| Revenue | script.revenue.js | Attach currency + amount to events |
| Manual / SPA | script.manual.js | Fire pageview yourself on route change |
<!-- 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.
Next.js (App Router) with next-plausible + proxy — recommended
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({
// 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,
});// 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.
// 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 };
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:
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 + caddyWhen 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
# 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-hostingAdd 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], data-domain = site ID |
| 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 data-domain exactly matches the site ID in Plausible (no https://, no trailing slash) |
| 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; use the tagged-events script (or next-plausible taggedEvents) |
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 — check spelling against the docs |
| Server event not attributed | Forward the visitor User-Agent and X-Forwarded-For; without them Plausible drops the event |
| Localhost shows no data | Production-only by default — set enabled/captureOnLocalhost (or use script.local.js) for dev testing |
| Self-hosted script 404 | Verify BASE_URL in plausible-conf.env and that customDomain/PLAUSIBLE_HOST point at the CE instance |