---
title: "Cloudflare integration"
description: "Enable the integration, authenticate and use the dashboard widgets."
requested_language: nl
language: en
translation_notice: "This page isn't translated yet"
url: https://docs.systhema.app/nl/payload/cloudflare
version: unreleased (main)
docs_index: https://docs.systhema.app/nl/llms.txt
---
> This page isn't translated yet. Showing English.


The Admin dashboard includes Cloudflare status widgets when the integration is enabled.

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

`@systhemaui/payload` ships a first-party Cloudflare integration — no extra plugin to install. Connect the zone your site already runs behind and you get edge cache purges mirrored from every content change, a native "Cloudflare" settings page in the admin Globals nav, four dashboard widgets backed by Cloudflare's GraphQL Analytics API, and one-click cache clearing from the front-end admin bar.

1. **Auto-purge** — every place Systhema revalidates Next.js (publishing a page or post, editing a component/form used across pages, categories, tags, author bylines, redirects, uploads, general-settings changes) also purges the matching Cloudflare edge cache, so visitors never see a stale cached page after an edit.
2. **Cloudflare dashboard widgets** — an overview KPI row (requests, bandwidth, cache ratio, unique visitors, threats blocked), a traffic chart (requests/bandwidth, total vs cached), a cache widget (hit-ratio donut + top content types), and a security widget (threats over time + top threat countries) — all sharing one period selector.
3. **The Cloudflare admin page** — a real Payload global with a connection-status panel, cache controls (Purge Everything, Purge by URL, Development Mode), security controls ("I'm under attack", Security Level, Automatic HTTPS Rewrites, WAF), speed controls (Always Online, Image Optimization), and a one-click "Optimize for Systhema" bundle.
4. **Admin-bar cache clearing** — "Clear cache" (current page) and "Clear all caches" buttons next to the existing preview/logout controls, for anyone holding the purge capability.

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

## Installation

There is nothing to install. All Cloudflare communication is plain `fetch` against the REST v4 API and the GraphQL Analytics API — zero runtime dependencies, nothing vendored.

## Enabling

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

```ts title="payload.config.ts"
withSysthema(buildConfig({ /* … */ }), {
  cloudflare: {
    apiToken: process.env.CLOUDFLARE_API_TOKEN,
    // zoneId auto-resolves from the site host when omitted
  },
})
```

