VeveeGuide · 12 min read
Guide · 12 min read

Add cookieless traffic attribution to a Next.js app

Six copy-paste steps to attribute every signup and every order to the visit that brought it, with @vevee/sdk 1.14. The visit lives in browser memory, a server route forwards events with the secret key, and nothing is written to the device.

Last updated: 2026-10-28

Ad links carry UTM tags, but the tags usually stop at the landing page. The checkout never sees them, and the analytics tool knows the user without knowing the visit that brought them. Asking "how did you hear about us?" at onboarding gives you self-reported answers that rarely match the traffic.

This guide wires @vevee/sdk 1.14 into a Next.js App Router app so the source of each visit reaches the signup and, later, the revenue. The browser keeps the visit in a JavaScript variable (no cookie, no localStorage, no sessionStorage), posts events to your own route, and your server calls Vevee with the secret key. It follows the setup a study app shipped, cut down to the parts you need.

The model has two halves. Anonymous events count visits. Identified events count people. Vevee never links one to the other: at signup, only the source of the visit is copied onto the person as their first touch.

Step-by-step

1. Install the SDK and create a server client

You need @vevee/sdk 1.14.0 or later. The server uses the main entry with your secret key. The browser imports only @vevee/sdk/browser, which holds no key and makes no network calls of its own.

// npm install @vevee/sdk@^1.14.0

// lib/vevee.ts (server only)
import { createClient } from "@vevee/sdk";

export const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });

2. Start the visit in the browser

Mount one client component in the root layout. attribution.start() creates a random visit_id in memory and reads the utm_* parameters, the referrer host and the landing path once. attribution.pageview() builds a $pageview event with those visit fields plus the path, and hands it to your send function. Every other event you track should carry attribution.properties() too, so the server can file it under the right visit. start() is idempotent, so calling it again from a child component is safe.

// components/site-analytics.tsx
"use client";

import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { attribution, type PageviewEvent } from "@vevee/sdk/browser";

function post(body: unknown) {
  void fetch("/api/analytics/capture", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
    keepalive: true,
  }).catch(() => undefined);
}

export function track(event: string, props: Record<string, string | number | boolean> = {}) {
  attribution.start(); // idempotent: a child effect can run before the layout's
  post({ event, properties: props, visit: attribution.properties() });
}

export function currentVisit() {
  return attribution.current(); // null if start() never ran
}

export function SiteAnalytics() {
  const pathname = usePathname();

  useEffect(() => {
    attribution.start({
      send: (e: PageviewEvent) =>
        post({ event: e.event, properties: { path: e.properties.path }, visit: e.properties }),
    });
  }, []);

  useEffect(() => {
    attribution.pageview(pathname ?? undefined);
  }, [pathname]);

  return null;
}

3. Forward events through your own route (hybrid mode)

The browser never talks to Vevee. It posts to /api/analytics/capture, which re-validates what the browser sent, adds the visitor's coarse location and user agent with contextFromHeaders, and calls Vevee with the secret key. Anonymous events carry the visit fields and the context. Events from a signed-in user carry a distinctId and neither. The study app also drops pageviews from signed-in users, because pageviews measure acquisition and a logged-in user's navigation adds only noise. contextFromHeaders reads the Vercel geo headers (with a Cloudflare country fallback) and the user agent. It never reads or forwards the IP.

// app/api/analytics/capture/route.ts
import { NextResponse } from "next/server";
import { contextFromHeaders, type AnalyticsProperties } from "@vevee/sdk";
import { vevee } from "@/lib/vevee";
import { getCurrentUser } from "@/lib/auth";
import { sanitizeVisit } from "@/lib/analytics/visit";

export async function POST(req: Request) {
  const body = (await req.json().catch(() => null)) as {
    event?: string;
    properties?: AnalyticsProperties;
    visit?: unknown;
  } | null;
  if (!body?.event) return NextResponse.json({ ok: false }, { status: 400 });

  const user = await getCurrentUser().catch(() => null);
  if (body.event === "$pageview" && user) return NextResponse.json({ ok: true });

  const props = body.properties ?? {};
  await vevee.analytics
    .capture(
      user
        ? { distinctId: user.id, event: body.event, properties: props }
        : {
            event: body.event,
            properties: { ...props, ...sanitizeVisit(body.visit) },
            context: contextFromHeaders(req.headers),
          },
    )
    .catch(() => undefined); // analytics must never break the page

  return NextResponse.json({ ok: true });
}

4. Re-validate the visit on the server

Anything from the browser can be forged, so check it before it reaches Vevee. The study app keeps visit_id only if it is a UUID, caps each UTM value at 200 characters, accepts a referrer only if it looks like a host, and keeps the landing path only as a same-origin path with the query string removed.

