npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

payload-plugin-ga4-ecommerce

v1.0.0

Published

GA4 analytics plugin for Payload CMS with admin dashboards, record analytics, caching, and retry/rate-limit controls.

Downloads

112

Readme

payload-plugin-ga4-ecommerce

Production-grade Google Analytics 4 reporting for Payload CMS v3.

Originally built as a custom integration for Fine's Gallery — a high-traffic marble and stone ecommerce storefront running Payload CMS in production. Extracted and open-sourced as a standalone plugin for any Payload v3 project that needs real GA4 analytics inside the admin panel.

Ships a full GA4 Data API service layer with admin analytics UI, per-record collection analytics, tiered caching, bounded concurrency, retry with exponential backoff, in-flight request deduplication, and dual-layer rate limiting — all as a single Payload plugin.

Also fully compatible with the official Payload Ecommerce Template — see Ecommerce Template Setup below.

Production Demo

https://github.com/user-attachments/assets/bd7f2496-d319-45f5-b2c0-2e3ff2b4be6c

Analytics dashboard and per-record analytics running on Fine's Gallery in production.

Features

Analytics dashboard — dedicated admin route with KPI cards (views, visitors, sessions, bounce rate, engagement time), global timeseries chart, top pages, top traffic sources, top events, and live visitor count.

Record-level analytics — automatic "Analytics" tab injected into configured collections (products, categories, pages, blog posts, etc.) showing per-URL aggregate metrics, timeseries chart, period-over-period comparison with percentage deltas, and traffic source breakdown for that specific record.

Business event tracking — configure tracked event names (purchase, add_to_cart, phone_call, begin_checkout_process, submit_order, etc.) and get prioritized event reporting in the dashboard.

Full GA4 Data API coveragerunReport, runRealtimeReport, getMetadata, checkCompatibility — all exposed as typed Payload endpoints with full request validation.

Tiered caching — two strategies:

  • payloadCollection — DB-backed cache with LRU eviction (default, zero infrastructure)
  • redis — distributed cache for multi-node deployments with race-safe eviction

Dual-layer rate limiting — outbound bounded concurrency queue with in-flight request deduplication to protect GA4 API quotas; inbound per-IP, per-route sliding window to protect your endpoints from abuse.

Retry with backoff — exponential backoff + full jitter for transient GA4 failures (HTTP 429/500/502/503/504, gRPC RESOURCE_EXHAUSTED / UNAVAILABLE / DEADLINE_EXCEEDED). Configurable max retries and delay caps.

GA4 quota visibility — optional includePropertyQuota returns GA4 PropertyQuota data with responses so you can monitor token consumption.

Installation

Requires Node ^18.20.2 || >=20.9.0 and Payload CMS ^3.37.0.

pnpm add payload-plugin-ga4-ecommerce

Optional peer dependencies:

| Package | When required | |---------|--------------| | recharts | Admin analytics UI (charts) | | redis | cache.strategy: 'redis' |

Quick start

import { buildConfig } from 'payload'
import { payloadGa4AnalyticsPlugin } from 'payload-plugin-ga4-ecommerce'

export default buildConfig({
  plugins: [
    payloadGa4AnalyticsPlugin({
      propertyId: process.env.GA4_PROPERTY_ID!,
      getCredentials: async () => ({
        type: 'keyFilename',
        path: process.env.GA4_CREDENTIALS_PATH!,
      }),
      collections: [
        { slug: 'products', getPathname: (doc) => `/products/${doc.slug}` },
        { slug: 'pages', getPathname: (doc) => `/${doc.slug}` },
      ],
    }),
  ],
})

That's it. The plugin mounts 9 API endpoints, injects an Analytics sidebar route, and adds an Analytics tab to each configured collection.

Each collection entry needs either pathnameField (a field name containing the URL path) or getPathname (a function that builds the path from the document). The plugin uses this to query GA4 for that specific page's analytics.

Ecommerce Template Setup

If you're using the official Payload Ecommerce Template, add the plugin after ecommercePlugin() in your plugins array (since ecommercePlugin creates the products collection):

// src/plugins/index.ts
export const plugins: Plugin[] = [
  seoPlugin({ /* ... */ }),
  formBuilderPlugin({ /* ... */ }),
  ecommercePlugin({ /* ... */ }),

  // Add after ecommercePlugin
  payloadGa4AnalyticsPlugin({
    propertyId: process.env.GA4_PROPERTY_ID!,
    getCredentials: async () => ({
      type: 'keyFilename',
      path: process.env.GA4_CREDENTIALS_PATH!,
    }),
    collections: [
      { slug: 'products', getPathname: (doc) => `/products/${doc.slug}` },
      { slug: 'pages', getPathname: (doc) => `/${doc.slug}` },
      { slug: 'categories', getPathname: (doc) => `/categories/${doc.slug}` },
    ],
  }),
]

