DEV Community

Léo Guillaume (Dibodev)
Léo Guillaume (Dibodev)

Posted on

First-touch attribution on a cookieless static Nuxt site

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 }
}
Enter fullscreen mode Exit fullscreen mode

Two details that matter:

  • The isExternal check. document.referrer on 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. localStorage throws 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()
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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)