Internal Linking and UTM Parameters: Complete Technical Guide

UTMGuard Team
9 min readtechnical-guides

Internal linking is one of the most misunderstood aspects of web analytics tracking. Developers add UTM parameters to internal links thinking they're "improving tracking," but they're actually destroying attribution data.

This comprehensive technical guide covers everything developers and technical marketers need to know about internal links, UTM parameters, and GA4 attribution.

🚨 Not sure what's breaking your tracking?

Run a free 60-second audit to check all 40+ ways UTM tracking can fail.

Scan Your Campaigns Free

✓ No credit card ✓ See results instantly

Understanding GA4 Session and Attribution Logic

How GA4 Defines a Session

Session = A group of user interactions within a given time frame

Session starts when:

  • User arrives from external source
  • New UTM parameters detected (creates new session)
  • 30 minutes of inactivity passes (configurable)

Session continues when:

  • User navigates between pages (same domain, no new UTMs)
  • User interacts with site
  • Less than 30 minutes between interactions

How GA4 Determines Traffic Source

GA4 uses this priority order:

  1. UTM parameters (highest priority)

    • If URL has utm_source and utm_medium → Use these
    • Overrides everything else
  2. Click IDs (gclid, fbclid, etc.)

    • If URL has gclid → Source = google, Medium = cpc
    • If URL has fbclid → Source = facebook, Medium = social
  3. HTTP Referrer

    • If external referrer → Source = referrer domain, Medium = referral
    • If internal referrer → Ignore, keep existing source
  4. Direct

    • No referrer, no UTMs, no click IDs → Source = (direct), Medium = (none)

Critical insight: UTM parameters ALWAYS win. This is why UTMs on internal links break attribution—they override the actual traffic source.

Traffic Source Overwriting Behavior

Example flow:

User Journey          | GA4 Source/Medium    | Why
----------------------|----------------------|---------------------
Clicks Google Ad      | google/cpc           | gclid parameter
Browses homepage      | google/cpc           | Same session
Clicks product page   | google/cpc           | Internal navigation
Clicks nav link       | google/cpc           | Internal navigation
with NO UTMs          |                      |

Correct behavior ✓

Example with internal UTMs (broken):

User Journey          | GA4 Source/Medium    | Why
----------------------|----------------------|---------------------
Clicks Google Ad      | google/cpc           | gclid parameter
Browses homepage      | google/cpc           | Same session
Clicks nav link with  | internal/navigation  | UTMs override gclid
utm_source=internal   |                      |
Converts              | internal/navigation  | Wrong attribution!

Broken behavior ✗

Definition: A link where the hostname matches the current site's hostname (or is in the same cross-domain group).

Examples:

<!-- Relative URLs (always internal) -->
<a href="/products">Products</a>
<a href="../about">About</a>
<a href="contact">Contact</a>
 
<!-- Absolute URLs (same domain) -->
<a href="https://yoursite.com/products">Products</a>
<a href="https://www.yoursite.com/about">About</a>
 
<!-- Subdomain (same root domain) -->
<a href="https://blog.yoursite.com/article">Blog Post</a>
<a href="https://shop.yoursite.com/products">Shop</a>

JavaScript check:

function isInternalLink(link) {
  const currentHost = window.location.hostname;
  const linkHost = new URL(link.href, window.location.origin).hostname;
 
  // Remove www if present
  const normalizedCurrent = currentHost.replace(/^www\./, '');
  const normalizedLink = linkHost.replace(/^www\./, '');
 
  return normalizedCurrent === normalizedLink;
}

Definition: A link where the hostname is different from the current site.

Examples:

<!-- Different domain -->
<a href="https://partner.com/page">Partner Site</a>
<a href="https://facebook.com/yourpage">Facebook Page</a>
 
<!-- Email -->
<a href="mailto:contact@yoursite.com">Email Us</a>
 
<!-- Phone -->
<a href="tel:+1234567890">Call Us</a>

😰 Is this your only tracking issue?

This is just 1 of 40+ ways UTM tracking breaks. Most marketing teams have 8-12 critical issues they don't know about.

• 94% of sites have UTM errors

• Average: $8,400/month in wasted ad spend

• Fix time: 15 minutes with our report

