Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,16 @@ The [recipes](docs/RECIPES.md) show how to send the Google Analytics client ID a

## Configuration

| Option | Default | Behavior |
| ------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------- |
| `trackingEnabled` | `true` | Initial tracking preference when no saved preference exists. |
| `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. |
| `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. |
| 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:

Expand Down Expand Up @@ -204,16 +205,18 @@ await utm.capture()
| `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()` | Save the enabled preference and capture the current route, after a held landing route. |
| `disableTracking()` | Save the disabled preference and cancel pending collection. |
| `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.

## Tracking preferences and storage

To start with collection disabled, configure `utm: { trackingEnabled: false }`. Connect `enableTracking()` and `disableTracking()` to your application's preference controls. A previously saved browser preference takes precedence over the configured initial value. Disabling does not delete existing history; also call `clearData()` when deletion is intended.
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](#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.

Expand All @@ -227,11 +230,11 @@ If visitors often take longer than a week to convert, send the first touch to yo

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 only when you call `enableTracking()` or `disableTracking()`. |
| 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:

Expand Down Expand Up @@ -285,7 +288,7 @@ export default defineNuxtPlugin((nuxtApp) => {
})
```

A saved preference outlives consent that expires without a callback, so the hook checks consent on every capture. If your consent UI shows `trackingEnabled`, see the `<ClientOnly>` note in [Composable](#composable).
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](#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.

Expand Down
1 change: 1 addition & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const defaults = {
trackOnRouteChange: false,
captureWithoutCampaign: true,
captureLandingOnEnable: true,
persistTrackingPreference: true,
}

export default defineNuxtModule<ModuleOptions>({
Expand Down
23 changes: 19 additions & 4 deletions src/runtime/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ export default defineNuxtPlugin((nuxtApp) => {
const options = useRuntimeConfig().public.utm as Required<
Pick<
ModuleOptions,
'trackingEnabled' | 'trackOnRouteChange' | 'captureWithoutCampaign' | 'captureLandingOnEnable'
| 'trackingEnabled'
| 'trackOnRouteChange'
| 'captureWithoutCampaign'
| 'captureLandingOnEnable'
| 'persistTrackingPreference'
>
> &
ModuleOptions
Expand All @@ -50,7 +54,7 @@ export default defineNuxtPlugin((nuxtApp) => {
const session = createStorage(() => sessionStorage)
// History is always replaced whole, so deep reactivity would only slow down serialization
const data = shallowRef<DataObject[]>([])
const storedPreference = isClient ? local.getItem(TRACKING_ENABLED_KEY) : null
const storedPreference = isClient ? loadPreference() : null
const trackingEnabled = ref(
storedPreference === null ? options.trackingEnabled : storedPreference === 'true',
)
Expand All @@ -64,6 +68,17 @@ export default defineNuxtPlugin((nuxtApp) => {
cancelPending = resolve
})

function loadPreference() {
if (options.persistTrackingPreference) return local.getItem(TRACKING_ENABLED_KEY)
// A preference saved under an earlier configuration would otherwise stay on the device unused
local.removeItem(TRACKING_ENABLED_KEY)
return null
}

function savePreference(enabled: boolean) {
if (options.persistTrackingPreference) local.setItem(TRACKING_ENABLED_KEY, String(enabled))
}

function saveHistory(entries: DataObject[]) {
if (entries.length) local.setItem(LOCAL_STORAGE_KEY, JSON.stringify(entries))
else local.removeItem(LOCAL_STORAGE_KEY)
Expand Down Expand Up @@ -187,7 +202,7 @@ export default defineNuxtPlugin((nuxtApp) => {
const enableTracking = () => {
trackingEnabled.value = true
if (isClient) {
local.setItem(TRACKING_ENABLED_KEY, 'true')
savePreference(true)
const current = visitedRoute()
const landing = untrackedLanding
void captureHeldLanding()
Expand All @@ -200,7 +215,7 @@ export default defineNuxtPlugin((nuxtApp) => {
trackingEnabled.value = false
untrackedLanding ??= capturingLanding
capturingLanding = undefined
if (isClient) local.setItem(TRACKING_ENABLED_KEY, 'false')
if (isClient) savePreference(false)
}

const clearData = () => {
Expand Down
1 change: 1 addition & 0 deletions src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface ModuleOptions {
trackOnRouteChange?: boolean
captureWithoutCampaign?: boolean
captureLandingOnEnable?: boolean
persistTrackingPreference?: boolean
// Intersecting string with {} keeps the known names as editor suggestions while accepting any name
clickParams?: (AdClickParameter | (string & {}))[]
}
Expand Down
1 change: 1 addition & 0 deletions test/package-fixture/consumer.mts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const options: ModuleOptions = {
trackOnRouteChange: true,
captureWithoutCampaign: false,
captureLandingOnEnable: false,
persistTrackingPreference: false,
clickParams: ['fbclid', 'msclkid', 'partner_click_id'],
}
// Known click IDs must stay literal members of the option type so editors suggest them.
Expand Down
43 changes: 43 additions & 0 deletions test/runtime-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ async function setupTracker() {
utm: Ref<readonly DataObject[]>
utmFirstTouch: Ref<DataObject | null>
utmLastTouch: Ref<DataObject | null>
utmTrackingEnabled: Ref<boolean>
utmCapture: () => Promise<void>
utmGetAttribution: () => Promise<AttributionSnapshot>
utmDisableTracking: () => void
Expand All @@ -80,6 +81,7 @@ beforeEach(() => {
trackingEnabled: true,
captureWithoutCampaign: true,
captureLandingOnEnable: true,
persistTrackingPreference: true,
trackOnRouteChange: false,
}
visit('/?utm_source=google')
Expand Down Expand Up @@ -360,6 +362,47 @@ describe('tracking enabled after landing', () => {
})
})

describe('tracking preference', () => {
const PREFERENCE_KEY = 'nuxt-utm-tracking-enabled'

it('saves the preference and prefers it over the configured value on later visits', async () => {
config.public.utm.trackingEnabled = false
const { controls } = await setupTracker()
controls.utmEnableTracking()

expect(localStorage.getItem(PREFERENCE_KEY)).toBe('true')
expect((await setupTracker()).controls.utmTrackingEnabled.value).toBe(true)
})

describe('when persistTrackingPreference is off', () => {
beforeEach(() => {
config.public.utm.persistTrackingPreference = false
})

it('starts every visit from the configured value', async () => {
localStorage.setItem(PREFERENCE_KEY, 'false')
const { controls, mount } = await setupTracker()
await mount()

expect(controls.utmTrackingEnabled.value).toBe(true)
expect(controls.utm.value.map(({ utmParams }) => utmParams.utm_source)).toEqual(['google'])
})

it('saves no preference and removes one saved earlier', async () => {
localStorage.setItem(PREFERENCE_KEY, 'true')
config.public.utm.trackingEnabled = false
const { controls } = await setupTracker()
expect(localStorage.getItem(PREFERENCE_KEY)).toBeNull()

controls.utmEnableTracking()
controls.utmDisableTracking()
expect(localStorage.getItem(PREFERENCE_KEY)).toBeNull()
expect(controls.utmTrackingEnabled.value).toBe(false)
expect((await setupTracker()).controls.utmTrackingEnabled.value).toBe(false)
})
})
})

describe('reactive history', () => {
it('updates history and touches after capture while keeping entries read-only', async () => {
const { controls, mount } = await setupTracker()
Expand Down
Loading