// lib/analytics/visit.ts
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HOST = /^[a-z0-9.-]+(:\d+)?$/i;
const UTM_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"] as const;

export type CleanVisit = Partial<Record<(typeof UTM_KEYS)[number] | "visit_id" | "referrer_host" | "landing_path", string>>;

export function sanitizeVisit(raw: unknown): CleanVisit {
  if (typeof raw !== "object" || raw === null) return {};
  const v = raw as Record<string, unknown>;
  const out: CleanVisit = {};
  if (typeof v.visit_id === "string" && UUID.test(v.visit_id)) out.visit_id = v.visit_id;
  for (const k of UTM_KEYS) {
    const s = v[k];
    if (typeof s === "string" && s.trim()) out[k] = s.trim().slice(0, 200);
  }
  if (typeof v.referrer_host === "string" && HOST.test(v.referrer_host)) {
    out.referrer_host = v.referrer_host.toLowerCase();
  }
  if (typeof v.landing_path === "string" && v.landing_path.startsWith("/") && !v.landing_path.startsWith("//")) {
    out.landing_path = v.landing_path.split("?")[0]!.slice(0, 500);
  }
  return out;
}

5. Store the first touch at signup

When the login page finishes, send the current visit to your auth init route. On the server, decide whether this request created the account, then call identify with isSignup: true and the visit as firstTouch. Vevee stores firstTouch only when isSignup is true, and only once per person; on any other call the response says firstTouch: "ignored". The study app removes visit_id before identify, so nothing that names the visit is ever sent with a person. Pass context too: a secret-key call cannot see the visitor, and on a first touch Vevee keeps only the country and the device class, browser and OS (region and city are accepted but not stored).

// browser, after the login succeeds
await fetch("/api/auth/init", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ visit: currentVisit() }),
});

// app/api/auth/init/route.ts (after you know isFreshSignup)
import { contextFromHeaders, type FirstTouch } from "@vevee/sdk";

const { visit } = (await req.json()) as { visit: (FirstTouch & { visit_id?: string }) | null };
const { visit_id: _dropped, ...firstTouch } = visit ?? {};

await vevee.analytics.identify(user.id, { plan: "free" }, undefined, {
  isSignup: isFreshSignup,
  ...(isFreshSignup && visit ? { firstTouch } : {}),
  context: contextFromHeaders(req.headers),
});

6. Send an anonymous twin of the signup

identify opens the identified half (people). To close the visit half, send a separate anonymous signup_completed from the same route: no distinctId, the sanitized visit fields including visit_id, and the request context. Nothing joins the two events. signup_completed is the last step of the visit funnel, and your identified signup event is the first step of the people funnel.

const clean = sanitizeVisit(visit);
if (isFreshSignup && clean.visit_id) {
  await vevee.analytics
    .capture({
      event: "signup_completed",
      properties: { provider: "google", ...clean },
      context: contextFromHeaders(req.headers),
    })
    .catch(() => undefined);
}

7. Send revenue from the payment webhook

The last road into Vevee is revenue. Call vevee.revenue.order when an order is paid and vevee.revenue.refund when a refund succeeds. Vevee credits each order to the person's first touch, so the Traffic page gets a revenue column per channel and campaign. The full pattern (amounts, idempotency, error handling) is in the guide "Send revenue from your payment webhook".

await vevee.revenue.order({
  distinctId: user.id,          // the id you pass to identify()
  orderId: order.id,            // idempotency key
  amount: order.netAmount,      // minor units, after discounts, before tax
  currency: order.currency,
  billingReason: "subscription_create",
});

Keep the visit across a full page load

Memory does not survive a reload, so a visit normally ends when the tab reloads or a new tab opens. Login flows that leave the page (an email OTP page, a magic link, an OAuth redirect) would lose the source at the worst moment. Carry the visit in the URL instead: attribution.toParam() returns a short string (or null if there is no visit), and attribution.fromParam() adopts it on the next page, right after start() and before the first pageview. The Vevee docs use the query name vv. Remove it from the address bar with history.replaceState afterwards, so a shared or bookmarked link does not merge different people into one visit. When the carried visit and the current one disagree, the one that started first wins.

// before leaving the page
const url = new URL("/login/verify", window.location.origin);
const vv = attribution.toParam();
if (vv) url.searchParams.set("vv", vv);

// on load, inside the SiteAnalytics start effect
attribution.start({ send });
const here = new URL(window.location.href);
const carried = here.searchParams.get("vv");
if (carried !== null) {
  attribution.fromParam(carried);
  here.searchParams.delete("vv");
  window.history.replaceState(window.history.state, "", here);
}

What Vevee does with the first touch