The template's products, pages, and categories collections all use Payload's built-in slugField(), and Products and Pages already use a tabs layout — auto-injection works with zero additional configuration.

Production ecommerce configuration

Full example for a production ecommerce storefront with Redis caching, role-based access, and business event tracking:

payloadGa4AnalyticsPlugin({
  propertyId: process.env.GA4_PROPERTY_ID!,

  getCredentials: async () => {
    // JSON string from secret manager (recommended for production)
    if (process.env.GA4_CREDENTIALS_JSON) {
      return {
        credentials: JSON.parse(process.env.GA4_CREDENTIALS_JSON),
        type: 'json',
      }
    }
    // File path fallback (Docker volumes, local dev)
    return {
      path: process.env.GA4_CREDENTIALS_PATH!,
      type: 'keyFilename',
    }
  },

  // Disable gracefully when credentials aren't available (CI, tests)
  disabled: !process.env.GA4_PROPERTY_ID,

  // Role-based access — admin and SEO roles only
  access: ({ user }) =>
    Boolean(user && user.roles?.some((r: string) => ['admin', 'seo'].includes(r))),

  admin: {
    mode: 'route',       // sidebar route, not dashboard injection
    navLabel: 'Analytics',
    route: '/analytics',
  },

  // Map collections to their public URL paths for per-record analytics
  collections: [
    { slug: 'products', getPathname: (doc) => `/products/${doc.slug}` },
    { slug: 'categories', getPathname: (doc) => `/categories/${doc.slug}` },
    { slug: 'pages', getPathname: (doc) => `/${doc.slug}` },
    { slug: 'blog', getPathname: (doc) => `/blog/${doc.slug}` },
  ],

  // Track ecommerce + lead-gen events
  events: {
    trackedEventNames: [
      'purchase',
      'add_to_cart',
      'begin_checkout_process',
      'submit_order',
      'phone_call',
      'product_inquiry',
    ],
    reportLimit: 10,
  },

  // Redis cache for multi-node deployment
  cache: {
    enabled: true,
    strategy: 'redis',
    redis: { url: process.env.REDIS_URL!, keyPrefix: 'ga4' },
    aggregateTtlMs: 5 * 60_000,    // 5 minutes
    timeseriesTtlMs: 5 * 60_000,
    maxEntries: 2_000,
  },

  // Conservative rate limiting for production
  rateLimit: {
    enabled: true,
    maxConcurrency: 2,              // 2 concurrent GA4 calls per node
    maxQueueSize: 100,
    maxRequestsPerMinute: 120,      // per IP per route
    maxRetries: 3,
    baseRetryDelayMs: 250,
    maxRetryDelayMs: 4_000,
    jitterFactor: 0.2,
    requestTimeoutMs: 10_000,
    includePropertyQuota: true,
  },

  source: { dimension: 'sessionSource' },
})

GA4 setup

  1. In Google Cloud Console, enable the Google Analytics Data API.
  2. Create a service account and generate a JSON key file.
  3. In GA4 Admin > Property Access Management, grant the service account Viewer access.
  4. Note your numeric GA4 property ID (GA4 Admin > Property Settings).

| Environment variable | Required | Description | |---------------------|----------|-------------| | GA4_PROPERTY_ID | Yes | Numeric GA4 property ID | | GA4_CREDENTIALS_JSON | One of these | Raw JSON string of service account key | | GA4_CREDENTIALS_PATH | One of these | File path to service account JSON key |

Admin integration modes

| Mode | Behavior | |------|----------| | route (default) | Dedicated sidebar route at /admin/analytics | | dashboard | Panel injected into the admin dashboard | | both | Sidebar route + dashboard panel | | headless | Endpoints only, no admin UI |

route is recommended for production apps with role-based custom dashboards (sales, warehouse, accounting, etc.) since it avoids layout conflicts.

Record-level analytics

When collections is configured, the plugin injects an "Analytics" tab into each collection's edit view showing:

  • Aggregate KPIs for that record's URL (views, visitors, session duration)
  • Timeseries chart (views + visitors over time)
  • Period-over-period comparison with percentage deltas
  • Top traffic sources for that specific page

Manual UI placement (optional)

