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 integrationapp/plugins/vercel-analytics.client.ts- Vercel Analytics integration
One unified composable:
app/composables/useAnalytics.ts- safetrack()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
- Create an Umami account:
- Umami Cloud (recommended) or self-host
- Create a new website in your dashboard
- Copy your website ID
- Configure environment variables:
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)
That's it! The plugin automatically injects the tracking script and tracks page views.
Vercel Analytics
Quick setup
- Enable in Vercel dashboard:
- Go to your project at vercel.com
- Settings → Analytics → Enable Web Analytics
- Configure environment variable:
NUXT_PUBLIC_VERCEL_ANALYTICS="true"
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:
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
| Event | Fires when | Payload |
|---|---|---|
signup_started | A sign-up OTP has been sent successfully | marketing_opt_in: boolean |
signup_completed | The code verified and the profile loaded, in sign-up mode | marketing_opt_in: boolean |
signin_completed | The 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
| Event | Fires when | Payload |
|---|---|---|
checkout_started | openCheckout() is called, before the session request | price_id: string, wants_trial: boolean |
billing_portal_opened | The Stripe billing portal is opened | source: 'direct' | 'checkout_redirect', flow: string |
checkout_completed | /checkout/success validates a session, once per session | payment_mode: 'auth' | 'authless', claim_required: boolean |
purchase_claimed | A guest buyer verifies the Stripe-captured email and takes ownership | marketing_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
| Event | Fires when | Payload |
|---|---|---|
onboarding_step_completed | Any of the first four steps is advanced | step: 'goal' | 'about' | 'style' | 'review' |
onboarding_completed | The profile saves and onboarding is marked complete | goal_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
| Event | Fires when | Payload |
|---|---|---|
drafts_generated | Generation returns drafts | count: number |
drafts_generate_denied | Generation is refused by the allowance check | reason: '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 alocalStoragekey, not in the payload. - Draft content, or any AI-generated text. Send a count.
- Free-text profile fields —
entityName,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.
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.clientguard — 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.
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)
