Web
Analytics
The kit records product analytics and ad conversions from the server. Events go to Workers Analytics Engine, PostHog or Umami, and conversions to Meta, TikTok, X or Reddit. No provider script runs in the browser and no key reaches the page. You work here when you add an event, connect a provider or change how consent works.
How it works
- Every event and its props are declared once, as a Zod schema, in
shared/signals/events.ts(Licensees only). - Server code calls
track(event, name, props). The browser callsuseAnalytics().track(name, props), which posts a beacon toanalytics.ingestPath. That route accepts only the events inCLIENT_EVENTS. - The server builds the context from the request: a cookieless visitor id, a session id, the signed-in user, the page, the referrer, UTM parameters, click ids and the visitor's consent.
- After the response,
server/signals/index.ts(Licensees only) sends the event to every configured sink the visitor consented to.trackreturns at once and never throws.
What is tracked already
| Event | Sent from |
|---|---|
page_view | Every GET answered 2xx with HTML, and client navigations under 'ssr+beacon' |
consent_updated | The consent banner |
signup_started | Declared for the browser. Nothing sends it yet |
signup_completed | Password, magic-link and OAuth registration |
checkout_started | POST /api/payments/checkout |
checkout_coupon_refused | The same route, each time it refuses a coupon with a 422 |
purchase, subscription_started, subscription_cancelled, refund | The payment webhook |
The webhook events carry the buyer's user id, plus the consent and attribution recorded when they started the checkout, because the webhook request itself comes from the payment provider (Payments).
Add an event
Declare it in SIGNAL_EVENTS:
report_exported: z
.object({ format: z.enum(['csv', 'pdf']) })
.strict(),
Then track it from any handler:
import { track } from '#server/signals'
export default defineEventHandler(async (event) => {
const { format } = await readValidatedBody(
event,
z.object({ format: z.enum(['csv', 'pdf']) }).parse,
)
track(event, 'report_exported', { format })
return { format }
})
A wrong name or prop fails type checking at the call. Name events in
snake_case, 40 characters at most, and keep each schema .strict() so an
unknown prop is rejected. Keep props small: Umami Cloud bills per stored
property, and Analytics Engine replaces props over 2,048 bytes with
{"$truncated":true}. shared/signals/events.ts belongs to the template, so
expect a small conflict there on updates.
Track after your handler has written the session. The context reads the session a few awaits after the call, so an event tracked before sign-in completes carries no user id, and Meta and X drop such a conversion.
From the browser
const { track } = useAnalytics()
track('signup_started', { method: 'password' })
Only names in CLIENT_EVENTS type-check here. To accept a new one, add it to
CLIENT_EVENTS and give it a case in parseClientEvent in
server/signals/ingest.post.ts(Licensees only), which
checks each beacon event against its schema. Anyone can post to that route, so
anything involving money stays a server event.
Page views
analytics.pageviews decides how page views are counted:
'ssr'counts document requests: GETs answered 2xx withtext/html. Navigations after hydration happen in the browser and are not counted.'ssr+beacon', the default, also sends a view from the client router on each navigation, query-only ones included. The first page is not counted twice.falsecounts none.
The stored path drops the locale prefix, so /sr/pricing and /pricing are
one page. Only utm_* and click-id parameters leave the Worker. The rest of the
query string is dropped, so tokens in reset or invitation links never reach a
provider.
Connect a provider
List it in analytics.providers. Every event goes to every listed provider.
| Provider | What it is | Set |
|---|---|---|
cloudflare | Workers Analytics Engine, the ANALYTICS binding | Nothing |
posthog | PostHog's /batch/ endpoint | NUXT_ANALYTICS_PROVIDERS_POSTHOG_KEY, and _HOST if not https://eu.i.posthog.com |
umami | Umami Cloud or your own instance, /api/send | NUXT_ANALYTICS_PROVIDERS_UMAMI_KEY, and _HOST if not https://cloud.umami.is |
- PostHog: the host is the ingest host,
https://eu.i.posthog.comorhttps://us.i.posthog.com. The dashboard host accepts the request and stores nothing. The key is the project key,phc_…. Page views arrive as$pageview. Person profiles are created only for signed-in events, so events from before sign-in are not joined to the user. - Umami: the host is the instance origin without a trailing slash, and the key is the website id.
- Cloudflare: Analytics Engine has no dashboard and keeps data for three months. Query it with SQL.
Outside pnpm dev, the Worker logs at boot every listed provider whose key is
unset.
Query Analytics Engine
Each event is one row in the nuxt_starter_kit_analytics dataset, written by
server/signals/analytics/cloudflare.ts(Licensees only):
| Column | Holds |
|---|---|
blob1 to blob4 | Event name, path, host, referrer host |
blob5 to blob7 | Country, device (desktop, mobile, tablet), locale |
blob8 to blob10 | utm_source, utm_medium, utm_campaign |
blob11 to blob15 | Origin (ssr, beacon, server), session id, visitor id, props as JSON, currency |
double1 to double4 | Always 1, revenue amount, 1 when signed in, 1 when a bot |
index1 | Host |
Top pages this week, bots excluded:
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/analytics_engine/sql" \
--header "Authorization: Bearer $API_TOKEN" \
--data "SELECT blob2 AS path, SUM(_sample_interval) AS views
FROM nuxt_starter_kit_analytics
WHERE blob1 = 'page_view' AND double4 = 0
AND timestamp > NOW() - INTERVAL '7' DAY
GROUP BY path ORDER BY views DESC LIMIT 20"
The token needs Account Analytics Read. Sum _sample_interval rather than
counting rows, because Analytics Engine samples under load.
Visitors and sessions
No cookie identifies a visitor. The visitor id is an HMAC of the IP, user agent
and host, keyed with a salt derived for each UTC day from
NUXT_ANALYTICS_SALT, or from NUXT_SESSION_PASSWORD when that is unset. A
session is a fixed 30-minute window, and signed-in events also carry the user
id. Feature flags bucket anonymous visitors on the same id
(Feature flags).
So a person is a new visitor every UTC day. Same-day funnels, sources and conversion rates are exact. Returning visitors, retention and cohorts can't be measured. A visit that crosses a window boundary counts as two sessions, and changing the salt makes everyone new.
Consent
analytics.consent sets the default for the analytics sinks:
'implied', the default, sends events unless the visitor objects (_ac=0, set from Privacy preferences in the user menu) or their browser sendsSec-GPC: 1.'explicit'sends nothing until_ac=1, and the consent banner asks. When the visitor accepts, the kit sends one page view for the page they are on.
Conversion sinks always need _mc=1, so turning marketing on shows the banner
until the visitor chooses. Sec-GPC: 1 refuses both. The choices last 180
days.
The beacon route
The browser posts to analytics.ingestPath, /api/e as shipped. Pick your own
per project, since blocklists add rules for paths many sites share. The build
refuses a path that isn't lowercase segments, and one containing a word that
blocklists match, such as track, analytic, collect, beacon, pixel or
metric. The words match anywhere, so /api/drum fails on rum. The full
list is in modules/signals-registry.ts(Licensees only).
The route exists whenever an analytics or marketing sink is on. It limits each
IP through the ANALYTICS_RATE_LIMIT binding (60 a minute per Cloudflare
location) and answers 429 past that, and 413 to a body over 4,096 bytes.
Otherwise it answers 204, and drops cross-site posts, malformed bodies and
batches of more than 10 events without saying so.
Send conversions to ad platforms
marketing: {
enabled: true,
providers: ['meta', 'tiktok'],
},
Then set the platform's variables. .env.example(Licensees only) says where
each value comes from.
| Platform | Variables | Test mode |
|---|---|---|
meta | NUXT_MARKETING_META_DATASET_ID, _ACCESS_TOKEN | NUXT_MARKETING_META_TEST_EVENT_CODE |
tiktok | NUXT_MARKETING_TIKTOK_PIXEL_CODE, _ACCESS_TOKEN | NUXT_MARKETING_TIKTOK_TEST_EVENT_CODE |
x | NUXT_MARKETING_X_PIXEL_ID, _CONSUMER_KEY, _CONSUMER_SECRET, _ACCESS_TOKEN, _ACCESS_SECRET, _EVENT_PURCHASE, _EVENT_SUBSCRIBE, _EVENT_SIGNUP | None |
reddit | NUXT_MARKETING_REDDIT_PIXEL_ID, _ACCESS_TOKEN | NUXT_MARKETING_REDDIT_TEST_ID |
Each platform receives these events and drops the rest:
| Event | Meta | TikTok | X | |
|---|---|---|---|---|
signup_completed | CompleteRegistration | CompleteRegistration | _EVENT_SIGNUP | SIGN_UP |
checkout_started | InitiateCheckout | InitiateCheckout | ||
subscription_started | Subscribe | Subscribe | _EVENT_SUBSCRIBE, else _EVENT_PURCHASE | PURCHASE |
purchase | Purchase | CompletePayment | _EVENT_PURCHASE | PURCHASE |
Click ids (gclid, gad_source, fbclid, msclkid, ttclid, twclid,
rdt_cid) are kept in an HttpOnly _cid cookie for 90 days once the visitor
grants marketing consent. Checkout copies them, with the consent, IP, user
agent and page, onto the buyer's account, and the webhook reads that copy back.
X accepts nothing until your developer app has Ads API approval.
Add a sink
Create server/signals/analytics/custom/<id>.ts, or marketing/custom/<id>.ts
for an ad platform, default-export a SignalAdapter, and list the id in
analytics.providers or marketing.providers. A sink that posts to your own
endpoint, server/signals/analytics/custom/warehouse.ts:
import type { SignalAdapter } from '#server/signals/types'
const warehouse: SignalAdapter = {
id: 'warehouse',
consentCategory: 'none',
batchSize: 50,
acceptsPageviews: true,
identityControl: 'explicit',
mixedIdentityBatch: true,
transport: 'fetch',
async send(batch, { host, key }) {
try {
const response = await $fetch.raw(`${host}/events`, {
method: 'POST',
headers: { authorization: `Bearer ${key}` },
body: batch.map(({ name, props, at, context }) => ({
name,
props,
at,
visitor: context.visitorId,
})),
ignoreResponseError: true,
})
return response.ok
? { ok: true, delivered: batch.length }
: {
ok: false,
reason: `HTTP ${response.status}`,
retryable: response.status >= 500,
}
} catch (error) {
return { ok: false, reason: String(error), retryable: true }
}
},
}
export default warehouse
Declare its credentials with
analytics.adapters: { warehouse: { credentials: ['host', 'key'] } } and set
NUXT_ANALYTICS_PROVIDERS_WAREHOUSE_HOST and
NUXT_ANALYTICS_PROVIDERS_WAREHOUSE_KEY. An analytics sink receives only
host and key, and is skipped while the key is empty. A marketing sink's
keys become NUXT_MARKETING_<ID>_<KEY> and arrive together as
config.credentials. Restart pnpm dev after adding the file.
The other fields tell the dispatcher what to send. consentCategory is the
consent the sink needs: 'none' for analytics, 'marketing' for conversions.
acceptsPageviews: false keeps page_view away, identityControl: 'derived'
sends only events that carry an IP and a user agent, and
mixedIdentityBatch: false batches one visitor at a time. A 'fetch' sink
never receives bot traffic and shares a budget of 12 outbound requests per
incoming request with the other sinks. send must not throw, since it runs
after the response. retryable: true earns one immediate repeat, so leave it
false for any 4xx.
Configuration
| Key | Default | What it does |
|---|---|---|
analytics.enabled | true | Off, no analytics sink is bundled. |
analytics.providers | ['cloudflare'] | Sinks every event goes to. |
analytics.ingestPath | '/api/e' | The beacon route. Rename it per project. |
analytics.pageviews | 'ssr+beacon' | 'ssr', 'ssr+beacon' or false. |
analytics.consent | 'implied' | 'explicit' holds analytics until the visitor accepts. |
analytics.adapters | none | Credential keys of a sink you added. |
marketing.enabled | false | Turns on conversion sinks and the consent banner. |
marketing.providers | [] | meta, tiktok, x, reddit or a sink you added. |
marketing.adapters | none | Credential keys of a conversion sink you added. |
These live in starter.config.ts. In wrangler.jsonc, the
analytics_engine_datasets entry binds ANALYTICS to
nuxt_starter_kit_analytics, and ratelimits holds ANALYTICS_RATE_LIMIT.
NUXT_ANALYTICS_SALT seeds the visitor id.
Good to know
- Under
pnpm dev, writes to Analytics Engine go nowhere. Use PostHog or Umami to watch events locally. - Keep the blob order in
cloudflare.tsas it is. Analytics Engine never rewrites a row, so reordering changes the meaning of everything already stored. Add columns at the end.test/unit/analytics-adapters.test.ts(Licensees only) pins the order. - A
getUserSessioncall of your own on an anonymous request sets a session cookie, which makes the page uncacheable. CheckrequestCarriesSession(event)first, or usereadSessionorverifySession, which already do. - Conversions carry no revenue. Meta requires a value on
Purchase, so it gets0 USD, which supports count-based optimization only. - With neither
NUXT_ANALYTICS_SALTnorNUXT_SESSION_PASSWORDset, events carry no visitor or session id, and the Worker logs[analytics] no salt availableonce.
A link with a lock opens the kit’s private repository, which every licensee can read.