---
title: "Analytics dashboard"
description: "The eight widgets, enabling them, period selection and access."
url: https://docs.systhema.app/payload/analytics
version: unreleased (main)
docs_index: https://docs.systhema.app/llms.txt
---

The dashboard displays analytics through the configured provider.

![Analytics dashboard widgets with demo data](./images/dashboard-widgets.light.webp)

`@systhemaui/payload` ships first-party analytics widgets for the PayloadCMS admin dashboard — no extra plugin to install. Connect your existing analytics provider (Google Analytics 4, Plausible, Umami, Matomo or Fathom) and the dashboard gains eight native widgets, all reading a single shared period you pick once:

1. **Analytics overview** — a KPI row of four stat tiles (Visitors, Views, Bounce rate, Avg duration): a compact value, a signed delta versus the previous period of equal length, and a sparkline under Visitors and Views. Metrics a provider cannot supply render as em-dashes instead of fake zeros.
2. **Analytics chart** — a Visitors + Views area chart over the selected period with a crosshair that snaps to the nearest bucket, a single tooltip listing every series at that point, and a legend. Buckets are hourly for a single day (when the provider supports it), daily for short ranges, and monthly for long ones. An optional "Compare to previous period" toggle overlays the prior period as dashed lines.
3. **Analytics breakdown** — a top-N horizontal bar list for one property out of eleven (pages, sources, countries, regions, cities, devices, browsers, operating systems, languages, age brackets, gender). Each instance is configured independently, and you can place several on the dashboard at once (the default layout seeds one for sources and one for pages).
4. **Audience devices** — a segmented Device type / Browser / OS tab control, each a donut chart of the split (top slices + a neutral "Other"). A tab is hidden when the configured provider can't serve it.
5. **Locations** — a segmented Countries / Regions / Cities tab control, each a top-10 bar list. Same per-tab capability gating as Devices.
6. **Age** — a top-N bar list of age brackets (`18-24` … `65+`, with an "Unknown" fold last), GA4-only (requires Google Signals). Seeded automatically only for GA4 sites; on any other provider it renders an informative "Age requires Google Analytics 4" card instead of an empty state.
7. **Gender** — a donut chart of the male/female split plus a neutral "Unknown" fold, GA4-only (requires Google Signals). Male renders blue, female pink, unknown gray. Seeded automatically only for GA4 sites; on any other provider it renders an informative "Gender requires Google Analytics 4" card instead of an empty state.
8. **Live visitors** — the current visitor count with a pulse indicator and a caption naming the provider's realtime window ("Active in the last N min"). The only widget that polls (every 30 seconds, visible tab only) and the only one without a period selector — "live" has no period.

Every data widget (everything but Live) shares one period — a compact selector hanging off each widget's bottom-right corner (its popover opens upward), GA/YouTube-Studio-style — see [Period selection](#period-selection) below.

Everything renders through Payload's native modular dashboard — widgets are dragged, resized, added and removed with the built-in dashboard editor, each widget's per-instance settings live in its native config drawer, and the arrangement persists per user. The module talks to the provider's HTTP API server-side with plain `fetch` and caches responses in `payload.kv`, so credentials never reach the browser and rate-limited providers aren't hammered.

The feature is **disabled by default** and activates through the `analytics` plugin option.

## Installation

There is nothing to install. The provider adapters are plain `fetch` calls — zero runtime dependencies, nothing vendored, no tracking script. The module **reads** analytics from a provider you already run; it does not collect traffic itself.

## Enabling

Configuration is forwarded through the `analytics` plugin option from your own environment — exactly like the captcha and AI credentials. The package never reads environment variables itself:

```ts title="payload.config.ts"
withSysthema({
  analytics: {
    provider: {
      source: 'ga4',
      propertyId: '123456789',
      clientEmail: process.env.SYSTHEMA_ANALYTICS_GA_CLIENT_EMAIL,
      privateKey: process.env.SYSTHEMA_ANALYTICS_GA_PRIVATE_KEY,
    },
  },
})
```

Every provider follows the same shape — swap `source` and forward that provider's credentials. For example, Plausible needs an API key and a site domain:

```ts
analytics: process.env.SYSTHEMA_ANALYTICS_API_KEY
  ? {
      provider: {
        source: 'plausible',
        apiKey: process.env.SYSTHEMA_ANALYTICS_API_KEY,
        siteId: 'example.com',
      },
    }
  : false,
```

The second form also shows the conditional pattern the Payload template uses: gate the option on the credential so a missing key leaves analytics off instead of warning.

If `analytics` is set but a required credential is missing or blank, the feature disables itself with a console warning — the config never crashes. A bare `analytics: true` also warns and stays disabled: configuration cannot come from the environment, so a boolean can never carry credentials.

### New projects: `systhema create`

