.avif)
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:
I wanted a deterministic, self-serve claim flow that:
Here’s how I designed and implemented it.
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:
Everything else was built around this rule.
From day one, users must fall into exactly one path:
Triggered when the user lands on:
/login?claim=dr-nicolas-cuylits&type=surgeon
Triggered via /pro (explicit onboarding choice).
Once one path is taken, the other is permanently disabled for that user.
This prevents:
Instead of passing claim state through URLs endlessly (fragile, insecure), I store it once in localStorage.
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:
all become stateless and predictable.
I deliberately use one signup form.
The difference happens after auth, not before.
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.
On dashboard load, the system decides what to do.
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:
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:
.update() logic never breaks,This architecture survives:
Most importantly:
claim logic never touches form logic.
That separation is what keeps the system stable.
All of those explode in complexity later.
The claim flow works because it is:
If you are building a directory, marketplace, or professional platform:
decide ownership rules early — everything else becomes easier.