How to Build a Conversion Funnel Without Cookies or Consent Banners
GuidesJune 14, 202611 min read

How to Build a Conversion Funnel Without Cookies or Consent Banners

Build funnels without cookies. Working code inside.

Three months ago I ripped out the consent banner from a SaaS checkout flow. Not because I wanted to — the legal review said we had to keep it, the PM said the 12% drop-off at the banner was acceptable, and marketing said "everyone has one."

Then I ran the numbers on what that 12% actually meant. At 8,000 monthly visitors to the pricing page, we were losing roughly 960 people before they even saw the checkout form. Assume a 3% conversion rate on the remaining traffic and a $49/mo product: that banner was costing somewhere around $1,400 in monthly recurring revenue. Every month. Forever.

I spent a weekend rebuilding the funnel tracking to be cookieless. The banner came down. The legal team signed off (after I explained it three times — lawyers love clarifying questions). The funnel started measuring again — without asking anyone's permission.

This is how to do that. Fair warning: the first time I tried this, I broke event tracking for two days before anyone noticed. Learn from my mistakes.

What We're Building

By the end of this, you'll have a working conversion funnel that tracks step completion rates across your signup or checkout flow. No cookies. No consent banners. No user IDs. The funnel data shows up in JustAnalytics (or any cookieless tool that accepts server-side events), and you can slice it by source, device, and time period like you would with GA4 — minus the legal headaches.

The approach works for any multi-step flow: pricing → checkout → payment success, or landing → signup → onboarding step 1 → onboarding step 2. I'll use a SaaS signup funnel as the example.

Prerequisites

  • A JustAnalytics account (free tier covers 100K events/month, which is plenty for funnel testing)
  • Your site running on any framework that can fire HTTP requests — I'll show Node.js and Python examples
  • Basic familiarity with sending events from server-side code
  • About 30 minutes

The Core Concept: Session-Based Funnel Steps

Traditional funnel tracking works like this: drop a cookie on the user's browser, assign them a user ID, track every page they visit, then reconstruct the funnel by filtering events for users who hit each step in order.

That model requires cookies. Cookies require consent under GDPR and ePrivacy. Consent requires a banner. Banners kill conversions. (If you're curious about the broader EU data transfer landscape after Schrems III, we wrote a deep dive.)

The cookieless alternative flips the approach. Instead of tracking users across steps, you track step completions within sessions. A session is a continuous visit — same browser tab, same sequence of actions, no need for a persistent identifier. When a visitor lands on your pricing page, that's step 1. When they submit the signup form, that's step 2. When the Stripe webhook confirms payment, that's step 3.

You don't know which step-1 visitor became which step-3 customer. But you know how many people reached each step, and you can calculate drop-off rates between them. For optimization decisions — "where are people leaving? which step needs work?" — that's usually enough.

Step 1: Define Your Funnel Steps

Before writing any code, write down your funnel as a simple numbered list:

Funnel: SaaS Signup
Step 1: Viewed pricing page
Step 2: Clicked "Start free trial"
Step 3: Submitted signup form
Step 4: Completed onboarding checklist
Step 5: Upgraded to paid (via Stripe webhook)

Keep it to 4-6 steps. More than that and you're tracking a flow chart, not a funnel. (I once built a 12-step funnel for a client. Never again. By step 8, the numbers were so small they were statistically useless.) Each step should represent a meaningful decision point where users might leave.

The event names matter. Pick something descriptive and stick with it:

funnel_pricing_viewed
funnel_trial_clicked
funnel_signup_submitted
funnel_onboarding_completed
funnel_upgrade_paid

These names will show up in your analytics dashboard and in any queries you write later. Don't get clever. funnel_step_1 works but tells you nothing when you're scanning a report at 11pm.

Step 2: Fire Client-Side Events for Browser Steps

The first few funnel steps happen in the browser — page views, button clicks, form submissions. Fire these from client-side JavaScript.

Here's a React example for the pricing page view:

// pages/pricing.tsx
import { useEffect } from 'react';

export default function PricingPage() {
  useEffect(() => {
    // Fire on mount — user has viewed pricing
    window.ja?.('event', 'funnel_pricing_viewed', {
      source: new URLSearchParams(window.location.search).get('utm_source') || 'direct',
    });
  }, []);

  return (
    <div>
      {/* Your pricing page content */}
      <button onClick={() => {
        window.ja?.('event', 'funnel_trial_clicked');
        // Navigate to signup
      }}>
        Start free trial
      </button>
    </div>
  );
}

For the signup form submission:

// components/SignupForm.tsx
function handleSubmit(e: React.FormEvent) {
  e.preventDefault();

  window.ja?.('event', 'funnel_signup_submitted', {
    plan: selectedPlan,
  });

  // Submit the form to your backend
}

Notice what we're not doing: no user IDs, no cookies, no localStorage. The JustAnalytics script handles session tracking internally using a fingerprint-free session identifier that expires when the tab closes. You just fire the event with whatever properties you care about.

If you're seeing "window.ja is undefined" errors, check that the script is loaded. Common culprit: ad blockers. We covered the proxy workaround in our Next.js 15 tutorial — same fix applies here.

Step 3: Fire Server-Side Events for Backend Steps

The last steps in most funnels happen on the server. A payment succeeds, a trial converts, an account gets created. The browser might be closed by then.

Server-side events solve this. Here's a Node.js example for handling a Stripe webhook:

// api/webhooks/stripe.js
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function POST(req) {
  const sig = req.headers['stripe-signature'];
  const event = stripe.webhooks.constructEvent(
    await req.text(),
    sig,
    process.env.STRIPE_WEBHOOK_SECRET
  );

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;

    // Fire the funnel event server-side
    await fetch('https://api.justanalytics.app/v1/events', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.JA_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        site_id: process.env.JA_SITE_ID,
        event: 'funnel_upgrade_paid',
        properties: {
          plan: session.metadata?.plan || 'unknown',
          amount_cents: session.amount_total,
        },
        // No user_id — that's intentional
      }),
    });
  }

  return new Response('OK', { status: 200 });
}

