Analytics

Track user behavior with Umami and Vercel Analytics

The template includes two privacy-friendly analytics integrations: Umami (self-hosted or cloud) and Vercel Analytics. Both are GDPR compliant, cookie-free, and load automatically when configured.

What's included

Two client plugins:

  • app/plugins/umami.client.ts - Umami integration
  • app/plugins/vercel-analytics.client.ts - Vercel Analytics integration

One unified composable:

  • app/composables/useAnalytics.ts - safe track() that fans out to all enabled providers

Features:

  • Privacy-focused (GDPR compliant, no cookies)
  • Conditional loading (only loads when env vars are set)
  • Automatic page view tracking
  • Can use one or both simultaneously
  • Unified custom event API — one call reaches every enabled provider

Umami Analytics

Quick setup

  1. Create an Umami account:
  2. Configure environment variables:
.env
NUXT_PUBLIC_UMAMI_ID="your-website-id"
NUXT_PUBLIC_UMAMI_HOST="https://cloud.umami.is"

For self-hosted: use your own domain (e.g., https://analytics.yourdomain.com)

Both variables are required. If either is missing, Umami won't load.

That's it! The plugin automatically injects the tracking script and tracks page views.

Vercel Analytics

Quick setup

  1. Enable in Vercel dashboard:
    • Go to your project at vercel.com
    • Settings → Analytics → Enable Web Analytics
  2. Configure environment variable:
.env
NUXT_PUBLIC_VERCEL_ANALYTICS="true"
Vercel Analytics only reports from a Vercel deployment.inject() posts its beacons to /_vercel/insights/*, a path served by Vercel's edge network. On any other host — dokploy, Fly, a VPS — the script loads, the requests 404, and no data ever arrives. Setting NUXT_PUBLIC_VERCEL_ANALYTICS off-Vercel is a no-op, not a fallback.

Using both providers

Enable both for comprehensive tracking:

.env
NUXT_PUBLIC_UMAMI_ID="your-website-id"
NUXT_PUBLIC_UMAMI_HOST="https://cloud.umami.is"
NUXT_PUBLIC_VERCEL_ANALYTICS="true"

Why use both?

  • Umami: Full control, self-hosting option, privacy compliance
  • Vercel Analytics: Web Vitals, performance metrics, built-in with Vercel deployments

Environment-specific setup

Development: Omit variables to disable tracking during local development

Production: Set all desired analytics variables in your hosting platform's environment settings

Custom event tracking

Use the useAnalytics() composable to track custom events. It fans out to all enabled providers in a single call, so you don't need to know (or care) which ones are configured at any given time.

<script setup lang="ts">
const { track } = useAnalytics()

const handlePurchase = () => {
  track('purchase', { plan: 'pro', amount: 29 })
}
</script>

<template>
  <Button @click="handlePurchase">Buy Now</Button>
</template>

The composable lives at app/composables/useAnalytics.ts and is auto-imported by Nuxt — no manual import needed.

Event catalogue

Page views are collected automatically, including SPA route changes, so no event below duplicates something a page view already answers. That is why there is no onboarding_started or checkout_canceled/app/onboarding and /checkout/canceled already record those.

Every event name is snake_case and stable. Payload values are always flat scalars (string, number, boolean, or null); nested objects are rejected by Vercel and flattened unhelpfully by Umami.

Authentication

EventFires whenPayload
signup_startedA sign-up OTP has been sent successfullymarketing_opt_in: boolean
signup_completedThe code verified and the profile loaded, in sign-up modemarketing_opt_in: boolean
signin_completedThe code verified and the profile loaded, in sign-in mode

Emitted from app/components/auth/OtpAuthForm.vue. signup_started fires after the send resolves, so a failed request is not counted as a started sign-up.

Checkout and billing

EventFires whenPayload
checkout_startedopenCheckout() is called, before the session requestprice_id: string, wants_trial: boolean
billing_portal_openedThe Stripe billing portal is openedsource: 'direct' | 'checkout_redirect', flow: string
checkout_completed/checkout/success validates a session, once per sessionpayment_mode: 'auth' | 'authless', claim_required: boolean
purchase_claimedA guest buyer verifies the Stripe-captured email and takes ownershipmarketing_opt_in: boolean

checkout_started lives in app/composables/useCheckout.ts rather than in the pricing components. There are five CTAs that open checkout; instrumenting the composable covers all of them and cannot drift when a sixth is added.

billing_portal_opened distinguishes a deliberate visit (direct) from an upgrade attempt the server rerouted to the portal because a subscription already exists (checkout_redirect).

wants_trial is always false while trials are off. No CTA passes wantsTrial, and payments.config.ts sets trialPeriodDays: 0, so the server zeroes it regardless. The dimension is kept so it starts reporting the moment trials are enabled, with no code change. Read it as what the UI requested, never as what Stripe granted — the server also drops the trial for a user who is not isTrialEligible, so a true here does not by itself mean a trial was applied.

purchase_claimed exists because the guest-claim flow on the success page is a second, entirely separate sign-up path that never touches OtpAuthForm. Without it, every guest buyer who later creates an account is missing from the sign-up funnel.

checkout_completed is deduplicated per Stripe session via a checkout-tracked:{sessionId} key in localStorage. The success page is re-entered on reload, on back-navigation, and from the emailed receipt in a fresh tab, and neither provider deduplicates. localStorage rather than sessionStorage precisely because the fresh-tab case is the one that inflates a revenue number. The session id stays in the storage key and never enters the payload.

Creator onboarding

EventFires whenPayload
onboarding_step_completedAny of the first four steps is advancedstep: 'goal' | 'about' | 'style' | 'review'
onboarding_completedThe profile saves and onboarding is marked completegoal_type: string

One event with a step dimension rather than four event names, so drop-off between any two steps is a single query. The fifth step has no step_completed event — finishing it isonboarding_completed.

Draft generation

EventFires whenPayload
drafts_generatedGeneration returns draftscount: number
drafts_generate_deniedGeneration is refused by the allowance checkreason: 'plan_required' | 'limit_reached' | 'unspecified'

reason passes through the closed enum on GenerationAllowance. unspecified covers a denial that arrives without one.

What must never go in a payload

Umami is used because it is privacy-preserving. Putting an identifier in custom event data defeats that and creates a GDPR problem no amount of cookieless page-view tracking fixes.

Never send:

  • Email addresses — including the Stripe-captured buyer address on the success page.
  • User, customer, or session identifiers — no user id, no Stripe customer id, no Stripe session_id. Where a purchase must be deduplicated, the identifier belongs in a localStorage key, not in the payload.
  • Draft content, or any AI-generated text. Send a count.
  • Free-text profile fieldsentityName, description, audience, goalTypeOther, niche, keywords, voiceSummary. These are user-authored and can contain anything.

Safe to send: booleans, counts, closed enums the codebase already defines, and Stripe price IDs. A price id names a product, not a person.

When adding an event, add its row to the table above in the same change. A catalogue that drifts from the code is worse than no catalogue — it gets trusted.

Why a composable instead of calling providers directly?

Calling umami.track(...) directly is unsafe: if the Umami script hasn't loaded (network failure, adblocker, missing config), referencing the bare umami identifier throws a ReferenceError that kills the surrounding handler. Optional chaining (umami?.track(...)) does not save you — it only short-circuits null/undefined values, not undeclared identifiers.

The composable handles all the safety concerns for you:

  • Optional chaining on window.umami — safe property access on an existing object (window).
  • Per-provider try/catch — a failure in one provider never affects the other or the surrounding UX.
  • import.meta.client guard — no-op on the server, so it's safe to call from anywhere.
  • Dev-only error logging — silent in production to keep the console clean.
Always go through useAnalytics(). Never reference a bare umami identifier or call window.umami.track() directly without the optional chain.

Privacy & compliance

GDPR compliant:

  • No cookies required
  • No personal data collection
  • IP addresses anonymized
  • Do Not Track (DNT) respected by Umami

Since these analytics are privacy-friendly, cookie consent banners typically aren't required. Check your local regulations.

This holds only as long as custom event payloads stay clean. See What must never go in a payload — that list is the compliance boundary, and the privacy policy's analytics section is written against it.

Disabling in development

Both plugins automatically skip loading when environment variables aren't set. To disable analytics during local development, simply omit the variables from your .env file.

Troubleshooting

Scripts not loading:

  • Verify environment variables are set correctly
  • Check browser console for errors
  • Check Network tab for script requests

Data not appearing:

  • Wait a few minutes for data to process
  • Verify website ID/configuration in dashboard
  • Check that the script is loading (Network tab)

Reference

Start with Umami Cloud for the easiest setup. Self-host later if you need full control over your data.