Single-Page App UTM Tracking: Fix Hash Routing Issues

UTMGuard Team
8 min readtechnical-guides

Your React app uses hash-based routing. Your marketing team launches a campaign. You check Google Analytics 4.

Zero campaign attribution.

Your SPA's routing architecture is silently destroying UTM parameters. Here's why it happens and how to fix it permanently.

🚨 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

The Problem: Hash Routing vs UTM Parameters

How Hash Routing Works

Single-page applications (React, Vue, Angular) often use hash-based routing:

app.com/#/dashboard
app.com/#/settings
app.com/#/profile

The # symbol tells the browser: "Don't reload the page, just change the visible component."

The UTM Conflict

When marketing adds UTM parameters to your SPA:

❌ app.com/#/dashboard?utm_source=facebook&utm_campaign=trial

What breaks:

  1. Browser sees # first
  2. Treats everything after # as a fragment
  3. Fragments aren't sent to servers
  4. Google Analytics receives: app.com
  5. All UTM parameters: lost

Your React Router works fine. Your GA4 tracking doesn't.

The Technical Reason

Browsers follow RFC 3986 URL specification:

https://domain.com/path?query#fragment
         │          │     │     │
      scheme      path  query  fragment

Fragment behavior:

  • Handled entirely client-side
  • Never sent in HTTP requests
  • Perfect for SPA routing
  • Terrible for UTM tracking

When your URL is:

app.com/#/dashboard?utm_params

Browsers parse it as:

  • Base: app.com
  • Fragment: #/dashboard?utm_params (local only)
  • Query string: none

😰 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 Fix: 3 Solutions for SPAs

Solution 1: Switch to History API Routing (Best)

Recommended for new apps or major refactors.

Change from hash routing to browser history routing:

React Router v6:

// ❌ Hash Router (breaks UTMs)
import { HashRouter } from 'react-router-dom';
 
function App() {
  return (
    <HashRouter>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
      </Routes>
    </HashRouter>
  );
}
 
// ✅ Browser Router (preserves UTMs)
import { BrowserRouter } from 'react-router-dom';
 
function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
      </Routes>
    </BrowserRouter>
  );
}

Vue Router:

// ❌ Hash mode
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
 
// ✅ History mode
const router = createRouter({
  history: createWebHistory(),
  routes,
})

Angular:

// ❌ Hash location strategy
RouterModule.forRoot(routes, { useHash: true })
 
// ✅ Path location strategy
RouterModule.forRoot(routes, { useHash: false })

Requires: Server-side configuration to handle direct URLs (see below).

Solution 2: Put UTMs Before Hash (Quick Fix)

For existing apps you can't refactor immediately.

Train your marketing team to structure URLs correctly:

✅ app.com?utm_source=facebook&utm_campaign=trial#/dashboard

NOT:
❌ app.com#/dashboard?utm_source=facebook&utm_campaign=trial

Pros:

  • Works with existing hash routing
  • No code changes needed
  • Immediate fix

Cons:

  • Requires training every marketer
  • Easy to forget
  • One mistake breaks tracking

Solution 3: Client-Side UTM Capture

For apps that must use hash routing.

Capture UTM parameters from the fragment on page load:

// utm-capture.js
function captureUTMsFromFragment() {
  const hash = window.location.hash;
 
  // Extract query string from fragment
  const queryMatch = hash.match(/\?(.+)/);
  if (!queryMatch) return;
 
  const params = new URLSearchParams(queryMatch[1]);
 
  // Save UTMs to sessionStorage
  ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'].forEach(key => {
    const value = params.get(key);
    if (value) {
      sessionStorage.setItem(key, value);
    }
  });
 
  // Send to GA4 manually
  if (window.gtag) {
    gtag('event', 'campaign_view', {
      campaign_source: sessionStorage.getItem('utm_source'),
      campaign_medium: sessionStorage.getItem('utm_medium'),
      campaign_name: sessionStorage.getItem('utm_campaign'),
    });
  }
}
 
// Run on page load
captureUTMsFromFragment();

Usage: Include this script before your routing initialization.

Server Configuration for History API

When using browser history routing, your server must return index.html for all routes.

Nginx

location / {
  try_files $uri $uri/ /index.html;
}

Apache (.htaccess)

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{"{"}{"{"}REQUEST_FILENAME{"}"}{"}"}} !-f
  RewriteCond %{"{"}{"{"}REQUEST_FILENAME{"}"}{"}"}} !-d
  RewriteRule . /index.html [L]
</IfModule>

Express.js

app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'build', 'index.html'));
});

Vercel (vercel.json)

{
  "rewrites": [
    { "source": "/(.*)", "destination": "/" }
  ]
}

Real Example: SaaS App Migration

Company: B2B SaaS with 10,000 trial signups/month Problem: Zero campaign attribution for 6 months

Original setup:

  • React app with HashRouter
  • Marketing URLs: app.com/#/signup?utm_source=linkedin
  • GA4 showed 100% direct traffic

Solution implemented:

  • Migrated to BrowserRouter
  • Configured Nginx rewrites
  • Updated marketing team training

Results:

  • 98% campaign attribution restored
  • Identified LinkedIn as top trial source (was invisible before)
  • Increased LinkedIn budget by 3x based on data
  • $120K additional MRR from optimized campaigns

Development time: 4 hours Annual value: $1.4M in better attribution

Migration Checklist

Phase 1: Planning (1 hour)

  • Audit current hash routing usage
  • Identify all entry points (marketing, bookmarks, external links)
  • Choose routing strategy (history API recommended)
  • Plan server configuration updates

Phase 2: Implementation (2-4 hours)

  • Update router configuration
  • Configure server rewrites
  • Test all routes manually
  • Verify deep linking works

Phase 3: Testing (1 hour)

  • Test UTM parameters in development
  • Check GA4 Real-Time attribution
  • Verify existing bookmarks work
  • Test 404 handling

Phase 4: Launch (30 minutes)

  • Deploy server config first
  • Deploy updated app
  • Monitor GA4 attribution
  • Update documentation

✅ 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

FAQ

Can I keep hash routing and still track UTMs?

Yes, using Solution 2 (correct URL structure) or Solution 3 (client-side capture). But migrating to history API (Solution 1) is most reliable.

Will changing routing break existing bookmarks?

If you migrate from hash to history routing, old hash URLs will still work, but you'll need redirect logic to convert them.

Does this affect SEO?

Hash fragments aren't crawled by search engines. History API routing is better for SEO because each route has a real URL.

What about users with JavaScript disabled?

Both hash routing and history API require JavaScript. SPAs don't work without JS. If this is a concern, consider server-side rendering (Next.js, Nuxt).

How do I test if UTMs work after the fix?

Visit your app with UTMs in incognito mode: app.com?utm_source=test&utm_campaign=test. Check GA4 Real-Time reports. If the campaign appears, it's working.

Should I use history API even for internal tools?

Yes. Even internal tools benefit from:

  • Proper UTM tracking for campaign attribution
  • Better analytics for product usage
  • Cleaner URLs
  • Improved developer experience

Conclusion

Single-page apps with hash routing break UTM tracking because fragments aren't sent to servers.

Solutions:

  1. Best: Migrate to History API routing (4 hours, permanent fix)
  2. Quick: Put UTMs before hash (training needed, error-prone)
  3. Workaround: Client-side UTM capture (complex, maintenance overhead)

For new projects: Always use History API routing. For existing apps: Migrate when feasible, or use proper URL structure.


Technical Reference: Fragment Before Query Validation Rule