diff --git a/README.md b/README.md
index f80033c..f494979 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
```
@@ -125,11 +129,7 @@ const rejectTracking = () => {
Privacy Settings
-
+
Enable UTM tracking
@@ -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"
}
}
]
@@ -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
+
+```
+
+#### 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
diff --git a/eslint.config.mjs b/eslint.config.mjs
index ad56c89..4f44f47 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -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',
diff --git a/src/module.ts b/src/module.ts
index 2264bda..8d1145a 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -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
@@ -27,5 +27,21 @@ export default defineNuxtModule({
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',
+ ' "utm:before-persist": (data: DataObject) => void | Promise',
+ ' "utm:tracked": (data: DataObject) => void | Promise',
+ ' }',
+ '}',
+ ].join('\n'),
+ })
},
})
diff --git a/src/runtime/composables.ts b/src/runtime/composables.ts
index c1838ac..e398965 100644
--- a/src/runtime/composables.ts
+++ b/src/runtime/composables.ts
@@ -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[>
trackingEnabled: Readonly][>
enableTracking: () => void
disableTracking: () => void
clearData: () => void
+ onBeforeTrack: (cb: (context: BeforeTrackContext) => void | Promise]) => HookCleanup
+ onBeforePersist: (cb: (data: DataObject) => void | Promise) => HookCleanup
+ onTracked: (cb: (data: DataObject) => void | Promise) => HookCleanup
}
export const useNuxtUTM = (): UseNuxtUTMReturn => {
@@ -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),
}
}
diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts
index 59af109..8450945 100644
--- a/src/runtime/plugin.ts
+++ b/src/runtime/plugin.ts
@@ -1,4 +1,4 @@
-import type { DataObject } from 'nuxt-utm'
+import type { DataObject, BeforeTrackContext } from 'nuxt-utm'
import { ref, readonly } from 'vue'
import {
readLocalData,
@@ -19,7 +19,6 @@ export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const data = ref([])
- // Initialize tracking enabled state from config or localStorage
const getInitialTrackingState = (): boolean => {
if (typeof window === 'undefined') return config.public.utm?.trackingEnabled ?? true
@@ -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()
@@ -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()
}
}
@@ -88,7 +93,6 @@ export default defineNuxtPlugin((nuxtApp) => {
}
}
- // Load existing data on initialization
if (typeof window !== 'undefined') {
data.value = readLocalData(LOCAL_STORAGE_KEY)
}
diff --git a/test/fixtures/hooks/app.vue b/test/fixtures/hooks/app.vue
new file mode 100644
index 0000000..cc11a3a
--- /dev/null
+++ b/test/fixtures/hooks/app.vue
@@ -0,0 +1,13 @@
+
+
+
UTM Tracker
+
Tracking: {{ utm.trackingEnabled.value ? 'Enabled' : 'Disabled' }}
+
{{ utm.data.value }}
+
+
+
+
diff --git a/test/fixtures/hooks/nuxt.config.ts b/test/fixtures/hooks/nuxt.config.ts
new file mode 100644
index 0000000..c412069
--- /dev/null
+++ b/test/fixtures/hooks/nuxt.config.ts
@@ -0,0 +1,5 @@
+import UtmModule from '../../../src/module'
+
+export default defineNuxtConfig({
+ modules: [UtmModule],
+})
diff --git a/test/fixtures/hooks/package.json b/test/fixtures/hooks/package.json
new file mode 100644
index 0000000..088cb33
--- /dev/null
+++ b/test/fixtures/hooks/package.json
@@ -0,0 +1,5 @@
+{
+ "private": true,
+ "name": "hooks",
+ "type": "module"
+}
diff --git a/test/fixtures/hooks/plugins/hooks-helper.client.ts b/test/fixtures/hooks/plugins/hooks-helper.client.ts
new file mode 100644
index 0000000..1680fdb
--- /dev/null
+++ b/test/fixtures/hooks/plugins/hooks-helper.client.ts
@@ -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))
+ })
+})
diff --git a/test/integration-hooks.test.ts b/test/integration-hooks.test.ts
new file mode 100644
index 0000000..14a3e9c
--- /dev/null
+++ b/test/integration-hooks.test.ts
@@ -0,0 +1,171 @@
+import { fileURLToPath } from 'node:url'
+import type { DataObject } from 'nuxt-utm'
+import { describe, it, expect } from 'vitest'
+import { setup, createPage } from '@nuxt/test-utils'
+import type { Page } from 'playwright-core'
+
+interface BeforeTrackResult {
+ route: unknown
+ query: Record
+ skip: boolean
+}
+
+interface HookResults {
+ beforeTrackCalled: boolean
+ beforeTrackContext: BeforeTrackResult | null
+ beforePersistCalled: boolean
+ beforePersistData: DataObject | null
+ trackedCalled: boolean
+ trackedData: DataObject | null
+}
+
+const getHookResults = (page: Page): Promise =>
+ page.evaluate(() => (window as unknown as { __utmHookResults: HookResults }).__utmHookResults)
+
+const getStoredEntries = async (page: Page): Promise => {
+ const raw = await page.evaluate(() => window.localStorage.getItem('nuxt-utm-data'))
+ return JSON.parse(raw ?? '[]')
+}
+
+describe('Hooks mechanism', async () => {
+ await setup({
+ rootDir: fileURLToPath(new URL('./fixtures/hooks', import.meta.url)),
+ server: true,
+ browser: true,
+ })
+
+ describe('utm:before-track', () => {
+ it('is called with route and query context', async () => {
+ const page = await createPage('/?utm_source=hook_test&utm_medium=test')
+ await page.waitForFunction(
+ () =>
+ (window as unknown as { __utmHookResults: HookResults }).__utmHookResults
+ ?.beforeTrackCalled,
+ )
+
+ const results = await getHookResults(page)
+ expect(results.beforeTrackCalled).toBe(true)
+ expect(results.beforeTrackContext).toBeDefined()
+ expect(results.beforeTrackContext!.query.utm_source).toBe('hook_test')
+ expect(results.beforeTrackContext!.skip).toBe(false)
+
+ await page.close()
+ })
+
+ it('skips tracking when skip is set to true', async () => {
+ const page = await createPage('/?utm_source=skipped&_skip_tracking=1')
+ await page.waitForFunction(
+ () =>
+ (window as unknown as { __utmHookResults: HookResults }).__utmHookResults
+ ?.beforeTrackCalled,
+ )
+
+ const results = await getHookResults(page)
+ expect(results.beforeTrackCalled).toBe(true)
+ expect(results.beforePersistCalled).toBe(false)
+ expect(results.trackedCalled).toBe(false)
+
+ const entries = await getStoredEntries(page)
+ const hasSkipped = entries.some((e: DataObject) => e.utmParams?.utm_source === 'skipped')
+ expect(hasSkipped).toBe(false)
+
+ await page.close()
+ })
+ })
+
+ describe('utm:before-persist', () => {
+ it('is called after data collection', async () => {
+ const page = await createPage('/?utm_source=persist_test&utm_medium=test')
+ await page.waitForFunction(
+ () =>
+ (window as unknown as { __utmHookResults: HookResults }).__utmHookResults
+ ?.beforePersistCalled,
+ )
+
+ const results = await getHookResults(page)
+ expect(results.beforePersistCalled).toBe(true)
+ expect(results.beforePersistData).toBeDefined()
+ expect(results.beforePersistData!.utmParams.utm_source).toBe('persist_test')
+
+ await page.close()
+ })
+
+ it('allows adding customParams to the data object', async () => {
+ const page = await createPage('/?utm_source=custom_test&utm_medium=test')
+ await page.waitForFunction(() => window.localStorage.getItem('nuxt-utm-data'))
+
+ const entries = await getStoredEntries(page)
+ expect(entries[0]?.customParams).toBeDefined()
+ expect(entries[0]?.customParams?.hooked).toBe(true)
+
+ await page.close()
+ })
+ })
+
+ describe('utm:tracked', () => {
+ it('is called after data is saved', async () => {
+ const page = await createPage('/?utm_source=tracked_test&utm_medium=test')
+ await page.waitForFunction(
+ () =>
+ (window as unknown as { __utmHookResults: HookResults }).__utmHookResults?.trackedCalled,
+ )
+
+ const results = await getHookResults(page)
+ expect(results.trackedCalled).toBe(true)
+ expect(results.trackedData).toBeDefined()
+ expect(results.trackedData!.utmParams.utm_source).toBe('tracked_test')
+ expect(results.trackedData!.customParams?.hooked).toBe(true)
+
+ await page.close()
+ })
+
+ it('receives the same data that was saved to localStorage', async () => {
+ const page = await createPage('/?utm_source=verify_save&utm_medium=test')
+ await page.waitForFunction(
+ () =>
+ (window as unknown as { __utmHookResults: HookResults }).__utmHookResults?.trackedCalled,
+ )
+
+ const results = await getHookResults(page)
+ const entries = await getStoredEntries(page)
+
+ expect(results.trackedData!.utmParams).toEqual(entries[0]?.utmParams)
+ expect(results.trackedData!.sessionId).toEqual(entries[0]?.sessionId)
+
+ await page.close()
+ })
+ })
+
+ describe('hook ordering', () => {
+ it('calls hooks in order: before-track, before-persist, tracked', async () => {
+ const page = await createPage('/?utm_source=order_test&utm_medium=test')
+ await page.waitForFunction(
+ () =>
+ (window as unknown as { __utmHookResults: HookResults }).__utmHookResults?.trackedCalled,
+ )
+
+ const results = await getHookResults(page)
+ expect(results.beforeTrackCalled).toBe(true)
+ expect(results.beforePersistCalled).toBe(true)
+ expect(results.trackedCalled).toBe(true)
+
+ await page.close()
+ })
+
+ it('does not call before-persist or tracked when before-track skips', async () => {
+ const page = await createPage('/?utm_source=skip_order&_skip_tracking=1')
+ await page.waitForFunction(
+ () =>
+ (window as unknown as { __utmHookResults: HookResults }).__utmHookResults
+ ?.beforeTrackCalled,
+ )
+
+ const results = await getHookResults(page)
+ expect(results.beforeTrackCalled).toBe(true)
+ expect(results.beforePersistCalled).toBe(false)
+ expect(results.trackedCalled).toBe(false)
+
+ await page.close()
+ })
+ })
+})
diff --git a/types/index.d.ts b/types/index.d.ts
index 3829484..abc907d 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -1,4 +1,6 @@
declare module 'nuxt-utm' {
+ import type { RouteLocationNormalized, LocationQuery } from 'vue-router'
+
interface UTMParams {
utm_source?: string
utm_medium?: string
@@ -29,5 +31,12 @@ declare module 'nuxt-utm' {
additionalInfo: AdditionalInfo
sessionId: string
gclidParams?: GCLIDParams
+ customParams?: Record
+ }
+
+ interface BeforeTrackContext {
+ route: RouteLocationNormalized
+ query: LocationQuery
+ skip: boolean
}
}