glokki
View .md

Page Analytics

Privacy-friendly, first-party, cookieless pageview analytics for your tenant site — views, unique visitors, referrers, and device breakdown. No cookies, no consent banner, two files of setup.

This guide covers wiring the SDK's createInsightsRoute proxy and the React PageAnalytics beacon into your tenant app. The collected data shows up on the CMS /analytics dashboard. For the rest of the delivery integration, see the Integration Guide.


What it is

First-party, cookieless pageview analytics. When a visitor loads a page on your site, a tiny client-side beacon reports the path and referrer to a proxy route you host on your own domain, which forwards it to the CMS. Because nothing is stored in a cookie or localStorage, and visitors are never tracked across days, no consent banner is required.

It captures:

  • Views — total pageviews.
  • Unique visitors — distinct visitors, counted per day (see Privacy).
  • Referrers — where traffic came from.
  • Device — desktop / mobile / tablet, derived server-side from the User-Agent.

Two pieces do the work:

  • createInsightsRoute (from @glokki-cms/sdk) — a same-origin route handler you mount in your app. It holds the delivery API key server-side and forwards beacons to the CMS ingest endpoint.
  • PageAnalytics (from @glokki-cms/react) — a client component that renders null and fires the beacon on mount and on every client-side route change.

Prerequisites

  1. A delivery API key with the delivery:read scope. Create one in the CMS Dashboard → Settings → API Keys. This key is used server-side only by the proxy route — it is never sent to the browser.
  2. Your tenant slug (the same slug you use elsewhere in the delivery integration).
  3. The base URL of your CMS instance (e.g. https://cms.example.com).

Store them as environment variables. The names below are illustrative — there is no canonical naming convention enforced by the SDK, so use whatever your app already uses; just pass the right value into each config field:

CMS_URL=https://cms.example.com
CMS_TENANT=acme
CMS_API_KEY=ek_live_...

Setup step 1 — add the proxy route

Create a route in your app that re-exports the handler from the SDK. Mounting it on a neutral, same-origin path (the default the component expects is /_e) is what makes the analytics first-party and adblock-resistant.

// app/_e/route.ts
import { createInsightsRoute } from "@glokki-cms/sdk"

export const { POST } = createInsightsRoute({
  baseUrl: process.env.CMS_BASE_URL!,
  tenantSlug: process.env.CMS_TENANT!,
  apiKey: process.env.CMS_API_KEY!,
})

Config fields:

FieldDescription
baseUrlThe base URL of your CMS instance. The handler appends the ingest path itself.
tenantSlugYour tenant slug, sent upstream as X-Tenant-Slug.
apiKeyA delivery API key with the delivery:read scope. Stays server-side — it is read from the request handler, never exposed to the browser.
ipHeadersOptional. Ordered header names to read the visitor IP from. Defaults cover Cloudflare (cf-connecting-ip), Fastly (fastly-client-ip), x-real-ip, then the first x-forwarded-for hop.

The handler forwards the beacon body to POST /api/v1/delivery/analytics on the CMS, attaching the visitor's IP and User-Agent so the CMS can derive uniques and device. It always returns 204 immediately and swallows upstream failures — analytics can never break or slow down your page.


Setup step 2 — drop in the component

Render <PageAnalytics /> once, high in your app — the root layout is fine — so it counts only real visitor page views:

// app/layout.tsx
import { PageAnalytics } from "@glokki-cms/react"

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <PageAnalytics />
      </body>
    </html>
  )
}

Since @glokki-cms/react 0.10.0 the component stays silent when the CMS, not a visitor, opens the page — the CMS only filters out bots, so these would otherwise inflate views:

  • every load inside the CMS visual editor, and the editor preview route used to edit block templates — the CMS opens both with ?_editor=1;
  • draft previews opened from the CMS (?preview=true&token=...).

If your app uses a different preview query parameter than the preview=true convention from the Integration Guide, don't render <PageAnalytics /> on those views yourself. Pageviews already recorded from editing sessions stay in the stats.

With @glokki-cms/react older than 0.10.0

Older versions fire on every page they are mounted on. Upgrade, or wrap the component in a client gate and mount <Analytics /> inside <Suspense fallback={null}> (useSearchParams() needs the boundary, or Next.js opts the whole layout out of static rendering):

// components/analytics.tsx
"use client"

import { PageAnalytics } from "@glokki-cms/react"
import { EDITOR_QUERY_PARAM } from "@glokki-cms/sdk"
import { useSearchParams } from "next/navigation"

export function Analytics() {
  const searchParams = useSearchParams()
  // The CMS opens the editor iframe and the editor preview route with ?_editor=1.
  const inEditor = searchParams.get(EDITOR_QUERY_PARAM) === "1"
  const inPreview = searchParams.get("preview") === "true"
  return inEditor || inPreview ? null : <PageAnalytics />
}

The component renders null. On mount and on every client-side route change it fires a single navigator.sendBeacon (falling back to a keepalive fetch on browsers without it), so the request survives navigation and never blocks render.

It accepts one optional prop:

PropDefaultDescription
endpoint"/_e"The same-origin path of your proxy route. Must match where you mounted createInsightsRoute. Override it on both sides if you use a different path.

How it handles the hard cases

CDN caching (Fastly / Cloudflare)

The beacon is client-side. It runs in the visitor's browser after the (possibly CDN-cached) HTML is delivered, so it always reaches your proxy and is counted — even for a fully edge-cached page. Server-side counting would miss every cache hit; this approach does not.

Adblockers

Serving the proxy from your own origin on a neutral path (/_e) sidesteps the domain- and path-based blocklists that target third-party analytics scripts, so the block rate is low. But be honest: any client-side analytics can be blocked, so expect a small undercount. This is not, and cannot be, 100% adblock-proof.

Privacy

Visitors are identified by a daily-rotating, salted SHA-256 hash of IP + User-Agent, computed on the CMS at ingest time. The raw IP is never stored — it is consumed to compute the hash and discarded. Because the salt rotates every day, today's hashes cannot be correlated with yesterday's: there is no durable cross-day identifier, "unique visitor" means unique-per-day, and no cookie/consent banner is required.


Viewing data

Open the /analytics dashboard in the CMS. It shows views, unique visitors, top referrers, and device breakdown, with 7-day, 30-day, and 90-day ranges and a trend chart. No extra configuration — pageviews appear as soon as the proxy and component are live.


FAQ

Do I need a cookie consent banner? No. Nothing is written to cookies or localStorage, and the visitor identifier rotates daily with no cross-day correlation, so there is no persistent tracking identifier.

The proxy returned 204 but I see no data. What's wrong? The proxy deliberately returns 204 and swallows upstream errors so analytics never breaks the page. Check that baseUrl, tenantSlug, and a valid delivery:read apiKey are set, and that <PageAnalytics endpoint> matches the path where you mounted the route.

Can I mount the proxy somewhere other than /_e? Yes — mount it anywhere and pass the same path to <PageAnalytics endpoint="/your-path" />. Keep it same-origin so the analytics stays first-party.

Will this slow down my pages? No. The component fires a non-blocking sendBeacon, and the proxy responds immediately without waiting on the CMS.

Why are my numbers higher than in my other analytics tool? A common cause is a @glokki-cms/react version older than 0.10.0, which also counts page loads inside the CMS editor, draft previews and the editor preview route. Upgrade, or gate it as shown in Setup step 2.