By default (autoInjectUI: true), the plugin appends an "Analytics" tab to the end of your collection's existing tabs — or appends a root field if no tabs exist. Most projects should use this and skip this section entirely.

If you need the analytics panel in a specific tab position (e.g. second tab instead of last) or with a custom label, set autoInjectUI: false and place AnalyticsUIPlaceholder exactly where you want it:

import {
  AnalyticsUIPlaceholder,
  payloadGa4AnalyticsPlugin,
} from 'payload-plugin-ga4-ecommerce'

// In plugin config:
payloadGa4AnalyticsPlugin({ autoInjectUI: false, /* ... */ })

// In a collection's field layout — analytics as the second tab with a custom label:
fields: [
  {
    type: 'tabs',
    tabs: [
      { label: 'Content', fields: [{ name: 'title', type: 'text' }] },
      { label: 'Insights', fields: [AnalyticsUIPlaceholder] },
      { label: 'SEO', fields: [/* ... */] },
      { label: 'Settings', fields: [/* ... */] },
    ],
  },
]

At config build time, the plugin replaces the placeholder with the fully hydrated analytics field. The placeholder itself never renders.

getAnalyticsField() and getAnalyticsTab() are also exported for programmatic construction when you need even more control.

API endpoints

Base path: /api/analytics/ga4 (configurable via api.basePath).

| Method | Path | Description | |--------|------|-------------| | GET | /health | Plugin status and configuration snapshot | | POST | /global/aggregate | Site-wide KPI metrics for a timeframe | | POST | /global/timeseries | Site-wide metrics over time | | POST | /page/aggregate | Per-URL KPI metrics for a timeframe | | POST | /page/timeseries | Per-URL metrics over time | | POST | /report | Property breakdown (page, country, device, source, event) | | GET | /metadata | Available GA4 dimensions and metrics | | POST | /compatibility | Check metric/dimension compatibility | | GET | /live | Real-time active visitor count |

Available metrics

views visitors sessions sessionDuration bounceRate eventCount

Available timeframes

7d 30d 6mo 12mo currentMonth

Available report properties

page country device source event

Example requests

# Site-wide KPIs with period comparison
curl -X POST http://localhost:3000/api/analytics/ga4/global/aggregate \
  -H 'Content-Type: application/json' \
  -d '{"timeframe":"30d","metrics":["views","visitors","sessions","bounceRate"],"comparePrevious":true}'

# Per-product analytics
curl -X POST http://localhost:3000/api/analytics/ga4/page/aggregate \
  -H 'Content-Type: application/json' \
  -d '{"pagePath":"/products/marble-fireplace-mantel","timeframe":"30d","comparePrevious":true}'

# Traffic sources for a specific page
curl -X POST http://localhost:3000/api/analytics/ga4/report \
  -H 'Content-Type: application/json' \
  -d '{"property":"source","pagePath":"/products/marble-fireplace-mantel","timeframe":"30d"}'

# Live visitors
curl http://localhost:3000/api/analytics/ga4/live

Cache strategies

payloadCollection (default)

Uses a hidden Payload collection as cache storage. Shared across all app instances on the same database. LRU eviction via accessedAt timestamps with expired entry cleanup every 30 seconds. No additional infrastructure.

redis

Distributed LRU cache using Redis sorted sets. Atomic eviction, race-safe for multi-node duplicate deletes. Requires cache.redis.url and the redis npm package installed in your project. Recommended for horizontally scaled deployments.

pnpm add redis
cache: {
  enabled: true,
  strategy: 'redis',
  redis: { url: 'redis://localhost:6379', keyPrefix: 'ga4' },
  aggregateTtlMs: 5 * 60_000,
  timeseriesTtlMs: 5 * 60_000,
  maxEntries: 2_000,
}

Rate limiting

Outbound (GA4 API protection)

Bounded concurrency queue (maxConcurrency default: 4). In-flight request deduplication — identical concurrent queries share one GA4 API call. Retry with exponential backoff + full jitter on transient failures. Queue overflow returns HTTP 429.

Inbound (endpoint abuse protection)

Per-route, per-client sliding window (60 seconds). Client IP resolved from x-forwarded-for / x-real-ip headers. Falls back to shared bucket without proxy headers.

Security note: IP resolution relies on a trusted reverse proxy. Deploy behind nginx, Cloudflare, or your cloud provider's load balancer.

Serverless environments

Both layers are per-node / per-process. In serverless (Vercel, AWS Lambda):

  • Limits are not globally coordinated across invocations.
  • Use redis cache strategy for cross-invocation consistency.
  • Conservative settings recommended: maxConcurrency: 1, maxRetries: 1, strict access policy.