Python version if you're on Django or Flask:

# webhooks/stripe.py
import stripe
import requests
import os

def handle_stripe_webhook(request):
    payload = request.body
    sig_header = request.headers.get('Stripe-Signature')

    event = stripe.Webhook.construct_event(
        payload, sig_header, os.environ['STRIPE_WEBHOOK_SECRET']
    )

    if event['type'] == 'checkout.session.completed':
        session = event['data']['object']

        requests.post(
            'https://api.justanalytics.app/v1/events',
            headers={
                'Authorization': f'Bearer {os.environ["JA_API_KEY"]}',
                'Content-Type': 'application/json',
            },
            json={
                'site_id': os.environ['JA_SITE_ID'],
                'event': 'funnel_upgrade_paid',
                'properties': {
                    'plan': session.get('metadata', {}).get('plan', 'unknown'),
                    'amount_cents': session['amount_total'],
                },
            },
        )

    return {'status': 'ok'}

Server-side events don't need a session identifier because they represent outcomes, not steps in a journey. The funnel math works on counts: X people fired step 4, Y people fired step 5, conversion rate is Y/X.

Step 4: View the Funnel in Your Dashboard

Once events are flowing, open JustAnalytics and navigate to Funnels. Create a new funnel with your event names in order:

  1. funnel_pricing_viewed
  2. funnel_trial_clicked
  3. funnel_signup_submitted
  4. funnel_onboarding_completed
  5. funnel_upgrade_paid

The dashboard shows you:

  • Total entries at step 1
  • Drop-off percentage between each step
  • Conversion rate from step 1 to final step
  • Time-series trends (daily/weekly)

If you're coming from GA4, this is the same data you'd get from a funnel exploration report — without the consent mode sampling that makes GA4 funnels unreliable for European traffic. We wrote up the complete GA4 migration guide if you're switching over.

Common Mistakes (And How to Avoid Them)

Firing the same event twice. Easy to do if you have both a page-load event and a button-click event that triggers on the same action. The symptom: step 2 has more entries than step 1, which makes no sense. Audit your event firing locations.

Forgetting server-side events for conversions. If your funnel ends at "submitted signup form" instead of "payment confirmed," you're measuring intent, not revenue. The Stripe webhook matters more than the checkout page view.

Using property values that are too specific. Properties like plan: 'pro-monthly-2026-q2-promo' give you nice drill-down capability but fragment your funnel into tiny slices. Keep property cardinality low — plan: 'pro' or plan: 'enterprise' is usually enough.

