Skip to content
Draft
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
122 changes: 117 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ If a visitor arrives at a website that uses the Nuxt UTM module and a UTM parame
- **📍 UTM Tracking**: Easily capture UTM parameters to gain insights into traffic sources and campaign performance.
- **🔍 Intelligent De-duplication**: Smart recognition of page refreshes to avoid data duplication, ensuring each visit is uniquely accounted for.
- **🔗 Comprehensive Data Collection**: Alongside UTM parameters, gather additional context such as referrer details, user agent, landing page url, browser language, and screen resolution. This enriched data empowers your marketing strategies with a deeper understanding of campaign impact.
- **🔌 Hooks & Extensibility**: Three runtime hooks (`utm:before-track`, `utm:before-persist`, `utm:tracked`) let you skip tracking, enrich data with custom parameters, or trigger side effects after tracking completes.

## Quick Setup

Expand Down Expand Up @@ -86,6 +87,9 @@ const utm = useNuxtUTM()
// - enableTracking(): Enable UTM tracking
// - disableTracking(): Disable UTM tracking
// - clearData(): Clear all stored UTM data
// - onBeforeTrack(cb): Hook called before data collection
// - onBeforePersist(cb): Hook called to enrich/modify collected data before saving
// - onTracked(cb): Hook called after data is saved
</script>
```

Expand Down Expand Up @@ -125,11 +129,7 @@ const rejectTracking = () => {
<div class="privacy-settings">
<h3>Privacy Settings</h3>
<label>
<input
type="checkbox"
:checked="utm.trackingEnabled.value"
@change="toggleTracking"
/>
<input type="checkbox" :checked="utm.trackingEnabled.value" @change="toggleTracking" />
Enable UTM tracking
</label>
<button @click="utm.clearData" v-if="utm.data.value.length > 0">
Expand Down Expand Up @@ -195,6 +195,9 @@ The `data` property contains an array of UTM parameters collected. Each element
"gclidParams": {
"gclid": "CjklsefawEFRfeafads",
"gad_source": "1"
},
"customParams": {
"fbclid": "abc123"
}
}
]
Expand All @@ -210,6 +213,115 @@ Each entry provides a `timestamp` indicating when the UTM parameters were collec
- **Data Clearing**: Ability to completely remove all collected data
- **Session Management**: Automatically manages sessions to avoid duplicate tracking

### Hooks

The module provides three runtime hooks that let you extend the tracking pipeline. You can use them to skip tracking, enrich data with custom parameters, or trigger side effects after tracking completes. Hooks can be registered via a Nuxt plugin or through the `useNuxtUTM` composable.

#### Available Hooks

| Hook | When it fires | Receives | Purpose |
| ------------------ | -------------------------------------- | ----------------------------------------------- | ---------------------------------------------------- |
| `utm:before-track` | Before data collection | `BeforeTrackContext` (`{ route, query, skip }`) | Conditionally skip tracking by setting `skip = true` |
| `utm:before-persist` | After data is collected, before saving | `DataObject` (mutable) | Enrich or modify the data, add `customParams` |
| `utm:tracked` | After data is saved to localStorage | `DataObject` (final) | Side effects: send to API, fire analytics, log |

#### Registering Hooks via Plugin

Create a Nuxt plugin to register hooks that run on every page visit:

```typescript
// plugins/utm-hooks.client.ts
export default defineNuxtPlugin((nuxtApp) => {
// Skip tracking on admin pages
nuxtApp.hook('utm:before-track', (context) => {
if (context.route.path.startsWith('/admin')) {
context.skip = true
}
})

// Add custom marketing parameters
nuxtApp.hook('utm:before-persist', (data) => {
const query = nuxtApp._route.query
if (query.fbclid) {
data.customParams = {
...data.customParams,
fbclid: String(query.fbclid),
}
}
if (query.msclkid) {
data.customParams = {
...data.customParams,
msclkid: String(query.msclkid),
}
}
})

// Send data to your backend after tracking
nuxtApp.hook('utm:tracked', async (data) => {
await $fetch('/api/marketing/track', {
method: 'POST',
body: data,
})
})
})
```

#### Registering Hooks via Composable

The `useNuxtUTM` composable provides convenience methods for registering hooks. Each method returns a cleanup function to unregister the hook.

```vue
<script setup>
const utm = useNuxtUTM()

// Register a before-persist hook
const cleanup = utm.onBeforePersist((data) => {
data.customParams = { ...data.customParams, source: 'vue-component' }
})

