payload-posthog
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
- Docs: payload-posthog.rynvo.media
- Issues: github.com/Rynvo/payload-posthog/issues
- Security: SECURITY.md