In one of my recent projects, the client asked me to forward UTM parameters when a user clicks a link that leads to a web application on a different domain. The goal was to preserve campaign attribution across the domain boundary so GA4 could tie conversions back to the original traffic source.
This is a common need, especially on landing pages where a CTA points to an external app or checkout flow. I’ll share the script I use and explain how it works.
What Does the Script Do?
The script scans every link on the page. When it finds one pointing to a specific external domain, it checks whether UTM parameters are already present.
If not, it appends the UTM parameters from the current page URL to that link.
An example makes this clearer. Say a user lands on a page with these parameters:
https://lp.com/?utm_source=google&utm_medium=cpc&utm_campaign=name-campaign&utm_term=keyword&utm_content=fb-bannerAnd the landing page has a CTA linking to an external application:
https://app.com/The script rewrites that link to:
https://app.com/?utm_source=google&utm_medium=cpc&utm_campaign=name-campaign&utm_term=keyword&utm_content=fb-bannerThe Script
(function () {
const targetDomain = "app.co.il";
const pageParams = new URLSearchParams(window.location.search);
const utmEntries = [...pageParams.entries()].filter(([key]) => key.startsWith("utm_"));
if (utmEntries.length === 0) return;
document.querySelectorAll("a[href]").forEach(link => {
try {
const url = new URL(link.href);
if (!url.hostname.includes(targetDomain)) return;
utmEntries.forEach(([key, value]) => {
if (!url.searchParams.has(key)) {
url.searchParams.set(key, value);
}
});
link.href = url.toString();
} catch (e) {}
});
})();How It Works
- Line 2 – set
targetDomainto the external domain you want to forward UTMs to. Only links matching this hostname will be modified. - Lines 3-4 – read the current page URL and extract all parameters that start with
utm_. - Line 6 – if no UTM parameters are present, the script exits immediately.
- Lines 8-18 – loop through every
<a>on the page. For each one, parse it with theURLconstructor, check the hostname, and usesearchParams.set()to append missing UTMs.
The script uses the URL API instead of string concatenation. This matters because searchParams.set() automatically handles URL encoding, avoids duplicate parameters, and correctly places query parameters before any hash fragment.
If you’re using Google Tag Manager, add this script as a Custom HTML tag and set it to fire on the DOM Ready trigger. Otherwise, place the script at the end of the page body.
Important Notes
- The script only affects links to the specified
targetDomain. All other links remain untouched. - Links that already contain UTM parameters are skipped – the script won’t overwrite existing attribution.
- The
try/catchblock silently skipsmailto:,tel:, and other non-HTTP links that would throw when parsed by theURLconstructor. - The script runs once on page load. If your page dynamically adds links after load (SPA, AJAX), you’ll need to call the function again after the new links are inserted.
Persisting UTMs Across Page Navigations
The script above works when UTM parameters are present in the current page URL. But what if the user lands on your homepage with UTMs, navigates to an inner page, and only then clicks the CTA to the external app?
The UTMs are lost because the inner page URL doesn’t contain them.
You can solve this with sessionStorage. The enhanced version stores UTMs when the user first arrives and applies them on every subsequent page:
(function () {
const targetDomain = "app.co.il";
const utmKeys = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"];
const params = new URLSearchParams(window.location.search);
const freshUtms = utmKeys.filter(key => params.has(key));
if (freshUtms.length > 0) {
utmKeys.forEach(key => sessionStorage.removeItem(key));
freshUtms.forEach(key => sessionStorage.setItem(key, params.get(key)));
}
const utms = utmKeys
.filter(key => sessionStorage.getItem(key))
.map(key => [key, sessionStorage.getItem(key)]);
if (utms.length === 0) return;
document.querySelectorAll("a[href]").forEach(link => {
try {
const url = new URL(link.href);
if (!url.hostname.includes(targetDomain)) return;
utms.forEach(([key, value]) => {
if (!url.searchParams.has(key)) {
url.searchParams.set(key, value);
}
});
link.href = url.toString();
} catch (e) {}
});
})();sessionStorage persists for the duration of the browser tab. Once the user closes the tab, the data is cleared.
This is the right scope for campaign attribution – you don’t want UTMs from last week’s visit overriding today’s.
Never add UTM parameters to internal links. UTMs on internal links override the original traffic source in GA4 and create false sessions, making your campaign data unreliable.
FAQs
utm_source, utm_medium, utm_campaign, utm_term, and utm_content - all map directly to GA4 session dimensions. The script preserves these parameters when forwarding to an external domain.URL constructor and URLSearchParams handle encoding automatically, prevent duplicate parameters, and correctly place query strings before hash fragments. String concatenation with += can break URLs that contain a # hash, because the parameters end up in the fragment instead of the query string.URLSearchParams is supported in Chrome 49+, Firefox 44+, Safari 10.1+, and Edge 17+. It covers over 98% of global browser traffic. Internet Explorer is the only notable exception, but IE usage is effectively zero in 2026.sessionStorage. It clears when the user closes the browser tab, which aligns with how analytics sessions work. localStorage persists indefinitely, which means a user returning days later would still carry old UTM values that no longer reflect their actual traffic source.targetDomain string with an array of domains and check against all of them. For example: const targetDomains = ["app.co.il", "checkout.co.il"]; then use targetDomains.some(d => url.hostname.includes(d)) instead of the single includes() check.Comments and questions are welcome – always happy to hear ideas for improving these snippets 🙂

