From 15cc2f06e1513c8ea16e8f4143f99cc3b44796c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sadjow=20Le=C3=A3o?= Date: Wed, 14 Oct 2026 20:51:31 -0300 Subject: [PATCH] docs: add recipes for Google click IDs and form field services --- README.md | 4 +- docs/RECIPES.md | 97 +++++++++++++++++++++++++++++++ test/package-fixture/consumer.mts | 13 +++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 docs/RECIPES.md diff --git a/README.md b/README.md index 2179f43..a8025bf 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Nuxt UTM keeps campaign information in the browser until your application is rea 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](CHANGELOG.md) · [Contributing](docs/CONTRIBUTING.md) +[Release notes](CHANGELOG.md) · [Recipes](docs/RECIPES.md) · [Contributing](docs/CONTRIBUTING.md) ## What is attribution? @@ -104,6 +104,8 @@ async function submitLead() { 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](docs/RECIPES.md) 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. + ## Configuration All new collection and retention options are opt-in. Existing behavior is preserved by default. diff --git a/docs/RECIPES.md b/docs/RECIPES.md new file mode 100644 index 0000000..79ae609 --- /dev/null +++ b/docs/RECIPES.md @@ -0,0 +1,97 @@ +# Recipes + +These recipes extend the [form example](../README.md#send-attribution-with-a-form). They are not part of the module; copy what you need. + +## Google Analytics and Google Ads + +Google Analytics reads UTM parameters, and `gclid` from Google Ads auto-tagging, from the landing page URL itself, so it does not need the attribution. Nuxt UTM does not load or depend on Google's tag. When your site loads it, two values let your backend connect a lead to Google's data later. + +### Google Analytics client ID + +Send the client ID with the lead. Your backend can then report later lead events for the same visitor, such as `qualify_lead` or `close_convert_lead`, through the [Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/ga4). + +```ts +// utils/google-analytics.ts +type GtagGet = ( + command: 'get', + targetId: string, + fieldName: 'client_id', + callback: (clientId: string | undefined) => void, +) => void + +export function getGoogleAnalyticsClientId(measurementId: string, timeout = 1000) { + const { gtag } = window as Window & { gtag?: GtagGet } + return new Promise((resolve) => { + if (typeof gtag !== 'function') return resolve(undefined) + // The callback waits for gtag.js, which may never load + const timer = setTimeout(() => resolve(undefined), timeout) + gtag('get', measurementId, 'client_id', (clientId) => { + clearTimeout(timer) + resolve(clientId) + }) + }) +} +``` + +In the form handler, with your measurement ID in `runtimeConfig.public.gaMeasurementId`: + +```ts +const [attribution, gaClientId] = await Promise.all([ + utm.getAttribution(), + getGoogleAnalyticsClientId(config.public.gaMeasurementId), +]) +await $fetch(config.public.leadEndpoint, { + method: 'POST', + body: { email: email.value, attribution, gaClientId }, +}) +``` + +Treat the client ID as optional. It is `undefined` at once when the page has no `gtag`, and after the timeout when gtag.js never loads, for example because a blocker stops it or basic [consent mode](https://developers.google.com/tag-platform/security/concepts/consent-mode) holds it back until the visitor consents. The lead is sent either way, and `$fetch` leaves `gaClientId` out of the JSON when it is `undefined`. If your project already types `gtag`, use that type instead of `GtagGet`. + +When your backend reports these events, leave out the email and any other personal data. Google's [policy](https://support.google.com/analytics/answer/6366371) forbids passing any data to Google that it could use or recognize as personally identifiable information, including in URLs. + +### Google Ads click ID + +For [offline conversion import](https://support.google.com/google-ads/answer/7012522), Google says to store the GCLID with the prospect's information in your lead management system, and its sample script keeps the most recent one. The last touch can be a later campaign without a Google click, so search the whole history: + +```ts +import type { AttributionSnapshot } from 'nuxt-utm' + +// history is newest first, so find returns the most recent Google Ads click +export const latestGoogleAdsClick = ({ history }: AttributionSnapshot) => + history.find( + ({ gclidParams }) => gclidParams?.gclid || gclidParams?.gbraid || gclidParams?.wbraid, + )?.gclidParams +``` + +Some iOS clicks carry `gbraid` or `wbraid` with or instead of `gclid`, and Google [recommends sending both](https://developers.google.com/google-ads/api/docs/conversions/upload-offline#populate-click-conversions) `gclid` and `gbraid` when a conversion has both. The function returns all of that visit's Google click parameters, and it works the same on the attribution your backend receives. + +## Form services that don't accept JSON + +Some form services accept form fields, not JSON. Flatten first and last touch into fixed field names, such as `first_utm_source` and `last_utm_source`, and send them URL-encoded: + +```ts +// utils/attribution-fields.ts +import type { AttributionSnapshot } from 'nuxt-utm' + +const utmNames = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'] as const + +// Unset values are sent empty so every submission has the same field names +export function attributionFields({ firstTouch, lastTouch }: AttributionSnapshot) { + const fields = new URLSearchParams() + for (const [touchName, touch] of Object.entries({ first: firstTouch, last: lastTouch })) { + for (const name of utmNames) fields.set(`${touchName}_${name}`, touch?.utmParams[name] ?? '') + } + return fields +} +``` + +In the form handler: + +```ts +const fields = attributionFields(await utm.getAttribution()) +fields.set('email', email.value) +await $fetch(config.public.leadEndpoint, { method: 'POST', body: fields }) +``` + +`$fetch` sends `URLSearchParams` as `application/x-www-form-urlencoded`. Add other values the same way with `fields.set()`. If your service learns a form's fields from its HTML, add a hidden input for each name. diff --git a/test/package-fixture/consumer.mts b/test/package-fixture/consumer.mts index 3789e07..7c9410b 100644 --- a/test/package-fixture/consumer.mts +++ b/test/package-fixture/consumer.mts @@ -35,6 +35,19 @@ export const metaClickId = (history: DataObject[], fbclid: string) => { const firstSeen = history.findLast(({ clickParams }) => clickParams?.fbclid === fbclid) return firstSeen && `fb.1.${Date.parse(firstSeen.timestamp)}.${fbclid}` } +// Copies of the docs/RECIPES.md helpers that use the module's types, so the recipes keep compiling against the published package. +export const latestGoogleAdsClick = ({ history }: AttributionSnapshot) => + history.find( + ({ gclidParams }) => gclidParams?.gclid || gclidParams?.gbraid || gclidParams?.wbraid, + )?.gclidParams +const utmNames = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'] as const +export function attributionFields({ firstTouch, lastTouch }: AttributionSnapshot) { + const fields = new URLSearchParams() + for (const [touchName, touch] of Object.entries({ first: firstTouch, last: lastTouch })) { + for (const name of utmNames) fields.set(`${touchName}_${name}`, touch?.utmParams[name] ?? '') + } + return fields +} export function skip(context: BeforeTrackContext): void { context.skip = true }