npm.io
0.2.0 • Published 2d ago

payload-posthog

Licence
MIT
Version
0.2.0
Deps
2
Size
158 kB
Vulns
0
Weekly
0

payload-posthog

CI Release Deploy Docs npm Docs License: MIT

A full-featured, fully customizable PostHog integration for Payload CMS 3.x — analytics capture, user identification, feature flags, error tracking, session replay/surveys inside the admin panel, a reverse ingestion proxy, inbound webhooks, and group analytics, all as one plugin.

Originally built by Rynvo — a United States–based software development and digital marketing firm — for client work; now open source under MIT.

Docs: payload-posthog.rynvo.media

Every feature is independently toggleable, and every automatic behavior (event names, captured properties, distinctId resolution) has an override. The underlying posthog-node client is also exported directly, so anything the plugin doesn't wrap is still one import away.

Features

Feature What it does
Capture Auto-captures collection create/update/delete via hooks, per collection, with overridable event names, properties, and distinctId resolution
Identify Calls PostHog identify() when a user logs into any auth-enabled collection
Feature flags Exported getFeatureFlags/isFeatureEnabled/getFeatureFlag helpers for access control, hooks, and endpoints, plus a server-rendered admin dashboard widget
Error tracking Reports Payload's global afterError hook to PostHog error tracking
Client (admin panel) Loads posthog-js inside the Payload admin panel — autocapture, session replay, surveys, and the toolbar all become available to admin users
Client (public site) A PostHogProvider for your Next.js App Router frontend — visitor pageviews on navigation, autocapture, and consent-gated opt-in/opt-out, with no @payloadcms/ui dependency
Reverse proxy Proxies PostHog ingestion + static assets through your own domain to reduce ad-blocker interception
Webhooks Signature-verified inbound endpoint for PostHog → Payload callbacks, with your own handler
Groups Tags every captured event with $groups, plus an explicit identifyGroup() for group-analytics setups (e.g. multi-tenant orgs)

Installation

pnpm add payload-posthog

Quick start

// payload.config.ts
import { buildConfig } from 'payload'
import { payloadPosthog } from 'payload-posthog'

export default buildConfig({
  // ...
  plugins: [
    payloadPosthog({
      apiKey: process.env.POSTHOG_API_KEY!,
      // host: 'https://us.i.posthog.com', // default — use https://eu.i.posthog.com for EU Cloud
      collections: {
        posts: true,
        pages: true,
      },
    }),
  ],
})

That's it: posts and pages now emit capture events on create/update/delete, logins get identified, server errors get reported, feature-flag helpers are ready to use, and the admin panel gets a PostHog-powered dashboard widget plus session replay.

Configuration reference

type PayloadPosthogConfig = {
  apiKey: string // required unless disabled: true
  host?: string // default: https://us.i.posthog.com
  personalApiKey?: string // enables local flag evaluation + the admin flags widget's management-API calls
  disabled?: boolean // keep the plugin registered but turn off all runtime behavior

  capture?: boolean | CaptureConfig // default: true
  collections?: CollectionSlug[] | Partial<Record<CollectionSlug, CollectionCaptureConfig>>
  identify?: boolean | IdentifyConfig // default: true
  featureFlags?: boolean | FeatureFlagsConfig // default: true
  errorTracking?: boolean | ErrorTrackingConfig // default: true
  client?: boolean | ClientConfig // default: true
  proxy?: boolean | ProxyConfig // default: false (opt-in — see Reverse proxy)
  webhooks?: WebhooksConfig // no default — provide a handler to enable
  groups?: GroupsConfig // no default — provide a mapper to enable
  admin?: { dashboardWidget?: boolean } // default: true

  posthogOptions?: Partial<PostHogNodeOptions> // passthrough to `new PostHog()`
}

Every boolean | XConfig option accepts true/false as shorthand for "defaults on/off", or an object for detailed control. Full type definitions ship with the package — see payload-posthog/types or your editor's autocomplete.