If `cloudflare` is set but no usable credentials resolve, the feature disables itself with a console warning — the config never crashes. A bare `cloudflare: 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 Cloudflare in its setup questionnaire (after analytics; `--yes` defaults to none), offering an auth method select — `none | token | key` — followed by the matching credential prompts and an optional zone id. The credentials are also settable non-interactively via `--cloudflare <token|key|none>`, `--cloudflare-api-token`, `--cloudflare-api-key`, `--cloudflare-email`, and `--cloudflare-zone-id`. Blank values are written as fill-later `.env` lines.

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

| Env var                          | Meaning                                                                                            |
| -------------------------------- | -------------------------------------------------------------------------------------------------- |
| `CLOUDFLARE_API_TOKEN`           | Scoped API token (recommended) — sent as a Bearer credential                                       |
| `CLOUDFLARE_API_KEY`             | Legacy Global API Key — paired with `CLOUDFLARE_EMAIL`                                             |
| `CLOUDFLARE_EMAIL`               | Account email, required alongside `CLOUDFLARE_API_KEY`                                             |
| `CLOUDFLARE_ZONE_ID`             | Explicit zone id — omit to auto-resolve from the site host                                         |
| `SYSTHEMA_CLOUDFLARE_SITE_URL`   | Canonical site origin — falls back to `NEXT_PUBLIC_SERVER_URL`, then `serverURL`                   |
| `SYSTHEMA_CLOUDFLARE_AUTO_PURGE` | `'false'` disables mirroring revalidations into edge purges. Default: enabled                      |
| `SYSTHEMA_CLOUDFLARE_ANALYTICS`  | `'false'` disables the dashboard widgets (the admin page and auto-purge stay on). Default: enabled |

`systhema doctor` validates this convention (the `cloudflare-config` check) and flags an ambiguous or incomplete credential combination (a Global API Key without an email, an email without a key, or both a token and a key configured at once).

## Authentication

Two auth modes, auto-detected by credential format (mirroring Cloudflare's own WordPress plugin) — you never set a mode explicitly:

- **API token (recommended)** — pass `apiToken`. Sent as `Authorization: Bearer <token>`. Scope it to the minimum this module needs:
  - `Zone.Zone:Read` — resolve/verify the zone
  - `Zone.Analytics:Read` — the GraphQL Analytics API (dashboard widgets)
  - `Zone.Cache Purge:Purge` — edge cache purges (auto-purge, admin-bar clearing, the Cache tab)
  - `Zone.Zone Settings:Edit` — reading/patching zone settings (the Security/Speed tabs, "Optimize for Systhema")

  A read-only dashboard with no purge/settings mutation needs only `Zone.Zone:Read` + `Zone.Analytics:Read`.

  **Scope the token to every domain the site publishes through**, not just the primary one. A token's _Zone Resources_ selector defaults to a single zone; a [multi-domain locale setup](https://docs.systhema.app/nl/payload/cloudflare/zones.md#multiple-domains-locale-domains) needs "All zones from an account" (or each zone listed explicitly), or the other domains' zones resolve to nothing and their edge cache is never purged. There is one credential for the whole module — Systhema does not support a different token per domain. If the domains live in different Cloudflare **accounts**, the token has to be a user-level token that can see both.

- **Legacy Global API Key** — pass `apiKey` + `email`. Sent as the `X-Auth-Email` / `X-Auth-Key` header pair. A Global API Key grants full account access — prefer a scoped token. If a value that looks like a Global API Key (Cloudflare's `cfk_`-prefixed format, or the older 37–45 character lowercase-hex format) is accidentally passed as `apiToken` alongside an `email`, it's still sent correctly with the key headers rather than failing as a malformed Bearer token.

Providing neither, or an incomplete pair (a key without an email, or vice versa), disables Cloudflare with a console warning.

## Dashboard widgets

Four widgets register on Payload's native modular dashboard (`admin.dashboard.widgets`, Payload 3.85+), gated on `cloudflare.read` server-side and on the `analytics` sub-option (so you can keep the admin page + auto-purge while turning the widgets off):

| Widget              | Slug                   | Shows                                                                                                                         | Sizes (min–max)                 |
| ------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| Cloudflare overview | `systhema-cf-overview` | Five KPI tiles — Requests, Bandwidth, Cached %, Unique visitors, Threats blocked — each with a delta vs. the previous period. | medium – full (seeded **full**) |
| Cloudflare traffic  | `systhema-cf-traffic`  | An area chart with a Requests/Bandwidth tab toggle, each total vs. cached, from one shared fetch.                             | medium – full                   |
| Cloudflare cache    | `systhema-cf-cache`    | A cache-hit-ratio donut (cached vs. uncached requests) plus a top-content-types bar list.                                     | small – medium                  |
| Cloudflare security | `systhema-cf-security` | Threats-blocked over time plus a top-threat-countries bar list (re-ranked by threat count, not request count).                | small – medium                  |

Every card carries a muted **"Provided by Cloudflare"** attribution, matching the analytics widgets' provider label — including the same small external-link icon right after the label, opening the zone's own Cloudflare dashboard in a new tab (built server-side from the resolved zone's account id + name; hidden until the zone resolves). Charts are hand-rolled SVG styled with Payload's theme variables (Cloudflare-orange accent within the validated categorical ramp), so they match the admin panel in both light and dark themes. Widgets render nothing when Cloudflare (or its `analytics` sub-option) is disabled or the user lacks `cloudflare.read` — access is checked server-side before any client code runs.

### Range presets

A compact selector — hanging off each widget's bottom-right corner — drives all four widgets from a single shared period, independent of the analytics module's own selector:

- **Today**, **Yesterday** — hourly buckets (Cloudflare's `httpRequests1hGroups`).
- **Last 7 days**, **Last 30 days** (default) — daily buckets (`httpRequests1dGroups`).

Only the **Today** and **Yesterday** presets ever use hourly buckets. A custom `since`/`until` range always resolves to daily buckets (`httpRequests1dGroups`) — even a one-day custom span — since a custom range is validated against the daily dataset's retention/span limits, not the hourly dataset's.

The selection persists per-user via a Payload preference (`systhema-cloudflare-range`) — the same mechanism as the analytics module's `systhema-analytics-range`, but a separate module singleton so the two dashboards' periods never entangle. An unrecognized stored preset (e.g. from a version that removed one) silently falls back to the default rather than erroring.

#### Plan-aware extended presets + custom ranges

Beyond the four presets above, the selector can offer **Last 90 days**, **Last 180 days**, **Last year**, and a **custom** `since`/`until` date-pair picker — but only when the connected zone's Cloudflare plan actually supports them. Rather than hardcoding a plan → range table (Pro/Business/Enterprise zones can't be tested against directly), the module asks Cloudflare's own GraphQL API how far back and how wide each dataset can be queried:

```graphql
# Schematic — the actual query inlines the JSON-escaped zone tag directly
# rather than passing it through a `variables` object.
query {
  viewer {
    zones(filter: { zoneTag: "<zone-tag>" }) {
      settings {
        httpRequests1hGroups { enabled maxDuration notOlderThan }
        httpRequests1dGroups { enabled maxDuration notOlderThan }
      }
    }
  }
}
```

`notOlderThan` is how far back a dataset can be queried; `maxDuration` is the longest span a _single_ query can cover — both in seconds. The result (cached ~1 hour in `payload.kv`, served stale-on-error, falling back to a conservative "3 days hourly / 31 days daily" default on any failure so the range UI never breaks) is turned into:

- The four base presets — kept unless the zone's own dataset genuinely can't support them (a defensive clamp, not expected in practice).
- **Last 90/180 days** and **Last year** — offered only when the daily dataset's lookback AND single-query span both cover the full length (a fixed window ending yesterday needs to fit in one query).
- **Custom ranges** — enabled only when the daily dataset's `maxDuration` exceeds 31 days. A large `notOlderThan` with a narrow `maxDuration` (exactly what a real Free zone reports: lookback around a year, but a query span capped near 31 days) means a custom picker couldn't offer anything the built-in presets don't already cover — so on a Free zone, **custom stays off and only the four base presets appear**, which is correct per Cloudflare's own product gating, not a bug.

`GET /systhema/cloudflare/settings` exposes this as a `range` block:

```json
{
  "range": {
    "presets": ["today", "yesterday", "last7Days", "last30Days"],
    "custom": { "enabled": false, "minDate": "2026-07-11", "maxSpanDays": 31 }
  }
}
```

Server-side, a custom `since`/`until` request is validated against these SAME discovered bounds — an out-of-range request 400s with a message like `Range exceeds your zone plan's 31-day span limit.` rather than silently clamping or erroring opaquely. A separate, absolute sanity cap of 366 days applies on top of the discovered bounds, regardless of plan.
