All writing

Where did this lead come from?

7 min read

In this post

NowCheckin runs ads on Facebook and Zalo at the same time. This is how I follow a visitor from the ad they tapped to the form they filled in, and why I wrote the tracking myself when I could have pasted in a script.

The NowCheckin page inviting venue owners to become partners

Run ads in two places at once and sooner or later someone asks a very simple question: how many partners signed up this week, and did they come from Facebook or Zalo?

Without tracking, the answer is a guess. With half-working tracking, the answer is a wrong number that looks confident, which is worse.

Tracking is not there to make charts. It is there to answer one question: which ad money is bringing people in.

A website cannot tell where a visitor came from. All it sees is the link they just followed, so everything has to be in that link already.

nowcheckin.io/vi/partnerutm_sourcefacebookutm_mediumcpcutm_campaigndoi_tac_thang_10utm_contentvideo_afbclidIwAR0abc
A NowCheckin ad link, taken apart. The black piece is the page that opens. The yellow pieces say where the visitor came from.

I set one fixed convention for everyone who runs ads, because the moment one person types Facebook and another types fb, the report splits into two rows.

ParameterQuestion it answersAllowed values
utm_sourceWhich platform?facebook, zalo, google
utm_mediumWhat kind of placement?cpc (paid), oa (Zalo OA message), post (organic post)
utm_campaignWhich campaign?campaign name, lowercase, joined with underscores
utm_contentWhich creative?video_a, anh_b

fbclid is different: Facebook adds it to every link a user taps. I do not create it, but I keep it. More on that below.

const UTM_KEYS = ['source', 'medium', 'campaign', 'content'];

export function readSource(url: URL) {
  const utm: Record<string, string> = {};
  for (const key of UTM_KEYS) {
    const value = url.searchParams.get('utm_' + key);
    if (value) utm[key] = value;
  }

  return {
    utm,
    fbclid: url.searchParams.get('fbclid') ?? undefined, // added by Facebook, we only keep it
    path: url.pathname // the first page the visitor saw
  };
}

Three ids for three different questions

This is where I spent the most time thinking. "A visitor" sounds simple, but there are three things to tell apart.

Stored in the browserWhat it isQuestion it answers
nc_aidAnonymous id, long-livedIs this the person who came by yesterday?
nc_sidId of one visitWhich actions belong to the same visit?
nc_lastTime of the last activityIs this visit still going, or did they leave and come back?
eventIdId of one eventWas this event already recorded, or is it a duplicate?

With a single id you cannot tell "one person visited three times" from "three people visited once". Those two lead to completely different ad decisions.

The cookie consent prompt on NowCheckin
Until the visitor taps Accept, no anonymous id is created and no event is sent.

The anonymous id is only created after the visitor agrees. Before that the site works as normal, and I simply know nothing about them. I accept losing part of the data so I never have to explain why I was following someone who had not said yes.

What one event looks like

This is what the browser really sends to the API, taken from one visit. I only changed the campaign name.

{
  "anonymousId": "e9d2fdca-a74e-4d25-8739-fe07dc417f6d",
  "sessionId": "1b7936f4-34b6-4cef-8df5-b0a9a6ee01dd",
  "source": "web",
  "ctx": {
    "path": "/vi",
    "utm": {
      "source": "facebook",
      "medium": "cpc",
      "campaign": "doi_tac_thang_10",
      "content": "video_a"
    },
    "fbclid": "IwAR0abc",
    "device": "desktop",
    "locale": "vi"
  },
  "events": [
    {"eventId": "31150ce6-...", "name": "page_view", "ts": "2026-10-11T07:42:47.027Z", "props": {"path": "/vi"}},
    {"eventId": "bf9d5430-...", "name": "page_view", "ts": "2026-10-11T07:42:53.919Z", "props": {"path": "/vi/explore"}}
  ]
}

Three things worth noticing:

  1. Context is sent once. The ad source, device and language live in ctx and are not repeated in every event.
  2. Events are batched. Two page views travel in one request. A visitor who taps ten times quickly does not cost the server ten requests.
  3. Every event has its own id. When the network stutters and a request is sent again, the server still knows it is the same event.
