Here's a design mistake that's easy to make and expensive to unwind: a landing page's call-to-action button links directly to the next page's URL.
It works immediately. It also means the funnel's structure is now encoded in the content of every page in it. Want to insert an upsell step between the landing page and checkout? Edit the landing page. Want to A/B test a different order? Edit the pages, or clone them. Want to know how many people moved from step 2 to step 3? You can't, because nothing on your server observed the transition.
So in my funnel builder, a CTA never points at a destination. It points at a position:
/n/{funnelId}/{stepSlug}
and the server decides what that means, at request time, with a 302.
What the route actually does
const { funnelId, slug } = Astro.params;
const cacheKey = `${funnelId}:${slug}`;
let redirectData = await getRedirectFromCache(cacheKey);
if (!redirectData) {
const funnel = await db.funnel.findFirst({ where: { id: funnelId }, select: { id: true, steps: true } });
const steps = Array.isArray(funnel?.steps) ? funnel.steps : [];
const currentIndex = steps.findIndex((s) => s?.stepSlug === slug);
if (currentIndex === -1) return new Response('Redirect not found', { status: 404 });
const nextStep = currentIndex < steps.length - 1 ? steps[currentIndex + 1] : null;
if (!nextStep?.pageId) return new Response('No next step in funnel', { status: 404 });
const nextPage = await db.page.findFirst({ where: { id: nextStep.pageId }, select: { slug: true } });
// ...cache the resolution
}
Read that carefully: the URL names the step you are leaving, and the handler resolves the step you are going to. The button on the landing page says "I am the exit from step lp", not "go to /checkout-b". Reordering the funnel is a data change in one place, and every page in it follows automatically.
That inversion is the whole idea. Everything below is the consequences.
Consequence 1: the query string has to be carried, deliberately
Traffic arrives with attribution parameters on it: fbclid, gclid, ttclid, utm_*. If a redirect drops them, ad conversion reporting silently stops working, and you find out at the end of the month when the numbers don't reconcile.
So the handler rebuilds the query string:
const params = new URLSearchParams(incomingUrl.searchParams);
// Identity travels via the domain-scoped anid cookie; stop re-stamping it into URLs.
params.delete('anid');
params.delete('goid');
params.set('funnelId', funnelId);
Forward everything, with two deliberate deletions. The visitor identifier used to be re-stamped into every URL, which meant it ended up in referrer headers, in shared links, in customers' pasted URLs, and in analytics tools that log full URLs. It travels in a domain-scoped cookie now, and stripping it here is what stops it leaking back in through a redirect.
If you build this, decide explicitly which parameters are identity and which are attribution. Identity should not be in URLs. Attribution has to be, because that's how the ad platforms hand it to you.
Consequence 2: cache the resolution, never the redirect
The step-to-page resolution is two database queries. Doing them on every click is silly, so the result is cached by funnelId:stepSlug.
But the response must never be cached:
return new Response(null, {
status: 302,
headers: {
'Location': finalUrl,
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Referrer-Policy': 'no-referrer-when-downgrade',
},
});
302 rather than 301 for the same reason, said in the status code. A 301 is permanent, browsers cache it aggressively, and some of them keep it after you've changed the funnel. You would be shipping a redirect that certain visitors can never escape, and you'd have no way to fix it for them. Any redirect whose target is data should be a 302 with no-store.
The final URL differs per visitor anyway, because it carries their query string. There is nothing cacheable about the response even in principle.
Consequence 3: the failure modes become visible
Because the resolution runs server-side, the failures are HTTP statuses and log lines, not a silent 404 on a hand-typed URL:
- Step slug isn't in the funnel:
404 Redirect not found - Step is the last one:
404 No next step in funnel - Step points at a deleted page:
404 Next page not found
Each of those is a distinct, diagnosable state. In the link-directly-to-the-URL design, all three are the same thing: a broken link discovered by a customer.
There's a corresponding cost, and it's the honest downside of this design: the funnel can be misconfigured in ways the page editor can't see. A user drags a button, sets it to "next step", and whether that works depends on funnel data they're not currently looking at. I handle it with a validator that checks a landing page contains a correctly-formed step link and shows an error in the funnel settings, but it's a second surface to maintain, and it exists purely because of this indirection.
Consequence 4: the redirect is the only place transitions are observable
Every step transition passes through one handler. That's where the funnel-level analytics event belongs, and it's server-side, which means it isn't subject to ad blockers, JavaScript errors, or someone closing the tab before an onclick fires.
Client-side tracking of a CTA click is fundamentally a best-effort signal. A 302 handler that ran is a fact. If you care about your funnel numbers being defensible, that difference is the whole ballgame.
When a plain link is correct
If your flow is fixed and you wrote it yourself in code, link directly and skip all of this. Indirection you can't reconfigure is just an extra hop.
The break-even is when someone who isn't you edits the flow. That's the moment "the page knows where the next page is" becomes a data-integrity problem instead of a URL, and one 94-line route handler is a cheap way to never have it.
Top comments (0)