✓ Connects directly to GA4 (read-only, secure)

✓ Scans 90 days of data in 2 minutes

✓ Prioritizes issues by revenue impact

✓ Shows exact sessions affected

Get Your Free Audit Report

The Technical Problem with Internal UTMs

Session Creation Logic

GA4's session logic (simplified):

function determineSession(request) {
  const hasNewUTMs = request.url.searchParams.has('utm_source');
  const existingSession = getExistingSession();
 
  if (hasNewUTMs) {
    // NEW SESSION - UTMs always create new session
    return createNewSession({
      source: request.url.searchParams.get('utm_source'),
      medium: request.url.searchParams.get('utm_medium'),
      campaign: request.url.searchParams.get('utm_campaign')
    });
  }
 
  if (existingSession && !existingSession.isExpired()) {
    // CONTINUE EXISTING SESSION
    return existingSession;
  }
 
  // NEW SESSION - timeout or first visit
  return createNewSession({
    source: determineSourceFromReferrer(request.referrer)
  });
}

The problem: UTM parameters on internal links trigger hasNewUTMs = true, creating a new session with the wrong source.

Attribution Overwrite

Before internal UTM click:

{
  "session_id": "abc123",
  "traffic_source": {
    "source": "google",
    "medium": "cpc",
    "campaign": "summer_sale"
  }
}

After internal UTM click:

{
  "session_id": "def456",  // NEW SESSION ID
  "traffic_source": {
    "source": "internal",  // OVERWRITTEN
    "medium": "navigation", // OVERWRITTEN
    "campaign": null
  }
}

Result: Original source (google/cpc) is lost forever.

Implementation: Correct Internal Linking

Always use clean URLs for internal navigation:

<!-- Navigation menu -->
<nav>
  <a href="/products">Products</a>
  <a href="/pricing">Pricing</a>
  <a href="/about">About</a>
  <a href="/contact">Contact</a>
</nav>
 
<!-- Footer -->
<footer>
  <a href="/blog">Blog</a>
  <a href="/careers">Careers</a>
  <a href="/privacy">Privacy Policy</a>
</footer>
 
<!-- In-content links -->
<p>Learn more about our <a href="/features">features</a>.</p>

No query parameters. No UTMs. Just clean paths.

Method 2: GA4 Event Tracking for Internal Behavior

If you need to track internal navigation patterns:

// Setup: Add once to your site
function setupInternalLinkTracking() {
  document.querySelectorAll('a').forEach(link => {
    // Check if internal
    if (isInternalLink(link)) {
      link.addEventListener('click', function(e) {
        // Track click without changing URL
        gtag('event', 'internal_navigation', {
          'link_text': this.textContent.trim(),
          'link_url': this.pathname,
          'link_location': getLinkLocation(this),
          'previous_page': window.location.pathname
        });
 
        // Link proceeds normally, no UTMs added
      });
    }
  });
}
 
function getLinkLocation(element) {
  // Determine where link is located
  if (element.closest('nav')) return 'navigation';
  if (element.closest('footer')) return 'footer';
  if (element.closest('aside')) return 'sidebar';
  return 'content';
}
 
function isInternalLink(link) {
  try {
    const url = new URL(link.href, window.location.origin);
    const currentHost = window.location.hostname.replace(/^www\./, '');
    const linkHost = url.hostname.replace(/^www\./, '');
    return currentHost === linkHost;
  } catch (e) {
    return false; // Invalid URL
  }
}
 
// Initialize on page load
document.addEventListener('DOMContentLoaded', setupInternalLinkTracking);

Benefits:

  • ✅ Track every internal click
  • ✅ Know which links users prefer
  • ✅ Measure navigation patterns
  • Preserve original traffic source

Method 3: Server-Side UTM Stripping

For dynamic sites where you can't control all link generation:

// Middleware to strip UTM parameters from internal links
function stripInternalUTMs(req, res, next) {
  const url = new URL(req.url, `${req.protocol}://${req.get('host')}`);
  const referrer = req.get('referrer');
 
  // Check if referrer is internal
  if (referrer && isInternalReferrer(referrer, req.get('host'))) {
    // Strip UTM parameters
    ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']
      .forEach(param => url.searchParams.delete(param));
 
    // Redirect to clean URL if UTMs were present
    if (req.url !== url.pathname + url.search) {
      return res.redirect(301, url.pathname + url.search);
    }
  }
 
  next();
}
 