const queue: TrackEvent[] = [];

export function track(name: string, props: Record<string, unknown> = {}) {
  if (!hasConsent() || disabledEvents.has(name)) return; // no consent yet, or this event is switched off

  queue.push({eventId: crypto.randomUUID(), name, ts: new Date().toISOString(), props});
  scheduleFlush(); // collect, then send in one go
}

One visitor's journey

When every event carries the same sessionId, sorting them by time gives you a story.

  1. 00:00Taps an ad on Facebookutm_source=facebook, utm_content=video_a
  2. 00:01Sees the homepagepage_view /vi
  3. 00:07Moves to explorepage_view /vi/explore
  4. 00:31Opens a phở shoppage_view /vi/merchant/...
  5. 01:12Taps Become a partnergoes to /vi/partner
  6. 02:40Sends the sign-up formlead saved with source facebook
One visit, read from top to bottom. Times are minutes and seconds since landing.

This shows what totals never do: what people look at before they decide, and the step where they leave.

From events to an answer

  1. Ad tap
  2. Landing with UTM
  3. Events to the API
  4. Lead with a source
The source is read at step two and stays with the visitor until they leave their details.

Because the source is already in the context of every event, the original question becomes one query.

SELECT ctx_utm_source        AS source,
       COUNT(DISTINCT anonymous_id) AS leads
FROM   events
WHERE  name = 'partner_signup'
GROUP  BY ctx_utm_source
ORDER  BY leads DESC;
  • Facebook46
  • Zalo31
  • Google18
  • Direct9
Leads by source. These are example numbers to show the shape of the report, not NowCheckin's real figures.

One decision has to be made on purpose. A visitor taps a Facebook ad on Monday, comes back through a Zalo message on Thursday and then signs up. Who gets the lead? I store both the first touch and the last, because two teams ask two different questions: the first touch shows which channel introduces people, the last shows which one closes.

Switching an event off without a deploy

Before it sends anything, the site asks the API one thing: is any event switched off?

{"success": true, "data": {"disabledEvents": []}}

It sounds unnecessary until the day an event is attached to something that fires every second and starts filling the database with noise. Then I add its name to a list on the server and it stops. No rebuild of the website.

What about the Facebook Pixel

The usual way is to paste Facebook's script into the page. On NowCheckin the browser does not load that script. The fbclid travels with the events to my own API, and reporting back to Facebook is the server's job.

I chose that for three reasons:

  • Ad blockers are very good at blocking third-party scripts and do not block requests to your own domain.
  • The page is lighter. One less outside script is one less thing slowing the first load.
  • I keep the original data. Facebook receives what I decide to send, and I still have the full record to compare when the two sides disagree.

Keep SEO and tracking out of each other's way

Tracking adds parameters to URLs, and that is exactly what SEO dislikes. To Google, /vi/partner and /vi/partner?utm_source=facebook are two addresses with the same content.

export async function generateMetadata({params}) {
  const {locale} = await params;

  return {
    alternates: {
      canonical: '/' + locale + '/partner', // no utm_*, no fbclid
      languages: {vi: '/vi/partner', en: '/en/partner'}
    }
  };
}

The canonical tag tells crawlers that whatever is on the end of the link, the page is the same one. Alongside it are the things NowCheckin did from the start: one path per language, hreflang joining the two versions, and venue pages rendered on the server so the content is in the HTML.

NowCheckin homepage in Vietnamese
/vi
NowCheckin homepage in English
/en

The tracking code itself runs after the page has appeared. No metric is worth making a visitor look at a blank screen for another half second.

Three things I would keep if I did it again

  1. A written UTM naming convention before the first ad runs. Cleaning dirty data later costs many times more.
  2. Person, visit and event kept separate from day one. Adding an id later means the old data cannot be used.
  3. A server-side switch for events. It is cheap, and the day you need it you will need it badly.