Mixing client and server events for the same step. If you fire funnel_signup_submitted from both the browser AND your API endpoint, you'll double-count. Pick one. Server-side is more reliable (no ad blockers), but client-side fires faster (no network round-trip delay).

Not testing the full flow locally. Run through your own funnel in dev mode with the network panel open. Make sure each event fires exactly once at the right moment. I've shipped broken funnels three times. Each time I could've caught it with a five-minute manual test. Embarrassing? Yes. Avoidable? Also yes. If you spot errors during testing, our guide on correlating errors with funnel drop-off shows how to connect the dots.

Advanced: Attribution Without Cookies

You probably want to know which traffic sources drive conversions. UTM parameters work fine — they're in the URL, not in cookies, so no consent required.

Pass the UTM source as an event property:

window.ja?.('event', 'funnel_pricing_viewed', {
  utm_source: new URLSearchParams(window.location.search).get('utm_source'),
  utm_medium: new URLSearchParams(window.location.search).get('utm_medium'),
  utm_campaign: new URLSearchParams(window.location.search).get('utm_campaign'),
});

Then filter your funnel by utm_source in the dashboard. You'll see that Google Ads visitors convert at 2.1% while organic traffic converts at 4.8% — actionable data without tracking individuals.

For click fraud detection on those paid campaigns, ClickzProtect can flag suspicious traffic before it enters your funnel. Saves you from optimizing a funnel that's 30% bot traffic.

What This Won't Fix

Cookieless funnels don't give you individual user journeys. You can't see that "user X visited pricing on Monday, came back Wednesday, and converted Friday." That level of tracking requires persistent identification, which requires consent.

If you need cross-session attribution (multi-touch models, 30-day lookback windows), you're back to cookies and banners. The good news: most conversion optimization doesn't need that data. Fixing a 60% drop-off at step 3 doesn't require knowing who dropped off — just that they did, and from which source. Session replay for onboarding flows can help you see exactly where users get stuck without identifying them.

For B2B with long sales cycles, consider triggering server-side events from your CRM when deals close. You can tie "demo booked" (client-side) to "deal won" (server-side from Salesforce webhook) by company domain rather than individual user ID. Still no cookies needed.

Honestly, I think most teams overthink attribution. Get the basic funnel working first. Optimize the obvious drop-offs. Multi-touch attribution is a distraction until you've fixed the 50% of visitors who leave at step 2.

Frequently Asked Questions

Can I still track individual user journeys without cookies?

Not in the traditional sense — and that's the point. Cookieless funnels track aggregate step completion rates, not individual paths. You see that 340 visitors reached step 2 and 180 reached step 3, giving you a 47% conversion rate between those steps. You don't see that "User A" specifically dropped off at step 3. For most optimization decisions (where are people dropping? which steps need work?), aggregate data is enough. If you need individual journey tracking, you'll need consent and a user ID — at which point you've reintroduced the banner.

For same-session funnels (signup flows, checkout, onboarding), accuracy is nearly identical because the session is continuous. For multi-session funnels (lead nurture over weeks, trial-to-paid conversions), cookieless tracking will undercount because returning visitors look like new ones. The workaround: trigger server-side events from your backend when state changes (trial converts, subscription activates) rather than relying on the browser to tie sessions together.

Does this work with single-page applications?

Yes. SPAs actually make this easier because the session stays alive across "navigations" — there's no page reload to lose context. Fire events on route changes and form submissions just like you would with any SPA analytics setup. The funnel steps accumulate in the same session automatically.

What about Safari's Intelligent Tracking Prevention?

ITP doesn't affect cookieless tracking because there are no cookies to block or expire. First-party server-side events bypass ITP entirely — the browser never touches them. This is one of the reasons server-side funnels have become more popular: they work identically across Safari, Firefox, Brave, and Chrome regardless of privacy settings.


That's it. Cookieless funnels aren't magic — they're just a different mental model. Track steps, not people. Count outcomes, not journeys.

The approach has limits (no cross-session attribution, no individual user paths), but for fixing conversion drop-offs? Works fine. And you don't have to explain cookie banners to your legal team anymore.

Try JustAnalytics

One script, under 5KB. Cookieless analytics + error tracking + APM + session replay + uptime + logs. The free tier handles 100K events/month. Pro is $49/month ($39 if you pay annually).

Start free →

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts