Quick start
Install the plugin, track pageviews with the native engine, and see numbers in the admin.
This walkthrough adds the plugin to a Payload app made with create-payload-app and its blank template, and uses the native engine, so nothing external is required. Swap in a provider adapter at any point; everything else stays the same. The commands use pnpm; with npm run npx payload ..., with yarn yarn payload ....
Install
pnpm add @10x-media/analytics @10x-media/fields@10x-media/fields is a required peer: the providers collection stores its keys in its encrypted fields.
Configure
Add the plugin, a collection to bind, and the dashboard layout to the payload.config.ts the template made. Shown on its own, with MongoDB:
import { analytics, analyticsDefaultWidgets, analyticsStatRow } from '@10x-media/analytics'
import { native } from '@10x-media/analytics/adapters/native'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { buildConfig } from 'payload'
export default buildConfig({
db: mongooseAdapter({ url: process.env.DATABASE_URL ?? '' }),
secret: process.env.PAYLOAD_SECRET ?? '',
admin: {
// Widgets render on Payload's Modular Dashboard. The app owns the layout;
// spread the plugin's defaults into it (or compose your own).
dashboard: { widgets: [], defaultLayout: [...analyticsDefaultWidgets()] },
},
collections: [
{
slug: 'pages',
fields: [
{ name: 'slug', type: 'text' },
analyticsStatRow(), // pageviews, visitors, visits, time on page
],
},
],
plugins: [
analytics({
adapters: [native()],
// Required, and the site operator's choice: 'all' holds every capture slot for your
// consent manager's answer, 'off' captures in full without waiting. This walkthrough uses
// 'off' so numbers appear at once. The plugin does not decide this; see Consent.
capture: { gate: 'off' },
collections: {
// Bind documents to the URL path their analytics were recorded under.
pages: { path: (doc) => (typeof doc.slug === 'string' ? `/${doc.slug}` : null) },
},
}),
],
})DATABASE_URL is the variable the template's .env holds. The plugin runs on MongoDB, Postgres (@payloadcms/db-postgres or @payloadcms/db-vercel-postgres) and SQLite (@payloadcms/db-sqlite). On Cloudflare D1 it reads vendor adapters only: native() refuses D1 at boot and stops on an in-memory SQLite database, and it does not run on MongoDB-compatible services such as Amazon DocumentDB. No other database adapter is supported.
On SQLite, open the database in WAL mode with a busy timeout of at least five seconds. The native engine then counts on worker threads of its own, and the site keeps answering while it does; without them it runs on the main thread, and the boot warns (SQLite):
import { sqliteAdapter } from '@payloadcms/db-sqlite'
export const db = sqliteAdapter({
client: { url: process.env.DATABASE_URL ?? '' },
wal: true,
busyTimeout: 5000,
})capture.gate has no default because whether a site may measure before a visitor answers is not the plugin's call. Choose one of:
capture.gate | Means |
|---|---|
'all' | Every capture slot waits for your consent manager's answer through setGate (Consent). |
'off' | Nothing waits: capture starts in full with the page. |
['tenant'], ['global'] | Only the slots listed wait. |
({ slot, adapterId, kind }) => boolean | You decide per slot; ({ kind }) => kind !== 'native' captures native at once and holds every vendor. |
A slot that waits goes through three phases, and capture.withoutConsent decides the first two:
| Phase | Default (withoutConsent unset) | capture: { withoutConsent: 'off' } |
|---|---|---|
| No answer yet | counts cookielessly: native as it always does, Plausible, Umami and PostHog without device storage; GA4 sends nothing | sends nothing; events wait in the page, at most 100 per slot |
| Refused | the same as before an answer | sends nothing; what waited is dropped |
| Granted | full tracking, on the same page | full tracking, and what waited is sent |
So with 'all' and no manager wired to the gate, the default still counts cookielessly, and 'off' counts nothing. Whether counting without consent suits your site is the operator's decision; Measuring without consent has what each mode sends and stores, and links the regulators' guidance on it.
On Vercel or behind Cloudflare, also set trustedPlatform: 'vercel' or 'cloudflare', so visitor countries come from the platform's own headers; see Geo resolution.
Then regenerate the generated types, so the binding above and your own code know the pages collection (until then tsc and next build fail on it), and the import map, so the admin resolves the plugin's components:
pnpm payload generate:types
pnpm payload generate:importmapCapture events
Boot the browser tracker from the site's root layout. In a Payload app that is the layout of the (frontend) route group: Payload's own app/(payload)/layout.tsx renders the admin's page, so the tracker never goes in a layout above both. Keep what the template's layout already has, its stylesheet and its metadata, and add the tracker:
import { AnalyticsScripts, getTrackerConfig } from '@10x-media/analytics/site'
import config from '@payload-config'
import { headers } from 'next/headers'
import { getPayload } from 'payload'
import React from 'react'
import './styles.css'
export const metadata = {
description: 'A blank template using Payload in a Next.js app.',
title: 'Payload Blank Template',
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
const payload = await getPayload({ config })
const tracker = await getTrackerConfig(payload, { headers: await headers() })
return (
<html lang="en">
<body>
<main>{children}</main>
<AnalyticsScripts config={tracker} />
</body>
</html>
)
}The tracker sends pageviews (including SPA route changes), outbound-link and file-download events, and any goal you tag. A statically rendered layout, or a site outside Next, boots it from the browser with initTracker() from @10x-media/analytics/tracker instead. Full setup, including the vendor slots, is on Capture; wiring the gate to your consent manager is on Consent.
The native engine's beacon endpoint (POST /api/_x/e) stays public, so a host that would rather post events itself still can; see Native engine for the event shape.
What the boot says
Two notices are expected with this config, and neither is an error:
- Payload's job controls.
native()registers theanalytics-maintaintask, which turns Payload's jobs on, and the plugin restricts every job control your config left unset to the users of the admin collection. The boot names them, on every start:analytics: Payload's job controls the config left unset now admit users of the "users" collection only: .... Setjobs.access(run,queueandcancel) and theaccessofjobs.jobsCollectionOverridesin your Payload config to choose who may run, queue, cancel and read jobs yourself, which also ends the notice (Who may run jobs). - Spoofable addresses, in production. With
NODE_ENV=production, an install that sets neithertrustedPlatformnortrustedProxyHopslogsanalytics: visitor IP addresses and countries can be spoofed on this install. ...once per start, since it then reads them from headers a visitor can write. SettrustedPlatform: 'vercel'or'cloudflare'on those platforms, ortrustedProxyHopsto the number of proxies in front of the app (Which headers to trust).
With capture.gate holding a slot ('all', say) and capture.withoutConsent unset, a third line says which default is in effect, on every start: analytics: capture.withoutConsent is unset, so held slots measure cookielessly before consent and after a refusal. Set it to 'off' to send nothing without consent. Setting the option, to any value, ends it. In production such an install also warns while neither trustedPlatform nor trustedProxyHops is set, since cookieless counting tells visitors apart by their address (Trusted proxies).
None of them prints while next build collects page data: the server that serves requests says them. On SQLite without WAL and a busy timeout, the engine also warns once, on its first statement, that it runs on the main thread, naming sqliteAdapter({ wal: true, busyTimeout: 5000 }).
Verify
- Hit a few frontend pages.
- Open Analytics in the admin nav (
/admin/analytics): the analytics view shows the cards, the trend and a breakdown of everything captured so far. It is the first thing to open after installing, and the fastest way to tell whether events are landing. On a site this new, today's numbers are at most ten seconds old when a read answers them, so reload the view after a moment, or press Refresh to read again at once. A busy day is read from a snapshot up to a few minutes old instead (How a read is answered). - Open the admin dashboard: the trend, metric, and breakdown widgets show the same traffic.
- Open a
pagesdocument whose slug matches a tracked path: the stat row shows that document's numbers.
Where to go next
- Read the full report and share a filtered view as a link: Analytics view.
- Point the plugin at an existing provider instead: Providers, and serve its tracker from your own origin with the first-party proxy.
- Mark conversions and read revenue: Goals.
- Bind more collections and tune the fields: Display fields.
- Configure the dashboard: Widgets, and restyle any of it: Customizing.
- Read the numbers from your own code: the query endpoint.
- Before production:
- On Postgres or SQLite in migrate mode, create and run the migration for the plugin's collections and Payload's jobs tables:
pnpm payload migrate:create, thenpnpm payload migrate(Upgrading has what an upgrade from an earlier beta needs). - Run the
analytics-maintaintask: a jobs worker or a scheduler calling Payload's run endpoint counts each day once it closes, whatever traffic the site gets (Running it). Without one, page views and visits do that work as they come. - Then geo resolution, caching and warming, bot filtering, retention, the Mongo
ensureIndexesnote in Native engine, and on Postgres the pool size the engine shares with your app.
- On Postgres or SQLite in migrate mode, create and run the migration for the plugin's collections and Payload's jobs tables:
- Answer what the install stores about a visitor, and keep your own visits out of it: Privacy.