Docs

This page isn't translated yet

Cloudflare integration

Enable the integration, authenticate and use the dashboard widgets.

On this page

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

@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.

InstallationLink to this section

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.

EnablingLink to this section

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:

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 createLink to this section

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 varMeaning
CLOUDFLARE_API_TOKENScoped API token (recommended) — sent as a Bearer credential
CLOUDFLARE_API_KEYLegacy Global API Key — paired with CLOUDFLARE_EMAIL
CLOUDFLARE_EMAILAccount email, required alongside CLOUDFLARE_API_KEY
CLOUDFLARE_ZONE_IDExplicit zone id — omit to auto-resolve from the site host
SYSTHEMA_CLOUDFLARE_SITE_URLCanonical 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).

AuthenticationLink to this section

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 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 widgetsLink to this section

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):

WidgetSlugShowsSizes (min–max)
Cloudflare overviewsysthema-cf-overviewFive KPI tiles — Requests, Bandwidth, Cached %, Unique visitors, Threats blocked — each with a delta vs. the previous period.medium – full (seeded full)
Cloudflare trafficsysthema-cf-trafficAn area chart with a Requests/Bandwidth tab toggle, each total vs. cached, from one shared fetch.medium – full
Cloudflare cachesysthema-cf-cacheA cache-hit-ratio donut (cached vs. uncached requests) plus a top-content-types bar list.small – medium
Cloudflare securitysysthema-cf-securityThreats-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 presetsLink to this section

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 rangesLink to this section

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:

# 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:

{
  "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.