Built in collaboration with The Durst Organization
Nuxt UTM keeps campaign information in the browser until your application is ready to use it. It was built for statically generated Nuxt websites: collect a visitor's source now, then include it with a lead, signup, or form submission later.
It supports Nuxt 3 and 4, and is tested against Nuxt 5 nightly builds. Collection needs no backend, analytics account, or network request. Your application decides whether and when to send the data to its own API or CRM.
Release notes · Recipes · Contributing
Attribution connects an action, such as submitting a contact form, to the campaign that brought the visitor to your site.
For example, someone arrives from a Google campaign, returns through a newsletter, and later opens your contact page directly. Nuxt UTM can give your backend:
- First touch: the oldest retained campaign, Google in this example.
- Last touch: the most recent retained campaign, the newsletter.
- History: the retained visits, newest first, including direct visits by default.
First and last touch consider nonempty UTM, Google click, or configured click parameters. Direct visits do not replace them; both are null when no campaign remains. Retention can remove the original first touch, so these values describe the history still available in this browser.
This is browser-local campaign context, not a reporting dashboard or a cross-device attribution service.
pnpm add --dev nuxt-utm// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-utm'],
})The module captures the initial page after the app mounts and saves its UTM parameters, Google click parameters, configured click parameters, and visit context in localStorage. The same behavior works after nuxt generate with ordinary static hosting. While the app hydrates, the module records the URL the visitor opened, even when Nuxt still shows the prerendered route without the query or a client middleware has already redirected.
Nuxt UTM supports the Node.js release lines the Node.js project still maintains, currently 22, 24, and 26, and CI tests each of them. The package adds no Node.js version range of its own, so other versions your Nuxt release accepts aren't blocked, but they are untested. For Nuxt, CI tests the latest Nuxt 3 and Nuxt 4 releases and Nuxt 5 nightly builds; earlier minor releases aren't blocked either, but they are untested.
A static site can send to an independently hosted API. Configure your actual endpoint; Nuxt UTM does not create one:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-utm'],
runtimeConfig: {
public: {
leadEndpoint: 'https://api.example.com/leads',
},
},
})<script setup lang="ts">
import { ref } from 'vue'
const utm = useNuxtUTM()
const config = useRuntimeConfig()
const email = ref('')
const submitting = ref(false)
const status = ref('')
async function submitLead() {
if (submitting.value) return
submitting.value = true
status.value = ''
try {
const attribution = await utm.getAttribution()
await $fetch(config.public.leadEndpoint, {
method: 'POST',
body: { email: email.value, attribution },
})
status.value = 'Thank you. Your request was sent.'
} catch {
status.value = 'Your request could not be sent. Please try again.'
} finally {
submitting.value = false
}
}
</script>
<template>
<form @submit.prevent="submitLead">
<label for="email">Email</label>
<input id="email" v-model="email" type="email" autocomplete="email" required />
<button type="submit" :disabled="submitting">
{{ submitting ? 'Sending…' : 'Contact us' }}
</button>
<p role="status">{{ status }}</p>
</form>
</template>getAttribution() waits for pending collection and enrichment, applies retention, and returns a detached, JSON-serializable { firstTouch, lastTouch, history } snapshot. It does not send anything. Capture the composable during setup as above, then use its methods in event handlers.
The API must accept your static site's origin and validate the submitted data. Campaign values and all browser-stored data are untrusted input. Authentication, duplicate submission handling, and retries belong to your application. A failed submission leaves the local history available; the module does not automatically retry requests or clear data after sending.
The recipes show how to send the Google Analytics client ID and Google Ads click IDs with a lead, and how to send attribution to form services that don't accept JSON.
| Option | Default | Behavior |
|---|---|---|
trackingEnabled |
true |
Tracking preference at the start of each page load, unless a saved preference applies. |
trackOnRouteChange |
false |
Also capture successful client-side navigations whose full URL path changes. |
captureWithoutCampaign |
true |
Include visits without UTM, Google click, or configured click parameters. |
captureLandingOnEnable |
true |
When tracking starts disabled, enableTracking() also records the landing page, held in memory until then. |
persistTrackingPreference |
true |
Save the preference set by enableTracking() and disableTracking(), which then takes precedence over trackingEnabled on later visits. |
maxAge |
Unset | Retain entries younger than this many seconds. Must be positive and finite. |
maxEntries |
Unset | Retain at most this many entries. Must be a positive integer. |
clickParams |
Unset | Extra campaign parameters to capture, such as other ad platforms' click IDs. Must not repeat UTM or Google names. |
For example, to retain up to 100 campaign visits for 30 days and capture client-side navigation:
export default defineNuxtConfig({
modules: ['nuxt-utm'],
utm: {
maxAge: 60 * 60 * 24 * 30,
maxEntries: 100,
captureWithoutCampaign: false,
trackOnRouteChange: true,
},
})To attribute clicks from other ad platforms, list their click ID parameters. A visit with a nonempty value for any of them is a campaign visit: the values are saved in the entry's clickParams and count toward first touch, last touch, and deduplication. UTM and Google click parameters are already captured in utmParams and gclidParams, so the build fails if the list repeats one of them.
export default defineNuxtConfig({
modules: ['nuxt-utm'],
utm: {
clickParams: ['fbclid', 'msclkid', 'ttclid', 'li_fat_id'],
},
})Your editor suggests these platforms' click IDs:
| Platform | Click ID | Notes |
|---|---|---|
| Meta | fbclid |
Facebook also adds it to links in organic posts, so an organic share is a campaign visit. |
| Microsoft Advertising | msclkid |
Added when auto-tagging is on. |
| TikTok | ttclid |
|
li_fat_id |
Added when enhanced conversion tracking is on in the Insight Tag settings, the default for new tags. | |
| X | twclid |
|
| Snapchat | ScCid |
Mixed case, as Snapchat sends it. |
epik |
||
rdt_cid |
Other names work the same way. Query parameter names are case-sensitive, so the build warns when a listed name matches one of these only by case, such as sccid. To capture another spelling on purpose, list it beside the documented one.
A click ID identifies an individual click, so check Privacy and consent before listing one.
Send click IDs to a platform's conversion API exactly as captured. Meta's fbc parameter also records when the click ID was first seen. A later session can save the same click ID again, so use the oldest entry that has it. When the Meta Pixel has set an _fbc cookie, send that value instead.
import type { DataObject } from 'nuxt-utm'
// history is newest first, so the last match is where the click ID was first seen
const metaClickId = (history: DataObject[], fbclid: string) => {
const firstSeen = history.findLast(({ clickParams }) => clickParams?.fbclid === fbclid)
return firstSeen && `fb.1.${Date.parse(firstSeen.timestamp)}.${fbclid}`
}Retention runs when history is loaded, a visit is saved, or getAttribution() is called. There is no background expiry timer.
Without retention options, history has no age or entry limit, and each new tab that lands on your site adds an entry, including direct visits by default. Two limits follow from that:
getAttribution()returns the whole history. A typical entry is about 0.7 KB, so around 150 entries exceed the 100 KB default request body limit of Express's body parser. Send onlyfirstTouchandlastTouchif your API doesn't need the history.- Browsers limit localStorage to about 5 MiB per origin, shared with the rest of your site. Once it is full, new visits stay in memory and are lost on reload.
Set maxEntries, and usually maxAge, to keep both in bounds.
Consecutive entries with the same UTM parameters, Google click parameters, configured click parameters, and session ID are deduplicated. URLs, timestamps, and customParams do not participate in that comparison. This is not a pageview counter. The session ID uses sessionStorage for the current browser tab; it has no inactivity timeout.
Nuxt auto-imports useNuxtUTM. With auto-imports disabled, import it from #imports.
const utm = useNuxtUTM()
const payload = await utm.getAttribution()
await utm.capture()| Member | Purpose |
|---|---|
data |
Read-only reactive history, newest first. |
firstTouch, lastTouch |
Read-only reactive campaign entries, or null. |
trackingEnabled |
Read-only reactive tracking preference. |
storageAvailable |
Whether browser history can persist in localStorage. |
getAttribution() |
Prepare a detached snapshot after pending persistence completes. |
capture() |
Capture the current route manually, respecting tracking controls, hooks, and deduplication. |
enableTracking() |
Turn tracking on and capture the current route, after a held landing route. |
disableTracking() |
Turn tracking off and cancel pending collection. |
clearData() |
Clear history, the session ID, and a held landing route, and cancel pending collection. Keeps the tracking preference. |
onBeforeTrack(cb), onBeforePersist(cb), onTracked(cb) |
Register a hook; return a function that unregisters it. |
Access refs with .value in script, for example utm.lastTouch.value?.utmParams.utm_source. Data is empty during server rendering; collection occurs in the browser after mounting. The server also renders the configured trackingEnabled value, while the browser loads any saved tracking preference. Wrap displays of browser history or the tracking preference in Nuxt’s <ClientOnly> to avoid hydration mismatches for returning visitors. Call getAttribution() from a client action after mounting, such as the form handler above.
To start with collection disabled, configure utm: { trackingEnabled: false }. Connect enableTracking() and disableTracking() to your application's preference controls. Both save the preference in localStorage, and on later visits a saved preference takes precedence over the configured initial value. Disabling does not delete existing history; also call clearData() when deletion is intended.
When another tool already remembers the visitor's choice, such as a consent manager, set persistTrackingPreference: false so that tool is the only record. The module then saves no preference and starts every page load from trackingEnabled. Pair this option with trackingEnabled: false, and call enableTracking() on each page load the tool allows, whether your visitors opt in or opt out; by default the landing page waits in memory until then, as described in Privacy and consent. With trackingEnabled: true, the module records the landing page as the app mounts, usually before the tool reports the visitor's choice, and disableTracking() lasts only until the next page load, so the landing page of every page load is recorded for a visitor who opted out. Turning the option off also removes a preference saved earlier, including an opt-out, so make sure your consent tool already holds those choices.
Disabling tracking or clearing data cancels pending collection, including collection waiting on an asynchronous hook. Already completed application side effects cannot be undone.
If storage is blocked or full, the module keeps data in memory for the current page lifetime. storageAvailable.value becomes false if localStorage access fails. Memory-only history and preferences do not survive reloads. Clearing always removes in-memory data and attempts to remove persisted data, but a browser restriction can prevent that removal too. Invalid stored entries are ignored.
Browsers can also delete the history themselves. Safari deletes localStorage after seven days without a click, tap, or key press on your site (tracking prevention), so a visitor who returns later starts with an empty history, and their lead loses the original first touch. Private windows discard history when they close. History belongs to one origin, so www.example.com and example.com keep separate histories.
If visitors often take longer than a week to convert, send the first touch to your server during the first visit, for example from a utm:tracked hook, and link it to the later lead with a cookie your server sets in its response. Safari's seven-day limit applies to storage written by scripts, not to cookies your own server sets. That cookie is also storage on the visitor's device, so the consent rules below apply to it.
Nuxt UTM stores data only in the visitor's browser, for your site's origin. It sets no cookies, makes no network requests, and shares nothing across devices or unrelated domains. Data leaves the browser only when your application sends it.
| Storage | Key | Contents |
|---|---|---|
| localStorage | nuxt-utm-data |
The history, one entry per captured visit. |
| sessionStorage | nuxt-utm-session-id |
A random ID, created when the tab's first visit is captured. |
| localStorage | nuxt-utm-tracking-enabled |
true or false, saved when you call enableTracking() or disableTracking() unless persistTrackingPreference is false. |
Each history entry holds:
- a timestamp and the session ID
- UTM and Google click parameters, and configured click IDs
- custom data from your hooks
- the referrer, user agent, browser language, and screen size
- the full landing-page URL, including its query string and fragment
The module reads its localStorage keys and applies retention when the app starts, even while tracking is disabled.
Whether you need consent depends on where your visitors are and what you use the data for. In the EU, Article 5(3) of the ePrivacy Directive requires consent to store information on a visitor's device or read it, with narrow exceptions such as storage strictly necessary for a service the visitor explicitly requested. The EDPB's Guidelines 2/2023 explain that this covers more than cookies, and the UK ICO's guidance on storage and access technologies covers local storage under the UK rules. Data protection law such as the GDPR applies when the data is personal data. The GDPR's definition includes online identifiers, so stored click IDs can qualify before any form is submitted, and history submitted with a lead's contact details qualifies. This README is not legal advice.
Nuxt UTM never checks consent itself. Your application chooses how consent affects it:
- Track regardless of consent. This is the default: keep
trackingEnabled: trueand don't calldisableTracking(). - Track, and check consent before using the data. Collection continues, and your application checks consent where the data leaves the browser. The history is stored on the visitor's device either way, and the rules above cover that storage. Call
clearData()if a refusal should delete it. - Track only after consent. Start with tracking disabled and enable it when the visitor accepts.
To check consent before using the data, guard the call in the form handler from Send attribution with a form:
import { hasConsent } from '~/utils/consent'
const attribution = hasConsent() ? await utm.getAttribution() : nullHere and below, ~/utils/consent stands for your consent tool's API. To tell later which entries were collected with consent, record it on each entry in a utm:before-persist hook with entry.customParams = { ...entry.customParams, consent: hasConsent() }.
To track only after consent, start with tracking disabled as described in Tracking preferences and storage, then connect the module to your consent tool:
// plugins/utm-consent.client.ts
import { hasConsent, onConsentChange } from '~/utils/consent'
export default defineNuxtPlugin((nuxtApp) => {
const utm = useNuxtUTM()
nuxtApp.hook('utm:before-track', (context) => {
if (!hasConsent()) context.skip = true
})
onConsentChange((granted) => {
if (granted) {
utm.enableTracking()
} else {
utm.disableTracking()
utm.clearData()
}
})
})A saved preference outlives consent that expires without a callback, so the hook checks consent on every capture. If your consent tool reports the stored choice on every page load, you can instead set persistTrackingPreference: false: tracking then starts disabled on each load until the tool reports consent, so expired consent can't leave it on. If your consent UI shows trackingEnabled, see the <ClientOnly> note in Composable.
Visitors often give consent after leaving the landing page. When the app starts with tracking disabled, the module holds the landing route in memory, never in storage, until tracking is enabled. enableTracking() then records the landing page with the time of the visit, followed by the current page. Hooks for the landing page run at that point, so read the page from context.route, not window.location. Pages visited in between are not recovered. When tracking is already enabled at startup, as with a saved preference that outlived expired consent, the landing page is recorded right away instead, so a utm:before-track consent check decides then; if disableTracking() cancels that capture, the landing page stays held.
The held route lives only as long as the page, so a reload or a new visit starts without it, and it is discarded once recorded. disableTracking() keeps it, so a visitor who declines and then accepts during the same visit still has the landing page recorded. clearData() discards it; if your consent tool reports a refusal before the visitor has chosen, call clearData() only once the visitor actually refuses. Set captureLandingOnEnable: false to hold nothing, so enableTracking() records only the current page.
To collect less:
- List in
clickParamsonly the click IDs your consent policy covers. - Set
captureWithoutCampaign: falseto skip visits without campaign parameters. - Set
maxAgeandmaxEntriesto limit retention. - Redact context you do not need in a
utm:before-persisthook. Every query parameter, listed or not, stays inadditionalInfo.landingPageUrlunless a hook removes it, as shown in Hooks. - Avoid placing sensitive information in URLs or custom data.
| Hook | Receives | Use |
|---|---|---|
utm:before-track |
Mutable { route, query, skip } |
Set skip = true to skip collection, or adjust the captured query. |
utm:before-persist |
Mutable DataObject |
Enrich or redact an entry before validation and deduplication. |
utm:tracked |
Detached DataObject |
React after a new entry is saved, including in memory fallback. |
Register application-wide hooks in a client plugin. Route and query information is captured when collection starts, so use that context or the entry's landing-page URL when enriching asynchronous captures:
// plugins/utm-hooks.client.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('utm:before-track', (context) => {
if (context.route.path.startsWith('/admin')) context.skip = true
})
nuxtApp.hook('utm:before-persist', (data) => {
const url = new URL(data.additionalInfo.landingPageUrl)
const promo = url.searchParams.get('promo')
if (promo) data.customParams = { ...data.customParams, promo }
data.additionalInfo.landingPageUrl = `${url.origin}${url.pathname}`
})
})Custom fields must be JSON-serializable. Keep the required data shape intact when redacting it, for example replace a string with ''. A failing before hook or invalid resulting entry skips that capture. Changes made by an after-save hook do not mutate retained history. getAttribution() waits for before hooks and persistence, but does not wait for after-save side effects.
Do not await capture() or getAttribution() inside a before hook: that would wait on the collection currently running the hook. Use the hook's input instead. Component hooks can be removed when their owner is disposed:
import { onScopeDispose } from 'vue'
const utm = useNuxtUTM()
const stop = utm.onBeforePersist((entry) => {
entry.customParams = { ...entry.customParams, pageCategory: 'apartments' }
})
onScopeDispose(stop)Each history entry has this shape:
import type { DataObject, AttributionSnapshot } from 'nuxt-utm'
const entry: DataObject = {
timestamp: '2026-01-01T12:00:00.000Z',
utmParams: { utm_source: 'google', utm_medium: 'cpc', utm_campaign: 'apartments' },
gclidParams: { gclid: 'example-click-id', gad_source: '1' },
additionalInfo: {
referrer: 'https://www.google.com/',
userAgent: 'Example browser',
language: 'en',
landingPageUrl: 'https://example.com/?utm_source=google',
screen: { width: 1280, height: 720 },
},
sessionId: 'example-session',
customParams: { pageCategory: 'apartments' },
}
const attribution: AttributionSnapshot = {
firstTouch: entry,
lastTouch: entry,
history: [entry],
}Supported UTM fields are the manual campaign parameters Google Analytics 4 documents: utm_id, utm_source, utm_medium, utm_campaign, utm_source_platform, utm_term, utm_content, utm_creative_format, and utm_marketing_tactic. Google click fields are gclid, gad_source, gad_campaignid, gbraid, wbraid, and dclid; Google Ads sends gbraid or wbraid alongside or instead of gclid for some iOS traffic. A nonempty value in any of these fields makes a visit a campaign visit. When a parameter repeats in the URL, each field, including configured click parameters, keeps its first nonempty value: ?utm_source=&utm_source=google&utm_source=newsletter saves google. clickParams holds the values of the names listed in the clickParams option. gclidParams, clickParams, and customParams are optional. The package also exports ModuleOptions, UTMParams, GCLIDParams, ClickParams, AdditionalInfo, BeforeTrackContext, NuxtUTMHooks, and UseNuxtUTMReturn.
CI compiles the published declarations with TypeScript 5.9 and with the latest TypeScript 6 and 7 releases. Older TypeScript releases are untested.
Use the pnpm version pinned in package.json. The commands below assume Corepack is installed; see pnpm installation if needed.
corepack enable
pnpm install --frozen-lockfile
pnpm dev:prepare
pnpm playwright-core install chromium
pnpm lint
pnpm format:check
pnpm test:types
pnpm test
pnpm test:package
pnpm test:compatibility
NUXT_VERSION=3 pnpm test:compatibility
pnpm dev:buildtest:package checks the actual npm tarball from an isolated TypeScript consumer. test:compatibility also installs it in an independent Nuxt app, checks types, builds and generates static pages, and exercises campaign navigation, reload persistence, and form submission in Chromium. It tests the latest Nuxt 4 release by default; set NUXT_VERSION=3 for Nuxt 3 or NUXT_VERSION=npm:nuxt-nightly@5x for the Nuxt 5 nightly. CI runs all three separately from the checks on each supported Node.js version, and a nightly failure does not fail the workflow. These are tested configurations, not runtime version restrictions.
Set TYPESCRIPT_VERSION to check the tarball with another TypeScript release, for example TYPESCRIPT_VERSION=7 pnpm test:package. Applications compile the published declarations with their own TypeScript, so CI also runs this check with each release listed in Data and TypeScript.
Use pnpm dev for the playground. See the release documentation for preparing and publishing a version.
MIT, see LICENSE.
