writing-skills
writing-skills
Use when creating new skills, editing existing skills, or verifying skills work before deployment
umamiUmami is an open-source, cookie-free web analytics platform: a Google Analytics alternative that is self-hosted with Docker and PostgreSQL or used as Umami Cloud. Use when someone asks to "track website analytics", "Umami", "privacy-friendly analytics", "self-hosted analytics", "GDPR analytics", "replace Google Analytics", or "website traffic tracking witho…
Independent GitHub snapshot · Automated classification · Original documentation governs
Umami is a privacy-focused web analytics platform — tracks page views, visitors, and custom events without cookies or personal data collection, so no cookie notice is needed for it. Self-hostable on your own infrastructure (one Next.js app plus PostgreSQL), or use Umami Cloud. Clean dashboard, real-time stats, and an API for programmatic access. Version 3 removed MySQL support, renamed several API values and added API keys for self-hosted installs.
# compose.yaml
services:
umami:
image: ghcr.io/umami-software/umami:3.4.0
ports:
- "127.0.0.1:${UMAMI_PORT:-3000}:3000" # localhost only until the default password is changed and a proxy is in front
environment:
DATABASE_URL: postgresql://umami:${POSTGRES_PASSWORD}@db:5432/umami
APP_SECRET: ${APP_SECRET}
depends_on:
db:
condition: service_healthy
init: true
restart: always
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- umami-db-data:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
interval: 5s
timeout: 5s
retries: 5
volumes:
umami-db-data:
printf 'POSTGRES_PASSWORD=%s\nAPP_SECRET=%s\n' "$(openssl rand -hex 16)" "$(openssl rand -hex 32)" > .env
chmod 600 .env
docker compose up -d
curl -s http://localhost:3000/api/heartbeat # {"ok":true} once migrations have run
The first start creates the tables and a login admin / umami. Sign in at http://localhost:3000, change that password at once, then add the site under Websites to get its website ID. Put a TLS-terminating reverse proxy in front before using it from a public site. The repository's own docker-compose.yml works too, but ships a placeholder APP_SECRET and database password that must be replaced. Without Docker: clone the repo, set DATABASE_URL in .env, then pnpm install && pnpm build && pnpm start.
Upgrade with docker compose pull && docker compose up -d after raising the image tag; after a major upgrade run ANALYZE; in PostgreSQL. An installation still on MySQL must migrate its data to PostgreSQL before moving to v3 (see "Migrate MySQL to PostgreSQL" in the docs).
<!-- Add to your site's <head>; on Umami Cloud the src is https://cloud.umami.is/script.js -->
<script
defer
src="https://stats.northwind.dev/script.js"
data-website-id="6fec7a5d-5da4-48c9-9d13-44b33cf0af4b"
></script>
// Next.js — app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Script
defer
src="https://stats.northwind.dev/script.js"
data-website-id="6fec7a5d-5da4-48c9-9d13-44b33cf0af4b"
/>
</head>
<body>{children}</body>
</html>
);
}
Page views, including client-side navigations in single-page apps, are tracked automatically. Optional attributes on the script tag:
| Attribute | Effect |
|---|---|
data-domains="northwind.dev,www.northwind.dev" | Only track on these hostnames (keeps staging and localhost out) |
data-host-url="https://stats.northwind.dev" | Send data to another host than the one serving the script |
data-auto-pageview="false" | No automatic page views; call umami.track() yourself (v3.2+) |
data-auto-track="false" | Disable automatic tracking entirely; send everything with umami.track() |
data-do-not-track="true" | Respect the browser's Do Not Track setting |
data-exclude-search="true", data-exclude-hash="true" | Drop query strings or hashes from recorded URLs |
data-tag="pricing-layout-b" | Tag events for filtering and A/B tests |
data-performance="true" | Collect Core Web Vitals (v3.1+) |
data-before-send="scrubPayload" | Global function (type, payload) that returns the payload, or a falsy value to cancel |
// The tracker defines window.umami; it is undefined when an ad blocker stops the script
declare global {
interface Window {
umami?: {
track(event?: string | object, data?: Record<string, unknown>): void;
identify(id: string | object, data?: Record<string, unknown>): void;
};
}
}
// Track button clicks
document.getElementById("signup-btn")?.addEventListener("click", () => {
window.umami?.track("signup-click", { plan: "pro", source: "header" });
});
// Attach your own ID and properties to the current session after login
window.umami?.identify("user_8f3k2", { plan: "pro", company: "Northwind" });
// Declarative alternative: no JavaScript, every value is stored as a string
<button data-umami-event="pricing-click" data-umami-event-plan="pro">Choose Pro</button>
Use one method per element, not both: an element with data-umami-event does not fire its other click listeners. Event names are limited to 50 characters; event data allows strings up to 500 characters and at most 50 properties.
Create an API key under Settings → API keys (shown once; self-hosted keys start with umami_) and send it as a Bearer token. Base URL: https://stats.northwind.dev/api when self-hosting, https://api.umami.is/v1 on Umami Cloud (limit: 50 calls per 15 seconds per key).
// umami-report.ts — weekly numbers from the Umami API
const UMAMI_API = process.env.UMAMI_API_URL!;
const headers = { Authorization: `Bearer ${process.env.UMAMI_API_KEY}`, Accept: "application/json" };
async function get<T>(path: string, params: Record<string, string | number>): Promise<T> {
const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]));
const res = await fetch(`${UMAMI_API}${path}?${query}`, { headers });
if (!res.ok) throw new Error(`${path}: HTTP ${res.status} ${await res.text()}`);
return res.json() as Promise<T>;
}
const websiteId = process.env.UMAMI_WEBSITE_ID!;
const endAt = Date.now();
const startAt = endAt - 7 * 24 * 60 * 60 * 1000; // milliseconds
const stats = await get<{ pageviews: number; visitors: number; visits: number; bounces: number; totaltime: number }>(
`/websites/${websiteId}/stats`, { startAt, endAt });
const topPages = await get<{ x: string; y: number }[]>(
`/websites/${websiteId}/metrics`, { startAt, endAt, type: "path", limit: 5 });
const daily = await get<{ pageviews: { x: string; y: number }[]; sessions: { x: string; y: number }[] }>(
`/websites/${websiteId}/pageviews`, { startAt, endAt, unit: "day", timezone: "Europe/Berlin" });
console.log(`${stats.visitors} visitors, ${stats.pageviews} pageviews, bounce rate ${Math.round((stats.bounces / Math.max(stats.visits, 1)) * 100)}%`);
for (const page of topPages) console.log(`${String(page.y).padStart(6)} ${page.x}`);
console.log(daily.pageviews.map((d) => `${d.x.slice(0, 10)}=${d.y}`).join(" "));
GET /websites lists sites and their IDs; GET /websites/{id}/active returns visitors in the last 5 minutes.metrics needs type: path, entry, exit, title, query, referrer, domain, channel, event, browser, os, device, country, region, city, language, utmSource, utmCampaign and others. The v2 value type=url now returns 400 Bad request; use path./metrics/expanded returns page views, visitors, visits, bounces and total time per row.startAt/endAt in Unix milliseconds. The API reference also lists startDate/endDate (ISO 8601), but on 3.4.0 they answered HTTP 500.POST /api/auth/login with a JSON body holding username and password returns a temporary token for the same header (self-hosted only).Server-side events go to POST /api/send with no authentication, but with a real browser-like User-Agent; requests that look like bots are answered {"beep":"boop"} and not recorded:
curl -s -X POST https://stats.northwind.dev/api/send \
-H 'Content-Type: application/json' \
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36' \
-d '{"type":"event","payload":{"website":"6fec7a5d-5da4-48c9-9d13-44b33cf0af4b","hostname":"docs.northwind.dev","url":"/checkout","name":"purchase","data":{"plan":"pro","revenue":49}}}'
User prompt: "Add analytics to our SaaS without cookies or consent banners."
Start the stack from "Self-Host Setup", then create the website and an API key from the terminal instead of the UI:
UMAMI=http://localhost:3000
TOKEN=$(curl -s -X POST $UMAMI/api/auth/login -H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"$UMAMI_ADMIN_PASSWORD\"}" | jq -r .token)
curl -s -X POST $UMAMI/api/websites -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Northwind Docs","domain":"docs.northwind.dev"}' | jq '{id, name, domain}'
curl -s -X POST $UMAMI/api/me/api-keys -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"report-script"}' | jq -r .key
Result: the second call prints {"id": "6fec7a5d-5da4-48c9-9d13-44b33cf0af4b", "name": "Northwind Docs", "domain": "docs.northwind.dev"}; that id goes into data-website-id. The third prints the API key once (umami_…). After the script tag and signup-click events from "Custom Event Tracking" are deployed, the Events page lists signup-click with its plan property.
User prompt: "Pull our Umami data for the last 7 days: visitors, top pages and a daily series."
export UMAMI_API_URL=https://stats.northwind.dev/api UMAMI_WEBSITE_ID=6fec7a5d-5da4-48c9-9d13-44b33cf0af4b
node umami-report.ts # UMAMI_API_KEY comes from the secret store; Node.js 24 runs .ts files directly
Result:
4415 visitors, 15171 pageviews, bounce rate 63%
2140 /pricing
1873 /docs/install
962 /
2026-09-24=2011 2026-09-25=2290 2026-09-26=2154 …
admin / umami password and set a unique APP_SECRET before the instance is reachable from the internet. A password change invalidates existing login tokens.script.js and /api/send. Self-hosted: set TRACKER_SCRIPT_NAME and COLLECT_API_ENDPOINT to custom names, or proxy the script through the site's own domain. Always call window.umami?.track, never bare umami.track.localStorage.setItem('umami.disabled', 1) in the browser console on each site; use data-domains to keep development hosts out.data-umami-event attribute — track clicks without JavaScript; use umami.track(name, data) when values must be numbers or booleans.script.js is about 4.8 KB (2.3 KB gzipped) in 3.4.0; load it with defer.MCP_ENABLED=1 to expose a read-only /mcp endpoint that accepts an API key.