Skip to content
Open
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
156 changes: 151 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,149 @@ 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.

This keeps your tracking strategy flexible: enrich once in your app, then forward the same enriched payload wherever you need it.

#### 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>
```

#### Example: add `pageCategory`

Use `utm:before-persist` to enrich every tracked event with a normalized `pageCategory`. This pattern is useful when you want one internal taxonomy that can be reused across your app and backend.

```typescript
// plugins/utm-page-category.client.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('utm:before-persist', (data) => {
const url = new URL(data.additionalInfo.landingPageUrl)
const explicitCategory = url.searchParams.get('page_category')

// Optional fallback categorization from pathname
const fallbackCategory = url.pathname.startsWith('/pricing') ? 'pricing' : 'general'

data.customParams = {
...data.customParams,
pageCategory: explicitCategory ?? fallbackCategory,
}
})
})
```

Tracked data will include:

```json
{
"customParams": {
"pageCategory": "pricing"
}
}
```

#### 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
46 changes: 36 additions & 10 deletions playground/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,18 @@
<h2>Tracking Controls</h2>
<p>
Tracking is currently:
<strong :class="{ enabled: utm.trackingEnabled.value, disabled: !utm.trackingEnabled.value }">
<strong
:class="{ enabled: utm.trackingEnabled.value, disabled: !utm.trackingEnabled.value }"
>
{{ utm.trackingEnabled.value ? 'ENABLED' : 'DISABLED' }}
</strong>
</p>

<div class="buttons">
<button
:disabled="utm.trackingEnabled.value"
@click="utm.enableTracking"
>
<button @click="utm.enableTracking">
Enable Tracking
</button>
<button
:disabled="!utm.trackingEnabled.value"
@click="utm.disableTracking"
>
<button @click="utm.disableTracking">
Disable Tracking
</button>
<button
Expand All @@ -32,11 +28,25 @@
</button>
</div>

<h2>Custom Hook Testing</h2>
<div class="buttons">
<button @click="visitWithPageCategory('pricing')">
Track pageCategory: pricing
</button>
<button @click="visitWithPageCategory('features')">
Track pageCategory: features
</button>
</div>

<div class="info">
<p>Try visiting with UTM parameters:</p>
<a href="/?utm_source=test&utm_medium=demo&utm_campaign=playground">
Add UTM params to URL
</a>
<p class="hint">
Then click a custom hook button above and check
<code>customParams.pageCategory</code> in collected data.
</p>
</div>
</div>

Expand All @@ -51,14 +61,26 @@
import { useNuxtUTM } from '#imports'

const utm = useNuxtUTM()

const visitWithPageCategory = (pageCategory) => {
const url = new URL(window.location.href)
url.searchParams.set('utm_source', 'playground')
url.searchParams.set('utm_medium', 'manual-test')
url.searchParams.set('utm_campaign', 'custom-hook')
url.searchParams.set('page_category', pageCategory)
window.location.href = `${url.pathname}?${url.searchParams.toString()}`
}
</script>

<style scoped>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
font-family:
system-ui,
-apple-system,
sans-serif;
}

h1 {
Expand Down Expand Up @@ -129,6 +151,10 @@ button.danger:hover {
text-decoration: underline;
}

.hint {
margin-top: 0.75rem;
}

.data {
background: #f8f9fa;
padding: 1.5rem;
Expand Down
16 changes: 16 additions & 0 deletions playground/plugins/page-category.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineNuxtPlugin } from '#app'
import { useNuxtUTM } from '#imports'

export default defineNuxtPlugin(() => {
const utm = useNuxtUTM()

utm.onBeforePersist((data) => {
const pageCategory = new URL(data.additionalInfo.landingPageUrl).searchParams.get('page_category')
if (!pageCategory) return

data.customParams = {
...data.customParams,
pageCategory,
}
})
})
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),
}
}
Loading