Plugins
Analytics

First-party proxy

Serve a vendor's tracker through your own origin, with the runtime endpoint or a Next.js route handler of your own.

A vendor's tracker normally loads from the vendor's domain and posts events there. Routing both through your own origin instead is what the vendors themselves recommend: ad blockers and DNS filters that block *.posthog.com do not block your site, the browser makes no third-party request, and there is one domain to reason about in a CSP.

The plugin does it two ways. Both relay by the descriptor each adapter declares (capture.proxy.routes) and by the same rules, so what is forwarded, and what comes back, is identical either way.

Runtime endpointRoute handler
SetupNone. Registered automatically.captureRoute in app/<mount>/[...path]/route.ts, and capture.paths.
Resolves perRequest (slot, scope, runtime providers)Nothing: the one adapter the route was built with
Multi-tenantYesOne adapter per route
CostPayload's REST routing, which signs every request in before the handler runsA Next route handler, outside Payload's routing

Use a route handler when one vendor serves the whole install and you would rather its traffic skipped Payload's routing. Use the runtime endpoint for anything per-tenant, and for a first setup, because it needs no config at all.

Runtime endpoint

Mounted at /api/_x/p/<slot>/*, one mount per capture slot. It registers whenever capture is possible: a config adapter declares capture, or runtime providers are enabled. Each slot's snippet already points at its own mount, so nothing needs configuring.

What it does with a request:

  • Only declared routes forward. The slot's adapter is resolved, its capture.proxy.routes are matched in declaration order, and the upstream URL is compiled from the route's own template. An unknown slot, an unfilled slot, an adapter without capture, or a path outside the declared routes is a 404 and nothing leaves the server. Upstream hosts come only from the descriptor, so this is never an open proxy. A matched path parameter whose segment is . or .., or whose decoded segment holds / or \ (an encoded %2F or %5C), answers 404 too, so a request cannot walk out of the route's prefix on the vendor host.
  • Methods. GET and POST are proxied, except that a POST to a script route answers 405 with Allow: GET, OPTIONS: nothing is posted to a vendor's bundle. OPTIONS is answered locally with 204 and Allow: GET, POST, OPTIONS, before slot resolution, so a preflight leaks nothing about which slots exist. PUT, PATCH, and DELETE answer 405.
  • Request headers. Allowlisted: content-type, accept, accept-encoding, accept-language, user-agent, origin, referer, if-none-match, if-modified-since, plus anything the descriptor names in forwardHeaders. cookie, authorization, and proxy-authorization are never forwarded, whatever a descriptor asks for, so Payload's own session never reaches a vendor.
  • Client IP. X-Forwarded-For is set from the resolved client IP. The incoming x-forwarded-for, x-forwarded-host, x-forwarded-proto, forwarded and x-real-ip are never passed through, and neither are the platform headers about the visitor's address and place: x-vercel-forwarded-for, x-vercel-ip-country, x-vercel-ip-country-region, x-vercel-ip-city, cf-connecting-ip, cf-connecting-ipv6, cf-ipcountry, cf-region, cf-region-code, cf-ipcity and true-client-ip, whatever a descriptor's forwardHeaders asks for. No built-in adapter sets forwardHeaders; a custom one that names another header (x-vercel-ip-latitude, say) relays it. Without this every visitor would geo-locate to your server. Which header the client IP comes from is the plugin's trustedPlatform or trustedProxyHops, below.
  • Other sites. A POST that a page on another site made the visitor's browser send, which the browser marks Sec-Fetch-Site: cross-site, answers 403 unless its Origin is listed in capture.allowedOrigins. So does a POST to the tenant slot from a sibling subdomain's page (Sec-Fetch-Site: same-site) whose Origin host scope.resolve maps to another scope than the request's; an Origin no scope answers for passes. Nothing is forwarded for either.
  • Response. Status and body stream back. Of the upstream headers only cache-control, etag, last-modified and vary pass, and the first three only on a script route. A data route answers about one visitor, so it always carries Cache-Control: private, no-store and no etag or last-modified, whatever the vendor sent: a cache in front of the site never keeps one visitor's answer for another. set-cookie never comes back. content-encoding and content-length are deliberately dropped, because fetch decodes the body but leaves both describing the compressed bytes. location is not forwarded either, so an upstream 3xx arrives without it rather than redirecting off your origin.
  • Content type. Every route declares a kind, and the kind decides the content type, never the vendor. A script route is always served as text/javascript; charset=utf-8. A data route keeps the vendor's type when it is application/json or text/plain, and anything else becomes text/plain; charset=utf-8. Every answer carries X-Content-Type-Options: nosniff, so a browser never guesses a type of its own, and every data answer also carries Content-Security-Policy: sandbox; default-src 'none'. A vendor answering HTML on your origin therefore puts neither a script nor a page there, even when someone opens the URL directly.
  • Connection. A slot filled from a provider document reaches its vendor through the same guarded connection the document's reads use, so a host that resolves to a private address is refused here too, and the request answers 502. A slot filled by an adapter from adapters uses that adapter's fetch option when it has one, and the global fetch otherwise.
  • Body cap. A POST over capture.proxy.maxBodyBytes (default 1 MiB, which clears a PostHog batch carrying session-replay data) is refused with 413 before anything is forwarded, and a body that dies in transit answers 400. Bodies are buffered, not streamed, since the cap has to be enforced before forwarding anyway.
  • Timeout. capture.proxy.timeoutMs (default 10000) is a deadline for the upstream response headers only, so a large tracker bundle still streams to completion. A timeout or a network failure answers 502 with no body and no echo of the upstream URL.
  • No error body. Every status above is sent bodyless, and this is the one endpoint the plugin registers that answers no error envelope. It relays a vendor SDK's own traffic, and that SDK, not admin code, reads the answer. Read the status alone; there is no code to branch on and nothing to parse.
analytics({
  adapters: [
    posthog({
      projectId: process.env.POSTHOG_PROJECT_ID ?? '',
      apiKey: process.env.POSTHOG_API_KEY ?? '',
      projectToken: 'phc_...',
      region: 'eu',
    }),
  ],
  capture: {
    gate: 'all',
    proxy: { timeoutMs: 5000, maxBodyBytes: 512 * 1024 },
  },
})

The endpoint is public and carries every beacon, so it is not rate limited: a per-adapter limiter would throttle the whole install's traffic rather than an abuser. Put a per-IP limit in front of it at the edge if you need one.

Trusted proxies

trustedProxyHops is a plugin option, not a per-adapter one, because one helper reads the client address for this proxy, for the native visitor hash, and for the geo resolvers:

analytics({
  adapters: [posthog({ projectId: '12345', apiKey: process.env.POSTHOG_API_KEY ?? '', projectToken: 'phc_...' })],
  capture: { gate: 'all' },
  trustedProxyHops: 1,
})

On Vercel or behind Cloudflare, set trustedPlatform instead, which reads the address from the header the platform's edge writes. The two cannot be set together.

Unset, the leftmost x-forwarded-for entry is read, or x-real-ip when the request carries no chain. That is the originating client for honest traffic and is whatever the client wrote otherwise, so the address this endpoint forwards upstream, and the one geo runs on, are both spoofable.

Set to n, the entry n from the right is read: the address the outermost trusted proxy saw, which no client can write past. A chain shorter than n yields no address at all rather than falling back to x-real-ip, which the client can write too. The value must be a non-negative integer or the boot fails.

Cookieless counting depends on this address more than anything else does. A slot capture.gate holds counts cookielessly before an answer and after a refusal by default (Consent), and every cookieless count tells visitors apart by a hash of the client address and user agent: native ingest computes it from the address read here, and PostHog, Plausible and Umami from the X-Forwarded-For this proxy sets. A hop count that reads the proxy's own address hashes every visitor as coming from it, which merges every visitor with the same browser into one. In production the boot warns, once, when a held slot can count cookielessly while neither trustedProxyHops nor trustedPlatform is set:

analytics: a held capture slot counts visitors cookielessly, telling them apart by a hash of the client IP address, yet neither trustedProxyHops nor trustedPlatform is set. Behind a proxy, a missing or wrong hop count hashes the proxy's address instead and collapses every visitor into one. Set trustedPlatform to 'vercel' or 'cloudflare', or trustedProxyHops to the number of proxies in front of the app.

Setting either option ends it; it cannot tell whether the count is right, so count your hops as below.

1 is not the default, deliberately. An install behind a CDN plus a proxy has two hops, so a default of 1 would read the CDN's address for every visitor and collapse geo and the visitor hash onto a single value, which is worse than a spoofable one. Count your own deployment's hops: one reverse proxy is 1, a CDN in front of it is 2.

Leave it unset if your proxy sets only x-real-ip. The count is read off x-forwarded-for alone, and at n >= 1 that chain is the only source: a proxy that forwards the client address in x-real-ip and writes no chain yields no address at all, and every visitor then hashes and geo-locates as one. Unset, x-real-ip is still consulted. The same applies to the hostname native ingest stores: x-forwarded-host is read only at one hop or more, so a proxy that does not send it leaves the Host header in charge either way.

A route handler of your own

captureRoute from @10x-media/analytics/next relays one adapter's routes from a Next.js route handler, by the runtime endpoint's rules: only declared routes, the same header allowlists, no cookie or credential upstream, no set-cookie back, and the same content types, nosniff and sandbox. Mount it as a catch-all under the path the slot's snippet will point at:

app/ph/[...path]/route.ts
import { posthog } from '@10x-media/analytics/adapters/posthog'
import { captureRoute } from '@10x-media/analytics/next'

const relay = captureRoute({
  path: '/ph',
  // Only the capture config is read here, so the read credentials can be left empty.
  adapter: posthog({ projectId: '', apiKey: '', region: 'eu', projectToken: 'phc_...' }),
  trustedProxyHops: 1, // the plugin's own value
})

export { relay as GET, relay as POST, relay as OPTIONS }

Then point the slot at the same path so its snippet stops using the runtime mount:

analytics({
  adapters: [posthog({ projectId: '12345', apiKey: process.env.POSTHOG_API_KEY ?? '', region: 'eu', projectToken: 'phc_...' })],
  capture: { gate: 'all', paths: { global: '/ph' } },
  trustedProxyHops: 1,
})

What differs from the runtime endpoint:

  • The adapter is fixed. The route relays the adapter it was built with, for every request: no slot, scope or provider document is resolved. Build it with the same capture config as the one in adapters, so the snippet and the route agree.
  • The plugin's options do not reach it. Pass trustedProxyHops or trustedPlatform (not both) and allowedOrigins as the plugin has them; without a trust option the leftmost x-forwarded-for entry is forwarded upstream (Trusted proxies): a trust option set on the plugin silences the boot warning about cookieless counting without reaching the route. timeoutMs (default 10000) and maxBodyBytes (default 1 MiB) stand in for capture.proxy.
  • Methods. Export the handler as GET, POST and OPTIONS. It answers OPTIONS with 204 itself, and any other method that reaches it with 405.
  • No scope. A cross-site POST is refused with 403 unless allowedOrigins lists it, but the runtime endpoint's check of a sibling subdomain's post against the tenant slot does not apply.
  • Connection and logs. The vendor is reached through the adapter's own fetch option, else the global fetch. With no Payload logger at hand, a failure is logged with console.warn: an upstream that fails answers 502, and an error in the route itself 500.
  • Refused when built. A mount that normalizes to the site root, an adapter with no proxy routes (native, GA4, or a PostHog without projectToken), both trust options at once, a timeoutMs or maxBodyBytes that is not a positive number, and an allowedOrigins entry that is not one exact origin each throw when the route module loads.
  • Trailing slashes. PostHog's capture endpoints end in a slash (its descriptor sets capture.proxy.trailingSlashes), so set skipTrailingSlashRedirect: true in next.config: otherwise Next redirects /ph/e/ to /ph/e before the route runs.
  • A Next basePath. Pass path and capture.paths without it. The route finds its mount anywhere in the address, and the tracker config puts the base path in front of every path it hands out.

Next rewrites

captureRewrites and posthogProxyRewrites from @10x-media/analytics/next throw when called. A Next rewrite to a vendor forwards every request header, the visitor's cookies and the site's payload-token among them, and passes the vendor's set-cookie back to the site. Relay the vendor with captureRoute instead, or leave capture.paths unset to use the runtime endpoint.

Per-vendor routes

Each row is the adapter's own descriptor, relative to the mount path. An adapter declares one only when the public config that capture needs is present, so a read-only PostHog (no projectToken) or a Plausible with neither domain nor a valid scriptId has no routes here, and captureRoute refuses it. A custom adapter that declares capture gives each of its routes a kind the same way: script for anything the browser runs, data for everything else.

PostHog

capture.proxy.trailingSlashes is set, so the runtime endpoint preserves a request's trailing slash upstream.

RouteKindUpstream (PostHog Cloud)Upstream (self-hosted)
/static/:p*scripthttps://<region>-assets.i.posthog.com/static/:p*<host>/static/:p*
/array/:p*scripthttps://<region>-assets.i.posthog.com/array/:p*<host>/array/:p*
/:p*datahttps://<region>.i.posthog.com/:p*none
/e, /i/v0/e, /batch, /capture, /track, /engage, /decide, /flags, /sdatacovered by /:p*<host> and the same path

PostHog Cloud's ingest host takes capture and nothing else, so the catch-all relays to it. A self-hosted instance is the whole PostHog app as well, its login, signup, password reset, API and admin included, and the site server's address is often one it trusts: its proxy relays the bundle and the paths posthog-js captures on (events, the older batch endpoints, flags and session replay) and answers every other path 404 without a request leaving the server.

A host on posthog.com (us.posthog.com, eu.posthog.com, the older app.posthog.com), or none, is PostHog Cloud: <region> is us or eu, from posthog({ region }), else derived from host (an eu.posthog.com host means eu, everything else us). Any other host is a self-hosted instance, the same address reads use, base path included, from the adapter or the provider document: capture goes there too, whatever region says, since an instance serves its own bundle and takes its own events, which is also where posthog-js sends everything for an api_host that is not PostHog Cloud's. Capture requires projectToken, the public phc_... browser key: without it the adapter declares no capture and these routes do not exist. The private apiKey used for reads never reaches the browser.

The snippet is PostHog's official install stub plus posthog.init(token, { api_host: <path>, ui_host: <app>, capture_pageview: 'history_change', opt_out_persistence_by_default: true }). <app> is posthog({ uiHost }), else https://<region>.posthog.com on PostHog Cloud; a self-hosted instance gets no ui_host unless uiHost names one, because every page carries the snippet and the instance's address has no business in it. Without it, links PostHog's toolbar shows point at the proxy path rather than the app. opt_out_persistence_by_default has an opt-out, from a closed gate or an excluded browser, delete PostHog's own ph_<token>_posthog storage as well. A held slot that counts without consent adds cookieless_mode: 'on_reject' and opt_out_capturing_by_default: true, and stamps scopeProperty in a before_send hook rather than with register (PostHog without consent). PostHog computes a cookieless visitor's hash on its own server from the request's address and user agent, so the proxy forwards User-Agent and the resolved client address in X-Forwarded-For on the routes posthog-js sends cookieless events and flag requests to (/e/, /i/v0/e/, /flags), as on every other route. The stub injects array.js itself, deriving the URL from api_host (the .i.posthog.com rewrite is a no-op on a first-party path), so it lands on <path>/static/array.js, exactly the route above. capture_pageview: 'history_change' has PostHog capture a pageview whenever the path changes through the history API, beside the first page load: the plugin's PostHog sink forwards no pageview of its own, so without it every navigation of a single-page app after the first would go uncounted.

Plausible

RouteKindUpstream
/js/:script*script<host>/js/:script*
/api/eventdata<host>/api/event

<host> is plausible({ host }), defaulting to https://plausible.io; pass your own for a self-hosted Community Edition instance. The snippet depends on which of the two script models you configure:

  • domain (the legacy tag): one deferred <path>/js/script.js tag with data-domain and data-api="<path>/api/event".
  • scriptId (the per-site model): an async <path>/js/pa-<scriptId>.js tag plus the official stub and plausible.init({ endpoint: '<path>/api/event' }).

domain wins when both are set, and with neither, the adapter declares no capture at all. scriptId lands in the script's address, so it takes letters, digits, _ and - alone, up to 64 characters; the adapter ignores any other value, as if it were unset. siteId is the Stats API id for reads and is unrelated to either.

Umami

RouteKindUpstream (cloud)Upstream (self-hosted)
/script.jsscripthttps://cloud.umami.is/script.js<origin>/script.js
/api/senddatahttps://gateway.umami.is/api/send<origin>/api/send

Umami Cloud serves the script and collects events on two different hosts, which is why the two routes differ there. For a self-hosted instance, <origin> is derived from umami({ host }) by stripping a trailing /api, so the one host value (https://analytics.example.com/api) serves both the Stats API and the tracker. A provider document names that origin itself (https://analytics.example.com), and the plugin adds /api for the Stats API. The snippet is a single deferred <path>/script.js tag with data-website-id and data-host-url="<path>".

GA4

No routes. GA4 captures once measurementId is set, but the tag is served from googletagmanager.com with the id in the query string, so there is nothing first-party to proxy: the descriptor declares an empty route list, the slot's runtime mount answers 404, and captureRoute refuses a GA4 adapter. Use Google's own server-side tagging if you need first-party collection for GA.

Native

No proxy routes and no snippet: the plugin's own tracker is the client, and it posts straight to the ingest endpoint on your origin. Nothing to proxy. A site taking many beacons a second can take them in a route of its own with ingestRoute.

Content Security Policy

Both paths keep every request same-origin, so connect-src 'self' and script-src 'self' cover the vendor traffic, except GA4's: its tag loads from www.googletagmanager.com and reports to Google's own hosts, which script-src and connect-src then have to name. Pass a nonce to <AnalyticsScripts nonce> and it reaches every script the plugin renders or injects, including the array.js tag PostHog's stub inserts on its own: the stub copies the running inline's nonce onto that tag, so a nonce policy without strict-dynamic does not pass the inline and then block the bundle.

On this page