This article was originally published on Jo4 Blog.
My affiliate tracking links were returning the entire React app instead of a 302 redirect.
Not a 404. Not a 500. A perfectly happy 200 with my SPA’s HTML shell. As if the browser was saying: “Here’s your single-page application! You wanted a redirect? Never heard of her.”
TL;DR
If you serve a React SPA from Cloudflare/CloudFront as a static bundle, every URL that doesn’t match a static file falls through to index.html. That includes your API-style routes like /go/*. Fix it by adding a routing branch in your Cloudflare Worker that intercepts those paths at the edge before the SPA bundle even loads.
The Setup
Jo4 has an affiliate tracking feature. When someone clicks a /go/partner-slug link, the server is supposed to:
- Record the click (timestamp, referrer, etc.)
- Return a
302 Foundredirect to the partner’s destination URL
Simple HTTP. The browser follows the redirect, the user lands on the partner’s page, and we have a clean record of the click.
The backend API at jo4-api.jo4.io handles this perfectly. The endpoint receives the request, logs the event, and returns:
HTTP/1.1 302 Found
Location: https://partner-site.com/landing
Enter fullscreen mode Exit fullscreen mode
The problem? Those requests never reached the backend.
What Was Actually Happening
Here’s the request flow I expected:
Browser -> Cloudflare -> Backend API -> 302 redirect
Enter fullscreen mode Exit fullscreen mode
Here’s what was actually happening:
Browser -> Cloudflare -> Static SPA bundle -> 200 OK (index.html)
Enter fullscreen mode Exit fullscreen mode
The SPA is deployed as a static bundle behind Cloudflare. Cloudflare’s default behavior for SPAs is to serve index.html for any path that doesn’t match a static file. That’s the whole point of a SPA — React Router handles routing client-side.
But React Router had no route for /go/*. It didn’t need one. These aren’t client-side routes. They’re server-side endpoints that need to hit the API.
So when someone clicked jo4.io/go/partner-slug:
- Cloudflare received the request
- No static file matched
/go/partner-slug - Cloudflare served
index.html(SPA fallback) - Browser got a 200 OK with HTML
- React Router loaded, found no matching route
- User saw… the app. Not a redirect. The app.
The affiliate click was never recorded. The partner never got their traffic. Nobody was happy.
The Fix: Edge Routing in the Cloudflare Worker
The key insight is embarrassingly simple once you see it: the routing decision needs to happen at the edge, before the SPA bundle is even considered.
Cloudflare Workers sit in front of everything. They see every request before it hits your static files, your origin, anything. That’s where the routing belongs.
// Cloudflare Worker - simplified routing
async function handleRequest(request) {
const url = new URL(request.url);
// Affiliate tracking routes - proxy to backend API
if (url.pathname.startsWith('/go/')) {
const apiUrl = new URL(url.pathname, 'https://jo4-api.jo4.io');
apiUrl.search = url.search;
return fetch(apiUrl.toString(), {
method: request.method,
headers: request.headers,
redirect: 'manual', // Don't follow the redirect — pass it through
});
}
// Everything else — serve the SPA
return fetch(request);
}
Enter fullscreen mode Exit fullscreen mode
The redirect: 'manual' part is important. Without it, the Worker would follow the 302 itself and return the final page content. We want to pass the 302 straight through to the browser so it handles the redirect.
After the Fix
Browser -> Cloudflare Worker -> intercepts /go/* -> proxies to jo4-api.jo4.io -> 302 redirect
Browser -> Cloudflare Worker -> everything else -> serves SPA
Enter fullscreen mode Exit fullscreen mode
The SPA never sees /go/* requests. They’re handled and done before React even loads.
I also updated the AffiliateNotificationService URLs to use the new route structure, so notification emails and Slack messages all point to the correct /go/ paths.
Why This Bit Me
I’ve been building web apps for years. I know how SPA routing works. But here’s the thing — when you’re moving fast and deploying a new feature, you test the feature. You test the affiliate link creation, the click tracking endpoint, the redirect logic. You test it all through the API directly.
What you don’t test is: “What happens when a real browser hits this URL through the full CDN/SPA stack?”
I was testing jo4-api.jo4.io/go/partner-slug directly. That worked fine. The bug only appeared when hitting jo4.io/go/partner-slug — through the SPA’s Cloudflare distribution.
The Lesson
If you’re running a SPA behind Cloudflare (or CloudFront, or any CDN with SPA fallback):
- Your SPA will eat every URL that doesn’t match a static file
- Server-side routes need edge-level interception — don’t rely on the SPA to “not handle” them
- Test through the full stack, not just the API directly
- Cloudflare Workers are the right place for this routing — they execute before anything else
The SPA fallback behavior is a feature, not a bug. It’s doing exactly what it’s supposed to do. The problem is assuming that “routes I didn’t define in React Router” will somehow magically reach your backend. They won’t. The CDN doesn’t know the difference between a client-side route and a server-side route. You have to tell it.
Ever had your SPA swallow routes meant for your backend? What was your fix — Workers, nginx rules, or something else?
Building jo4.io – a URL shortener with affiliate tracking that actually redirects now.
답글 남기기