The touch is stored once per person and never changes after that. It is mirrored into person properties you can filter and split by. The channel is derived on the server from the UTMs and the referrer; a channel sent by the client is ignored.

  • first_utm_source, first_utm_medium, first_utm_campaign, first_utm_term, first_utm_content
  • first_referrer_host and first_landing_path
  • first_channel: paid, email, ai, organic_search, social, referral or direct
  • first_touch_at (a day in the app's time zone), first_country and first_device
  • isSignup: true without firstTouch stores a direct first touch for that day

Set the app time zone first

Visits, the Traffic page and split funnels all count by the app's local day. Set it in the dashboard under Settings, Time zone (an IANA name such as Europe/Rome; the default is UTC). If you skip this, an evening visit can land on the previous or next UTC day and the numbers look wrong for no visible reason.

Limits of a cookieless visit

Keeping the visit in memory is what removes the device-storage question, and it has costs you should know before you read the numbers.

  • There are no unique visitors across visits. Each visit gets a new visit_id.
  • A visitor who comes back in a new visit before signing up shows as direct.
  • A visit that crosses midnight in the app's time zone counts as two visits.
  • People who signed up before you shipped this have no first touch.
  • Visit and source tables read anonymous events, which only the hybrid analytics mode writes.

Related docs

Full reference for each piece: /docs/guides/traffic-attribution (the end-to-end setup), /docs/methods/capture (capture and context), /docs/methods/identify (isSignup, firstTouch, context) and /docs/methods/revenue (orders and refunds).

Frequently asked questions

Does this need a cookie banner?

The browser helper writes nothing to cookies, localStorage, sessionStorage or IndexedDB, and reads no click IDs. The study app's reading is that the ePrivacy device-storage rule therefore does not apply to it, and it relies on legitimate interest with data minimisation for the rest. That is the app's own reading, not legal advice; check it against your own situation.

Can I skip the proxy and call Vevee from the browser?

Yes, with a public pk_ key: pass (e) => vevee.analytics.capture(e) as send. The proxy is worth it because your server can re-validate the visit, decide which events are anonymous, and forward coarse geo from your host's headers.

Why remove visit_id before identify?

Vevee does not store the raw visit_id either way, but the study app prefers that nothing naming a visit ever travels with a person. The first touch only needs the source fields and started_at.

What happens to people who signed up before the integration?

They have no first touch. In a split by first_channel they land in a row called "(no first touch)", and their orders show under "Before tracking" on the Traffic page.

Does a signed-in user's click change their source?

No. Identified events carry no visit fields, and the first touch is set once at signup. A visit split also reads the channel from the earliest anonymous event of each visit, so a later event cannot move a visit to another channel.

Other guides

8 min · Guide

How to track OpenAI API usage by user (with quotas, in real time)

Step-by-step guide to tracking OpenAI API usage per end-user with real-time quotas. Pure-TypeScript pattern using @vevee/sdk. Concurrency-safe, provider-agnostic, ten-minute integration.

7 min · Guide

How to implement per-user rate limits in your AI app

Per-user rate limits for AI apps need atomic enforcement, plan awareness, and refundable reservations. Here is the pattern that works under load - using @vevee/sdk and ~30 lines of code.

9 min · Guide

How to implement usage-based pricing for an AI product

Decide between per-token, per-action, and hybrid pricing. Implement quotas, refunds, and Stripe meter sync. Step-by-step with code, from a developer who has shipped all three models.

6 min · Guide

The reserve / commit / release pattern: atomic AI quota enforcement

Why naive AI usage metering breaks under concurrency, and the only correct pattern that fixes it. Reserve / commit / release explained, with full TypeScript example and the failure modes it prevents.

8 min · Guide

How to charge AI app users by token usage (with refunds and live balance)

Step-by-step: charge users for actual token consumption with pre-paid credits, post-paid invoicing, and a live balance display. Atomic reservations, accurate refunds, and Stripe meter sync.

10 min · Guide

Build a freemium image generator: end-to-end tutorial

Ship a freemium AI image generator with hard caps, upgrade prompts, real-time analytics, and a live "renders left" badge - using @vevee/sdk + your image provider of choice. Full code, ten-minute build.

8 min · Guide

Send revenue from your payment webhook to Vevee

Record paid orders and refunds with vevee.revenue.order and vevee.revenue.refund so every channel and campaign gets a revenue column. Covers amounts in minor units net of tax, idempotency on the provider id, refunds that arrive early, and a webhook that never fails because of analytics.

7 min · Guide

Build the two attribution funnels from JSON

Paste one JSON file into Vevee's funnel import to get a visit to signup funnel and a signup to paid funnel, both split by channel. Covers the split forms, step filters, units, and how to read the split table.

9 min · Guide

Test attribution with fake traffic in test mode

Check a cookieless attribution setup before real visitors arrive: a probe script that hits every call once, a seed script that fills test mode with believable traffic and refuses anything but an sk_test_ key, and a cross-check of the visit funnel against the Traffic page.