Tracking where your early users come from, without adding an analytics service
I have a static landing page on Netlify collecting early-access signups, and I wanted to know which link people arrived from. Not a full analytics suite. One question: which post, which community, which link. The obviou
I have a static landing page on Netlify collecting early-access signups, and I wanted to know which link people arrived from. Not a full analytics suite. One question: which post, which community, which link.
The obvious answers all felt too big. Plausible, GA4, PostHog are all fine products, all more than I needed, and every one of them adds a third-party script to a page whose whole pitch is that it doesn't load third-party scripts.
The version I ended up with is about fifteen lines and costs nothing. Here it is, including the parts that went wrong, because those were the interesting bits.
The shape of it
The site is one static HTML file with a Netlify form. So:
- Links carry a
?ref=parameter.?ref=quora_poe,?ref=reddit_sideproject. - A few lines of inline JS read that parameter and drop it into a hidden field.
- The hidden field submits with the form.
- The value shows up as a column in Netlify Forms.
That's it. No script, no cookie, no vendor.
<form name="waitlist" method="POST" data-netlify="true" data-netlify-honeypot="bot-field">
<input type="hidden" name="form-name" value="waitlist">
<input type="hidden" name="ref" value="nojs" class="js-ref">
<input type="email" name="email" required>
<button type="submit">Request early access</button>
</form>
var ref = "direct";
try {
var raw = new URLSearchParams(window.location.search).get("ref") || "";
var clean = raw.toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 40);
if (clean) ref = clean;
} catch (e) { /* keep the default */ }
var fields = document.querySelectorAll(".js-ref");
for (var i = 0; i < fields.length; i++) fields[i].value = ref;
Sanitising matters more than it looks. That value is going to be read back by a human in a dashboard, and anything arriving from a URL is attacker-controlled. Lowercase it, strip to [a-z0-9_-], cap the length. If you ever render these anywhere, you'll be glad you did it at the door.
Gotcha 1: without JavaScript, you get nothing
This is the one that actually bit me.
The ref is read by script. If JavaScript doesn't run, the hidden field submits with whatever it was born with. I'd written value="", so those submissions arrived with a blank ref, indistinguishable from a bug in my own code.
On static hosting there's no server to stamp the value, so you can't fix it. What you can do is stop the gap being silent:
<input type="hidden" name="ref" value="nojs" class="js-ref">
Now a blank ref means something is broken, and nojs means attribution wasn't available. Those are different facts and you want to tell them apart at a glance.
I only caught this because I tested the no-JS path with a ref in the URL. I'd tested no-JS before, and I'd tested refs before, just never the two together. Worth remembering when you write the test matrix.
Gotcha 2: Netlify registers form fields at deploy time
Netlify builds the form schema by parsing your deployed HTML. Add a new hidden field and it doesn't exist until the deploy containing it goes live.
Practically: add the field, deploy, then start sending traffic. If you paste your links out first and add the field after, those early submissions are gone. Not recoverable.
Gotcha 3: the spam filter will eat your tests
I lost a genuinely confusing half hour to this.
I tested the live form with curl and with a headless browser. Every request came back HTTP 200. Not one appeared in the dashboard. It looked exactly like submissions being silently dropped, which for a signup form is about the worst bug you can imagine.
They were being dropped, as spam, correctly. Netlify runs submissions through spam filtering, and a bare curl user agent hitting a form is precisely the pattern it exists to catch.
The tell was that a headless run carrying an iPhone user agent did land, and so did a real submission from a real browser. Nothing was wrong with the form.
Test signup forms from a real browser. A 200 from an automated request means the request was accepted, not that the row was stored. Check the dashboard, not the status code.
Gotcha 4: pretty URLs rewrite your form action
Minor, but it'll confuse you for a minute. I set action="/thanks.html". Netlify's asset optimisation rewrites that to /thanks and strips the data-netlify attribute from the served markup once it has registered the form.
Both still resolve. Nothing is broken. But if you diff your local file against what's live and panic, that's why.
Is it good enough?
For what I need, yes, with one honest limitation: it measures conversions, not clicks. I know a signup came from ?ref=quora_poe. I don't know how many people saw that link and didn't sign up, so I can't compute a conversion rate.
Real analytics would tell me that. It would also mean a third-party script on every page load, a cookie banner conversation, and a vendor.
For a pre-launch landing page whose only success metric is whether an email arrived, knowing the source of each email is most of the value. I'd rather have the simple thing working than the sophisticated thing half-configured. When there's enough traffic for the conversion rate to be a real question, that's a good problem and I'll add something then.
Netlify's own bandwidth panel gives a free, coarse traffic signal you can line up against posting dates, which covers more of the gap than I expected.
One more field worth adding
I added a second hidden field for which form on the page converted:
<input type="hidden" name="placement" value="hero">
One in the hero form, one in the footer form, different values. It cost nothing and answers a question I'd otherwise have guessed at.
That's the general principle: hidden fields are free. If there's something you'd want to know later and the page already knows it at submit time, put it in the form.
For context, since it explains why I cared: I'm building blackocloud, a workspace for working across several AI models, and this is the landing page collecting early access. The attribution problem is the same for anybody running a pre-launch page though, which is why I wrote it up rather than leaving it in a commit message.
Happy to hear how other people handle this on static sites, particularly if you've found a way around the no-JS gap that doesn't involve adding a backend.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.