// Unregister when no longer needed
// cleanup()
</script>
```

#### Hook: `utm:before-track`

Called before any data collection begins. The handler receives a `BeforeTrackContext` object with `route`, `query`, and a `skip` flag. Set `skip = true` to prevent tracking for the current page visit.

```typescript
nuxtApp.hook('utm:before-track', (context) => {
// context.route - the current route object
// context.query - the current URL query parameters
// context.skip - set to true to skip tracking
})
```

#### Hook: `utm:before-persist`

Called after the `DataObject` is built but before it is checked for duplicates and saved. The handler receives the `DataObject` directly and can mutate it to add or modify fields. This is the primary hook for adding `customParams`.

```typescript
nuxtApp.hook('utm:before-persist', (data) => {
// Add any custom tracking parameters
data.customParams = {
...data.customParams,
myCustomField: 'value',
}
})
```

> Note: `customParams` are not included in the de-duplication check. Only UTM parameters, GCLID parameters, and session ID are compared.

#### Hook: `utm:tracked`

Called after data is saved to localStorage. The handler receives the final `DataObject`. Use this for side effects like sending data to a backend or triggering analytics events.

```typescript
nuxtApp.hook('utm:tracked', async (data) => {
console.log('Tracked:', data.utmParams, data.customParams)
})
```

## Development

```bash
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default createConfigForNuxt({
'@stylistic/quotes': ['error', 'single', { avoidEscape: true }],
'@stylistic/semi': ['error', 'never'],
'@stylistic/comma-dangle': ['error', 'always-multiline'],
'@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/operator-linebreak': 'off',
'@stylistic/brace-style': 'off',
'@stylistic/indent-binary-ops': 'off',
Expand Down
18 changes: 17 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineNuxtModule, addPlugin, addImports, createResolver } from '@nuxt/kit'
import { defineNuxtModule, addPlugin, addImports, addTypeTemplate, createResolver } from '@nuxt/kit'

export interface ModuleOptions {
trackingEnabled?: boolean
Expand Down Expand Up @@ -27,5 +27,21 @@ export default defineNuxtModule<ModuleOptions>({
name: 'useNuxtUTM',
from: resolver.resolve('runtime/composables'),
})

addTypeTemplate({
filename: 'types/utm-hooks.d.ts',
getContents: () =>
[
'import type { DataObject, BeforeTrackContext } from "nuxt-utm"',
'',
'declare module "#app" {',
' interface RuntimeNuxtHooks {',
' "utm:before-track": (context: BeforeTrackContext) => void | Promise<void>',
' "utm:before-persist": (data: DataObject) => void | Promise<void>',
' "utm:tracked": (data: DataObject) => void | Promise<void>',
' }',
'}',
].join('\n'),
})
},
})
10 changes: 9 additions & 1 deletion src/runtime/composables.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import type { Ref } from 'vue'
import type { DataObject } from 'nuxt-utm'
import type { DataObject, BeforeTrackContext } from 'nuxt-utm'
import { useNuxtApp } from '#imports'

type HookCleanup = () => void

export interface UseNuxtUTMReturn {
data: Readonly<Ref<readonly DataObject[]>>
trackingEnabled: Readonly<Ref<boolean>>
enableTracking: () => void
disableTracking: () => void
clearData: () => void
onBeforeTrack: (cb: (context: BeforeTrackContext) => void | Promise<void>) => HookCleanup
onBeforePersist: (cb: (data: DataObject) => void | Promise<void>) => HookCleanup
onTracked: (cb: (data: DataObject) => void | Promise<void>) => HookCleanup
}