Capture
payloadPosthog({
  apiKey: process.env.POSTHOG_API_KEY!,
  collections: {
    // shorthand: enable defaults for all three operations
    posts: true,
    // per-operation control, including a custom event name
    orders: {
      create: 'order_placed',
      update: true,
      delete: false, // don't capture deletes for this collection
    },
  },
  capture: {
    // Applies across every enabled collection unless a collection-level
    // override takes precedence.
    properties: ({ collection, doc, operation }) => ({
      collection,
      title: doc.title,
    }),
    eventName: ({ collection, operation }) =>
      operation === 'delete' ? false : `payload:${collection}.${operation}`,
  },
})

Default event name is payload:{collection}.{operation} (e.g. payload:posts.create); default properties are { collection, id }. distinctId resolves from the acting user's email, then id, then falls back to a stable 'system' id — see defaultResolveDistinctId — so webhook-triggered writes, seed scripts, and cron jobs are still captured instead of silently dropped.

Identify
payloadPosthog({
  apiKey: process.env.POSTHOG_API_KEY!,
  identify: {
    collections: ['users'], // default: every auth-enabled collection
    properties: ({ user }) => ({ email: user.email, role: user.role }),
  },
})
Feature flags
import { getFeatureFlags, isFeatureEnabled } from 'payload-posthog'

// In an endpoint, hook, or access control function:
export const showBetaField: FieldHook = async ({ req }) => {
  return isFeatureEnabled('beta-fields', { req })
}

// Or evaluate everything at once (single /flags request):
const flags = await getFeatureFlags({ req })
if (flags?.isEnabled('new-dashboard')) {
  /* ... */
}

The admin dashboard widget (featureFlags.adminWidget, default true) lists every flag active for the logged-in admin user. Set personalApiKey to enable local flag evaluation.

Error tracking
payloadPosthog({
  apiKey: process.env.POSTHOG_API_KEY!,
  errorTracking: {
    minStatusCode: 500, // default — only server errors, not validation/4xx
    properties: ({ error, req }) => ({ path: req?.pathname }),
  },
})
Client (admin panel)
payloadPosthog({
  apiKey: process.env.POSTHOG_API_KEY!,
  client: {
    autocapture: true, // default
    identifyAdminUser: true, // default
    posthogOptions: {
      // Any posthog-js init option is reachable here.
      session_recording: { maskAllInputs: false },
    },
  },
})

The project API key is safe to expose to the browser this way — PostHog project tokens are write-only and public by design, similar to a Stripe publishable key. personalApiKey is never sent to the client.

Client (public site / frontend)

The admin provider above is scoped to the admin panel (it reads @payloadcms/ui config and identifies the logged-in admin). For your public Next.js frontend, use the separate payload-posthog/react entry instead — it has no @payloadcms/ui dependency, reads a public key/host from props or NEXT_PUBLIC_POSTHOG_KEY / NEXT_PUBLIC_POSTHOG_HOST, and tracks anonymous visitors as themselves rather than tagging them with an admin's distinct id.

// app/layout.tsx
import { PostHogProvider } from 'payload-posthog/react'

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

