Case Study
Building a Vetted Membership and Paid-Ticketing Platform Under Hard Compliance Constraints
A private membership organization in the adult/lifestyle sector needed a platform that most standard SaaS stacks quietly refuse to serve; mainstream payment processors and hosts restrict adult-adjacent content, and the data involved (identity verification, vetting notes, health documents, member-to-member messages) is unusually sensitive in a community where discretion is the product, not a feature. I built the platform end to end on Next.js, Supabase, and Stripe, delivering a multi-reviewer vetting workflow, a member portal, and a paid-ticketing system with per-category capacity and a waitlist that only charges a member's card when a spot actually opens. The core vetting, member-portal, and ticketing systems are all shipped and functional ahead of a planned launch, on an architecture that can fail payments over to an adult-friendly processor without a code change.

Background & Context
The client is a solo-founded private membership organization preparing to launch a vetted, closed community platform. Unlike an open social network, membership is gated behind a real application: age verification, background and identity checks, references, and interviews. That vetting model is both the product's core value and its central engineering constraint, because it means the platform holds legally and personally sensitive records from the first interaction.
The organization operates in a category that mainstream infrastructure treats as a liability. General-purpose hosts (GoDaddy- and Namecheap-style registrars) name adult-adjacent content as prohibited in their acceptable-use policies. Payment processors are stricter still: the default processor for a launch of this scope may pass underwriting today and restrict or terminate the account tomorrow. Building as if any single vendor is permanent was not an option.
The founder is also the sole operator, which shaped every build decision toward one codebase, one deployable app, and admin tooling that lets one or two people run vetting, payments, moderation, and member support without a five-screen scavenger hunt.
Problem Statement
The Question: How do you build a payments-enabled, data-sensitive community platform on modern infrastructure when the two things you most depend on (your payment processor and your host) can revoke you at any time, and when the data you hold is more sensitive than a typical SaaS app ever touches?
The Problem: The organization needed vetting, a member portal, and paid event ticketing, but off-the-shelf event and membership tools either prohibit the category outright, route discretion-sensitive data through third parties, or lock payments to a single processor. There was no product to buy; it had to be built.
The Challenge: Three constraints ran through every decision. First, payment resilience: losing the ability to take payment at all is the real failure mode, not just losing one product line. Second, least-privilege data access: identity and health-verification documents can be readable only by the narrow vetting role, and every access has to be auditable. Third, member safety and discretion: block/report, anti-stalking directory limits, and no-staff-read messaging are safety requirements, not nice-to-haves, in a community where a leak is more damaging than in most consumer apps.
Methodology / Approach
I built a single Next.js (App Router) application rather than splitting the public marketing site and the gated member portal into separate deployments. Route groups plus session-and-role middleware handle the public/member/admin split cleanly inside one codebase, which is the right tradeoff for a solo operator: separate deployments would buy cache and security isolation at the cost of maintenance overhead one person can't absorb.
The stack:
- Next.js (App Router) for public pages, the gated member portal, and role-gated
/admintooling, all in one app. - Supabase (Postgres, Auth, Storage, Realtime) for authentication, application and vetting records, events, content, file storage, and real-time messaging, with row-level security as the primary authorization boundary rather than app-layer checks alone.
- Stripe as the default processor, integrated behind a processor-agnostic payment abstraction so an adult-friendly processor (CCBill/Epoch) can take over any or all transaction types without rewriting the checkout or ledger code.
The guiding principle was that the sensitive parts of the system (who can read a health document, whether a spoofed webhook can fake a payment, whether a revoked member can still walk into an event) had to be enforced at the data and protocol layer, not just in UI conditionals.
Analysis and Findings
Before building the member portal, I studied the dominant open platform in this space and deliberately borrowed its proven content primitives (structured member profiles, private messaging, albums, block/report) while rejecting the infrastructure it built to manage anonymity and discovery at open-platform scale. A vetted, closed community doesn't have the same problems, so it shouldn't inherit the same complexity. That analysis produced concrete decisions:
- Directory search is deliberately restricted. The member-facing directory does not allow filtering by demographic characteristics. Being vetted doesn't remove the risk of one member using search to target another, so the same anti-stalking restriction that open platforms adopt for anonymity is retained here for safety. The admin side stays unrestricted for legitimate moderation.
- The payment layer is the single biggest resilience risk. The failure to guard against isn't "one product line gets cut off," it's "we can't charge anyone at all." That reframing is why failover is designed to be automatic and processor-agnostic rather than a manual cutover a founder triggers mid-incident.
- The most sensitive data must never sit behind app-layer checks alone. Identity and health-verification documents are stored as access-restricted Storage objects under RLS scoped to the vetting-reviewer role, with every access captured in an audit log.
Solutions and Implementation
Vetting and admin core. A multi-reviewer application queue (claim, recommend, approve, deny) with a full audit log, application-time references that gate approval, and a revocation model that is distinct from a simple denial: a revoked identity is blocked from silently reapplying under different contact details until an admin explicitly lifts the lockout. Alongside it, a safety/incident log with immutable incident facts, a KPI reporting view (approval rate, referral share, revocation rate, safety-escalation rate, all-time and last-30-days), a referral tracker with an append-only credit ledger, and an individual-recipient broadcast tool that never exposes a shared recipient list, to protect member discretion.
Paid ticketing with capacity and a charge-on-promotion waitlist. This was the most interesting engineering work. Events support per-category pricing and capacity (the community tracks attendance by relationship category, shown as remaining slots, never exact headcounts), an optional RSVP cutoff, and Stripe Elements checkout with referral-credit redemption. The waitlist is the standout: for a paid category, a waitlisted member is charged only at the moment they are promoted into a confirmed slot, never before. A confirmed paid RSVP is final sale by default, with a one-step manual admin refund for exceptional cases, deliberately chosen over automatic self-service refunds. Couple RSVPs either link an existing member (who must confirm before it counts toward capacity) or open a guest request for admin approval.
Payment integrity. Checkout runs through a dedicated route; a Stripe webhook handler verifies signatures server-side and is idempotent, so a spoofed or duplicated event can't fake a payment or double-charge. Every transaction is written to a ledger through the processor-agnostic abstraction, so a combined cross-processor reconciliation view slots in once failover is live.
Member portal. Structured member profiles and a directory (with the search restriction above), one-way follows and mutual friend requests, private 1:1 and group messaging over Supabase Realtime with attachments and no staff read access, and a member blog that flows private → submit-for-review → public through the same moderation queue used for applications. A member-facing block/report tool feeds the admin safety log directly: blocking severs and hides relationships mutually; reporting opens a review status visible only to the reporter.
Security posture. RLS-enforced member/reviewer/admin roles with least-privilege document access, rehype-sanitize on every Markdown field (stored-XSS is a real vector once members write content others read), verified payment webhooks, CSRF-protected state-changing admin actions, rate limiting on auth and application endpoints, and an audit log that also captures security-relevant events like unusual access to verification documents.
Conclusion and Lessons Learned
The vetting core, member portal, and paid-ticketing system are all shipped and functional ahead of the organization's planned launch. The platform can run its whole vetting-to-ticketing lifecycle today, and it is built so that the two vendor dependencies most likely to fail (the payment processor and, by extension, the host) can be swapped without a rewrite.
What worked well was treating the hardest constraints as architecture rather than features. Making the payment layer processor-agnostic from the first line meant "what if Stripe drops us" was never a rebuild; pushing authorization into row-level security meant a UI bug can't leak a health document; making the ticketing webhook idempotent from the start meant payment integrity didn't depend on getting retries perfect. The waitlist that charges on promotion is a small idea with an outsized payoff: it removes the refund-churn and the hold-a-charge-you-might-cancel problem in one design decision.
Some things are deliberately deferred rather than half-built: cross-processor reconciliation and the actual second-processor integration wait on account provisioning; photo/video albums and health-document uploads are correctly gated behind a content-safety scanning decision before any user-uploaded media goes live. The broader lesson generalizes past this one client: when the risky dependency is external and can revoke you, the resilient move is to build the seam before you need it, and when the data is sensitive, enforce the boundary at the layer a bug can't cross.
Quotes and Testimonials
[Omitted: no attributable client quote is included, consistent with the confidentiality of this engagement.]
Project README
CARNALIFE
A private membership platform for the kink, fetish, BDSM, and sex-positive lifestyle community. Built with Next.js (App Router) on the frontend and Supabase (Postgres, Auth, Storage, Row Level Security) on the backend.
Product/business context lives in docs/ (Brand Bible, Business Plan, Website Plan, Marketing Campaign, Legal Research). Engineering specs and implementation plans for each build phase live in docs/superpowers/specs/ and docs/superpowers/plans/. A running status summary of what's built vs. planned is in .claude/memory.md.
Repo layout
frontend/ Next.js app (this directory)
backend/ Supabase config, SQL migrations, RLS integration tests
docs/ Product/business docs and engineering specs & plans
Note on the Next.js version: this repo pins a Next.js release with breaking changes relative to older docs/training data. Before writing code that touches routing, middleware, or server/client boundaries, check
frontend/node_modules/next/dist/docs/for the actual current behavior.
Prerequisites
- Node.js
- Docker (for local Supabase)
- Supabase CLI
Getting started
1. Start the local Supabase stack (from backend/):
cd backend
supabase start
This applies every migration in backend/supabase/migrations/ to a fresh local Postgres instance and prints your local API URL and keys. Studio, Mailpit (catches outgoing email locally), and the Postgres connection are all available from the URLs it prints.
2. Configure environment variables (frontend/.env.local, gitignored):
SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_ANON_KEY=<from `supabase status`>
SUPABASE_SERVICE_ROLE_KEY=<from `supabase status`>
STRIPE_SECRET_KEY=<Stripe test key>
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=<Stripe test publishable key — client-side Elements checkout>
STRIPE_IDENTITY_WEBHOOK_SECRET=<Stripe CLI `stripe listen` webhook secret, applicant verification>
STRIPE_PAYMENTS_WEBHOOK_SECRET=<Stripe CLI `stripe listen` webhook secret, ticket payments — separate endpoint/secret from Identity>
RESEND_API_KEY=<optional — omitted, transactional emails log to the console instead of sending>
There is no root .env or .env.example checked in (secrets never belong in git); ask whoever set up the project for real values, or use your own Stripe test-mode keys.
3. Run the dev server (from frontend/):
npm install
npm run dev
Open http://localhost:3000.
Testing
Frontend (Vitest + Testing Library, Supabase client mocked):
cd frontend
npm test # run once
npm run test:watch
Backend (Vitest, run against a real local Postgres via RLS — not mocked; requires supabase start first):
cd backend
npm test
Both suites are expected to be green on master at all times.
Linting & type-checking
cd frontend
npm run lint
npx tsc --noEmit
What's here
Public site (frontend/app/(public)/): homepage, About, sub-brand pages (Revelry, Knowledge live; others coming-soon), consent and vetting-criteria policy pages, the membership application funnel (/apply, with Stripe Identity verification and a self-service reference-confirmation flow), and an application-status page.
Member portal (frontend/app/member/(protected)/, gated by lib/auth/require-active-member.ts): a profile editor with directory-visibility controls, a member directory (browse + detail pages), a friends/follows system (one-way follow with optional approval, plus mutual friend requests), a connections page (Friends/Following/Followers/Blocked), a private-draft-to-public-review member blog (reuses the admin Markdown editor), a support-ticket submission flow, a member-facing block/report tool — block severs and prevents relationships and mutually hides both parties from each other's directory; report feeds the admin safety-incident log with a review status visible only to the reporter — private messaging (1:1 and group threads, real-time via Supabase Realtime, text and file/image attachments, no staff read access to message content), and event listings/RSVP/ticketing (per-category pricing and capacity, a real waitlist for both free and paid categories with automatic promotion on cancellation, a couple RSVP either links a confirming existing member or opens an admin-reviewed guest request, Stripe Elements checkout with referral-credit redemption for priced categories — cancelling a confirmed paid RSVP is a final sale, no refund).
Admin (frontend/app/admin/(protected)/, gated by frontend/proxy.ts + lib/auth/require-staff.ts / require-admin.ts, navigable via a categorized sidebar in the shared layout): a founder-authored blog CMS, the vetting reviewer queue (claim/recommend/approve/deny with a full audit log), members management (revoke/lift with reinstatement lockout enforced at application intake), a referral tracker with an admin-configurable per-referral credit system and a settings page for that amount, a safety/incident log (staff can log an incident directly, or triage member-filed reports — any staff can mark one reviewed, only an admin can link it to a resulting revocation), a KPI/analytics dashboard (Recharts), a broadcast/mass-communication tool (one individual email per recipient, never a shared to:), a member-blog moderation queue, a support-ticket queue, and event management (create/publish/cancel events with per-category pricing and an RSVP cutoff, an attendee roster, guest-request approval, and a payments page with a one-step manual refund action for exceptional cases).
Auth (frontend/app/forgot-password/, frontend/app/reset-password/, frontend/app/api/forgot-password/, frontend/app/api/reset-password/): a shared password-recovery flow serving both /admin/login and /apply/login, built on Supabase Auth's generateLink/exchangeCodeForSession rather than a custom token system, plus phone-number login as an identifier alternative to email (auto-enabled on application approval; one form server-side-detects which identifier type was entered). Password recovery requires <origin>/api/reset-password/callback to be on the Supabase project's Redirect URLs allow-list before it works (see .claude/memory.md).
Backend (backend/supabase/migrations/): one SQL file per schema change, applied in order. RLS is the enforcement layer throughout — the app's Supabase clients rely on policies, not just application-code checks, to gate access by role (member / reviewer / admin). A recurring pattern worth knowing before touching RLS here: a policy that needs to check a fact about an other row's owner (not the caller's own) can't use a plain inline subquery, because the subqueried table's own RLS silently filters the result — the fix each time has been a SECURITY DEFINER helper function that bypasses RLS for that one lookup (is_staff_member(), member_is_active(), members_blocked()).
See .claude/memory.md for the fuller built-vs-planned status by phase.
Key conventions
- Every admin-facing route handler calls a
verifyOrigincheck before anything else, then gates on the session's role via Supabase RLS (requireStaff/requireAdmin), never trusting client-supplied identity. - State-changing actions that matter for audit/compliance write to an append-only log table (
application_reviews,member_revocations,member_revocation_attempts) rather than only updating a status column in place. - Transactional email (
frontend/lib/email/) falls back to a console log whenRESEND_API_KEYisn't set, so the full application/vetting flow works end-to-end in local dev with no email provider configured. - No browser-side Supabase client exists anywhere in this codebase. Every Supabase call happens server-side, in Route Handlers or Server Components (
lib/supabase/server.tsfor service-role,lib/supabase/session-server.tsfor cookie-bound sessions); client components only everfetch()this app's own API routes. - Member-originated writes (profile, posts, follows/friend-requests, blocks, reports) always go through a service-role-backed API route with ownership/target pinned server-side from the verified session, never a direct client-side RLS-gated insert/update.
- Any user- or path-supplied id that gets interpolated into a raw PostgREST
.or()filter string must be validated as UUID format first (,.()are structurally significant to that filter syntax) — every id in this schema is a real UUID, so a value that fails the check can only be malicious input, safe to treat as not-found/400.
Project Gallery
This album serves as the gallery for CARNALIFE.


