I run a small static Nuxt site (SSG + Storyblok) with cookieless analytics: no consent banner, nothing stored server-side about who visits. Great for privacy, but it left me blind on the one question every freelancer or small business asks: where does each lead actually come from?
When someone fills my contact form, I want to know: Google organic? A referral from another site? A specific campaign? Cookieless tools (PostHog in memory mode, Umami) give me aggregate referrers, but they cannot easily tell me which source this specific contact came from. So I added a tiny first-touch attribution layer (no cookies, no library) that tags each contact with where the visitor first landed.
Here is the whole thing.
Capture first-touch once, in localStorage
First-touch means the very first time someone lands on the site, I record the external document.referrer, the landing path, and the UTM params. I store it once, so a visitor who found me via Google last week and returns direct today is still attributed to Google: real first-touch, not last-click.
// app/composables/useLeadSource.ts
const KEY = 'lead_source'
export function useLeadSource() {
function captureLeadSourceOnce(): void {
if (typeof window === 'undefined') return
try {
if (window.localStorage.getItem(KEY)) return // already captured
const params = new URLSearchParams(window.location.search)
const referrer = document.referrer
const isExternal = referrer !== '' && !referrer.startsWith(window.location.origin)
window.localStorage.setItem(KEY, JSON.stringify({
referrer: isExternal ? referrer : null,
landingPage: window.location.pathname || null,
utmSource: params.get('utm_source'),
utmMedium: params.get('utm_medium'),
utmCampaign: params.get('utm_campaign'),
}))
} catch {
// localStorage throws in private mode or blocked storage: never crash a page over analytics
}
}
function getLeadSource() {
if (typeof window === 'undefined') return null
try {
const raw = window.localStorage.getItem(KEY)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
return { captureLeadSourceOnce, getLeadSource }
}
Two details that matter:
-
The isExternal check.
document.referreron an internal navigation is your own domain. Keep it only when it is a real external referrer; otherwise it is null (direct). -
try/catch everywhere.
localStoragethrows in private mode or when storage is blocked. Analytics should never take down a page.
Fire it once, client-side
// app/plugins/lead-source.client.ts
import { useLeadSource } from '~/composables/useLeadSource'
export default defineNuxtPlugin(() => {
useLeadSource().captureLeadSourceOnce()
})
It is a .client.ts plugin because localStorage and document.referrer do not exist during SSG prerender. The guard inside the composable makes it idempotent, so SPA navigations and reloads never overwrite the first touch.
Attach it to the contact form
On submit (and even on a contact intent, the first time they blur the email field before they finish), read the stored source and send it with the payload:
const { getLeadSource } = useLeadSource()
const payload = {
// ...name, email, message...
source: getLeadSource(), // { referrer, landingPage, utmSource, ... } or null
}
Server-side, turn it into a one-line label for the notification email:
function formatAcquisitionSource(source) {
if (!source) return ''
if (source.utmSource) {
const base = source.utmMedium ? `${source.utmSource} / ${source.utmMedium}` : source.utmSource
return source.utmCampaign ? `${base} (${source.utmCampaign})` : base
}
if (source.referrer) return source.referrer
return 'Direct / unknown'
}
Now every contact email arrives with Acquisition source: google.com and the landing page, so I know where each lead came from, per lead, without a single cookie.
Why not just read PostHog or GA?
Cookieless analytics already capture $referrer and UTM on events, which is perfect for aggregate dashboards. Two gaps this fills:
- Per-lead. Correlating one specific submission to a source in the analytics UI is fiddly. Having it in the email is instant.
- First-touch persistence. With in-memory or cookieless persistence, the tool often cannot stitch a returning visitor back to their original source. localStorage does that cheaply for the one thing I care about: leads.
Around 40 lines, zero dependencies, no cookie, no consent banner. For a small business or freelance site, that is the attribution problem solved well enough.
I build these small, boring-but-useful things for small businesses as a freelance dev. More of them (and the site this runs on): dibodev.fr.
Top comments (0)