Field note / Analytics
PostHog Reported Zero Signups While Supabase Showed One
My analytics funnel said nobody signed up. The authentication database said someone had. Both systems were behaving as configured; my event model was wrong.
I found this discrepancy after launching Life Compass, a small Next.js application backed by Supabase. For the first few days I was watching a PostHog funnel that treated $identify as the closest available proxy for a completed signup. The funnel reported zero completions. Supabase contained one newly confirmed user, one new profile, and one newly created seven-day Pro trial.
This was not an exotic data warehouse problem. It was a redirect, an in-memory identifier, and an event I had never explicitly captured.
The conflicting evidence
I compared both systems over the same launch window, September 1 through September 4 in the project timezone. PostHog described interest at the top of the funnel, but no confirmed identity at the bottom:
| Observation | PostHog | Supabase |
|---|---|---|
| Pageviews | 119 | Not applicable |
| Distinct pageview IDs | 96 | Not applicable |
| Visitors reaching the registration page | 5 | Not applicable |
| Identity step in the ordered funnel | 0 | Not applicable |
| New confirmed users | No explicit event | 1 |
| New trials | No explicit event | 1 |
The number 96 deserves a qualifier. With memory-only persistence, a distinct ID is not necessarily a distinct human across page loads or browser restarts. It is better described as a distinct identifier observed by the analytics client.
The database proved that a signup happened. PostHog proved that my analytics setup could not connect that signup to the earlier anonymous journey.
The configuration that created the gap
I deliberately configured PostHog without cookies or local storage. The client kept its state in memory:
posthog.init(key, {
api_host: 'https://us.i.posthog.com',
persistence: 'memory',
autocapture: true,
});The privacy tradeoff was intentional. The attribution consequence was not. An email signup followed this sequence:
- An anonymous visitor opened the landing page and received an in-memory ID.
- The visitor submitted the registration form.
- Supabase sent a confirmation email.
- The confirmation link caused a full navigation back to the application.
- The new page initialized PostHog with a new anonymous ID.
- The authenticated application called
identify(user.id).
The final call identified the new page session, but the anonymous ID from before the email round trip no longer existed. There was nothing persistent in the browser with which to stitch the pre-confirmation journey.
There was a second, simpler problem: I had never captured a domain event for a successful registration submission or confirmation. I was asking a generic identity operation to stand in for a business event. Those are not the same thing. Existing users also identify, while a new user waiting for email confirmation does not yet do so in the application.
The event model I replaced it with
I split the signup into events that correspond to observable product transitions. For the email flow, the browser now captures signup_submitted only after Supabase accepts a genuinely new identity. Supabase can return a deliberately vague success response for an existing email, so the code excludes the response with an empty identities array before recording the event.
track('signup_submitted', {
method: 'email',
requires_email_confirmation: !data.session,
});This event happens before the email redirect, while the original anonymous session and its acquisition properties still exist. It measures signup intent and can still be attributed to the landing session.
Completion is tied to a database transition
In the server-side auth callback, the application exchanges the Supabase code and attempts to create the user's first trial. The trial function already enforces one trial per user. Only a successful first insert marks the redirect as a newly completed signup; returning users do not receive that marker.
Once the authenticated client loads, it identifies the user and records the two downstream product events:
identifyUser(user.id);
track('signup_completed', {
method: user.app_metadata.provider ?? 'unknown',
});
track('trial_started', {
plan: 'pro',
trial_days: 7,
});The client removes the marker from the URL immediately after capture to prevent a normal refresh from recording the events again.
What this fixes, and what it does not
The revised funnel can answer two different questions without pretending they are one question:
- Did an acquired visitor submit the signup form? Use
signup_submitted, which remains connected to the anonymous landing session. - Did the product create a confirmed account and trial? Use
signup_completedandtrial_started, then reconcile them against Supabase.
Memory-only persistence still cannot provide perfect person-level attribution across the email round trip. The two halves can be compared as aggregate conversion counts, but they should not be presented as a reliably stitched user journey.
The redirect marker is also an analytics coordination mechanism, not a security boundary or billing ledger. Query parameters can be revisited or manipulated. The server decides when to add it and the client de-duplicates normal rendering, which is sufficient for this low-volume diagnostic signal. A higher-risk system should capture the completion server-side or use a signed, single-use handoff.
Why Supabase remains the source of truth
Analytics tools are optimized for behavioral questions: where visitors came from, which page they saw, and which interaction preceded another. The application database owns different facts: whether an account exists, whether its email is confirmed, and whether a subscription row was created.
I now treat PostHog as the explanation layer and Supabase as the accounting layer. A launch report starts with a small reconciliation table rather than a single dashboard:
- Count new auth users and confirmed users in Supabase.
- Count new trials and active subscriptions in Supabase.
- Compare those counts with explicit PostHog domain events.
- Investigate any mismatch before interpreting channel conversion.
This also prevents a missing analytics event from becoming a product conclusion. In my case, “zero signups” could easily have led to rewriting the landing page when the immediate problem was observability.
The funnel now has business semantics
The resulting sequence is intentionally explicit:
$pageview
-> signup_submitted
-> signup_completed
-> trial_started
-> checkout_opened
-> checkout_completed
-> upgrade_successNot every step will be stitchable into one person-level funnel under memory-only persistence, but every step now names an actual product transition. That makes missing data debuggable and lets the database validate the events that matter financially.
A practical checklist
If your auth database and analytics disagree, check these before changing the product:
- Confirm both queries use the same timezone and date boundaries.
- Separate anonymous IDs, identified users, and actual database accounts.
- List every full navigation, OAuth hop, and email confirmation redirect.
- Inspect the analytics persistence mode and what survives each hop.
- Use explicit domain events instead of treating
identifyas conversion. - Exclude anti-enumeration responses that resemble successful registration.
- Reconcile trial and payment events with database rows or provider webhooks.
- Test event de-duplication under refreshes, retries, and React development behavior.
The tradeoff I kept
I did not switch PostHog to persistent browser storage just to make the chart cleaner. Cookieless, memory-only analytics still matches the current privacy posture of the product. The cost is weaker cross-navigation attribution, and that cost is now explicit in both the event model and the way I report conversion.
A trustworthy funnel is not the one with the most perfectly connected lines. It is the one that states what each event proves, what it cannot prove, and which system can independently verify it.