Security

  • Anonymous requests denied (HTTP 403).
  • Default access: admin-only. Override with access option.
  • Inbound rate limiting on all endpoints (HTTP 429 on abuse).
  • Input validation on all endpoints (metrics, timeframes, paths, content types).
  • Error responses sanitized; full details to server logs only.

Lifecycle

The plugin registers SIGINT, SIGTERM, and beforeExit hooks to gracefully destroy GA4 clients, Redis connections, and limiter state.

createAnalyticsService() is exported for custom runtime integration and exposes destroy().

Configuration reference

type PayloadGA4AnalyticsPluginOptions = {
  /** Numeric GA4 property ID (required) */
  propertyId: string

  /** Async credential provider (required) */
  getCredentials: (args: {
    payload: null | Payload
    req?: PayloadRequest
  }) => Promise<
    | { credentials: { client_email: string; private_key: string; project_id?: string }; type: 'json' }
    | { path: string; type: 'keyFilename' }
  >

  /** Disable the plugin without removing it (default: false) */
  disabled?: boolean

  /** Custom access control (default: admin-only) */
  access?: (args: {
    payload: Payload; req: PayloadRequest; user: PayloadRequest['user']
  }) => boolean | Promise<boolean>

  /** Auto-inject Analytics tab into collections (default: true) */
  autoInjectUI?: boolean

  /** Collections with per-record analytics */
  collections?: Array<{
    slug: string
    pathnameField?: string
    getPathname?: (doc: Record<string, unknown>) => string
  }>

  admin?: {
    mode?: 'route' | 'dashboard' | 'both' | 'headless'  // default: 'route'
    navLabel?: string                                     // default: 'Analytics'
    route?: `/${string}`                                  // default: '/analytics'
  }

  api?: {
    basePath?: `/${string}`  // default: '/analytics/ga4'
  }

  cache?: {
    enabled?: boolean                            // default: true
    strategy?: 'payloadCollection' | 'redis'     // default: 'payloadCollection'
    collectionSlug?: string                      // default: 'ga4-cache-entries'
    redis?: { url: string; keyPrefix?: string }
    aggregateTtlMs?: number                      // default: 300000 (5 min)
    timeseriesTtlMs?: number                     // default: 300000 (5 min)
    maxEntries?: number                          // default: 1000
  }

  rateLimit?: {
    enabled?: boolean              // default: true
    maxConcurrency?: number        // default: 4
    maxQueueSize?: number          // default: 100
    maxRequestsPerMinute?: number  // default: 120
    maxRetries?: number            // default: 3
    baseRetryDelayMs?: number      // default: 250
    maxRetryDelayMs?: number       // default: 4000
    jitterFactor?: number          // default: 0.2
    requestTimeoutMs?: number      // default: 10000
    includePropertyQuota?: boolean // default: true
  }

  events?: {
    trackedEventNames?: string[]  // default: []
    reportLimit?: number          // default: 10
  }

  source?: {
    dimension?: 'sessionSource' | 'firstUserSource' | 'source'  // default: 'sessionSource'
  }
}

Local development

git clone https://github.com/ContiDigital/payload-plugin-ga4-ecommerce.git
cd payload-plugin-ga4-ecommerce
pnpm install
cp dev/.env.example dev/.env
# Set GA4_PROPERTY_ID and GA4_CREDENTIALS_PATH in dev/.env
pnpm dev

| Command | Description | |---------|-------------| | pnpm dev | Dev server with Turbopack | | pnpm lint | ESLint (zero-warning policy) | | pnpm test:int | Unit + integration tests | | pnpm test:coverage | Tests with v8 coverage | | pnpm build | Type declarations + SWC compilation | | pnpm pack:smoke | Package + install verification | | pnpm release:check | Full pre-release gate |

CI/CD

CI (PR + push to main): lint, tests, coverage, build, pack smoke — matrix tested on Node 18 + 20. Optional GA4 live smoke test when secrets are configured.

Release (on v*.*.* tags): verifies tag/version alignment and main branch membership, runs full quality gates, publishes to npm with provenance, creates GitHub release.

GitHub secrets

| Secret | Required for | Description | |--------|-------------|-------------| | GA4_PROPERTY_ID | CI (optional) | GA4 property for live smoke tests | | GA4_CREDENTIALS_JSON | CI (optional) | Service account JSON for live smoke tests | | NPM_TOKEN | Release | npm publish token |

Contributing

See CONTRIBUTING.md.

License

MIT