Plausible vs Umami vs Fathom: Privacy-First Web Analytics (2026)
TL;DR
Plausible is the lightweight, open-source analytics platform — no cookies, GDPR-compliant, simple dashboard, <1KB script, proxy support, self-hostable. Umami is the open-source web analytics alternative — cookie-free, customizable, team support, self-hostable, API-first. Fathom is the privacy-focused analytics SaaS — cookie-free, EU isolation, intelligent bot filtering, simple setup, built for businesses that need compliance. In 2026: Plausible for the simplest privacy analytics, Umami for open-source self-hosted analytics, Fathom for privacy-first SaaS with EU compliance.
Key Takeaways
- Plausible: Open-source, <1KB script, no cookies, proxy support
- Umami: Open-source, self-hosted, API-first, custom events
- Fathom: SaaS, EU isolation, intelligent bot filtering, script <2KB
- All three are cookie-free and GDPR-compliant by default
- Plausible has the lightest script (~800 bytes)
- Umami is the most customizable (open-source, full API)
- Fathom has the best bot filtering and EU data isolation
Plausible
Plausible — lightweight open-source analytics:
Setup
<!-- Add to <head> — just one script tag: -->
<script
defer
data-domain="pkgpulse.com"
src="https://plausible.io/js/script.js"
></script>
<!-- With custom events: -->
<script
defer
data-domain="pkgpulse.com"
src="https://plausible.io/js/script.tagged-events.js"
></script>
<!-- Self-hosted: -->
<script
defer
data-domain="pkgpulse.com"
src="https://analytics.pkgpulse.com/js/script.js"
></script>
Next.js integration
// app/layout.tsx
import Script from "next/script"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<Script
defer
data-domain="pkgpulse.com"
src="https://plausible.io/js/script.js"
strategy="afterInteractive"
/>
</head>
<body>{children}</body>
</html>
)
}
// Proxy through Next.js API route (avoid ad blockers):
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: "/js/script.js",
destination: "https://plausible.io/js/script.js",
},
{
source: "/api/event",
destination: "https://plausible.io/api/event",
},
]
},
}
Custom events and goals
// Track custom events:
declare global {
interface Window {
plausible: (event: string, options?: { props?: Record<string, string>; revenue?: { currency: string; amount: number } }) => void
}
}
// Basic event:
window.plausible("Signup")
// Event with properties:
window.plausible("Package Compared", {
props: {
packages: "react vs vue",
comparison_type: "downloads",
},
})
// Revenue tracking:
window.plausible("Subscription", {
revenue: { currency: "USD", amount: 29.99 },
props: { plan: "pro" },
})
// CSS class-based events (no JS needed):
// <button class="plausible-event-name=Signup">Sign Up</button>
// <a class="plausible-event-name=Download+Report" href="/report.pdf">Download</a>
// React hook:
function usePlausible() {
return (event: string, props?: Record<string, string>) => {
if (typeof window !== "undefined" && window.plausible) {
window.plausible(event, props ? { props } : undefined)
}
}
}
function CompareButton({ packages }: { packages: string[] }) {
const plausible = usePlausible()
return (
<button onClick={() => plausible("Compare Clicked", {
packages: packages.join(" vs "),
})}>
Compare
</button>
)
}
Stats API
// Plausible Stats API:
const PLAUSIBLE_API = "https://plausible.io/api/v1"
const headers = {
Authorization: `Bearer ${process.env.PLAUSIBLE_API_KEY}`,
}
// Realtime visitors:
const realtime = await fetch(
`${PLAUSIBLE_API}/stats/realtime/visitors?site_id=pkgpulse.com`,
{ headers }
)
console.log(`Current visitors: ${await realtime.text()}`)
// Aggregate stats:
const stats = await fetch(
`${PLAUSIBLE_API}/stats/aggregate?site_id=pkgpulse.com&period=30d&metrics=visitors,pageviews,bounce_rate,visit_duration`,
{ headers }
)
const { results } = await stats.json()
console.log(`Visitors: ${results.visitors.value}`)
console.log(`Pageviews: ${results.pageviews.value}`)
console.log(`Bounce rate: ${results.bounce_rate.value}%`)
// Top pages:
const pages = await fetch(
`${PLAUSIBLE_API}/stats/breakdown?site_id=pkgpulse.com&period=30d&property=event:page&limit=10`,
{ headers }
)
const { results: topPages } = await pages.json()
topPages.forEach((page: any) => {
console.log(`${page.page}: ${page.visitors} visitors`)
})
// Top sources:
const sources = await fetch(
`${PLAUSIBLE_API}/stats/breakdown?site_id=pkgpulse.com&period=30d&property=visit:source&limit=10`,
{ headers }
)
Umami
Umami — open-source web analytics:
Setup
<!-- Add tracking script: -->
<script
defer
src="https://analytics.pkgpulse.com/script.js"
data-website-id="your-website-id"
></script>
<!-- With auto-track disabled: -->
<script
defer
src="https://analytics.pkgpulse.com/script.js"
data-website-id="your-website-id"
data-auto-track="false"
></script>
Next.js integration
// app/layout.tsx
import Script from "next/script"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<Script
defer
src="https://analytics.pkgpulse.com/script.js"
data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
strategy="afterInteractive"
/>
</head>
<body>{children}</body>
</html>
)
}
Custom events and tracking
// Umami tracking API:
declare global {
interface Window {
umami: {
track: (event: string | Function, data?: Record<string, string | number>) => void
}
}
}
// Track page view manually:
window.umami.track()
// Track custom event:
window.umami.track("signup", { method: "email" })
// Track with properties:
window.umami.track("package-compared", {
packages: "react vs vue",
type: "downloads",
})
// Track using callback for dynamic data:
window.umami.track((props) => ({
...props,
name: "custom-pageview",
data: {
path: window.location.pathname,
theme: document.documentElement.dataset.theme || "light",
},
}))
// CSS data attributes (no JS):
// <button data-umami-event="signup-click">Sign Up</button>
// <button data-umami-event="compare" data-umami-event-packages="react,vue">Compare</button>
// React hook:
function useUmami() {
return {
track: (event: string, data?: Record<string, string | number>) => {
if (typeof window !== "undefined" && window.umami) {
window.umami.track(event, data)
}
},
}
}
function DownloadButton({ packageName }: { packageName: string }) {
const umami = useUmami()
return (
<button
onClick={() => umami.track("download-clicked", { package: packageName })}
data-umami-event="download-clicked"
data-umami-event-package={packageName}
>
Download
</button>
)
}
API access
// Umami API:
const UMAMI_API = "https://analytics.pkgpulse.com/api"
const headers = {
Authorization: `Bearer ${process.env.UMAMI_API_KEY}`,
"Content-Type": "application/json",
}
// Get website stats:
const stats = await fetch(
`${UMAMI_API}/websites/${WEBSITE_ID}/stats?startAt=${startDate}&endAt=${endDate}`,
{ headers }
)
const data = await stats.json()
console.log(`Pageviews: ${data.pageviews.value}`)
console.log(`Visitors: ${data.visitors.value}`)
console.log(`Bounces: ${data.bounces.value}`)
// Get pageviews over time:
const pageviews = await fetch(
`${UMAMI_API}/websites/${WEBSITE_ID}/pageviews?startAt=${startDate}&endAt=${endDate}&unit=day`,
{ headers }
)
const { pageviews: daily } = await pageviews.json()
daily.forEach((day: any) => {
console.log(`${day.date}: ${day.y} views`)
})
// Get top pages:
const pages = await fetch(
`${UMAMI_API}/websites/${WEBSITE_ID}/metrics?startAt=${startDate}&endAt=${endDate}&type=url`,
{ headers }
)
const topPages = await pages.json()
topPages.forEach((page: any) => {
console.log(`${page.x}: ${page.y} views`)
})
// Get events:
const events = await fetch(
`${UMAMI_API}/websites/${WEBSITE_ID}/events?startAt=${startDate}&endAt=${endDate}`,
{ headers }
)
// Send event via API (server-side):
await fetch(`${UMAMI_API}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payload: {
website: WEBSITE_ID,
name: "api-package-lookup",
data: { package: "react" },
hostname: "api.pkgpulse.com",
language: "en-US",
url: "/api/packages/react",
},
type: "event",
}),
})
Self-hosting
# Docker Compose:
# docker-compose.yml
version: "3"
services:
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://umami:password@db:5432/umami
DATABASE_TYPE: postgresql
APP_SECRET: your-secret-key
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: password
volumes:
- umami-data:/var/lib/postgresql/data
volumes:
umami-data:
Fathom
Fathom — privacy-focused analytics SaaS:
Setup
<!-- Fathom script: -->
<script
src="https://cdn.usefathom.com/script.js"
data-site="ABCDEFGH"
defer
></script>
<!-- With custom domain (avoid ad blockers): -->
<script
src="https://pkgpulse-analytics.pkgpulse.com/script.js"
data-site="ABCDEFGH"
defer
></script>
<!-- SPA support: -->
<script
src="https://cdn.usefathom.com/script.js"
data-site="ABCDEFGH"
data-spa="auto"
defer
></script>
Next.js integration
// Using fathom-client package:
import * as Fathom from "fathom-client"
import { useEffect } from "react"
import { usePathname, useSearchParams } from "next/navigation"
// app/components/FathomAnalytics.tsx
export function FathomAnalytics() {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
Fathom.load("ABCDEFGH", {
includedDomains: ["pkgpulse.com", "www.pkgpulse.com"],
url: "https://pkgpulse-analytics.pkgpulse.com/script.js", // Custom domain
})
}, [])
// Track page views on route change:
useEffect(() => {
Fathom.trackPageview()
}, [pathname, searchParams])
return null
}
// app/layout.tsx
import { FathomAnalytics } from "./components/FathomAnalytics"
import { Suspense } from "react"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Suspense fallback={null}>
<FathomAnalytics />
</Suspense>
{children}
</body>
</html>
)
}
Custom events and goals
import * as Fathom from "fathom-client"
// Track event (goal):
Fathom.trackEvent("Signup")
// Track with monetary value (cents):
Fathom.trackEvent("Subscription", { _value: 2999 }) // $29.99
// Track comparison:
Fathom.trackEvent("Package Compared")
// Track download:
Fathom.trackEvent("Report Downloaded")
// React hook:
function useFathom() {
return {
trackEvent: (name: string, value?: number) => {
Fathom.trackEvent(name, value ? { _value: value } : undefined)
},
trackPageview: () => Fathom.trackPageview(),
}
}
function CompareButton({ packages }: { packages: string[] }) {
const fathom = useFathom()
return (
<button onClick={() => {
fathom.trackEvent("Compare Clicked")
// Navigate to comparison
}}>
Compare {packages.length} Packages
</button>
)
}
function PricingCard({ plan, price }: { plan: string; price: number }) {
const fathom = useFathom()
return (
<button onClick={() => {
fathom.trackEvent("Plan Selected", price * 100) // Value in cents
}}>
Subscribe to {plan} — ${price}/mo
</button>
)
}
API access
// Fathom API:
const FATHOM_API = "https://api.usefathom.com/v1"
const headers = {
Authorization: `Bearer ${process.env.FATHOM_API_KEY}`,
}
// Get site details:
const sites = await fetch(`${FATHOM_API}/sites`, { headers })
const { data: siteList } = await sites.json()
siteList.forEach((site: any) => {
console.log(`${site.name}: ${site.id}`)
})
// Get aggregated stats:
const aggregation = await fetch(
`${FATHOM_API}/aggregations?entity=pageview&entity_id=${SITE_ID}&aggregates=visits,uniques,pageviews,avg_duration,bounce_rate&date_from=2026-02-01&date_to=2026-03-01`,
{ headers }
)
const stats = await aggregation.json()
console.log(`Visits: ${stats[0].visits}`)
console.log(`Uniques: ${stats[0].uniques}`)
console.log(`Pageviews: ${stats[0].pageviews}`)
console.log(`Avg duration: ${stats[0].avg_duration}s`)
// Get top pages:
const pages = await fetch(
`${FATHOM_API}/aggregations?entity=pageview&entity_id=${SITE_ID}&aggregates=pageviews&field_grouping=pathname&date_from=2026-02-01&date_to=2026-03-01&sort_by=pageviews:desc&limit=10`,
{ headers }
)
// Get events (goals):
const events = await fetch(`${FATHOM_API}/sites/${SITE_ID}/events`, { headers })
const { data: eventList } = await events.json()
eventList.forEach((event: any) => {
console.log(`${event.name}: ${event.id}`)
})
// Get event completions:
const completions = await fetch(
`${FATHOM_API}/aggregations?entity=event&entity_id=${EVENT_ID}&aggregates=completions,value&date_from=2026-02-01&date_to=2026-03-01&date_grouping=day`,
{ headers }
)
Feature Comparison
| Feature | Plausible | Umami | Fathom |
|---|---|---|---|
| Open-source | ✅ | ✅ | ❌ |
| Self-hosted | ✅ | ✅ | ❌ |
| Cookie-free | ✅ | ✅ | ✅ |
| GDPR compliant | ✅ | ✅ | ✅ |
| Script size | ~800 bytes | ~2KB | ~1.5KB |
| Custom events | ✅ | ✅ | ✅ (goals) |
| Event properties | ✅ | ✅ | ❌ (value only) |
| Revenue tracking | ✅ | ❌ | ✅ |
| Bot filtering | ✅ (basic) | ✅ (basic) | ✅ (intelligent) |
| EU data isolation | ✅ (EU servers) | ✅ (self-host) | ✅ (EU isolation) |
| Custom domains | ✅ (proxy) | ✅ (self-host) | ✅ (built-in) |
| API access | ✅ | ✅ | ✅ |
| Team support | ✅ | ✅ | ✅ |
| Realtime | ✅ | ✅ | ✅ |
| SPA support | ✅ | ✅ | ✅ |
| Import GA data | ✅ | ❌ | ✅ |
| Free tier | Self-host | Self-host | ❌ |
| Pricing | From $9/month | Free (self-host) / cloud | From $15/month |
When to Use Each
Use Plausible if:
- Want the lightest analytics script (~800 bytes)
- Need open-source with option to self-host or use cloud
- Prefer the simplest, cleanest dashboard
- Want proxy support to avoid ad blockers
Use Umami if:
- Want fully open-source analytics you self-host
- Need custom event tracking with properties
- Want API-first analytics for building custom dashboards
- Prefer maximum control over your analytics infrastructure
Use Fathom if:
- Want privacy-first SaaS with zero maintenance
- Need intelligent bot filtering and EU data isolation
- Building a business that needs compliance guarantees
- Want built-in custom domains without proxy setup
Methodology
Feature comparison based on Plausible, Umami, and Fathom platforms and pricing as of March 2026.