Hybrid mode for privacy: put your own server in the middle
The browser never talks to Vevee. It talks to a forty-line route on the app's own server, which decides what leaves and what does not.
Two meanings of hybrid
In the Vevee SDK, hybrid is the default analytics mode: it writes anonymous events for visitors who have not signed up and identified events for people who have, and keeps the two apart. The visit and source tables on the Traffic page are built from the anonymous side, so they only fill in hybrid mode. The second meaning is the one this post is about: how the events get there. A study app runs every event through a proxy route on its own server instead of letting the browser call Vevee. The browser posts to the app, the app posts to Vevee. It sounds like extra plumbing, and it is the part of the setup that does the most for privacy.
The path of one pageview
Here is what happens when a student opens the pricing page from an ad, in order.
browser
attribution.pageview('/pricing')
POST /api/analytics/capture (same origin, no key, no cookie)
{ event: '$pageview', properties: { path, visit_id, utm_*, landing_path } }
app server
signed in? -> drop pageviews, strip visit fields
re-validate fields -> UUID, lengths, host shape, path without query
add context -> country, region, city from host headers; user agent
vevee.analytics.capture(...) with the secret key
Vevee
hash visit_id with a daily salt, drop the raw id, the raw user agent
and any click ids; store the anonymous eventThe secret key never reaches the browser
With a public pk_ key in the page, anyone can read the key from the bundle and send whatever events they like as your app. Vevee limits what a public key can do, but the events themselves are whatever the browser says they are. With the proxy, the only key is a secret one in a server environment variable. The browser can still post junk to the app's route, but the route decides what gets forwarded, and it can refuse. That also gives the app a single place to enforce its own rules. Signed-in users send no pageviews at all, because pageviews exist to measure acquisition and a logged-in student clicking around the editor adds noise. Identified events go out with no visit fields. Users the app knows to be under 16 are opted out automatically: their events are recorded under a random code, and no revenue events are sent for them.
// app/api/analytics/capture/route.ts
import { NextResponse } from 'next/server';
import { createClient, contextFromHeaders } from '@vevee/sdk';
const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });
export async function POST(req: Request) {
const body = (await req.json()) as { event: string; properties?: Record<string, unknown> };
const user = await getCurrentUser().catch(() => null);
// Pageviews measure acquisition; a signed-in user's clicks add only noise.
if (body.event === '$pageview' && user) return NextResponse.json({ ok: true });
const props = cleanProps(body.properties); // the app's own allowlist
await vevee.analytics.capture(
user
? { event: body.event, distinctId: user.id, properties: props }
: {
event: body.event,
properties: { ...props, ...sanitizeVisit(body.properties) },
context: contextFromHeaders(req.headers),
},
);
return NextResponse.json({ ok: true });
}Location without the IP address
A server-side call has a problem: Vevee sees the request coming from the app's server, so it cannot tell where the visitor is. The usual fix is to forward the visitor's IP and let the analytics tool look it up. The app does not forward the IP at all. Its host already did the lookup. On Vercel, every request arrives with x-vercel-ip-country, x-vercel-ip-country-region and x-vercel-ip-city headers; behind Cloudflare there is cf-ipcountry. The SDK's contextFromHeaders reads those, decodes the city, drops the unknown-country values XX and T1, adds the user agent, and returns a small object the app passes as context. The IP address never leaves the app's server, and Vevee never stores one. A secret key call that sends no geo stores no geo, since anything Vevee could infer would describe the server. On anonymous events, Vevee keeps the country, region and city. On a first touch, it keeps only the country and the device class, browser and OS.
Trust nothing from the page
The visit fields come from the browser, which means anyone can send anything. The proxy re-checks every field before forwarding it, even though the SDK's browser helper already produces clean values:
- visit_id must be a UUID. Anything else is dropped, and the event is sent without one.
- Each utm_* value is trimmed and capped at 200 characters.
- referrer_host must look like a host name: no scheme, no path, no spaces.
- landing_path must be a same-origin path starting with a slash, and its query string is removed so no email address or token in a URL can ride along.
- Unknown properties are dropped by the app's own allowlist rather than forwarded.
What it costs
One route, one environment variable, and one extra hop per event, which a keepalive fetch from the browser hides. Preview deployments need the secret key too, or they fail quietly; the app learned that one the hard way. In exchange, the app can describe exactly what leaves its server, because there is exactly one place where it does. This is the app's own setup and its own reading of what it needs; it is not legal advice. The step by step version, including the auth route that stores the first touch, is in the traffic attribution guide at /docs/guides/traffic-attribution.
Keep reading
Reading a split funnel
The funnel chart tells you how many got through. The split table under it tells you who. Here is a row by row reading of one, from a study app's mock month.
AnalyticsFirst touch, stored once
A first touch is written exactly once, at the moment an account is created, and never again. Getting that "exactly once" right is most of the work.