Pageviews are captured on the initial load and on every App Router client-side navigation automatically (via posthog-js's capture_pageview: 'history_change') — no manual usePathname tracker needed.

Consent gating (CMP integration). Start opted out and flip capturing on once your consent-management platform grants permission. The declarative consent prop keeps posthog-js in sync (true → opt in, false → opt out, undefined → leave as-is):

'use client'
import { PostHogProvider } from 'payload-posthog/react'
import { useCookieConsent } from 'your-cmp' // c15t, cookie consent, etc.

export function Analytics({ children }: { children: React.ReactNode }) {
  const { hasConsent } = useCookieConsent()
  return (
    <PostHogProvider optOutCapturingByDefault consent={hasConsent}>
      {children}
    </PostHogProvider>
  )
}

Or drive it imperatively with the usePostHogConsent hook, and reach the underlying instance with usePostHog:

'use client'
import { usePostHog, usePostHogConsent } from 'payload-posthog/react'

function ConsentBanner() {
  const { optIn, optOut } = usePostHogConsent()
  return (
    <>
      <button onClick={optIn}>Accept</button>
      <button onClick={optOut}>Reject</button>
    </>
  )
}

function Cta() {
  const posthog = usePostHog() // the posthog-js instance, or null before init
  return <button onClick={() => posthog?.capture('cta_clicked')}>Sign up</button>
}

PostHogProvider props: apiKey?, host?, optOutCapturingByDefault?, consent?, and options? (passed verbatim to posthog.init(), overriding every default). If you enabled the reverse proxy, set host="/ingest" to route ingestion through your own domain.

Reverse proxy
payloadPosthog({
  apiKey: process.env.POSTHOG_API_KEY!,
  proxy: {
    path: '/ingest', // default
  },
})

Then point posthog-js at the same origin: client: { posthogOptions: { api_host: '/ingest' } }. See PostHog's reverse proxy docs for background on why this helps.

Webhooks
payloadPosthog({
  apiKey: process.env.POSTHOG_API_KEY!,
  webhooks: {
    secret: process.env.POSTHOG_WEBHOOK_SECRET, // HMAC-SHA256 over the raw body; omit to skip verification
    handler: async ({ payload: webhookPayload, req }) => {
      await req.payload.create({ collection: 'posthog-events', data: webhookPayload })
      return Response.json({ received: true })
    },
  },
})
Groups
import { identifyGroup } from 'payload-posthog'

payloadPosthog({
  apiKey: process.env.POSTHOG_API_KEY!,
  groups: {
    mapper: ({ req }) =>
      req.user?.organizationId
        ? { groupType: 'organization', groupKey: req.user.organizationId }
        : undefined,
  },
})

// Elsewhere, when an org's properties actually change:
identifyGroup({ groupType: 'organization', groupKey: org.id, properties: { plan: org.plan } })

Exports

Export From Description
payloadPosthog payload-posthog The plugin itself
getPostHogClient payload-posthog The singleton posthog-node client — use for anything the plugin doesn't wrap
shutdownPostHogClient payload-posthog Flushes and tears down the client (mainly for tests)
getFeatureFlags, isFeatureEnabled, getFeatureFlag payload-posthog Server-side flag evaluation
identifyGroup, resolveGroupMapping payload-posthog Group analytics
defaultResolveDistinctId, resolveDistinctIdFromUser, SYSTEM_DISTINCT_ID payload-posthog distinctId resolution, exported so overrides can fall back to the same defaults
PostHogProvider payload-posthog/client The admin-panel provider component (registered automatically by client: true)
PostHogProvider, usePostHog, usePostHogConsent payload-posthog/react The public-site frontend provider + hooks (no @payloadcms/ui dependency)
FeatureFlagsWidget payload-posthog/rsc The dashboard widget component (registered automatically)
Types payload-posthog/types Every config type, importable standalone
distinctId resolution

Every feature that needs a distinctId accepts its own distinctId override and otherwise falls back to defaultResolveDistinctId: the acting user's email, then id, then SYSTEM_DISTINCT_ID ('system') for unauthenticated/system-initiated operations. Return undefined from your own override to skip capture for a given event instead.

Development

This package lives in a pnpm + Turborepo monorepo. From the repository root:

pnpm install
cp packages/payload-posthog/dev/.env.example packages/payload-posthog/dev/.env
# fill in DATABASE_URL, PAYLOAD_SECRET, POSTHOG_API_KEY
pnpm dev   # sandbox Payload app at localhost:3000/admin
pnpm test  # vitest integration tests + Playwright e2e

See CONTRIBUTING.md for PR workflow, changesets, and conventions.

Support

License

MIT Rynvo

Keywords