export const useNuxtUTM = (): UseNuxtUTMReturn => {
Expand All @@ -19,5 +24,8 @@ export const useNuxtUTM = (): UseNuxtUTMReturn => {
enableTracking: nuxtApp.$utmEnableTracking,
disableTracking: nuxtApp.$utmDisableTracking,
clearData: nuxtApp.$utmClearData,
onBeforeTrack: (cb) => nuxtApp.hook('utm:before-track', cb),
onBeforePersist: (cb) => nuxtApp.hook('utm:before-persist', cb),
onTracked: (cb) => nuxtApp.hook('utm:tracked', cb),
}
}
22 changes: 13 additions & 9 deletions src/runtime/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DataObject } from 'nuxt-utm'
import type { DataObject, BeforeTrackContext } from 'nuxt-utm'
import { ref, readonly } from 'vue'
import {
readLocalData,
Expand All @@ -19,7 +19,6 @@ export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const data = ref<DataObject[]>([])

// Initialize tracking enabled state from config or localStorage
const getInitialTrackingState = (): boolean => {
if (typeof window === 'undefined') return config.public.utm?.trackingEnabled ?? true

Expand All @@ -32,15 +31,20 @@ export default defineNuxtPlugin((nuxtApp) => {

const trackingEnabled = ref(getInitialTrackingState())

const processUtmData = () => {
const processUtmData = async () => {
if (typeof window === 'undefined') return
if (!trackingEnabled.value) return

data.value = readLocalData(LOCAL_STORAGE_KEY)

const sessionId = getSessionID(SESSION_ID_KEY)

const query = nuxtApp._route.query
const route = nuxtApp._route

const beforeTrackContext: BeforeTrackContext = { route, query, skip: false }
await nuxtApp.callHook('utm:before-track', beforeTrackContext)
if (beforeTrackContext.skip) return

const sessionId = getSessionID(SESSION_ID_KEY)
const utmParams = getUtmParams(query)
const additionalInfo = getAdditionalInfo()
const timestamp = new Date().toISOString()
Expand All @@ -56,19 +60,20 @@ export default defineNuxtPlugin((nuxtApp) => {
dataObject.gclidParams = getGCLID(query)
}

// Exit if the last entry is the same as the new entry
await nuxtApp.callHook('utm:before-persist', dataObject)

if (isRepeatedEntry(data, dataObject)) return

// Add the new item to the data array
data.value.unshift(dataObject)
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(data.value))

await nuxtApp.callHook('utm:tracked', dataObject)
}

const enableTracking = () => {
trackingEnabled.value = true
if (typeof window !== 'undefined') {
localStorage.setItem(TRACKING_ENABLED_KEY, 'true')
// Process current page data when enabling
processUtmData()
}
}
Expand All @@ -88,7 +93,6 @@ export default defineNuxtPlugin((nuxtApp) => {
}
}

// Load existing data on initialization
if (typeof window !== 'undefined') {
data.value = readLocalData(LOCAL_STORAGE_KEY)
}
Expand Down
13 changes: 13 additions & 0 deletions test/fixtures/hooks/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<template>
<div>
<h1>UTM Tracker</h1>
<p>Tracking: {{ utm.trackingEnabled.value ? 'Enabled' : 'Disabled' }}</p>
<pre>{{ utm.data.value }}</pre>
</div>
</template>

<script setup>
import { useNuxtUTM } from '#imports'

const utm = useNuxtUTM()
</script>
5 changes: 5 additions & 0 deletions test/fixtures/hooks/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import UtmModule from '../../../src/module'

export default defineNuxtConfig({
modules: [UtmModule],
})
5 changes: 5 additions & 0 deletions test/fixtures/hooks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"private": true,
"name": "hooks",
"type": "module"
}
57 changes: 57 additions & 0 deletions test/fixtures/hooks/plugins/hooks-helper.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { DataObject, BeforeTrackContext } from 'nuxt-utm'
import { defineNuxtPlugin } from '#app'
import { useNuxtUTM } from '#imports'

declare global {
interface Window {
useNuxtUTM: typeof useNuxtUTM
__utmHookResults: {
beforeTrackCalled: boolean
beforeTrackContext: BeforeTrackContext | null
beforePersistCalled: boolean
beforePersistData: DataObject | null
trackedCalled: boolean
trackedData: DataObject | null
}
}
}

export default defineNuxtPlugin((nuxtApp) => {
if (typeof window === 'undefined') return

window.useNuxtUTM = useNuxtUTM

window.__utmHookResults = {
beforeTrackCalled: false,
beforeTrackContext: null,
beforePersistCalled: false,
beforePersistData: null,
trackedCalled: false,
trackedData: null,
}

nuxtApp.hook('utm:before-track', (context) => {
window.__utmHookResults.beforeTrackCalled = true
window.__utmHookResults.beforeTrackContext = { ...context }

if (context.query._skip_tracking === '1') {
context.skip = true
}
})

nuxtApp.hook('utm:before-persist', (data) => {
window.__utmHookResults.beforePersistCalled = true
window.__utmHookResults.beforePersistData = { ...data }

data.customParams = {
...data.customParams,
hooked: true,
fbclid: data.additionalInfo?.landingPageUrl?.includes('fbclid') ? 'from-hook' : undefined,
}
})

nuxtApp.hook('utm:tracked', (data) => {
window.__utmHookResults.trackedCalled = true
window.__utmHookResults.trackedData = JSON.parse(JSON.stringify(data))
})
})
Loading