The Payload template ships pre-wired. `systhema create` asks about analytics in its setup questionnaire (after the AI assistant; `--yes` defaults to none), offering a provider select — `none | ga4 | plausible | umami | matomo | fathom` — followed by per-provider credential prompts. The simple credentials are also settable non-interactively via `--analytics <provider>`, `--analytics-site-id`, `--analytics-api-key` and `--analytics-host`; the GA4 service-account fields and the Umami self-hosted username/password are env/prompt-only (a PEM private key has no business being a shell flag). Blank values are written as fill-later `.env` lines.

The analytics config lives in its own module, `src/payload/analytics.ts` (`export const analytics`), which `payload.config.ts` imports — the same pattern as the database/email/AI modules. The env-gated module activates whenever `SYSTHEMA_ANALYTICS_PROVIDER` is set:

| Env var                              | Meaning                                                                                     |
| ------------------------------------ | ------------------------------------------------------------------------------------------- |
| `SYSTHEMA_ANALYTICS_PROVIDER`        | `ga4` \| `plausible` \| `umami` \| `matomo` \| `fathom`                                     |
| `SYSTHEMA_ANALYTICS_SITE_ID`         | GA4 property id, Plausible domain, Umami website UUID, Matomo `idSite`, Fathom site id      |
| `SYSTHEMA_ANALYTICS_API_KEY`         | Plausible / Umami Cloud / Fathom API key; Matomo `token_auth` (unused for GA4)              |
| `SYSTHEMA_ANALYTICS_HOST`            | Instance base URL — Matomo (required), self-hosted Plausible/Umami                          |
| `SYSTHEMA_ANALYTICS_GA_CLIENT_EMAIL` | GA4 service account `client_email` (from its JSON key file)                                 |
| `SYSTHEMA_ANALYTICS_GA_PRIVATE_KEY`  | GA4 service account `private_key` — keep the literal `\n` escapes (see the GA4 guide below) |
| `SYSTHEMA_ANALYTICS_USERNAME`        | Umami self-hosted login username (when not using an API key)                                |
| `SYSTHEMA_ANALYTICS_PASSWORD`        | Umami self-hosted login password                                                            |

`systhema doctor` validates this convention (the `analytics-config` check) and flags an unknown provider or missing per-provider required variables.

## Access control

Reading analytics data is gated by a single **`analytics.read` capability**, registered only when analytics is enabled. The built-in `dev`, `admin`, `editor` and `seoManager` roles all hold it by default — dashboards are for editors (unlike the dev-only `ai.usage.read`). Adjust via the existing roles API (`roles.overrideRoles`, custom roles): a user without the capability gets no widgets (they render nothing) and 403s from the data endpoints.

## Period selection

Every data widget shares one period, GA/YouTube-Studio-style: change it in any widget's header and every other data widget on the dashboard refetches for the new period. Live has no selector — "live" has no period.

**Presets**, offered in this order: Today, Yesterday, Last 7 days, Last 28 days, Last 30 days, Last 90 days, This month, Last month, Last 6 months, Last 12 months, This year, Last year. Last-N-day presets end **yesterday**, matching Google Analytics' own "last N days" convention, so they never include a partially-accumulated today. A provider that can't serve hourly buckets (Matomo — see the capability matrix below) drops Today and Yesterday from its preset list entirely, since a single day would otherwise render as a flat, bucket-less line.

**Custom ranges** use two native date inputs (From/To). Rules: `from` ≤ `to`, `to` ≤ today, the span is at most 731 days (~2 years), and `from` is no earlier than 2000-01-01.

**Bucketing** follows the resolved span automatically: a single day buckets hourly (when the provider supports it, else it falls back to one daily bucket), 2–92 days bucket daily, and anything wider buckets monthly.

**Compare to previous period** is a checkbox on the chart widget only. When enabled, the chart overlays the immediately preceding period of equal length (calendar-shifted for month/year presets — "last month" compares against the month before that, "this year" against the same year-to-date span last year) as dashed lines at reduced opacity in the same hues, with no area fill; the tooltip lists the current period's values first, then the previous period's with a "(previous)" suffix.

**Per-user persistence**: the selected period and the compare toggle are saved to a Payload user preference (`systhema-analytics-range`) the same way the AI module remembers a user's chosen model — an optimistic local update plus a debounced background write, so switching periods feels instant and survives a reload. A preference that no longer makes sense (an unrecognized preset, an invalid custom pair, or a preset the current provider doesn't offer) is silently discarded in favor of the default 28-day window rather than erroring.

