Single-Page App UTM Tracking: Fix Hash Routing Issues
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:
- Browser sees
#first - Treats everything after
#as a fragment - Fragments aren't sent to servers
- Google Analytics receives:
app.com - 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
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
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:
- Best: Migrate to History API routing (4 hours, permanent fix)
- Quick: Put UTMs before hash (training needed, error-prone)
- 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