Cookieless traffic attribution with Next.js

Find out which channel, campaign and landing page bring your signups and your revenue, without a cookie, without device storage and without a banner. This guide wires a Next.js App Router app in six steps. It follows the integration a study app shipped with @vevee/sdk 1.14.0; the code is simplified, and every number on this page is example data.

Let your coding agent do it

Using Claude Code, Cursor or another coding agent? Paste this prompt into it from your app's repository. It points the agent at these docs, asks for a plan first, and tests with an sk_test_ key before going live.

Prompt for your coding agent
Add Vevee cookieless traffic attribution to this app. Use only the official docs below as the source of truth: fetch them before writing code, and follow them over anything you remember.

Read first (whole SDK reference plus the docs in one file):
https://www.vevee.org/llms-full.txt

Then the pages for this feature:
- Step by step guide: https://www.vevee.org/docs/guides/traffic-attribution
- Browser helper (@vevee/sdk/browser): https://www.vevee.org/docs/methods/browser-attribution
- identify at signup (isSignup, firstTouch, context): https://www.vevee.org/docs/methods/identify
- capture with context: https://www.vevee.org/docs/methods/capture
- Revenue from the payment webhook: https://www.vevee.org/docs/methods/revenue
- How channels are assigned: https://www.vevee.org/docs/concepts/channels
- Funnel splits and the JSON import: https://www.vevee.org/docs/guides/funnel-splits
- Next.js walkthrough: https://www.vevee.org/guides/cookieless-attribution-nextjs
- Webhook walkthrough: https://www.vevee.org/guides/revenue-from-payment-webhook
- Testing with fake traffic: https://www.vevee.org/guides/test-attribution-fake-traffic

Requirements:
- Install @vevee/sdk@^1.14.0. The browser uses only @vevee/sdk/browser (no API key there). The secret key stays on the server.
- Hybrid mode: the browser posts to our own API route, which forwards to Vevee with contextFromHeaders(req.headers). Never send or store the IP.
- No cookies, localStorage or sessionStorage for attribution: the visit lives in memory only.
- At signup: identify with isSignup: true and firstTouch from attribution.current().
- Send revenue.order / revenue.refund from the existing payment webhook. Amounts are integer minor units; the order id is the idempotency key.
- Ad links must use a paid utm_medium (for example utm_medium=paid_social), or the visit counts as social, not paid.
- If this app is not Next.js, apply the same pattern with its own routing and server.
- Test everything with an sk_test_ key before going live.

Before coding, read the codebase and give me a short plan: which files you will add or change, and where our signup and payment webhook live. Wait for my OK, then implement.

How it works

  • The visit lives in memory. @vevee/sdk/browser reads the landing URL once and keeps a random visit id, the UTM tags, the referrer host and the landing path in a JavaScript variable. A reload or a new tab is a new visit.
  • Hybrid mode, through your server.The browser posts to your own route. Your server adds the visitor's coarse location and device from the request headers and calls Vevee with the secret key. The browser never holds a Vevee key.
  • First touch at signup. The signup call carries the visit. Vevee stores it once on the person as their first touch and derives a channel from it.
  • Two halves. Anonymous events count visits; identified events count people. Vevee never links one to the other. Only the source crosses over, copied into the first touch.

So three roads lead into Vevee: pageviews into the anonymous half, the signup into both halves (as two unlinked events), and revenue into the person's record.

1. Install

npm install @vevee/sdk@^1.14.0

Your server uses @vevee/sdk with a secret key. The browser imports only @vevee/sdk/browser, which holds no key and makes no request on its own.

// 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

One client module owns the visit. Mount a component that calls it from the root layout, and call trackPageview on every route change.

// lib/analytics/client.ts
'use client';
import { attribution, type PageviewEvent, type VisitProperties } from '@vevee/sdk/browser';

type Props = Record<string, string | number | boolean | null>;

function post(body: { event: string; properties?: Props; visit?: VisitProperties | Record<string, never> }) {
  void fetch('/api/analytics/capture', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
    keepalive: true,
  }).catch(() => undefined);
}

export function startVisit(path: string) {
  attribution.start({
    send: (e: PageviewEvent) =>
      post({ event: e.event, properties: { path: e.properties.path }, visit: attribution.properties() }),
  });
  attribution.pageview(path);
}

export function trackPageview(path: string) {
  attribution.pageview(path);
}

export function currentVisit() {
  return attribution.current();
}

// Every other event carries the visit too.
export function track(event: string, props?: Props) {
  attribution.start(); // idempotent: a child effect can run before the layout's
  post({ event, ...(props ? { properties: props } : {}), visit: attribution.properties() });
}

If a magic link, an OTP page or an OAuth redirect forces a full page load, carry the visit with toParam() and fromParam(), or the reloaded page starts a new visit.

3. Forward through a server proxy

The browser posts to /api/analytics/capture. The route decides whether the event is anonymous or identified, re-validates everything the browser sent, and forwards it.

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

type Props = Record<string, string | number | boolean | null>;

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

  const user = await getCurrentUser().catch(() => null);

  // Pageviews measure acquisition; a signed-in user's navigation only adds noise.
  if (body.event === '$pageview' && user) return NextResponse.json({ ok: true });

  const props = body.properties ?? {};
  void vevee.analytics
    .capture(
      user
        ? // Identified: no visit fields, no context.
          { distinctId: user.id, event: body.event, properties: props }
        : // Anonymous: visit fields plus coarse geo and user agent from the host headers.
          {
            event: body.event,
            properties: { ...props, ...sanitizeVisit(body.visit) },
            context: contextFromHeaders(req.headers),
          },
    )
    .catch(() => undefined);

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

