Client counting
Mount ExperimentsProvider for viewport blocks, whole-page pageviews, click goals and client-side flag reads.
Some exposures and conversions happen in the browser: a block scrolling into view, a page appearing after navigation, a click, a client component reading a flag point. These need an ExperimentsProvider with a value built on the server.
Mount the provider
import { ExperimentsProvider } from '@delmaredigital/payload-experiments/client'
import { getAssignments, toClientAssignments } from '@delmaredigital/payload-experiments/rsc'
import { serverRuntime } from '@/experiments/serverRuntime'
export async function ExperimentsLayer({ pathname, children }: { pathname: string; children: React.ReactNode }) {
const assignments = await getAssignments(serverRuntime)
return (
<ExperimentsProvider value={toClientAssignments(assignments, { pathname })}>
{children}
</ExperimentsProvider>
)
}toClientAssignments(assignments, { pathname, apiRoute?, flagPoints?, experiments? }) builds a value with no visitor identifiers.
| Option | Purpose |
|---|---|
pathname | The served page's own pathname, including after a rewrite |
apiRoute | Your Payload API base, if not /api. Use one value for every provider on a page |
flagPoints | Flag point ids client components may read with useFlagPoint. Default none |
experiments | Test short names client components may read with useExperiment. Default none |
Both allow-lists are empty by default, so the browser only learns about the tests it needs. Like getAssignments, render this inside <Suspense> and outside 'use cache'.
What needs a provider
| Feature | What to mount |
|---|---|
| Puck viewport blocks | A provider on every page that renders them. A layout-level provider with empty allow-lists is enough |
| Payload-blocks viewport groups | A provider, plus ExperimentGroup around each group (Payload blocks) |
| Whole-page tests | On each target page, a nested provider built for that page's pathname, with an ExperimentPageview component inside |
| Click goals | Any provider around the content; click recording is armed when exposure recording is not suppressed and at least one assignment is exposable, regardless of that test's goals |
useFlagPoint, useExperiment | A provider whose allow-list includes the id or short name |
trackConversionBeacon | A provider; without one it sends nothing |
Without a provider, viewport exposures, pageviews and clicks cannot be sent.
Pageviews for whole-page tests
import { ExperimentsProvider, ExperimentPageview } from '@delmaredigital/payload-experiments/client'
import { toClientAssignments } from '@delmaredigital/payload-experiments/rsc'
<ExperimentsProvider value={toClientAssignments(assignments, { pathname })}>
<ExperimentPageview />
{children}
</ExperimentsProvider>ExperimentPageview reads the nearest provider and records the page that appeared, including after client navigation.
Client hooks
useFlagPoint(id) and useExperiment(slug) return the current group and settings with an expose(renderedArm) function. Neither records anything on its own: call expose with the group you actually rendered, once it shows. expose only sends for tests that use the manual policy.
An id or short name missing from the allow-list throws in development and returns the Original with one console error in production.
Client conversions
'use client'
import { trackConversionBeacon } from '@delmaredigital/payload-experiments/client'
trackConversionBeacon('newsletter_signup', { idempotencyKey: `newsletter:${formId}` })The server accepts it only for an event source that is client-writable. Pass a stable key for conversions that matter, so retries count as one.
Event limits
Browser events go to POST /xp/events. A request needs a valid signed visitor cookie, its body is limited to 8192 bytes by default (events.maxBodyBytes) and to 20 events (MAX_EVENTS_PER_BODY), and your events.rateLimit adapter runs on it. Accepted and dropped events both return 202 {}. See Security.

