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.

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.
It starts with the ad link
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.
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.
| Parameter | Question it answers | Allowed values |
|---|---|---|
utm_source | Which platform? | facebook, zalo, google |
utm_medium | What kind of placement? | cpc (paid), oa (Zalo OA message), post (organic post) |
utm_campaign | Which campaign? | campaign name, lowercase, joined with underscores |
utm_content | Which 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 browser | What it is | Question it answers |
|---|---|---|
nc_aid | Anonymous id, long-lived | Is this the person who came by yesterday? |
nc_sid | Id of one visit | Which actions belong to the same visit? |
nc_last | Time of the last activity | Is this visit still going, or did they leave and come back? |
eventId | Id of one event | Was 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.
No consent, no tracking

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:
- Context is sent once. The ad source, device and language live in
ctxand are not repeated in every event. - Events are batched. Two page views travel in one request. A visitor who taps ten times quickly does not cost the server ten requests.
- 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.
- 00:00Taps an ad on Facebookutm_source=facebook, utm_content=video_a
- 00:01Sees the homepagepage_view /vi
- 00:07Moves to explorepage_view /vi/explore
- 00:31Opens a phở shoppage_view /vi/merchant/...
- 01:12Taps Become a partnergoes to /vi/partner
- 02:40Sends the sign-up formlead saved with source facebook
This shows what totals never do: what people look at before they decide, and the step where they leave.
From events to an answer
- Ad tap
- Landing with UTM
- Events to the API
- Lead with a source
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;
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.


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