Skip to main content

Guide

Plausible vs Umami vs Fathom (2026)

Compare Plausible, Umami, and Fathom for privacy-first web analytics. Cookie-free tracking, GDPR compliance, lightweight scripts, and which privacy analytics.

·PkgPulse Team·
0

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

FeaturePlausibleUmamiFathom
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 tierSelf-hostSelf-host
PricingFrom $9/monthFree (self-host) / cloudFrom $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

The primary business value of privacy-first analytics in 2026 is operational: by collecting no personal data and placing no cookies, these platforms eliminate the need for cookie consent banners in most EU jurisdictions under GDPR's legitimate interest basis for analytics. This removes both the development cost of implementing cookie consent infrastructure and the conversion rate impact of requiring users to interact with cookie consent dialogs before accessing content. Plausible, Umami, and Fathom all use a fingerprinting approach that does not persist across days or identify individual users — instead of storing a persistent user ID in a cookie, they hash a combination of IP address, user agent, and a daily rotating salt to create a temporary session identifier. This approach satisfies most interpretations of GDPR's privacy requirements without a consent requirement, though organizations in regulated industries should verify this assessment with their own legal counsel given that EU data protection authority interpretations vary by country.

Self-Hosting Plausible and Umami in Production

For teams self-hosting analytics, the operational requirements differ between Plausible and Umami. Plausible self-hosted requires PostgreSQL for user and site configuration data and Clickhouse as the event storage backend — Clickhouse is a columnar analytical database optimized for the kind of time-series aggregation queries that analytics requires, and it handles hundreds of millions of events without the performance degradation that would occur storing events in PostgreSQL. The Plausible Docker Compose configuration sets up both databases, but teams should plan for Clickhouse disk usage (typically 100-500GB for millions of monthly pageviews) when sizing their infrastructure. Umami is simpler to self-host because it stores all data in PostgreSQL (or MySQL), which most teams already operate. The tradeoff is that Umami's query performance degrades at very high event volumes compared to Plausible's Clickhouse backend, though for sites under 50 million monthly pageviews, PostgreSQL performs adequately with proper indexing.

Custom Event Tracking and Analytics Architecture

The custom event tracking capabilities of each platform determine how deeply you can instrument your application's user behavior. Plausible and Umami both support event properties (custom dimensions attached to events), which is essential for answering business questions like "which package categories drive the most comparisons?" or "which pricing plan pages have the highest conversion rate?" Fathom tracks events as goals but supports only a single monetary value dimension per event, not arbitrary string properties — this limits the analytical depth possible with Fathom for complex behavior tracking. For server-side event tracking (important for events that happen in Next.js Server Actions or API routes where there is no browser context), all three platforms accept events via their HTTP collection API, so backend-triggered events like API requests, background job completions, or payment events can be tracked alongside pageviews. Umami's server-side tracking API is the most flexible for this use case, accepting arbitrary event data without the user-agent and screen-size fields that browser tracking automatically provides.

Proxy and Ad-Blocker Bypassing

Ad blockers and browser privacy extensions block analytics scripts from known domains, which can cause 20-40% of pageviews to go untracked depending on your audience's privacy tooling adoption. All three platforms provide solutions, but the implementation differs. Plausible's proxy approach routes the analytics script and event collection API through your own domain (using Next.js rewrites, Nginx, or a CDN worker), so the requests appear to come from your own domain rather than plausible.io. This bypasses most ad blockers because they block known tracker domains but cannot block your own domain. Fathom provides custom domains as a first-class feature — you can configure a subdomain like analytics.yourdomain.com that Fathom serves from, with no proxy configuration required on your end. Umami self-hosting inherently solves this problem because the script is served from your own domain, and you can alias the script URL to make it less identifiable as analytics traffic.

Data Portability and Migration Between Platforms

Long-term ownership of your analytics data matters when evaluating analytics platforms. Plausible provides a data export feature that downloads your event data as CSV, covering the full event history stored in Clickhouse. The exported data can be re-imported into another Plausible instance or processed externally. Umami's PostgreSQL storage makes data access particularly flexible — because it's a standard relational database, you can query the raw events table directly using SQL, export to any format, or integrate with BI tools like Metabase or Grafana for custom reporting beyond the Umami dashboard. Fathom's data portability is limited compared to self-hosted alternatives — data export is available through the API but not as a bulk historical export in the current free tier. For teams evaluating long-term platform commitment, Umami's self-hosted PostgreSQL model provides the strongest data independence, while Fathom's SaaS model requires trusting Fathom's continued operation and export capabilities.

Team Access and Multi-Site Management

For agencies, consultancies, or SaaS companies managing analytics across many client or product sites, multi-site management and access control are critical platform selection criteria. Plausible supports multiple sites under a single account, with each site having its own dashboard, and supports inviting team members with viewer or admin roles per site — useful for giving clients read-only access to their own analytics without exposing other sites. Fathom's team access model similarly supports multiple sites and team member invitations with configurable permissions, and its site-specific API keys allow different team members or integrations to access only the data they need. Umami's self-hosted model provides full flexibility here: you manage users and sites in the database directly, and the application UI supports creating multiple sites and assigning users to specific sites. For larger teams using Umami Cloud, site-based access control is managed through the dashboard. When managing analytics for external clients, Fathom's white-labeling for client reporting dashboards (available on higher plans) or Umami's self-hosted approach with a custom domain are both viable options for presenting analytics data without exposing the underlying platform vendor to clients.


Methodology

Feature comparison based on Plausible, Umami, and Fathom platforms and pricing as of March 2026.

Compare analytics and developer tooling on PkgPulse →

See also: AVA vs Jest and PostHog vs Mixpanel vs Amplitude, Hotjar vs FullStory vs Microsoft Clarity.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.