Skip to content

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

  1. Every event and its props are declared once, as a Zod schema, in shared/signals/events.ts(Licensees only).
  2. Server code calls track(event, name, props). The browser calls useAnalytics().track(name, props), which posts a beacon to analytics.ingestPath. That route accepts only the events in CLIENT_EVENTS.
  3. 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.
  4. After the response, server/signals/index.ts(Licensees only) sends the event to every configured sink the visitor consented to. track returns at once and never throws.

What is tracked already

EventSent from
page_viewEvery GET answered 2xx with HTML, and client navigations under 'ssr+beacon'
consent_updatedThe consent banner
signup_startedDeclared for the browser. Nothing sends it yet
signup_completedPassword, magic-link and OAuth registration
checkout_startedPOST /api/payments/checkout
checkout_coupon_refusedThe same route, each time it refuses a coupon with a 422
purchase, subscription_started, subscription_cancelled, refundThe 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 with text/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.
  • false counts 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.

ProviderWhat it isSet
cloudflareWorkers Analytics Engine, the ANALYTICS bindingNothing
posthogPostHog's /batch/ endpointNUXT_ANALYTICS_PROVIDERS_POSTHOG_KEY, and _HOST if not https://eu.i.posthog.com
umamiUmami Cloud or your own instance, /api/sendNUXT_ANALYTICS_PROVIDERS_UMAMI_KEY, and _HOST if not https://cloud.umami.is
  • PostHog: the host is the ingest host, https://eu.i.posthog.com or https://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):

ColumnHolds
blob1 to blob4Event name, path, host, referrer host
blob5 to blob7Country, device (desktop, mobile, tablet), locale
blob8 to blob10utm_source, utm_medium, utm_campaign
blob11 to blob15Origin (ssr, beacon, server), session id, visitor id, props as JSON, currency
double1 to double4Always 1, revenue amount, 1 when signed in, 1 when a bot
index1Host

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.

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 sends Sec-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.

PlatformVariablesTest mode
metaNUXT_MARKETING_META_DATASET_ID, _ACCESS_TOKENNUXT_MARKETING_META_TEST_EVENT_CODE
tiktokNUXT_MARKETING_TIKTOK_PIXEL_CODE, _ACCESS_TOKENNUXT_MARKETING_TIKTOK_TEST_EVENT_CODE
xNUXT_MARKETING_X_PIXEL_ID, _CONSUMER_KEY, _CONSUMER_SECRET, _ACCESS_TOKEN, _ACCESS_SECRET, _EVENT_PURCHASE, _EVENT_SUBSCRIBE, _EVENT_SIGNUPNone
redditNUXT_MARKETING_REDDIT_PIXEL_ID, _ACCESS_TOKENNUXT_MARKETING_REDDIT_TEST_ID

Each platform receives these events and drops the rest:

EventMetaTikTokXReddit
signup_completedCompleteRegistrationCompleteRegistration_EVENT_SIGNUPSIGN_UP
checkout_startedInitiateCheckoutInitiateCheckout
subscription_startedSubscribeSubscribe_EVENT_SUBSCRIBE, else _EVENT_PURCHASEPURCHASE
purchasePurchaseCompletePayment_EVENT_PURCHASEPURCHASE

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

KeyDefaultWhat it does
analytics.enabledtrueOff, 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.adaptersnoneCredential keys of a sink you added.
marketing.enabledfalseTurns on conversion sinks and the consent banner.
marketing.providers[]meta, tiktok, x, reddit or a sink you added.
marketing.adaptersnoneCredential 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.ts as 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 getUserSession call of your own on an anonymous request sets a session cookie, which makes the page uncacheable. Check requestCarriesSession(event) first, or use readSession or verifySession, which already do.
  • Conversions carry no revenue. Meta requires a value on Purchase, so it gets 0 USD, which supports count-based optimization only.
  • With neither NUXT_ANALYTICS_SALT nor NUXT_SESSION_PASSWORD set, events carry no visitor or session id, and the Worker logs [analytics] no salt available once.

A link with a lock opens the kit’s private repository, which every licensee can read.