function isInternalReferrer(referrer, currentHost) {
  try {
    const referrerHost = new URL(referrer).hostname.replace(/^www\./, '');
    const normalizedHost = currentHost.replace(/^www\./, '');
    return referrerHost === normalizedHost;
  } catch (e) {
    return false;
  }
}
 
// Express.js example
app.use(stripInternalUTMs);

Use case: When using a CMS or framework that automatically adds UTM parameters to some internal links.

Cross-Domain Tracking Setup

For multiple domains/subdomains that should be treated as one site:

GA4 Configuration

Admin → Data Streams → Configure tag settings → Configure your domains:

yoursite.com
shop.yoursite.com
blog.yoursite.com

gtag.js Implementation

// Global site tag with cross-domain tracking
gtag('config', 'G-XXXXXXXXXX', {
  'linker': {
    'domains': [
      'yoursite.com',
      'shop.yoursite.com',
      'blog.yoursite.com'
    ],
    'accept_incoming': true
  },
  'allow_linker': true
});

How It Works

Without cross-domain tracking:

User on yoursite.com (source: google/cpc)
↓
Clicks link to shop.yoursite.com
↓
New session created (source: yoursite.com/referral)
❌ Attribution broken

With cross-domain tracking:

User on yoursite.com (source: google/cpc)
↓
Clicks link to shop.yoursite.com?_gl=XXXX (linker parameter added)
↓
Session continues (source: google/cpc)
✅ Attribution preserved

Testing and Validation

Manual Testing

// Run in browser console
function testInternalLinks() {
  const issues = [];
 
  document.querySelectorAll('a').forEach(link => {
    const url = new URL(link.href, window.location.origin);
 
    // Check if internal
    const isInternal = url.hostname.replace(/^www\./, '') ===
                       window.location.hostname.replace(/^www\./, '');
 
    if (isInternal) {
      // Check for UTM parameters
      const hasUTMs = url.search.match(/utm_(source|medium|campaign|content|term)/);
 
      if (hasUTMs) {
        issues.push({
          text: link.textContent.trim(),
          href: link.href,
          location: link.closest('nav, footer, aside')?.tagName || 'content'
        });
      }
    }
  });
 
  if (issues.length > 0) {
    console.error(`❌ Found ${issues.length} internal links with UTMs:`, issues);
    return false;
  } else {
    console.log('✅ All internal links are clean (no UTMs)');
    return true;
  }
}
 
testInternalLinks();

Automated Testing (Playwright)

// test/internal-links.spec.js
import { test, expect } from '@playwright/test';
 
test('internal links should not have UTM parameters', async ({ page }) => {
  await page.goto('/');
 
  // Get all links
  const links = await page.$$('a');
 
  for (const link of links) {
    const href = await link.getAttribute('href');
    if (!href) continue;
 
    try {
      const url = new URL(href, page.url());
 
      // Check if internal
      const currentHost = new URL(page.url()).hostname.replace(/^www\./, '');
      const linkHost = url.hostname.replace(/^www\./, '');
 
      if (currentHost === linkHost) {
        // Should not have UTM parameters
        expect(url.search).not.toMatch(/utm_(source|medium|campaign|content|term)/);
      }
    } catch (e) {
      // Invalid URL, skip
    }
  }
});
 
test('navigation preserves test UTM parameters', async ({ page }) => {
  // Visit with test UTMs
  await page.goto('/?utm_source=test&utm_medium=test&utm_campaign=test');
 
  // Click internal link
  await page.click('nav a:first-child');
 
  // Wait for navigation
  await page.waitForLoadState('networkidle');
 
  // Check URL still has UTMs
  const url = new URL(page.url());
  expect(url.searchParams.get('utm_source')).toBe('test');
  expect(url.searchParams.get('utm_medium')).toBe('test');
  expect(url.searchParams.get('utm_campaign')).toBe('test');
});

Automated Testing (Cypress)

// cypress/e2e/internal-links.cy.js
describe('Internal link UTM validation', () => {
  it('should not add UTMs to internal links', () => {
    cy.visit('/');
 
    cy.get('a').each(($link) => {
      const href = $link.attr('href');
      if (!href) return;
 
      cy.url().then((currentUrl) => {
        try {
          const url = new URL(href, currentUrl);
          const currentHost = new URL(currentUrl).hostname;
          const linkHost = url.hostname;
 
          if (currentHost === linkHost) {
            // Internal link should not have UTMs
            expect(href).not.to.match(/utm_(source|medium|campaign|content|term)/);
          }
        } catch (e) {
          // Invalid URL, skip
        }
      });
    });
  });
 
  it('should preserve UTMs during internal navigation', () => {
    cy.visit('/?utm_source=test&utm_medium=test&utm_campaign=test');
 
    // Click first navigation link
    cy.get('nav a').first().click();
 
    // UTMs should still be present
    cy.url().should('include', 'utm_source=test');
    cy.url().should('include', 'utm_medium=test');
    cy.url().should('include', 'utm_campaign=test');
  });
});

✅ Fixed this issue? Great! Now check the other 39...

You just fixed one tracking issue. But are your Google Ads doubling sessions? Is Facebook attribution broken? Are internal links overwriting campaigns?

Connects to GA4 (read-only, OAuth secured)

Scans 90 days of traffic in 2 minutes

Prioritizes by revenue impact

Free forever for monthly audits

Run Complete UTM Audit (Free Forever)

Join 2,847 marketers fixing their tracking daily

Advanced: UTM Preservation in SPAs

For React, Vue, Angular applications:

React Router Example

import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
 
function usePreserveUTMs() {
  const location = useLocation();
  const navigate = useNavigate();
 
  useEffect(() => {
    // Get initial UTMs from URL
    const params = new URLSearchParams(location.search);
    const utms = {
      utm_source: params.get('utm_source'),
      utm_medium: params.get('utm_medium'),
      utm_campaign: params.get('utm_campaign'),
      utm_content: params.get('utm_content'),
      utm_term: params.get('utm_term')
    };
 
    // Store in sessionStorage
    Object.keys(utms).forEach(key => {
      if (utms[key]) {
        sessionStorage.setItem(key, utms[key]);
      }
    });
  }, [location]);
}
 
// Use in Link components
function PreservedLink({ to, children, ...props }) {
  // Get stored UTMs
  const utms = {
    utm_source: sessionStorage.getItem('utm_source'),
    utm_medium: sessionStorage.getItem('utm_medium'),
    utm_campaign: sessionStorage.getItem('utm_campaign'),
    utm_content: sessionStorage.getItem('utm_content'),
    utm_term: sessionStorage.getItem('utm_term')
  };
 
  // Add to URL
  const url = new URL(to, window.location.origin);
  Object.keys(utms).forEach(key => {
    if (utms[key]) {
      url.searchParams.set(key, utms[key]);
    }
  });
 
  return <Link to={url.pathname + url.search} {...props}>{"{"}{"{"}children{"}"}{"}"}}</Link>;
}

Note: This preserves UTMs across navigation, which is correct behavior for SPAs.

FAQ

Why does GA4 documentation say UTMs create new sessions?

Correct. UTMs DO create new sessions. This is exactly why you should NEVER use them on internal links—you don't want internal navigation to create new sessions.

Yes, as long as they're not utm_* parameters:

<!-- These are fine -->
<a href="/products?sort=price">Products</a>
<a href="/search?q=shoes">Search</a>
<a href="/article?ref=homepage">Article</a>

Only utm_source, utm_medium, utm_campaign, utm_content, and utm_term trigger new sessions.

Also problematic. Never add click IDs to internal links. They create the same attribution issues as UTMs.

How do I track internal campaign promotions?

Use GA4 events:

gtag('event', 'promotion_click', {
  'promotion_id': 'summer_sale_banner',
  'promotion_name': 'Summer Sale 2024',
  'creative_slot': 'homepage_hero'
});

Should I strip UTMs from URLs visually (browser history)?

No. Users should see UTMs if they arrived with them. Only avoid ADDING new UTMs to internal links.


Related: Internal Links Attribution Rule Documentation