contextFromHeadersreads the Vercel geo headers (country, region, city), falls back to Cloudflare's cf-ipcountry, and adds the user agent. It never reads the IP, and Vevee never stores the raw user agent. Off Vercel and Cloudflare, build the same CaptureContext from whatever geo headers your host provides.

Treat everything from the browser as untrusted. A validator like this one keeps only fields that look right:

// 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 val = v[k];
    if (typeof val === 'string' && val.trim()) out[k] = val.trim().slice(0, 200);
  }
  if (typeof v.referrer_host === 'string' && HOST.test(v.referrer_host)) out.referrer_host = v.referrer_host;
  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;
}

4. Store the first touch at signup

The login page sends the visit to your auth route once the session exists:

// login page (client)
import { currentVisit } from '@/lib/analytics/client';

await fetch('/api/auth/init', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ visit: currentVisit() }),
});

Only your server knows for sure that the account is new. Call identify() with isSignup: true on that request only, and drop the visit id first:

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

const body = (await req.json().catch(() => null)) as { visit?: unknown } | null;
const { visit_id: _visitId, ...touch } = sanitizeVisit(body?.visit);
const firstTouch: FirstTouch = touch; // no visit_id: the person never learns their visit

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

Vevee stores the touch once and mirrors it to person properties such as first_utm_source, first_referrer_host, first_landing_path and a server-derived first_channel. From the context it keeps only the country and the device class, browser and OS. This validator drops started_at, so the first touch is dated to the signup day; keep a valid ISO 8601 started_at if you want the visit day instead (Vevee uses it when it is at most 7 days old).

5. Send an anonymous twin of the signup

The identified half now knows the signup. The anonymous half does not: nothing links the new person to their visit, by design. To close the visit funnel inside the anonymous half, send a second, anonymous event from the same request, carrying the visit fields and the visit id and no distinctId:

const visit = sanitizeVisit(body?.visit);
if (isFreshSignup && visit.visit_id) {
  await vevee.analytics.capture({
    event: 'signup_completed',                  // anonymous: closes the visit funnel
    properties: { provider: 'google', ...visit },
    context: contextFromHeaders(req.headers),
  });
}

signup_completed closes the anonymous funnel; your identified signup event (for example signed_up) opens the people funnel. Nothing joins them. On the server, put visit_id in properties yourself: the automatic visit-id pickup only works in the browser.

6. Send revenue from the payment webhook

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

// Only when the refund has succeeded:
await vevee.revenue.refund({ orderId: order.id, refundId: refund.id, amount: refund.amount });

Both calls are idempotent on the provider's ids, so a redelivered webhook is safe. Full reference, including the refund cap and not_found: revenue.order() / revenue.refund().

App settings

  • Time zone. Under Settings, set the app's IANA time zone (for example Europe/Rome; the default is UTC). It decides which day a visit, a first touch and an order belong to, so Vevee's days match yours. A visit that crosses local midnight counts as two visits.
  • Funnel events. Under Settings, name your Signup page event (for example login_page_viewed) and your Activation event (the first success, for example generate_succeeded). They fill the “Signup page” and “Activated” steps on the Traffic page. A change applies to counting from then on only.

The Traffic page

The Traffic page in your app's dashboard reads everything above:

  • Headline numbers: Visits, Signups, New paying, Revenue, Conversion and Revenue / visit, each against the previous period.
  • Funnel strip: Visits, Signup page, Signups, Activated, New paying.
  • Breakdowns: Channel, Referrer, Campaign, Keyword (utm_term), Landing page, Country, Region, City, Device, Browser and OS, sortable by visits, revenue, signups and paying customers.

Visits come from the anonymous half; signups, activation and revenue come from first touches. Revenue is shown in USD, converted with the daily ECB reference rate of each order's day.

i
Example data. In a mock month with 7 channels and 1,070 visits, paid ads brought the most visits while ai converted to signup at almost three times the rate. Revenue per visit was $0.66 across all channels and $1.64 for ai. Your numbers will differ; the point is that each channel finally has a revenue column.

Testing in test mode

  1. Use an sk_test_ key. Test data lives in its own universe and never mixes with live numbers. If you write a script that seeds fake traffic, make it refuse any key that does not start with sk_test_.
  2. Land on a local build with ?utm_source=newsletter&utm_medium=email, sign up, and check the new person: first_channel should be email. Check the browser too: no Vevee cookie and no storage key.
  3. Call identify() with isSignup: true and a firstTouch from a probe script: the response should have firstTouchStored: true the first time and false after.
  4. Build the visit funnel split by channel and compare it with the Traffic page for the same days: the rows should match.

Gotchas

SymptomCauseFix
Traffic shows 0 visits for today.The app time zone is unset, so data landed on the previous UTC day.Set the app time zone.
Revenue shows 0 or “not converted”.Orders in a currency whose exchange rate for that day is not loaded yet.Rates load once a day; check again tomorrow.
“Activated” stays at 0.The activation event name in Settings does not match exactly (for example a trailing dot).Fix the name; it applies from then on.
Signup fails on preview deploys.The secret key is missing in the preview environment.Add it there, or test in production with a test key.
A UTM value disappeared from an identified event's URL property./api/v1-13/capture strips utm_* and click IDs from URLs.Pass the value as its own property.

What cookieless cannot do

  • No unique visitors across visits: each visit has a new id.
  • A visitor who comes back in a new visit before signing up shows as Direct.
  • A full reload without a carried toParam() value starts a new visit.
  • People who signed up before you shipped this have no first touch.

What is stored, where and for how long is listed in Privacy & GDPR.

Where to next