**Timezone stance** (read this before comparing numbers across providers): preset boundaries — where "today" starts and ends — are computed in the dashboard **viewer's own browser timezone**. Each provider then interprets the resulting calendar dates in **its own** configured timezone: GA4 uses the property's reporting timezone, Plausible/Matomo/Fathom use the site's own configured timezone, and Umami resolves everything in UTC. When the viewer's timezone differs from the provider's, bucket boundaries — and therefore "today"'s numbers — can shift by up to a day around midnight. This is expected variance, not a bug: no provider in this lineup offers a per-request timezone override precise enough to eliminate it (Fathom has one, but it's deprecated and intentionally unused here).

## Widgets

Eight widgets register on Payload's native modular dashboard (`admin.dashboard.widgets`). The shared period selector (see [Period selection](#period-selection)) replaced the old per-widget "Timeframe" config field — only the breakdown widget still has per-instance configuration:

| Widget              | Slug                           | Config drawer fields                                              | Sizes (min–max)  | Default layout seed                                          |
| ------------------- | ------------------------------ | ----------------------------------------------------------------- | ---------------- | ------------------------------------------------------------ |
| Live visitors       | `systhema-analytics-live`      | —                                                                 | x-small – medium | x-small, first — a compact pulse leading the analytics band  |
| Analytics overview  | `systhema-analytics-overview`  | — (period set via the shared selector)                            | medium – full    | x-large                                                      |
| Analytics chart     | `systhema-analytics-chart`     | —                                                                 | medium – full    | full width                                                   |
| Audience devices    | `systhema-analytics-devices`   | —                                                                 | x-small – full   | one x-small instance                                         |
| Analytics breakdown | `systhema-analytics-breakdown` | Breakdown property (default _Top pages_), Rows (1–50, default 10) | x-small – full   | two x-small instances: top sources + top pages               |
| Locations           | `systhema-analytics-locations` | —                                                                 | x-small – full   | one x-small instance                                         |
| Age                 | `systhema-analytics-age`       | —                                                                 | x-small – medium | one x-small instance, only for a GA4-capable provider        |
| Gender              | `systhema-analytics-gender`    | —                                                                 | x-small – medium | one x-small instance (last), only for a GA4-capable provider |

- **Devices** shows a segmented Device type / Browser / OS control, each tab a **donut chart** — device/browser/OS are compositional splits, so shares read better than a ranking. The top-8 fetch folds into at most five hued slices (a fixed, per-theme-validated categorical ramp) plus a neutral "Other"; the legend carries label, share and count as real text. **Locations** shows Countries / Regions / Cities, each a top-10 bar list (rankings, not compositions). Both hide a tab entirely when the configured provider can't serve that property (per the capability matrix below) — every shipped provider currently supports all three tabs on each widget.
- **Age** renders a bar list of age brackets in canonical order (`18-24` … `65+`, with an "Unknown" fold last) with a footnote noting Google privacy thresholds may limit rows. **Gender** renders a donut chart of male/female plus a neutral "Unknown" fold — male blue, female pink, unknown gray. Both are GA4-only: on any other provider each widget renders an informative "requires Google Analytics 4" card instead of fetching, and each is only added to the default layout automatically when the configured provider supports its property (`age`/`gender` respectively).
- **Per-instance configuration** for the breakdown widget happens in the native config drawer (the pencil in dashboard edit mode) — the property and row count are stored in the user's dashboard-layout preference, so one user can place three breakdown widgets showing pages, countries and browsers side by side. The period itself is never per-instance; it's the shared dashboard-wide selection.
- **Per-user layouts win.** The default layout above only applies to users who have never customized their dashboard. A user who has **saved a dashboard layout will not see newly registered widgets automatically** — they add them from the dashboard editor. The same applies when a project upgrades to a Systhema version that adds analytics (or adds the new devices/locations/age/gender widgets): existing saved layouts keep their arrangement until the widgets are added by hand.
- A consumer-provided `admin.dashboard.defaultLayout` array is appended to, never replaced; a function layout is left untouched (the widgets stay available to add manually).
- Every widget card carries a muted **"Provided by &lt;Provider&gt;"** attribution in the bottom-left of its footer (opposite the period selector) — e.g. "Provided by Google Analytics 4", "Provided by Plausible", or a custom adapter's `label` — so editors always know where the numbers come from. When the provider has a resolvable dashboard (see `dashboardUrl` below), a small external-link icon right after the label opens that provider's own dashboard in a new tab.
- Widgets render nothing when analytics is disabled or the user lacks `analytics.read` — access is checked server-side before any client code runs.
- Data loads client-side after the dashboard paints (skeleton first, then the data — the dashboard never blocks on a slow provider), refetches on window focus and whenever the shared period changes, and keeps the previous render dimmed during background refreshes. Only the live widget polls: every 30 seconds, and only while the tab is visible.
- Charts are hand-rolled SVG styled with Payload's theme variables, so they match the admin panel in both light and dark themes.
- Registering the widgets changes the generated `payload-types.ts` on the next `payload generate:types` (Payload emits a type per dashboard widget) — an expected diff after enabling analytics.
- **The import map must be generated with analytics enabled.** Payload's `generate:importmap` walks the live config, and the widgets only register when the `analytics` option resolves as enabled — so a build pipeline whose environment lacks the `SYSTHEMA_ANALYTICS_*` variables writes an import map **without** the widget components, and a runtime that _does_ have them logs `PayloadComponent not found in importMap` with empty dashboard cells. Make the analytics variables available wherever `payload generate:importmap` runs (CI/Docker builds included — shaped-valid placeholder values are enough at build time; nothing calls the provider), or commit an import map generated with analytics on.
