Implementing a Self-Serve Profile Claim Flow Without Moderation Queues (Webflow + Supabase)

Implementing a Self-Serve Profile Claim Flow Without Moderation Queues (Webflow + Supabase)

December 8, 2025
Reading time:
2 min
Table of content
Technical case study on implementing a self-serve profile claim system using Webflow and Supabase, with deterministic ownership rules and a stable, moderation-free architecture.

When building iGlowly, I faced a problem most listing platforms eventually hit:

How do you let professionals claim an existing profile without breaking signup, roles, permissions, or future scalability?

I did not want:

  • a marketplace-style moderation queue,
  • a fragile “email us to claim” process,
  • or logic that blocked users based on plan, role, or timing.

I wanted a deterministic, self-serve claim flow that:

  • works for clinics and surgeons,
  • survives free vs premium plans,
  • does not interfere with normal account creation,
  • and remains auditable and reversible.

Here’s how I designed and implemented it.

1. The Core Constraint: One User = One Listing

Before touching UI or code, I locked one non-negotiable rule:

Each user can own exactly one listing.
No exceptions. No switching later.

That single constraint simplified:

  • permissions,
  • dashboard logic,
  • Stripe coupling,
  • and long-term data integrity.

Everything else was built around this rule.

2. Two Mutually Exclusive Paths

From day one, users must fall into exactly one path:

Path A — Claim an Existing Profile

Triggered when the user lands on:

/login?claim=dr-nicolas-cuylits&type=surgeon

Path B — Create a New Profile

Triggered via /pro (explicit onboarding choice).

Once one path is taken, the other is permanently disabled for that user.

This prevents:

  • duplicate listings,
  • post-hoc role switching,
  • messy “merge” logic later.

3. The Key Idea: Claim Context Lives in localStorage

Instead of passing claim state through URLs endlessly (fragile, insecure), I store it once in localStorage.

Claim bootstrap script (public page)

const params = new URLSearchParams(window.location.search);

if (params.get("claim")) {
 localStorage.setItem("pendingClaim", params.get("claim"));
 localStorage.setItem("pendingClaimType", params.get("type")); // clinic | surgeon
 localStorage.setItem("isClaimer", "true");
}

From this point on:

  • signup,
  • email confirmation,
  • login redirects

all become stateless and predictable.

4. Signup: Same Form, Different Outcome

I deliberately use one signup form.

The difference happens after auth, not before.

After signup (Supabase Auth callback)

const isClaimer = localStorage.getItem("isClaimer") === "true";await supabase  .from("user_roles")  .insert({    id: user.id,    role: selectedRole,    plan: selectedPlan,    is_claimer: isClaimer  });


No branching UI.
No duplicated forms.
Only clean metadata.

5. Dashboard Load: Claim or Create — Automatically

On dashboard load, the system decides what to do.

Claim path

if (isClaimer) {  const slug = localStorage.getItem("pendingClaim");  const { data: listing } = await supabase    .from("surgeons")    .select("*")    .eq("master_slug", slug)    .single();  // Prefill dashboard form  populateForm(listing);}

if (isClaimer) {
 const slug = localStorage.getItem("pendingClaim");

 const { data: listing } = await supabase
   .from("surgeons")
   .select("*")
   .eq("master_slug", slug)
   .single();

 // Prefill dashboard form
 populateForm(listing);
}

On first save only:

await supabase  .from("surgeons")  .update({    claimed_by: user.id,    is_claimed: true  })  .eq("id", listing.id);

await supabase
 .from("surgeons")
 .update({
   claimed_by: user.id,
   is_claimed: true
 })
 .eq("id", listing.id);

After this:

  • the listing is locked to the user,
  • the claim context is cleared,
  • the dashboard behaves like a normal owned profile.

Create path (non-claimers)

If the user is not a claimer:

const { data: existing } = await supabase  .from("surgeons")  .select("id")  .eq("claimed_by", user.id)  .maybeSingle();if (!existing) {  await supabase.from("surgeons").insert({    claimed_by: user.id,    is_claimed: true,    master_slug: null  });}

const { data: existing } = await supabase
 .from("surgeons")
 .select("id")
 .eq("claimed_by", user.id)
 .maybeSingle();

if (!existing) {
 await supabase.from("surgeons").insert({
   claimed_by: user.id,
   is_claimed: true,
   master_slug: null
 });
}

This guarantees:

  • the dashboard always has something to edit,
  • .update() logic never breaks,
  • no conditional spaghetti in form handlers.

6. Why This Holds Up Long-Term

This architecture survives:

  • free → premium upgrades,
  • Stripe checkout redirects,
  • email verification delays,
  • multilingual dashboards,
  • future moderation tooling.

Most importantly:
claim logic never touches form logic.

That separation is what keeps the system stable.

7. What I Explicitly Avoided

  • No “pending approval” state blocking dashboards
  • No role switching after signup
  • No plan-based claim restrictions
  • No shared listings between users

All of those explode in complexity later.

Final Takeaway

The claim flow works because it is:

  • deterministic,
  • path-exclusive,
  • and boring by design.

If you are building a directory, marketplace, or professional platform:
decide ownership rules early — everything else becomes easier.