You can build a Spotify clone using Next.js and Whop's infrastructure to solve problems like user authentication, payments system, and more. Learn how to build such a platform with this guide.

Key takeaways

  • Whop enables developers to build a complete music platform with OAuth, payments, KYC, and payouts through a single integration.
  • Artists keep more revenue by selling premium tracks via one-time Whop Direct Charges, while the platform collects an application fee on each sale.
  • The tutorial combines Next.js, Prisma, Supabase Storage, and Whop's embedded components to deliver uploads, gated playback, checkout, and in-app payout management.
Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

Independent artists need a way to publish their music, charge for premium tracks, handle payments, and receive payouts without giving up 30% to streaming platforms or using five different services.

This tutorial walks through building a music distribution platform with a single integration.

We'll build a fully working music platform where artists can upload songs, set prices, and gate premium tracks behind a one-time payment.

The platform collects a fee on every sale, and artists can withdraw their earnings through an embedded payout portal right inside the app, all powered by Whop.

You can see a demo of the project we'll build here, and the full GitHub repository here.

Project overview

  • Artist sign-up and login via Whop OAuth
  • A dashboard for uploading tracks, setting prices, and toggling between premium/free
  • File uploads for audio and cover art stored directly on Supabase storage
  • Connected account enrollment (Whop handles KYC for artists)
  • A public artist page at /a/[handle] where free songs play instantly and premium songs are locked
  • A one-time payment checkout via Whop Direct Charges with platform application fees
  • Post-payment unlock with Whop redirect verification and webhooks as fallback
  • An embedded payout portal for artists to manage balances and withdrawals
  • Listener playlists: a + button on every song that opens a dropdown to save to any playlist or create one inline, a /library page to browse all saved playlists, and a /library/[id] detail page

Preview the finished product at the live demo, or check the full codebase in the GitHub repository.

Tech stack

  • Next.js
  • React, Tailwind CSS
  • Whop OAuth and Whop Payments Network (@whop/sdk, @whop/embedded-components-react-js, @whop/embedded-components-vanilla-js)
  • PostgreSQL via Prisma
  • Supabase Storage
  • iron-session 8
  • Zod
  • Vercel

Pages

  • / - Public landing page with trending songs, popular artists, and new releases
  • /a/[handle] - Public artist page: free songs play inline, premium songs show price and unlock button
  • /dashboard - Auth-gated artist dashboard (profile, song uploads, earnings, payout portal)
  • /library - Auth-gated listener library with playlist cards
  • /library/[id] - Auth-gated playlist detail with inline players and unlock-on-artist-page links

API routes

  • /api/auth/login - Whop OAuth initiation with PKCE
  • /api/auth/callback - OAuth callback, token exchange, user upsert
  • /api/auth/logout - Session destroy (POST only)
  • /api/upload - Generates a signed Supabase upload URL for audio and cover files
  • /api/earnings/complete - KYC return handler that flips payoutEnabled
  • /api/payout-token - Mints a short-lived Whop access token for the embedded payout portal
  • /api/webhooks/whop - Whop payment webhooks (payment.succeeded, payment.failed)

Payment flow

  1. Artist clicks "Enable Earnings" and the app creates a Whop company under the platform's parent, then redirects to Whop's hosted KYC flow.
  2. Artist publishes a premium song with a price; no Whop product is created upfront.
  3. Listener clicks "Unlock for $X.XX"; the app creates a PENDING Unlock row and a Whop checkout configuration that targets the artist's company with an application fee for the platform.
  4. Listener pays through Whop's hosted checkout and is redirected back to /a/[handle]?payment_id=...; the page verifies the payment with whop.payments.retrieve and flips the unlock to PAID.
  5. Whop fires a payment.succeeded webhook as a fallback that processes the unlock if the redirect didn't complete.
  6. Artist withdraws earnings through the embedded payout portal on the dashboard, powered by @whop/embedded-components-react-js.

Prerequisites

  • Node.js 18+
  • A PostgreSQL database (local, Neon, Supabase, or Railway)
  • A Supabase project for file storage
  • A Whop developer account at whop.com/developer
  • ngrok (for local webhook + OAuth testing)

Step 1: Project setup

Create a new Next.js app and install all project dependencies:

Terminal
npx create-next-app@latest soundify --typescript --tailwind --app --src-dir
cd soundify
npm install -D prisma
npm install @prisma/client @prisma/adapter-pg pg
npm install iron-session zod @supabase/supabase-js
npm install @whop/sdk @whop/embedded-components-react-js @whop/embedded-components-vanilla-js

This creates a folder soundify with the Next.js app and installs Prisma, the Supabase client, the Whop SDK, iron-session, and Zod.

Set up the database schema:

Terminal
npx prisma init

This creates prisma/schema.prisma and a .env file. Add this code to schema.prisma:

schema.prisma
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["driverAdapters"]
}

datasource db {
  provider = "postgresql"
}

model User {
  id         String         @id @default(cuid())
  whopUserId String         @unique
  email      String?
  createdAt  DateTime       @default(now())
  updatedAt  DateTime       @updatedAt
  creator    Artist?
  playlists  UserPlaylist[]
}

model Artist {
  id             String   @id @default(cuid())
  userId         String   @unique
  user           User     @relation(fields: [userId], references: [id])
  handle         String   @unique
  displayName    String
  bio            String?
  avatarUrl      String?
  whopCompanyId  String?
  payoutEnabled  Boolean  @default(false)
  applicationFee Int      @default(50)
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
  songs          Song[]
  unlocks        Unlock[]
}

model Song {
  id            String             @id @default(cuid())
  artistId      String
  artist        Artist             @relation(fields: [artistId], references: [id])
  title         String
  description   String?
  coverUrl      String?
  audioUrl      String
  previewUrl    String?
  duration      Int                @default(0)
  isPremium     Boolean            @default(false)
  price         Int                @default(199)
  plays         Int                @default(0)
  createdAt     DateTime           @default(now())
  updatedAt     DateTime           @updatedAt
  unlocks       Unlock[]
  playlistSongs UserPlaylistSong[]
}

model UserPlaylist {
  id        String             @id @default(cuid())
  userId    String
  user      User               @relation(fields: [userId], references: [id])
  name      String
  createdAt DateTime           @default(now())
  updatedAt DateTime           @updatedAt
  songs     UserPlaylistSong[]
}

model UserPlaylistSong {
  id         String       @id @default(cuid())
  playlistId String
  playlist   UserPlaylist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
  songId     String
  song       Song         @relation(fields: [songId], references: [id], onDelete: Cascade)
  position   Int
  addedAt    DateTime     @default(now())

  @@unique([playlistId, songId])
}

model Unlock {
  id              String       @id @default(cuid())
  artistId        String
  artist          Artist       @relation(fields: [artistId], references: [id])
  songId          String
  song            Song         @relation(fields: [songId], references: [id])
  buyerWhopUserId String?
  buyerEmail      String?
  status          UnlockStatus @default(PENDING)
  whopPaymentId   String?      @unique
  createdAt       DateTime     @default(now())
  updatedAt       DateTime     @updatedAt
}

enum UnlockStatus {
  PENDING
  PAID
  FAILED
  REFUNDED
}

The schema has six models:

  • User stores the Whop identity after login
  • Artist is the creator profile with the public handle and the Whop company ID used for payouts
  • Song holds the audio/cover URLs and the premium flag
  • Unlock is the payment record: it starts PENDING when checkout begins and flips to PAID when payment completes
  • UserPlaylist is a listener-owned playlist, and UserPlaylistSong is the junction table that links songs to playlists with a position integer for ordering and a unique constraint on [playlistId, songId] to prevent duplicates
  • applicationFee defaults to 50 cents, the cut the platform takes on every sale.

Create a database for the project, then update DATABASE_URL in the .env file:

.env
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/DB_NAME?schema=public"

Every time we save a file in development, Next.js restarts parts of the app. Each restart creates a new database connection, which can pile up open connections until the database rejects new ones.

The singleton pattern solves this by keeping one shared client alive for the lifetime of the dev server. In production this isn't an issue: the app starts once, so the caching logic is skipped entirely.

Create a Prisma client singleton at src/lib/prisma.ts:

prisma.ts
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

function createPrismaClient() {
  const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL as string });
  return new PrismaClient({
    adapter,
    log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
  });
}

export const prisma = globalForPrisma.prisma ?? createPrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

This uses PrismaPg, a driver adapter that connects Prisma to PostgreSQL without a native binary.

Run the migration:

Terminal
npx prisma migrate dev --name init

Before writing any next/image component that loads from Supabase, we need to whitelist the domain. Open next.config.ts and replace its contents:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  reactCompiler: true,
  allowedDevOrigins: [
    "*.ngrok-free.app",
    "*.ngrok.io",
  ],
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "*.supabase.co",
        pathname: "/storage/v1/object/public/**",
      },
    ],
  },
  experimental: {
    serverActions: {
      bodySizeLimit: "50mb",
    },
  },
};

export default nextConfig;

The fonts for the app come from next/font/google, registered as CSS variables so any component can reference them via var(--font-bricolage). Open src/app/layout.tsx and replace its contents:

layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono, Bricolage_Grotesque } from "next/font/google";
import "./globals.css";

const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
const bricolage = Bricolage_Grotesque({
  variable: "--font-bricolage",
  subsets: ["latin"],
  weight: ["400", "500", "600", "700", "800"],
});

export const metadata: Metadata = {
  title: "Soundify",
  description: "Publish your music. Get paid.",
};

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en" className={`${geistSans.variable} ${geistMono.variable} ${bricolage.variable} h-full`}>
      <body className="min-h-full bg-white text-black antialiased" suppressHydrationWarning>
        {children}
      </body>
    </html>
  );
}

The homepage is a public browse page that lists trending songs, popular artists, and new releases. It uses revalidate = 60 so Next.js refetches the data at most once a minute. Open src/app/page.tsx and replace its contents:

page.tsx
import Link from "next/link";
import Image from "next/image";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";

export const revalidate = 60;

export default async function Home() {
  const [session, trendingSongs, newReleases, artists] = await Promise.all([
    getSession(),
    prisma.song.findMany({
      take: 6,
      orderBy: { plays: "desc" },
      include: { artist: { select: { displayName: true, handle: true } } },
    }),
    prisma.song.findMany({
      take: 6,
      orderBy: { createdAt: "desc" },
      include: { artist: { select: { displayName: true, handle: true } } },
    }),
    prisma.artist.findMany({
      take: 6,
      orderBy: { createdAt: "desc" },
      include: { _count: { select: { songs: true } } },
    }),
  ]);

  const userId = session.userId ?? null;

  return (
    <div className="flex h-screen overflow-hidden" style={{ background: "#121212", color: "#fff" }}>
      {/* Left Sidebar */}
      <aside className="flex-shrink-0 flex flex-col h-full py-6 px-3" style={{ width: 220, background: "#000" }}>
        <Link href="/" className="px-3 mb-7 text-xl font-extrabold tracking-tight block"
          style={{ fontFamily: "var(--font-bricolage)", color: "#fff" }}>
          soundify
        </Link>
        <nav className="flex flex-col gap-1">
          <SidebarLink href="/" icon={<IconHome />} label="Home" active />
          {userId && <SidebarLink href="/library" icon={<IconLibrary />} label="Your Library" />}
          {userId && <SidebarLink href="/dashboard" icon={<IconDashboard />} label="Dashboard" />}
        </nav>
        <div className="flex-1" />
        {!userId && (
          <div className="px-3 flex flex-col gap-3">
            <a href="/api/auth/login" className="text-center text-sm font-semibold py-3 rounded-full"
              style={{ background: "#7c3aed", color: "#fff" }}>
              Sign up free
            </a>
            <a href="/api/auth/login" className="text-center text-sm font-semibold py-3 rounded-full"
              style={{ border: "1px solid rgba(255,255,255,0.2)", color: "rgba(255,255,255,0.7)" }}>
              Log in
            </a>
          </div>
        )}
      </aside>

      {/* Main content */}
      <main className="flex-1 overflow-y-auto px-8 py-6">
        {trendingSongs.length > 0 && (
          <Section title="Trending Songs">
            <CardGrid>
              {trendingSongs.map((song) => <SongCard key={song.id} song={song} />)}
            </CardGrid>
          </Section>
        )}
        {artists.length > 0 && (
          <Section title="Popular Artists">
            <CardGrid>
              {artists.map((artist) => <ArtistCard key={artist.id} artist={artist} />)}
            </CardGrid>
          </Section>
        )}
        {newReleases.length > 0 && (
          <Section title="New Releases">
            <CardGrid>
              {newReleases.map((song) => <SongCard key={song.id} song={song} />)}
            </CardGrid>
          </Section>
        )}
        {trendingSongs.length === 0 && artists.length === 0 && (
          <div className="h-full flex flex-col items-center justify-center text-center">
            <p className="text-sm mb-6" style={{ color: "rgba(255,255,255,0.5)" }}>
              Be the first artist to share your music on Soundify.
            </p>
            <a href="/api/auth/login" className="text-sm font-semibold px-6 py-2.5 rounded-full"
              style={{ background: "#7c3aed", color: "#fff" }}>
              Start sharing music
            </a>
          </div>
        )}
      </main>
    </div>
  );
}

function SidebarLink({ href, icon, label, active = false }: {
  href: string; icon: React.ReactNode; label: string; active?: boolean;
}) {
  return (
    <Link href={href}
      className="flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-semibold transition-colors"
      style={{ color: active ? "#fff" : "rgba(255,255,255,0.5)", background: active ? "rgba(255,255,255,0.06)" : "transparent" }}>
      {icon}{label}
    </Link>
  );
}

function Section({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <div className="mb-8">
      <h2 className="text-lg font-bold mb-4" style={{ fontFamily: "var(--font-bricolage)" }}>{title}</h2>
      {children}
    </div>
  );
}

function CardGrid({ children }: { children: React.ReactNode }) {
  return (
    <div style={{ display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: "12px" }}>
      {children}
    </div>
  );
}

type SongWithArtist = {
  id: string; title: string; coverUrl: string | null;
  isPremium: boolean; price: number; plays: number;
  artist: { displayName: string; handle: string };
};

function SongCard({ song }: { song: SongWithArtist }) {
  return (
    <Link href={`/a/${song.artist.handle}`} className="group rounded-lg p-3 transition-colors"
      style={{ background: "rgba(255,255,255,0.04)", minWidth: 0 }}>
      <div className="relative w-full rounded-md overflow-hidden mb-3"
        style={{ aspectRatio: "1", background: "linear-gradient(135deg, #3b1f6e 0%, #7c3aed 100%)" }}>
        {song.coverUrl ? (
          <Image src={song.coverUrl} alt={song.title} fill
            className="object-cover transition-transform duration-300 group-hover:scale-105" sizes="(max-width: 1200px) 16vw, 160px" />
        ) : (
          <div className="w-full h-full flex items-center justify-center">
            <svg className="w-8 h-8" fill="rgba(255,255,255,0.4)" viewBox="0 0 24 24">
              <path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
            </svg>
          </div>
        )}
        <div className="absolute bottom-2 right-2 w-9 h-9 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 translate-y-2 group-hover:translate-y-0 shadow-lg"
          style={{ background: "#7c3aed" }}>
          <svg className="w-4 h-4 ml-0.5" fill="#fff" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
        </div>
      </div>
      <p className="font-semibold text-xs truncate" style={{ color: "#fff" }}>{song.title}</p>
      <p className="text-xs truncate mt-0.5" style={{ color: "rgba(255,255,255,0.5)" }}>{song.artist.displayName}</p>
      <p className="text-xs mt-1 font-medium" style={{ color: song.isPremium ? "#a78bfa" : "rgba(255,255,255,0.3)" }}>
        {song.isPremium ? `$${(song.price / 100).toFixed(2)}` : "Free"}
      </p>
    </Link>
  );
}

type ArtistWithCount = {
  id: string; handle: string; displayName: string;
  avatarUrl: string | null; _count: { songs: number };
};

function ArtistCard({ artist }: { artist: ArtistWithCount }) {
  return (
    <Link href={`/a/${artist.handle}`} className="group rounded-lg p-3 text-center transition-colors"
      style={{ background: "rgba(255,255,255,0.04)", minWidth: 0 }}>
      <div className="relative rounded-full overflow-hidden mb-3 mx-auto"
        style={{ width: "80%", aspectRatio: "1", background: "linear-gradient(135deg, #3b1f6e 0%, #7c3aed 100%)" }}>
        {artist.avatarUrl ? (
          <Image src={artist.avatarUrl} alt={artist.displayName} fill
            className="object-cover transition-transform duration-300 group-hover:scale-105" sizes="(max-width: 1200px) 13vw, 130px" />
        ) : (
          <div className="w-full h-full flex items-center justify-center">
            <span className="text-2xl font-extrabold text-white uppercase"
              style={{ fontFamily: "var(--font-bricolage)" }}>{artist.displayName[0]}</span>
          </div>
        )}
      </div>
      <p className="font-semibold text-xs truncate" style={{ color: "#fff" }}>{artist.displayName}</p>
      <p className="text-xs mt-0.5" style={{ color: "rgba(255,255,255,0.5)" }}>Artist</p>
    </Link>
  );
}

function IconHome() {
  return <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M12 3L2 12h3v9h6v-5h2v5h6v-9h3L12 3z" /></svg>;
}
function IconLibrary() {
  return <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z" /></svg>;
}
function IconDashboard() {
  return <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" /></svg>;
}

Whop requires an https:// URL for OAuth callbacks and webhook delivery; plain http://localhost won't work. Start ngrok to get a stable public HTTPS tunnel to our local dev server:

Terminal
npx ngrok http 3000

Copy the https:// URL ngrok prints; we'll need it in the next step.

Checkpoint

  1. npm run dev boots the Next.js app at http://localhost:3000.
  2. ngrok prints a public https://*.ngrok-free.app URL that loads the same homepage.
  3. npx prisma migrate dev --name init creates the database tables without errors.
  4. prisma/schema.prisma defines User, Artist, Song, UserPlaylist, UserPlaylistSong, and Unlock.
  5. The homepage renders an empty-state message; no songs are listed yet.

Step 2: Setting up Whop

Now, let's follow the steps below to get all the secret keys we need from Whop:

  1. Go to whop.com/developer and create a new app.
  2. Under OAuth / Redirect URIs, add the ngrok callback URL: https://your-ngrok-url.ngrok-free.app/api/auth/callback.
  3. Copy Client ID into WHOP_CLIENT_ID.
  4. Copy Client Secret into WHOP_CLIENT_SECRET.
  5. Create an API Key with all permissions enabled and copy it into WHOP_API_KEY.
  6. From the company dashboard, go to Settings and copy the Company ID (starts with biz_) into WHOP_PARENT_COMPANY_ID.
  7. Under Webhooks, add https://your-ngrok-url.ngrok-free.app/api/webhooks/whop, enable payment.succeeded and payment.failed, and copy the signing secret into WHOP_WEBHOOK_SECRET.
  8. Generate a session secret with openssl rand -base64 32 and copy it into SESSION_SECRET.

Replace the placeholders in the .env file with the real values from above:

.env
# Database
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/DB_NAME?schema=public"

# Whop OAuth: from https://whop.com/developer, your app, OAuth settings
WHOP_CLIENT_ID="app_xxxxxxxxxxxx"
WHOP_CLIENT_SECRET="apik_xxxxxxxxxxxx"
WHOP_REDIRECT_URI="https://your-ngrok-url.ngrok-free.app/api/auth/callback"

# Whop API: platform-level key with all permissions enabled
WHOP_API_KEY="apik_xxxxxxxxxxxx"
WHOP_PARENT_COMPANY_ID="biz_xxxxxxxxxxxx"

# Whop OAuth base URL: omit for production, set for sandbox
WHOP_OAUTH_BASE="https://sandbox-api.whop.com"

# Whop SDK base URL: omit for production, set for sandbox
WHOP_BASE_URL="https://sandbox-api.whop.com/api/v1"

# Whop Webhook: from https://whop.com/developer, your app, Webhooks
WHOP_WEBHOOK_SECRET="whsec_xxxxxxxxxxxx"

# Embedded components environment: "sandbox" or "production"
NEXT_PUBLIC_WHOP_ENV="sandbox"

# Session secret: generate via terminal with `openssl rand -base64 32`
SESSION_SECRET=""

# App URL: ngrok URL while developing
NEXT_PUBLIC_APP_URL="https://your-ngrok-url.ngrok-free.app"

# Supabase: from the Supabase project settings
SUPABASE_URL="https://xxxxxxxxxxxx.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="eyJ..."

Callout: Whop provides a full sandbox at sandbox-api.whop.com; all payments are fake. We use it for local development. To go live, remove the WHOP_BASE_URL and WHOP_OAUTH_BASE lines and set NEXT_PUBLIC_WHOP_ENV=production.

Setting up Supabase storage

In the Supabase project dashboard, go to Storage and create three buckets: songs, covers, and previews. For each bucket, open its settings and turn on Public bucket so the URLs work without auth tokens. Without this, the audio player and cover images fail silently: the URLs resolve but return a 400.

Checkpoint

  1. The Whop developer app is created and the OAuth redirect URI matches the ngrok callback URL exactly.
  2. .env contains values for WHOP_CLIENT_ID, WHOP_CLIENT_SECRET, WHOP_API_KEY, WHOP_PARENT_COMPANY_ID, WHOP_WEBHOOK_SECRET, and SESSION_SECRET.
  3. WHOP_BASE_URL and WHOP_OAUTH_BASE point at sandbox-api.whop.com and NEXT_PUBLIC_WHOP_ENV is sandbox.
  4. The three Supabase buckets songs, covers, and previews exist and are marked public.
  5. SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are filled in.

Step 3: Set up the Whop SDK client

Create src/lib/whop.ts:

whop.ts
import { Whop } from "@whop/sdk";

const WHOP_API_BASE = "https://api.whop.com/api/v1";

export const whop = new Whop({
  apiKey: process.env.WHOP_API_KEY,
  baseURL: WHOP_API_BASE,
});

export function whopAsUser(oauthToken: string) {
  return new Whop({
    apiKey: oauthToken,
    baseURL: WHOP_API_BASE,
  });
}

whopAsUser creates a client scoped to a specific user's OAuth token, for API calls made on behalf of a logged-in creator.

Checkpoint

  1. src/lib/whop.ts exists and exports both whop and whopAsUser.
  2. Importing whop from any server file does not throw at module load.
  3. The platform-level WHOP_API_KEY is set to the value from the Whop developer dashboard.

Step 4: Implement Whop OAuth

Whop uses OAuth 2.1 with PKCE. The server generates a code_verifier, hashes it into a code_challenge, and sends the hash to Whop. When the user returns with an authorization code, the server sends the original verifier to exchange it for tokens.

After OAuth completes, we store the user's ID in an encrypted cookie via iron-session. Go to src/lib/ and create a file called session.ts:

session.ts
import { getIronSession, SessionOptions } from "iron-session";
import { cookies } from "next/headers";

export interface SessionData {
  userId?: string;
  whopUserId?: string;
}

const sessionOptions: SessionOptions = {
  cookieName: "snd_session",
  password: process.env.SESSION_SECRET as string,
  cookieOptions: {
    secure: process.env.NODE_ENV === "production",
    httpOnly: true,
    sameSite: "lax",
  },
};

export async function getSession() {
  const cookieStore = await cookies();
  return getIronSession<SessionData>(cookieStore, sessionOptions);
}

export async function getCurrentUserId(): Promise<string | null> {
  const session = await getSession();
  return session.userId ?? null;
}

The login route generates PKCE values, stores the verifier in a short-lived cookie, and redirects to Whop's authorize endpoint. Go to src/app/api/auth/login/ and create a file called route.ts:

route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import crypto from "crypto";

function base64url(buf: Buffer) {
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}

export async function GET() {
  const codeVerifier = base64url(crypto.randomBytes(32));
  const codeChallenge = base64url(
    crypto.createHash("sha256").update(codeVerifier).digest()
  );
  const state = base64url(crypto.randomBytes(16));
  const nonce = base64url(crypto.randomBytes(16));

  const cookieStore = await cookies();
  cookieStore.set("pkce_verifier", codeVerifier, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 60 * 10,
    path: "/",
  });
  cookieStore.set("oauth_state", state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 60 * 10,
    path: "/",
  });

  const params = new URLSearchParams({
    client_id: process.env.WHOP_CLIENT_ID as string,
    redirect_uri: process.env.WHOP_REDIRECT_URI as string,
    response_type: "code",
    scope: "openid profile email",
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });

  const authUrl = `${process.env.WHOP_OAUTH_BASE}/oauth/authorize?${params}`;
  return NextResponse.redirect(authUrl);
}

The callback route exchanges the code for tokens, upserts the user, saves the session, and redirects to the dashboard. Go to src/app/api/auth/callback/ and create a file called route.ts:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSession } from "@/lib/session";
import { prisma } from "@/lib/prisma";

function decodeJwt(token: string) {
  const payload = token.split(".")[1];
  const decoded = Buffer.from(payload, "base64url").toString("utf-8");
  return JSON.parse(decoded) as { sub: string; email?: string };
}

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const code = searchParams.get("code");
  const returnedState = searchParams.get("state");

  const cookieStore = await cookies();
  const storedState = cookieStore.get("oauth_state")?.value;
  const codeVerifier = cookieStore.get("pkce_verifier")?.value;

  if (!code || !returnedState || returnedState !== storedState || !codeVerifier) {
    return NextResponse.json({ error: "Invalid OAuth state" }, { status: 400 });
  }

  const tokenEndpoint = `${process.env.WHOP_BASE_URL}/oauth/token`;
  const tokenRes = await fetch(tokenEndpoint, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      client_id: process.env.WHOP_CLIENT_ID as string,
      client_secret: process.env.WHOP_CLIENT_SECRET as string,
      redirect_uri: process.env.WHOP_REDIRECT_URI as string,
      code,
      code_verifier: codeVerifier,
    }),
  });

  const rawText = await tokenRes.text();

  if (!tokenRes.ok) {
    console.error("Token exchange failed:", tokenRes.status, rawText);
    return NextResponse.json(
      { error: "Token exchange failed", detail: rawText },
      { status: 400 }
    );
  }

  let tokens: { id_token: string; access_token: string };
  try {
    tokens = JSON.parse(rawText);
  } catch {
    console.error("Token response is not JSON:", rawText);
    return NextResponse.json({ error: "Invalid token response" }, { status: 500 });
  }
  const { sub, email } = decodeJwt(tokens.id_token);

  const user = await prisma.user.upsert({
    where: { whopUserId: sub },
    update: { email },
    create: { whopUserId: sub, email },
  });

  const session = await getSession();
  session.userId = user.id;
  session.whopUserId = sub;
  await session.save();

  cookieStore.delete("pkce_verifier");
  cookieStore.delete("oauth_state");

  return NextResponse.redirect(new URL("/dashboard", process.env.NEXT_PUBLIC_APP_URL as string));
}

The logout route destroys the session and redirects home. It uses POST so link prefetchers can't accidentally log the user out. Go to src/app/api/auth/logout/ and create a file called route.ts:

route.ts
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";

export async function POST() {
  const session = await getSession();
  session.destroy();
  return NextResponse.redirect(new URL("/", process.env.NEXT_PUBLIC_APP_URL as string));
}

The dashboard layout guards the whole dashboard tree: without a session cookie, the user is redirected home. Go to src/app/dashboard/ and create a file called layout.tsx:

layout.tsx
import { redirect } from "next/navigation";
import { getCurrentUserId } from "@/lib/session";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const userId = await getCurrentUserId();
  if (!userId) {
    redirect("/");
  }

  return <>{children}</>;
}

Checkpoint

  1. Hitting /api/auth/login redirects to Whop's sandbox login page.
  2. Logging in returns to /dashboard and the snd_session cookie is set in the browser's devtools.
  3. A row appears in the User table with the Whop user ID.
  4. Hitting /dashboard while logged out redirects home.
  5. Submitting the log-out form clears the cookie and lands back on /.

Step 5: Build the artist dashboard

The dashboard has three sections: Profile, Songs, and Earnings. Mutations run through Next.js Server Actions.

The profile action validates with Zod, checks the handle isn't taken, and upserts the Artist row. Go to src/app/actions/ and create a file called artist.ts:

artist.ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { getCurrentUserId } from "@/lib/session";
import { prisma } from "@/lib/prisma";

const profileSchema = z.object({
  handle: z
    .string()
    .min(2, "Handle must be at least 2 characters")
    .max(32, "Handle must be at most 32 characters")
    .regex(/^[a-z0-9_-]+$/, "Handle must be lowercase alphanumeric, dash, or underscore"),
  displayName: z.string().min(1, "Display name required").max(80, "Display name max 80 characters"),
  bio: z.string().max(300, "Bio max 300 characters").optional(),
});

export type ProfileFormState = {
  errors?: Record<string, string[]>;
  success?: boolean;
  message?: string;
};

export async function saveProfile(
  _prev: ProfileFormState,
  formData: FormData
): Promise<ProfileFormState> {
  const userId = await getCurrentUserId();
  if (!userId) return { message: "Not authenticated" };

  const raw = {
    handle: formData.get("handle")?.toString().toLowerCase() ?? "",
    displayName: formData.get("displayName")?.toString() ?? "",
    bio: formData.get("bio")?.toString() ?? "",
  };

  const result = profileSchema.safeParse(raw);
  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }

  const { handle, displayName, bio } = result.data;

  const existing = await prisma.artist.findUnique({ where: { handle } });
  if (existing && existing.userId !== userId) {
    return { errors: { handle: ["Handle already taken"] } };
  }

  await prisma.artist.upsert({
    where: { userId },
    update: { handle, displayName, bio: bio || null },
    create: { userId, handle, displayName, bio: bio || null },
  });

  revalidatePath("/dashboard");
  return { success: true };
}

The song actions handle upload, delete, premium toggle, and price update, each gated by an ownership check. uploadSong receives URLs from a direct Supabase upload (covered in Step 6) and just stores them. Go to src/app/actions/ and create a file called songs.ts:

songs.ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { getCurrentUserId } from "@/lib/session";
import { prisma } from "@/lib/prisma";

export type SongFormState = {
  errors?: Record<string, string[]>;
  success?: boolean;
  message?: string;
};

export async function uploadSong(
  _prev: SongFormState,
  formData: FormData
): Promise<SongFormState> {
  const userId = await getCurrentUserId();
  if (!userId) return { message: "Not authenticated" };

  const artist = await prisma.artist.findUnique({ where: { userId } });
  if (!artist) return { message: "Create a profile first" };

  const title = formData.get("title")?.toString() ?? "";
  const description = formData.get("description")?.toString() ?? "";
  const priceStr = formData.get("price")?.toString() ?? "1.99";
  const isPremium = formData.get("isFree") !== "on";
  const audioUrl = formData.get("audioUrl")?.toString() ?? "";
  const coverUrl = formData.get("coverUrl")?.toString() || null;

  const schema = z.object({
    title: z.string().min(1, "Title required").max(100, "Title max 100 chars"),
    price: z
      .number()
      .min(0.99, "Minimum price $0.99")
      .max(50, "Maximum price $50"),
  });

  const priceNum = parseFloat(priceStr);
  const result = schema.safeParse({ title, price: priceNum });
  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }

  if (!audioUrl) {
    return { errors: { audioFile: ["Audio file required"] } };
  }

  try {
    await prisma.song.create({
      data: {
        artistId: artist.id,
        title,
        description: description || null,
        audioUrl,
        coverUrl,
        previewUrl: null,
        duration: 0,
        isPremium,
        price: Math.round(result.data.price * 100),
      },
    });

    revalidatePath("/dashboard");
    return { success: true };
  } catch (err) {
    return { message: err instanceof Error ? err.message : "Upload failed" };
  }
}

export async function deleteSong(
  _prev: SongFormState,
  formData: FormData
): Promise<SongFormState> {
  const userId = await getCurrentUserId();
  if (!userId) return { message: "Not authenticated" };

  const songId = formData.get("songId")?.toString();
  if (!songId) return { message: "Missing song ID" };

  const artist = await prisma.artist.findUnique({ where: { userId } });
  if (!artist) return { message: "Artist not found" };

  const song = await prisma.song.findUnique({ where: { id: songId } });
  if (!song || song.artistId !== artist.id) return { message: "Not authorized" };

  await prisma.song.delete({ where: { id: songId } });
  revalidatePath("/dashboard");
  return { success: true };
}

export async function togglePremium(
  _prev: SongFormState,
  formData: FormData
): Promise<SongFormState> {
  const userId = await getCurrentUserId();
  if (!userId) return { message: "Not authenticated" };

  const songId = formData.get("songId")?.toString();
  if (!songId) return { message: "Missing song ID" };

  const artist = await prisma.artist.findUnique({ where: { userId } });
  if (!artist) return { message: "Artist not found" };

  const song = await prisma.song.findUnique({ where: { id: songId } });
  if (!song || song.artistId !== artist.id) return { message: "Not authorized" };

  await prisma.song.update({
    where: { id: songId },
    data: { isPremium: !song.isPremium },
  });

  revalidatePath("/dashboard");
  return { success: true };
}

export async function updateSongPrice(
  _prev: SongFormState,
  formData: FormData
): Promise<SongFormState> {
  const userId = await getCurrentUserId();
  if (!userId) return { message: "Not authenticated" };

  const songId = formData.get("songId")?.toString();
  const priceStr = formData.get("price")?.toString() ?? "";
  const priceNum = parseFloat(priceStr);

  if (!songId) return { message: "Missing song ID" };
  if (isNaN(priceNum) || priceNum < 0.99 || priceNum > 50) {
    return { errors: { price: ["Price must be between $0.99 and $50.00"] } };
  }

  const artist = await prisma.artist.findUnique({ where: { userId } });
  if (!artist) return { message: "Artist not found" };

  const song = await prisma.song.findUnique({ where: { id: songId } });
  if (!song || song.artistId !== artist.id) return { message: "Not authorized" };

  await prisma.song.update({
    where: { id: songId },
    data: { price: Math.round(priceNum * 100) },
  });

  revalidatePath("/dashboard");
  return { success: true };
}

The profile form wires saveProfile to the form's action and uses useActionState for inline validation errors. Go to src/app/dashboard/ and create a file called ProfileForm.tsx:

ProfileForm.tsx
"use client";

import { useActionState } from "react";
import { saveProfile, type ProfileFormState } from "@/app/actions/artist";
import type { Artist } from "@prisma/client";

const initialState: ProfileFormState = {};

const inputClass = "w-full rounded-lg px-3 py-2.5 text-sm outline-none transition-shadow";
const inputStyle = {
  background: "rgba(255,255,255,0.06)",
  border: "1px solid rgba(255,255,255,0.1)",
  color: "#fff",
};

export function ProfileForm({ artist }: { artist: Artist | null }) {
  const [state, action, pending] = useActionState(saveProfile, initialState);

  return (
    <form action={action} className="space-y-5">
      <div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
        <div>
          <label className="block text-sm font-medium text-white mb-1.5">Handle</label>
          <div
            className="flex rounded-lg overflow-hidden"
            style={{ border: "1px solid rgba(255,255,255,0.1)" }}
          >
            <span
              className="flex items-center px-3 text-sm font-mono select-none border-r"
              style={{ background: "rgba(255,255,255,0.04)", color: "rgba(255,255,255,0.4)", borderColor: "rgba(255,255,255,0.1)" }}
            >
              /a/
            </span>
            <input
              name="handle"
              type="text"
              defaultValue={artist?.handle ?? ""}
              placeholder="your-handle"
              className="flex-1 px-3 py-2.5 text-sm outline-none"
              style={{ background: "transparent", color: "#fff" }}
            />
          </div>
          {state.errors?.handle && (
            <p className="text-xs text-red-400 mt-1.5">{state.errors.handle[0]}</p>
          )}
        </div>

        <div>
          <label className="block text-sm font-medium text-white mb-1.5">Display Name</label>
          <input
            name="displayName"
            type="text"
            defaultValue={artist?.displayName ?? ""}
            placeholder="Your Name"
            className={inputClass}
            style={inputStyle}
          />
          {state.errors?.displayName && (
            <p className="text-xs text-red-400 mt-1.5">{state.errors.displayName[0]}</p>
          )}
        </div>
      </div>

      <div>
        <label className="block text-sm font-medium text-white mb-1.5">
          Bio <span className="font-normal" style={{ color: "rgba(255,255,255,0.3)" }}>(optional)</span>
        </label>
        <textarea
          name="bio"
          defaultValue={artist?.bio ?? ""}
          placeholder="Tell listeners about yourself..."
          rows={3}
          className={inputClass + " resize-none"}
          style={inputStyle}
        />
        {state.errors?.bio && (
          <p className="text-xs text-red-400 mt-1.5">{state.errors.bio[0]}</p>
        )}
      </div>

      {state.message && <p className="text-sm text-red-400">{state.message}</p>}
      {state.success && (
        <div className="flex items-center gap-2 text-sm text-emerald-400">
          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
          </svg>
          Profile saved successfully.
        </div>
      )}

      <button
        type="submit"
        disabled={pending}
        className="inline-flex items-center justify-center text-white text-sm font-semibold px-5 py-2.5 rounded-full transition-all disabled:opacity-50"
        style={{ background: "#7c3aed" }}
      >
        {pending ? "Saving…" : "Save Profile"}
      </button>
    </form>
  );
}

Each song row has its toggle and delete buttons in separate forms so each can call its own server action. Go to src/app/dashboard/ and create a file called SongRow.tsx:

SongRow.tsx
"use client";

import { useActionState } from "react";
import Image from "next/image";
import { togglePremium, deleteSong, type SongFormState } from "@/app/actions/songs";
import type { Song } from "@prisma/client";

const initialState: SongFormState = {};

function formatDuration(seconds: number) {
  if (seconds === 0) return "—";
  const m = Math.floor(seconds / 60);
  const s = seconds % 60;
  return `${m}:${s.toString().padStart(2, "0")}`;
}

export function SongRow({ song }: { song: Song }) {
  const [, toggleAction, togglePending] = useActionState(togglePremium, initialState);
  const [, deleteAction, deletePending] = useActionState(deleteSong, initialState);

  return (
    <div
      className="flex items-center gap-4 py-3 transition-colors"
      style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}
    >
      <div
        className="w-10 h-10 rounded-md flex-shrink-0 overflow-hidden flex items-center justify-center"
        style={{ background: "rgba(124,58,237,0.3)" }}
      >
        {song.coverUrl ? (
          <Image src={song.coverUrl} alt={song.title} width={40} height={40} className="w-full h-full object-cover" />
        ) : (
          <svg className="w-4 h-4" fill="rgba(255,255,255,0.4)" viewBox="0 0 24 24">
            <path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z" />
          </svg>
        )}
      </div>

      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium text-white truncate">{song.title}</p>
        <div className="flex items-center gap-2 mt-0.5">
          <span className="text-xs" style={{ color: "rgba(255,255,255,0.4)" }}>
            {formatDuration(song.duration)}
          </span>
          <span style={{ color: "rgba(255,255,255,0.2)" }}>·</span>
          <span
            className="text-xs font-semibold px-1.5 py-0.5 rounded-full"
            style={
              song.isPremium
                ? { background: "#7c3aed", color: "#fff" }
                : { background: "rgba(255,255,255,0.08)", color: "rgba(255,255,255,0.5)" }
            }
          >
            {song.isPremium ? `$${(song.price / 100).toFixed(2)}` : "Free"}
          </span>
        </div>
      </div>

      <div className="flex items-center gap-2 flex-shrink-0">
        <form action={toggleAction}>
          <input type="hidden" name="songId" value={song.id} />
          <button
            type="submit"
            disabled={togglePending}
            className="text-xs font-medium px-3 py-1.5 rounded-full transition-colors disabled:opacity-40"
            style={{ border: "1px solid rgba(255,255,255,0.15)", color: "rgba(255,255,255,0.6)" }}
          >
            {togglePending ? "…" : song.isPremium ? "Set Free" : "Set Premium"}
          </button>
        </form>

        <form action={deleteAction}>
          <input type="hidden" name="songId" value={song.id} />
          <button
            type="submit"
            disabled={deletePending}
            className="text-xs font-medium px-3 py-1.5 rounded-full transition-colors disabled:opacity-40"
            style={{ color: "#f87171" }}
          >
            {deletePending ? "…" : "Delete"}
          </button>
        </form>
      </div>
    </div>
  );
}

A shared AppShell wraps every authenticated page with the sidebar, nav, and auth buttons. Go to src/app/components/ and create a file called AppShell.tsx:

AppShell.tsx
import Link from "next/link";

export function AppShell({
  userId,
  artistHandle,
  activeHref,
  children,
}: {
  userId: string | null;
  artistHandle?: string | null;
  activeHref?: string;
  children: React.ReactNode;
}) {
  return (
    <div className="flex h-screen overflow-hidden" style={{ background: "#121212", color: "#fff" }}>
      <aside
        className="flex-shrink-0 flex flex-col h-full py-6 px-3"
        style={{ width: 220, background: "#000" }}
      >
        <Link
          href="/"
          className="px-3 mb-7 text-xl font-extrabold tracking-tight block"
          style={{ fontFamily: "var(--font-bricolage)", color: "#fff" }}
        >
          soundify
        </Link>

        <nav className="flex flex-col gap-1">
          <SidebarLink href="/" icon={<IconHome />} label="Home" active={activeHref === "/"} />
          {userId && (
            <SidebarLink href="/library" icon={<IconLibrary />} label="Your Library" active={activeHref === "/library"} />
          )}
          {userId && (
            <SidebarLink href="/dashboard" icon={<IconDashboard />} label="Dashboard" active={activeHref === "/dashboard"} />
          )}
          {artistHandle && (
            <SidebarLink href={`/a/${artistHandle}`} icon={<IconProfile />} label="My Artist Page" active={false} />
          )}
        </nav>

        <div className="flex-1" />

        {userId ? (
          <div className="px-3">
            <form action="/api/auth/logout" method="POST">
              <button
                type="submit"
                className="w-full text-left text-sm px-3 py-2 rounded-md transition-colors"
                style={{ color: "rgba(255,255,255,0.4)" }}
              >
                Log out
              </button>
            </form>
          </div>
        ) : (
          <div className="px-3 flex flex-col gap-3">
            <a
              href="/api/auth/login"
              className="text-center text-sm font-semibold py-3 rounded-full"
              style={{ background: "#7c3aed", color: "#fff" }}
            >
              Sign up free
            </a>
            <a
              href="/api/auth/login"
              className="text-center text-sm font-semibold py-3 rounded-full"
              style={{ border: "1px solid rgba(255,255,255,0.2)", color: "rgba(255,255,255,0.7)" }}
            >
              Log in
            </a>
          </div>
        )}
      </aside>

      <main className="flex-1 overflow-y-auto">
        {children}
      </main>
    </div>
  );
}

function SidebarLink({ href, icon, label, active }: {
  href: string; icon: React.ReactNode; label: string; active: boolean;
}) {
  return (
    <Link
      href={href}
      className="flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-semibold transition-colors"
      style={{
        color: active ? "#fff" : "rgba(255,255,255,0.5)",
        background: active ? "rgba(255,255,255,0.06)" : "transparent",
      }}
    >
      {icon}{label}
    </Link>
  );
}

function IconHome() {
  return <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M12 3L2 12h3v9h6v-5h2v5h6v-9h3L12 3z" /></svg>;
}
function IconLibrary() {
  return <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z" /></svg>;
}
function IconDashboard() {
  return <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" /></svg>;
}
function IconProfile() {
  return <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" /></svg>;
}

The dashboard page loads the artist row and composes the profile form, song list, earnings button, and payout portal. Go to src/app/dashboard/ and create a file called page.tsx:

page.tsx
import { redirect } from "next/navigation";
import Link from "next/link";
import { getCurrentUserId } from "@/lib/session";
import { prisma } from "@/lib/prisma";
import { AppShell } from "@/app/components/AppShell";
import { ProfileForm } from "./ProfileForm";
import { SongForm } from "./SongForm";
import { SongRow } from "./SongRow";
import { EarningsButton } from "./EarningsButton";
import { PayoutPortal } from "./PayoutPortal";

export default async function DashboardPage() {
  const userId = await getCurrentUserId();
  if (!userId) redirect("/");

  const user = await prisma.user.findUnique({
    where: { id: userId },
    include: {
      creator: {
        include: { songs: { orderBy: { createdAt: "desc" } } },
      },
    },
  });

  const artist = user?.creator ?? null;

  return (
    <AppShell userId={userId} artistHandle={artist?.handle} activeHref="/dashboard">
      <div className="px-8 py-8 max-w-3xl">
        <div className="flex items-center justify-between mb-8">
          <div>
            <h1
              className="text-2xl font-extrabold tracking-tight"
              style={{ fontFamily: "var(--font-bricolage)" }}
            >
              Dashboard
            </h1>
            <p className="text-sm mt-1" style={{ color: "rgba(255,255,255,0.4)" }}>
              Manage your artist profile, songs, and earnings.
            </p>
          </div>
          {artist && (
            <Link
              href={`/a/${artist.handle}`}
              target="_blank"
              rel="noopener noreferrer"
              className="flex items-center gap-1.5 text-sm font-medium px-4 py-2 rounded-full transition-colors"
              style={{ background: "rgba(255,255,255,0.08)", color: "rgba(255,255,255,0.7)" }}
            >
              View artist page
              <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
              </svg>
            </Link>
          )}
        </div>

        <div className="space-y-4">
          {!artist && (
            <div
              className="text-white rounded-xl p-5 flex items-start gap-3"
              style={{ background: "linear-gradient(135deg, #7c3aed 0%, #9f67fa 100%)" }}
            >
              <svg className="w-5 h-5 mt-0.5 flex-shrink-0 opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
              </svg>
              <div>
                <p className="font-semibold text-sm">Set up your profile</p>
                <p className="text-sm opacity-80 mt-0.5">Create your artist profile to start uploading songs and earning.</p>
              </div>
            </div>
          )}

          {/* Profile */}
          <section
            className="rounded-xl overflow-hidden"
            style={{ background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)" }}
          >
            <div className="px-6 py-4" style={{ borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
              <h2 className="font-semibold text-sm text-white" style={{ fontFamily: "var(--font-bricolage)" }}>Profile</h2>
            </div>
            <div className="px-6 py-5">
              <ProfileForm artist={artist} />
            </div>
          </section>

          {/* Songs: only shown once a profile exists */}
          {artist && (
            <section
              className="rounded-xl overflow-hidden"
              style={{ background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)" }}
            >
              <div className="px-6 py-4 flex items-center justify-between" style={{ borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
                <h2 className="font-semibold text-sm text-white" style={{ fontFamily: "var(--font-bricolage)" }}>Songs</h2>
                <span className="text-xs font-medium" style={{ color: "rgba(255,255,255,0.4)" }}>
                  {artist.songs.length} track{artist.songs.length !== 1 ? "s" : ""}
                </span>
              </div>

              {artist.songs.length > 0 && (
                <div className="px-6">
                  {artist.songs.map((song) => (
                    <SongRow key={song.id} song={song} />
                  ))}
                </div>
              )}

              <div className="px-6 py-5" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
                <p className="text-xs font-semibold uppercase tracking-widest mb-4" style={{ color: "rgba(255,255,255,0.3)" }}>
                  Upload new track
                </p>
                <SongForm />
              </div>
            </section>
          )}

          {/* Earnings: only shown once a profile exists */}
          {artist && (
            <section
              className="rounded-xl overflow-hidden"
              style={{ background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)" }}
            >
              <div className="px-6 py-4" style={{ borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
                <h2 className="font-semibold text-sm text-white" style={{ fontFamily: "var(--font-bricolage)" }}>Earnings</h2>
              </div>
              <div className="px-6 py-5">
                <EarningsButton
                  enrolled={!!artist.whopCompanyId}
                  payoutEnabled={artist.payoutEnabled}
                />
              </div>
            </section>
          )}

          {/* Payout Portal: only shown after KYC is complete */}
          {artist?.payoutEnabled && (
            <section
              className="rounded-xl overflow-hidden"
              style={{ background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)" }}
            >
              <div className="px-6 py-4" style={{ borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
                <h2 className="font-semibold text-sm text-white" style={{ fontFamily: "var(--font-bricolage)" }}>Payout Portal</h2>
              </div>
              <div className="px-6 py-5">
                <PayoutPortal companyId={artist.whopCompanyId!} />
              </div>
            </section>
          )}
        </div>
      </div>
    </AppShell>
  );
}

Checkpoint

  1. /dashboard shows the Profile, Songs, and Earnings sections (Songs and Earnings only appear after a profile exists).
  2. Submitting the profile form with a unique handle creates an Artist row.
  3. Submitting again with a handle already taken by another user shows the "Handle already taken" inline error.
  4. The "View artist page" link in the dashboard header opens /a/<handle> in a new tab.
  5. The sidebar surfaces Home, Your Library, Dashboard, and My Artist Page links once a profile is set.

Step 6: File uploads via Supabase

Uploading audio through a Next.js server action would stream megabytes through the server on every upload. We avoid this: the browser gets a signed URL from the server and uploads the file directly to Supabase. That's why uploadSong from the previous step takes URLs.

The Supabase client uses the service-role key to mint signed upload URLs. Go to src/lib/ and create a file called supabase.ts:

supabase.ts
import { createClient, SupabaseClient } from "@supabase/supabase-js";

let _client: SupabaseClient | null = null;

export function getSupabase(): SupabaseClient {
  if (!_client) {
    const url = process.env.SUPABASE_URL;
    const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
    if (!url || !key) {
      throw new Error("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required for file uploads.");
    }
    _client = createClient(url, key);
  }
  return _client;
}

The upload route validates the bucket name, generates a unique file path, and returns a signed URL the browser can PUT directly to. Go to src/app/api/upload/ and create a file called route.ts:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { getSupabase } from "@/lib/supabase";

const ALLOWED_BUCKETS = ["songs", "covers", "previews"] as const;
type Bucket = (typeof ALLOWED_BUCKETS)[number];

export async function POST(req: NextRequest) {
  try {
    const session = await getSession();
    if (!session.userId) {
      return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
    }

    const body = await req.json();
    const { bucket, filename, contentType } = body;

    if (!ALLOWED_BUCKETS.includes(bucket as Bucket)) {
      return NextResponse.json({ error: "Invalid bucket" }, { status: 400 });
    }

    const ext = (filename as string).split(".").pop();
    const filePath = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;

    const supabase = getSupabase();
    const { data, error } = await supabase.storage
      .from(bucket)
      .createSignedUploadUrl(filePath);

    if (error || !data) {
      return NextResponse.json(
        { error: error?.message ?? "Failed to create signed URL" },
        { status: 500 }
      );
    }

    const { data: urlData } = supabase.storage.from(bucket).getPublicUrl(filePath);

    return NextResponse.json({
      signedUrl: data.signedUrl,
      token: data.token,
      path: filePath,
      publicUrl: urlData.publicUrl,
      contentType,
    });
  } catch (err) {
    console.error("[/api/upload]", err);
    return NextResponse.json(
      { error: err instanceof Error ? err.message : "Internal server error" },
      { status: 500 }
    );
  }
}

The song upload form intercepts onSubmit so the audio and cover files finish uploading to Supabase before the server action runs. The resulting public URLs are injected into the FormData and uploadSong is called with that. Go to src/app/dashboard/ and create a file called SongForm.tsx:

SongForm.tsx
"use client";

import { useActionState, useRef, useState } from "react";
import { uploadSong, type SongFormState } from "@/app/actions/songs";

const initialState: SongFormState = {};

const inputStyle = {
  background: "rgba(255,255,255,0.06)",
  border: "1px solid rgba(255,255,255,0.1)",
  color: "#fff",
};

async function parseJsonSafe(res: Response): Promise<unknown> {
  const text = await res.text();
  if (!text) throw new Error(`Empty response (status ${res.status})`);
  try {
    return JSON.parse(text);
  } catch {
    throw new Error(`Unexpected response: ${text.slice(0, 100)}`);
  }
}

async function uploadToSupabase(file: File, bucket: string): Promise<string> {
  const res = await fetch("/api/upload", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ bucket, filename: file.name, contentType: file.type }),
  });

  const json = await parseJsonSafe(res) as Record<string, string>;
  if (!res.ok) throw new Error(json.error ?? `Failed to get upload URL (${res.status})`);

  const { signedUrl, publicUrl } = json;
  const uploadRes = await fetch(signedUrl, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file,
  });
  if (!uploadRes.ok) {
    const detail = await uploadRes.text().catch(() => "");
    throw new Error(`File upload failed (${uploadRes.status})${detail ? `: ${detail.slice(0, 100)}` : ""}`);
  }

  return publicUrl;
}

function Field({ label, required, error, children }: { label: string; required?: boolean; error?: string; children: React.ReactNode }) {
  return (
    <div>
      <label className="block text-sm font-medium text-white mb-1.5">
        {label}
        {required && <span className="ml-1" style={{ color: "rgba(255,255,255,0.3)" }}>*</span>}
      </label>
      {children}
      {error && <p className="text-xs text-red-400 mt-1.5">{error}</p>}
    </div>
  );
}

export function SongForm() {
  const [state, action, pending] = useActionState(uploadSong, initialState);
  const [uploading, setUploading] = useState(false);
  const [uploadError, setUploadError] = useState<string | null>(null);
  const formRef = useRef<HTMLFormElement>(null);
  const isLoading = pending || uploading;

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setUploadError(null);

    const form = e.currentTarget;
    const audioInput = form.elements.namedItem("audioFile") as HTMLInputElement;
    const coverInput = form.elements.namedItem("coverFile") as HTMLInputElement;
    const audioFile = audioInput.files?.[0];
    if (!audioFile) { setUploadError("Audio file required"); return; }

    setUploading(true);
    try {
      const [audioUrl, coverUrl] = await Promise.all([
        uploadToSupabase(audioFile, "songs"),
        coverInput.files?.[0] ? uploadToSupabase(coverInput.files[0], "covers") : Promise.resolve(""),
      ]);

      const formData = new FormData(form);
      formData.set("audioUrl", audioUrl);
      formData.set("coverUrl", coverUrl);
      formData.delete("audioFile");
      formData.delete("coverFile");

      setUploading(false);
      action(formData);
    } catch (err) {
      setUploading(false);
      setUploadError(err instanceof Error ? err.message : "Upload failed");
    }
  }

  return (
    <form ref={formRef} onSubmit={handleSubmit} className="space-y-4">
      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <Field label="Title" required error={state.errors?.title?.[0]}>
          <input
            name="title"
            type="text"
            placeholder="Song title"
            className="w-full rounded-lg px-3 py-2.5 text-sm outline-none"
            style={inputStyle}
          />
        </Field>

        <Field label="Price" error={state.errors?.price?.[0]}>
          <div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid rgba(255,255,255,0.1)" }}>
            <span
              className="flex items-center px-3 text-sm border-r select-none"
              style={{ background: "rgba(255,255,255,0.04)", color: "rgba(255,255,255,0.4)", borderColor: "rgba(255,255,255,0.1)" }}
            >
              $
            </span>
            <input
              name="price"
              type="number"
              step="0.01"
              min="0.99"
              max="50"
              defaultValue="1.99"
              className="flex-1 px-3 py-2.5 text-sm outline-none"
              style={{ background: "transparent", color: "#fff" }}
            />
          </div>
        </Field>
      </div>

      <Field label="Description">
        <textarea
          name="description"
          rows={2}
          placeholder="Optional description"
          className="w-full rounded-lg px-3 py-2.5 text-sm outline-none resize-none"
          style={inputStyle}
        />
      </Field>

      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <Field label="Audio file" required error={state.errors?.audioFile?.[0]}>
          <input
            name="audioFile"
            type="file"
            accept="audio/*"
            className="w-full text-sm cursor-pointer file:mr-3 file:py-1.5 file:px-3 file:rounded-full file:border-0 file:text-xs file:font-semibold file:bg-[#7c3aed] file:text-white hover:file:opacity-90"
            style={{ color: "rgba(255,255,255,0.4)" }}
          />
        </Field>

        <Field label="Cover image">
          <input
            name="coverFile"
            type="file"
            accept="image/*"
            className="w-full text-sm cursor-pointer file:mr-3 file:py-1.5 file:px-3 file:rounded-full file:border-0 file:text-xs file:font-semibold hover:file:opacity-90"
            style={{ color: "rgba(255,255,255,0.4)" }}
          />
        </Field>
      </div>

      <div className="flex items-center gap-2.5">
        <input
          name="isFree"
          type="checkbox"
          id="isFree"
          className="w-4 h-4 rounded"
          style={{ accentColor: "#7c3aed" }}
        />
        <label htmlFor="isFree" className="text-sm font-medium text-white cursor-pointer">
          Mark as Free
        </label>
      </div>

      {(uploadError || state.message) && (
        <p className="text-sm text-red-400">{uploadError ?? state.message}</p>
      )}
      {state.success && (
        <div className="flex items-center gap-2 text-sm text-emerald-400">
          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
          </svg>
          Song uploaded successfully.
        </div>
      )}

      <button
        type="submit"
        disabled={isLoading}
        className="inline-flex items-center gap-2 text-white text-sm font-semibold px-5 py-2.5 rounded-full transition-all disabled:opacity-50"
        style={{ background: "#7c3aed" }}
      >
        {isLoading ? (
          <>
            <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
            </svg>
            {uploading ? "Uploading files…" : "Saving…"}
          </>
        ) : (
          <>
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
            </svg>
            Upload Song
          </>
        )}
      </button>
    </form>
  );
}

Checkpoint

  1. Uploading an audio file from the dashboard lands the file in the songs bucket in Supabase Storage.
  2. A new Song row is created with audioUrl pointing at the public Supabase URL.
  3. Uploading a cover image lands it in the covers bucket and coverUrl is set on the row.
  4. The dashboard "Songs" section lists the new track with title, price, and the toggle/delete buttons.
  5. Toggling premium/free flips isPremium on the row; deleting removes it.

Step 7: Build the public artist page

The page at /a/[handle] lists the artist's tracks. Free songs play inline; premium songs show a price badge and an unlock button. After a successful purchase, the URL carries the unlock ID so the server can verify and swap the lock for a player.

The audio player wraps <audio controls> with title, artist name, and an optional "PREVIEW" badge. Go to src/app/a/[handle]/ and create a file called AudioPlayer.tsx:

AudioPlayet.tsx
"use client";

interface AudioPlayerProps {
  src: string;
  title: string;
  artist: string;
  isPreview?: boolean;
}

export function AudioPlayer({ src, title, artist, isPreview }: AudioPlayerProps) {
  return (
    <div
      className="mt-3 rounded-xl p-4"
      style={{ background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.06)" }}
    >
      <div className="flex items-center gap-2 mb-3">
        <svg className="w-3.5 h-3.5 flex-shrink-0" fill="#7c3aed" viewBox="0 0 24 24">
          <path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z" />
        </svg>
        <p className="text-xs truncate" style={{ color: "rgba(255,255,255,0.5)" }}>
          <span className="font-medium text-white">{title}</span>
          <span> · {artist}</span>
          {isPreview && (
            <span
              className="ml-1.5 font-mono text-[10px] px-1.5 py-0.5 rounded font-semibold"
              style={{ background: "rgba(124,58,237,0.2)", color: "#a78bfa" }}
            >
              PREVIEW
            </span>
          )}
        </p>
      </div>
      <audio
        controls
        src={src}
        className="w-full h-9"
        style={{ colorScheme: "dark" }}
      />
    </div>
  );
}

The unlock button calls createCheckout, which creates a PENDING Unlock and redirects to Whop's hosted checkout. Go to src/app/a/[handle]/ and create a file called UnlockButton.tsx:

UnlockButton.tsx
"use client";

import { useActionState } from "react";
import { createCheckout } from "@/app/actions/checkout";

interface UnlockButtonProps {
  songId: string;
  artistId: string;
  price: number;
}

export function UnlockButton({ songId, artistId, price }: UnlockButtonProps) {
  const [state, action, pending] = useActionState(createCheckout, { message: "" });

  return (
    <div className="mt-3">
      <form action={action}>
        <input type="hidden" name="songId" value={songId} />
        <input type="hidden" name="artistId" value={artistId} />
        <button
          type="submit"
          disabled={pending}
          className="group inline-flex items-center justify-center gap-2.5 text-white text-sm font-semibold px-5 py-2.5 rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto"
          style={{ background: "linear-gradient(135deg, #7c3aed 0%, #9f67fa 100%)", boxShadow: "0 4px 16px rgba(124,58,237,0.3)" }}
        >
          {pending ? (
            <>
              <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
                <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
                <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
              </svg>
              Redirecting to checkout…
            </>
          ) : (
            <>
              <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
              </svg>
              Unlock for ${(price / 100).toFixed(2)}
            </>
          )}
        </button>
      </form>
      {state?.message && (
        <p className="text-xs text-red-500 mt-2">{state.message}</p>
      )}
    </div>
  );
}

The artist page loads the artist, session, and visitor's playlists in parallel, verifies any post-checkout payment from the URL, then hands the songs to a client SongList. Go to src/app/a/[handle]/ and create a file called page.tsx:

page.tsx
import { notFound } from "next/navigation";
import Image from "next/image";
import { prisma } from "@/lib/prisma";
import { whop } from "@/lib/whop";
import { getSession } from "@/lib/session";
import { AppShell } from "@/app/components/AppShell";
import { SongList } from "./SongList";

interface PageProps {
  params: Promise<{ handle: string }>;
  searchParams: Promise<{
    checkout_status?: string;
    payment_id?: string;
    unlocked?: string;
    song?: string;
  }>;
}

export default async function ArtistPage({ params, searchParams }: PageProps) {
  const { handle } = await params;
  const { checkout_status, payment_id, unlocked, song: songParam } = await searchParams;

  const [artist, session] = await Promise.all([
    prisma.artist.findUnique({
      where: { handle },
      include: { songs: { orderBy: { createdAt: "desc" } } },
    }),
    getSession(),
  ]);

  if (!artist) notFound();

  const userId = session.userId ?? null;

  // Verify payment on return from Whop checkout
  if (checkout_status === "success" && payment_id && unlocked) {
    try {
      const payment = await whop.payments.retrieve(payment_id);
      if (payment.status === "paid") {
        await prisma.unlock.updateMany({
          where: { id: unlocked, status: "PENDING" },
          data: { status: "PAID", whopPaymentId: payment_id },
        });
      }
    } catch {
      // Non-fatal: the webhook handles it if this fails
    }
  }

  // Check if the visitor has a paid unlock for the song they just bought
  let unlockedSongId: string | null = null;
  if (unlocked && songParam) {
    const unlock = await prisma.unlock.findUnique({ where: { id: unlocked } });
    if (unlock && unlock.artistId === artist.id && unlock.songId === songParam && unlock.status === "PAID") {
      unlockedSongId = unlock.songId;
    }
  }

  // Fetch the logged-in user's playlists with their song IDs (used by SongList)
  const userPlaylists = userId
    ? await prisma.userPlaylist.findMany({
        where: { userId },
        include: { songs: { select: { songId: true } } },
        orderBy: { createdAt: "desc" },
      })
    : [];

  return (
    <AppShell userId={userId}>
      <div className="px-8 py-8 max-w-2xl">
        {/* Artist header */}
        <div
          className="rounded-2xl p-8 mb-6 flex items-start gap-6"
          style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)" }}
        >
          <div
            className="w-24 h-24 rounded-2xl flex-shrink-0 overflow-hidden flex items-center justify-center"
            style={!artist.avatarUrl ? { background: "linear-gradient(135deg, #3b1f6e 0%, #7c3aed 100%)" } : {}}
          >
            {artist.avatarUrl ? (
              <Image src={artist.avatarUrl} alt={artist.displayName} width={96} height={96} className="w-full h-full object-cover" />
            ) : (
              <span className="text-4xl font-bold text-white uppercase" style={{ fontFamily: "var(--font-bricolage)" }}>
                {artist.displayName[0]}
              </span>
            )}
          </div>
          <div className="flex-1 min-w-0">
            <h1 className="text-2xl font-extrabold tracking-tight text-white" style={{ fontFamily: "var(--font-bricolage)" }}>
              {artist.displayName}
            </h1>
            <p className="font-mono text-sm mt-0.5" style={{ color: "rgba(255,255,255,0.4)" }}>@{artist.handle}</p>
            {artist.bio && (
              <p className="text-sm mt-3 leading-relaxed" style={{ color: "rgba(255,255,255,0.6)" }}>
                {artist.bio}
              </p>
            )}
            <p className="text-xs mt-3" style={{ color: "rgba(255,255,255,0.3)" }}>
              {artist.songs.length} track{artist.songs.length !== 1 ? "s" : ""}
            </p>
          </div>
        </div>

        {/* Song list: delegates to SongList client component for playlist state */}
        <SongList
          songs={artist.songs.map((s) => ({
            id: s.id,
            title: s.title,
            description: s.description,
            coverUrl: s.coverUrl,
            audioUrl: s.audioUrl,
            previewUrl: s.previewUrl,
            duration: s.duration,
            isPremium: s.isPremium,
            price: s.price,
          }))}
          artist={{ id: artist.id, displayName: artist.displayName, whopCompanyId: artist.whopCompanyId }}
          userId={userId}
          unlockedSongId={unlockedSongId}
          initialPlaylists={userPlaylists.map((p) => ({
            id: p.id,
            name: p.name,
            songIds: p.songs.map((s) => s.songId),
          }))}
        />
      </div>
    </AppShell>
  );
}

We'll build SongList itself in Step 11 alongside the playlist controls.

Checkpoint

  1. /a/<handle> loads and shows the artist's display name, bio, and song count.
  2. Free songs render with the inline <audio> player.
  3. Premium songs show a price badge and an "Unlock for $X.XX" button.
  4. If the artist has not yet enabled earnings, premium songs show "coming soon" instead of the unlock button.
  5. Visiting /a/<unknown-handle> returns a 404.

Step 8: Payments with Whop

The unlock click creates a PENDING Unlock and redirects to Whop's hosted checkout. On return, the artist page verifies the payment ID from the URL; a webhook covers the case where the redirect fails.

The checkout action validates the song, creates the Unlock, and builds a checkout configuration that redirects back with the payment ID embedded. Go to src/app/actions/ and create a file called checkout.ts:

checkout.ts
"use server";

import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { whop } from "@/lib/whop";

export async function createCheckout(
  _prev: { message: string },
  formData: FormData
): Promise<{ message: string }> {
  const songId = formData.get("songId")?.toString();
  const artistId = formData.get("artistId")?.toString();

  if (!songId || !artistId) return { message: "Missing song or artist" };

  const song = await prisma.song.findUnique({
    where: { id: songId },
    include: { artist: true },
  });

  if (!song) return { message: "Song not found" };
  if (song.artistId !== artistId) return { message: "Invalid artist" };
  if (!song.artist.whopCompanyId) return { message: "Artist has not enabled earnings" };

  // Create a pending Unlock record before redirecting to checkout
  const unlock = await prisma.unlock.create({
    data: {
      artistId: song.artistId,
      songId: song.id,
      status: "PENDING",
    },
  });

  const appUrl = process.env.NEXT_PUBLIC_APP_URL as string;
  const handle = song.artist.handle;
  // {PAYMENT_ID} is a Whop template variable replaced with the real ID on redirect
  const redirectUrl = `${appUrl}/a/${handle}?checkout_status=success&payment_id={PAYMENT_ID}&unlocked=${unlock.id}&song=${song.id}`;

  const checkout = await whop.checkoutConfigurations.create({
    plan: {
      company_id: song.artist.whopCompanyId,
      currency: "usd",
      plan_type: "one_time",
      initial_price: song.price / 100,
      application_fee_amount: song.artist.applicationFee / 100,
    },
    redirect_url: redirectUrl,
    metadata: {
      unlock_id: unlock.id,
      song_id: song.id,
      artist_id: song.artistId,
    },
  });

  redirect(checkout.purchase_url);
}

The webhook handler matches events to their Unlock row via the metadata we attached at checkout.

Callout: call req.text() before anything else. unwrap() needs the raw body for HMAC verification; req.json() consumes the stream and breaks the signature check.

Go to src/app/api/webhooks/whop/ and create a file called route.ts:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { whop } from "@/lib/whop";

export const dynamic = "force-dynamic";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();

  const headers: Record<string, string> = {};
  req.headers.forEach((value, key) => { headers[key] = value; });

  let event;
  try {
    event = whop.webhooks.unwrap(rawBody, {
      headers,
      key: process.env.WHOP_WEBHOOK_SECRET,
    });
  } catch {
    return NextResponse.json({ error: "Invalid webhook signature" }, { status: 401 });
  }

  if (event.type === "payment.succeeded") {
    const payment = event.data;
    const paymentId = payment.id;

    try {
      // Skip if the redirect already processed this payment
      const existing = await prisma.unlock.findUnique({
        where: { whopPaymentId: paymentId },
      });

      if (!existing) {
        const metadata = payment.metadata as Record<string, string> | null;
        const unlockId = metadata?.unlock_id;

        if (unlockId) {
          await prisma.unlock.updateMany({
            where: { id: unlockId, status: "PENDING" },
            data: { status: "PAID", whopPaymentId: paymentId },
          });

          const unlock = await prisma.unlock.findUnique({ where: { id: unlockId } });
          if (unlock) {
            await prisma.song.update({
              where: { id: unlock.songId },
              data: { plays: { increment: 1 } },
            });
          }
        }
      }
    } catch (err) {
      console.error("Webhook payment.succeeded error:", err);
    }
  }

  if (event.type === "payment.failed") {
    const payment = event.data;
    const paymentId = payment.id;

    try {
      await prisma.unlock.updateMany({
        where: { whopPaymentId: paymentId },
        data: { status: "FAILED" },
      });
    } catch (err) {
      console.error("Webhook payment.failed error:", err);
    }
  }

  return NextResponse.json({ received: true });
}

Checkpoint

  1. Clicking "Unlock for $X.XX" on a premium song creates an Unlock row with status: PENDING and redirects to a Whop checkout URL.
  2. Paying with the sandbox test card 4242 4242 4242 4242 redirects back to /a/<handle>?checkout_status=success&payment_id=....
  3. The Unlock row flips to status: PAID and the whopPaymentId is filled in.
  4. The audio player replaces the unlock button on the artist page for the buyer.
  5. The webhook endpoint at /api/webhooks/whop receives a payment.succeeded event and returns { received: true }; the ngrok inspector at localhost:4040 shows a 200.
  6. Replaying the same webhook does not double-process: the second attempt finds the row already PAID and skips.

Step 9: Artist earnings and payouts

Before an artist can receive payments, they need a Whop company (a sub-account under the platform) and they need to complete KYC. Whop handles the entire onboarding form.

enableEarnings runs twice in the lifecycle of an artist: the first call creates a Whop company under the platform's parent and saves its ID; subsequent calls (e.g., the artist updates their bank details) skip company creation and just generate a fresh onboarding link for the existing company. Go to src/app/actions/ and create a file called earnings.ts:

earnings.ts
"use server";

import { redirect } from "next/navigation";
import { getCurrentUserId } from "@/lib/session";
import { prisma } from "@/lib/prisma";
import { whop } from "@/lib/whop";

export async function enableEarnings(): Promise<{ message: string }> {
  const userId = await getCurrentUserId();
  if (!userId) return { message: "Not authenticated" };

  const user = await prisma.user.findUnique({
    where: { id: userId },
    include: { creator: true },
  });

  const artist = user?.creator;
  if (!artist) return { message: "Create a profile first" };

  let companyId = artist.whopCompanyId;

  if (!companyId) {
    if (!user.email) return { message: "No email on account" };

    const suffix = Math.random().toString(36).slice(2, 6);
    const company = await whop.companies.create({
      title: `${artist.displayName || artist.handle}-${suffix}`,
      parent_company_id: process.env.WHOP_PARENT_COMPANY_ID as string,
      email: user.email,
    });
    companyId = company.id;

    await prisma.artist.update({
      where: { id: artist.id },
      data: { whopCompanyId: companyId },
    });
  }

  const appUrl = process.env.NEXT_PUBLIC_APP_URL as string;
  const accountLink = await whop.accountLinks.create({
    company_id: companyId,
    use_case: "account_onboarding",
    return_url: `${appUrl}/api/earnings/complete`,
    refresh_url: `${appUrl}/dashboard?refresh=true`,
  });

  redirect(accountLink.url);
}

When the artist finishes Whop's onboarding, they're redirected back to this route, which flips payoutEnabled to true so the payout portal appears in the dashboard. Go to src/app/api/earnings/complete/ and create a file called route.ts:

route.ts
import { NextResponse } from "next/server";
import { getCurrentUserId } from "@/lib/session";
import { prisma } from "@/lib/prisma";

export async function GET() {
  const userId = await getCurrentUserId();
  if (!userId) {
    return NextResponse.redirect(new URL("/", process.env.NEXT_PUBLIC_APP_URL as string));
  }

  const artist = await prisma.artist.findUnique({ where: { userId } });
  if (artist?.whopCompanyId) {
    await prisma.artist.update({
      where: { id: artist.id },
      data: { payoutEnabled: true },
    });
  }

  return NextResponse.redirect(
    new URL("/dashboard?enrolled=true", process.env.NEXT_PUBLIC_APP_URL as string)
  );
}

The earnings button has three states driven by enrolled (whether whopCompanyId is set) and payoutEnabled (whether KYC is complete): not enrolled, enrolled but onboarding incomplete, and ready. Go to src/app/dashboard/ and create a file called EarningsButton.tsx:

EarningsButton.tsx
"use client";

import { useActionState } from "react";
import { enableEarnings } from "@/app/actions/earnings";

interface EarningsButtonProps {
  enrolled: boolean;
  payoutEnabled: boolean;
}

const initialState = { message: "" };

export function EarningsButton({ enrolled, payoutEnabled }: EarningsButtonProps) {
  const [state, action, pending] = useActionState(enableEarnings, initialState);

  if (payoutEnabled) {
    return (
      <div
        className="flex items-center justify-between p-4 rounded-xl"
        style={{ background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)" }}
      >
        <div className="flex items-center gap-3">
          <div
            className="w-8 h-8 rounded-full flex items-center justify-center"
            style={{ background: "rgba(52,211,153,0.15)" }}
          >
            <svg className="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
            </svg>
          </div>
          <div>
            <p className="text-sm font-semibold text-white">Earnings enabled</p>
            <p className="text-xs mt-0.5" style={{ color: "rgba(255,255,255,0.4)" }}>Ready to receive payments</p>
          </div>
        </div>
        <form action={action}>
          <button
            type="submit"
            disabled={pending}
            className="text-sm font-medium px-4 py-2 rounded-full transition-colors disabled:opacity-40"
            style={{ border: "1px solid rgba(255,255,255,0.15)", color: "rgba(255,255,255,0.6)" }}
          >
            {pending ? "Loading…" : "Manage"}
          </button>
        </form>
      </div>
    );
  }

  if (enrolled) {
    return (
      <div className="space-y-3">
        <div
          className="flex items-start gap-3 p-4 rounded-xl"
          style={{ background: "rgba(251,191,36,0.08)", border: "1px solid rgba(251,191,36,0.2)" }}
        >
          <svg className="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="#fbbf24" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
          </svg>
          <div>
            <p className="text-sm font-semibold" style={{ color: "#fde68a" }}>Onboarding incomplete</p>
            <p className="text-xs mt-0.5" style={{ color: "rgba(253,230,138,0.7)" }}>
              Complete your payout setup to start receiving payments.
            </p>
          </div>
        </div>
        <form action={action}>
          <button
            type="submit"
            disabled={pending}
            className="inline-flex items-center gap-2 text-white text-sm font-semibold px-5 py-2.5 rounded-full disabled:opacity-50"
            style={{ background: "#7c3aed" }}
          >
            {pending ? "Loading…" : "Complete onboarding"}
          </button>
        </form>
      </div>
    );
  }

  return (
    <div className="space-y-4">
      <p className="text-sm" style={{ color: "rgba(255,255,255,0.5)" }}>
        Enable earnings to set prices on your tracks and get paid directly by listeners.
      </p>
      <form action={action}>
        <button
          type="submit"
          disabled={pending}
          className="inline-flex items-center gap-2 text-white text-sm font-semibold px-5 py-2.5 rounded-full disabled:opacity-50"
          style={{ background: "#7c3aed" }}
        >
          {pending ? (
            <>
              <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
                <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
                <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
              </svg>
              Setting up…
            </>
          ) : "Enable Earnings"}
        </button>
      </form>
      {state.message && <p className="text-sm text-red-400">{state.message}</p>}
    </div>
  );
}

Checkpoint

  1. Clicking "Enable Earnings" creates a Whop company under the platform's parent and saves its ID on the artist row.
  2. The flow redirects to Whop's hosted onboarding form.
  3. Completing onboarding (or hitting the sandbox skip) returns to /api/earnings/complete and flips payoutEnabled to true.
  4. The dashboard re-renders with the "Earnings enabled" status and the Payout Portal section becomes visible.
  5. Hitting "Enable Earnings" again on an already-enrolled artist generates a fresh onboarding link without creating a new company.

Step 10: Embedded payout portal

Once an artist has completed onboarding, they can see their balance and withdraw funds right inside the app without being redirected to a separate Whop dashboard. The embedded components need a short-lived session token, fetched from the server on each render.

The payout token route mints a short-lived accessTokens for the artist's company. Go to src/app/api/payout-token/ and create a file called route.ts:

route.ts
import { NextResponse } from "next/server";
import { getCurrentUserId } from "@/lib/session";
import { prisma } from "@/lib/prisma";
import { whop } from "@/lib/whop";

export async function GET() {
  const userId = await getCurrentUserId();
  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const artist = await prisma.artist.findUnique({ where: { userId } });
  if (!artist?.whopCompanyId) {
    return NextResponse.json({ error: "Earnings not enabled" }, { status: 400 });
  }

  const token = await whop.accessTokens.create({
    company_id: artist.whopCompanyId,
  });

  return NextResponse.json({ token: token.token });
}

The Elements provider loads Whop's web components and PayoutsSession authenticates them with the token from /api/payout-token. Inside that session we drop in balance, verification status, the withdraw button, and the withdrawal history. Go to src/app/dashboard/ and create a file called PayoutPortal.tsx:

PayoutPortal.tsx
"use client";

import { useMemo } from "react";
import { loadWhopElements } from "@whop/embedded-components-vanilla-js";
import {
  Elements,
  PayoutsSession,
  BalanceElement,
  VerifyElement,
  WithdrawButtonElement,
  WithdrawalsElement,
  StatusBannerElement,
} from "@whop/embedded-components-react-js";

interface PayoutPortalProps {
  companyId: string;
}

async function fetchPayoutToken(): Promise<string> {
  const res = await fetch("/api/payout-token");
  const data = await res.json();
  return data.token as string;
}

export function PayoutPortal({ companyId }: PayoutPortalProps) {
  const environment = process.env.NEXT_PUBLIC_WHOP_ENV as string | undefined;

  const whopElementsPromise = useMemo(
    () => loadWhopElements({ environment: environment as "production" | "sandbox" | undefined }),
    [environment]
  );

  const redirectUrl = typeof window !== "undefined" ? window.location.href : "";

  return (
    <Elements elements={whopElementsPromise}>
      <PayoutsSession
        companyId={companyId}
        token={fetchPayoutToken}
        currency="usd"
        redirectUrl={redirectUrl}
      >
        <div className="space-y-4">
          <StatusBannerElement />
          <VerifyElement />
          <BalanceElement />
          <WithdrawButtonElement />
          <WithdrawalsElement />
        </div>
      </PayoutsSession>
    </Elements>
  );
}

Checkpoint

  1. The "Payout Portal" section renders on the dashboard once payoutEnabled is true.
  2. The balance, verification status, withdraw button, and withdrawal history all load (the /api/payout-token endpoint returns a non-empty token).
  3. The portal pulls live data from the Whop sandbox: a successful sale shows the artist's net amount (price minus the platform application fee).
  4. Browser devtools shows no Content-Security-Policy errors loading scripts or iframes from *.whop.com.

Step 11: Listener playlists

Each song row has a + button that opens a dropdown listing existing playlists with checkboxes and a "New playlist" input. All buttons on the page share state so a playlist created in one dropdown shows up in every other without a reload.

The playlist actions cover create, add-song, remove-song, and delete; each verifies ownership before writing. createPlaylist takes an optional songId so a new playlist can include its first song atomically. Go to src/app/actions/ and create a file called playlists.ts:

playlists.ts
"use server";

import { revalidatePath } from "next/cache";
import { getCurrentUserId } from "@/lib/session";
import { prisma } from "@/lib/prisma";

export async function createPlaylist(name: string, songId?: string) {
  const userId = await getCurrentUserId();
  if (!userId) return { error: "Not authenticated" as const };

  const trimmed = name.trim().slice(0, 100);
  if (!trimmed) return { error: "Name required" as const };

  const playlist = await prisma.userPlaylist.create({
    data: {
      userId,
      name: trimmed,
      ...(songId
        ? { songs: { create: { songId, position: 0 } } }
        : {}),
    },
  });

  revalidatePath("/library");
  return { playlist: { id: playlist.id, name: playlist.name } };
}

export async function addSongToPlaylist(playlistId: string, songId: string) {
  const userId = await getCurrentUserId();
  if (!userId) return { error: "Not authenticated" as const };

  const playlist = await prisma.userPlaylist.findUnique({ where: { id: playlistId } });
  if (!playlist || playlist.userId !== userId) return { error: "Not authorized" as const };

  const agg = await prisma.userPlaylistSong.aggregate({
    where: { playlistId },
    _max: { position: true },
  });
  const position = (agg._max.position ?? -1) + 1;

  await prisma.userPlaylistSong.upsert({
    where: { playlistId_songId: { playlistId, songId } },
    update: {},
    create: { playlistId, songId, position },
  });

  revalidatePath("/library");
  return { success: true as const };
}

export async function removeSongFromPlaylist(playlistId: string, songId: string) {
  const userId = await getCurrentUserId();
  if (!userId) return { error: "Not authenticated" as const };

  const playlist = await prisma.userPlaylist.findUnique({ where: { id: playlistId } });
  if (!playlist || playlist.userId !== userId) return { error: "Not authorized" as const };

  await prisma.userPlaylistSong.deleteMany({ where: { playlistId, songId } });

  revalidatePath("/library");
  return { success: true as const };
}

export async function deletePlaylist(playlistId: string) {
  const userId = await getCurrentUserId();
  if (!userId) return { error: "Not authenticated" as const };

  const playlist = await prisma.userPlaylist.findUnique({ where: { id: playlistId } });
  if (!playlist || playlist.userId !== userId) return { error: "Not authorized" as const };

  await prisma.userPlaylist.delete({ where: { id: playlistId } });

  revalidatePath("/library");
  return { success: true as const };
}

AddToPlaylistButton is controlled: the parent SongList owns the playlist state and passes it down, with onToggle and onPlaylistCreated as callbacks back up. Go to src/app/a/[handle]/ and create a file called AddToPlaylistButton.tsx:

AddToPlaylistButton.tsx
"use client";

import { useState, useTransition, useRef, useEffect, KeyboardEvent } from "react";
import {
  addSongToPlaylist,
  removeSongFromPlaylist,
  createPlaylist,
} from "@/app/actions/playlists";

interface Playlist {
  id: string;
  name: string;
  hasSong: boolean;
}

interface Props {
  songId: string;
  userId: string | null;
  playlists: Playlist[];
  onPlaylistCreated: (playlist: { id: string; name: string }) => void;
  onToggle: (playlistId: string, added: boolean) => void;
}

export function AddToPlaylistButton({ songId, userId, playlists, onPlaylistCreated, onToggle }: Props) {
  const [open, setOpen] = useState(false);
  const [creating, setCreating] = useState(false);
  const [newName, setNewName] = useState("");
  const [isPending, startTransition] = useTransition();
  const containerRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (!open) return;
    function onMouseDown(e: MouseEvent) {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setOpen(false);
        setCreating(false);
        setNewName("");
      }
    }
    document.addEventListener("mousedown", onMouseDown);
    return () => document.removeEventListener("mousedown", onMouseDown);
  }, [open]);

  useEffect(() => {
    if (creating) inputRef.current?.focus();
  }, [creating]);

  function toggle(playlist: Playlist) {
    onToggle(playlist.id, !playlist.hasSong);
    startTransition(async () => {
      if (playlist.hasSong) {
        await removeSongFromPlaylist(playlist.id, songId);
      } else {
        await addSongToPlaylist(playlist.id, songId);
      }
    });
  }

  function handleCreate() {
    if (!newName.trim()) return;
    const name = newName.trim();
    setNewName("");
    setCreating(false);

    startTransition(async () => {
      const result = await createPlaylist(name, songId);
      if ("playlist" in result && result.playlist) {
        onPlaylistCreated(result.playlist);
      }
    });
  }

  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (e.key === "Enter") handleCreate();
    if (e.key === "Escape") { setCreating(false); setNewName(""); }
  }

  if (!userId) {
    return (
      <a
        href="/api/auth/login"
        title="Sign in to save songs"
        className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center bg-[#7c3aed]/10 text-[#7c3aed] hover:bg-[#7c3aed] hover:text-white transition-all"
      >
        <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 4v16m8-8H4" />
        </svg>
      </a>
    );
  }

  return (
    <div className="relative flex-shrink-0" ref={containerRef}>
      <button
        onClick={() => { setOpen((o) => !o); if (open) { setCreating(false); setNewName(""); } }}
        title="Add to playlist"
        className={`w-7 h-7 rounded-full flex items-center justify-center transition-all ${
          open
            ? "bg-[#7c3aed] text-white shadow-md shadow-[#7c3aed]/30"
            : "bg-[#7c3aed]/10 text-[#7c3aed] hover:bg-[#7c3aed] hover:text-white hover:shadow-md hover:shadow-[#7c3aed]/30"
        }`}
      >
        <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 4v16m8-8H4" />
        </svg>
      </button>

      {open && (
        <div className="absolute right-0 top-full mt-1.5 bg-white rounded-xl border border-black/10 shadow-xl z-30 w-56">
          <p className="px-3 pt-2.5 pb-1 text-[10px] font-semibold text-gray-400 uppercase tracking-widest">
            Save to playlist
          </p>
          {playlists.length === 0 && !creating && (
            <p className="px-3 py-2 text-xs text-gray-400">No playlists yet</p>
          )}
          <div className="max-h-44 overflow-y-auto">
            {playlists.map((playlist) => (
              <button
                key={playlist.id}
                onClick={() => toggle(playlist)}
                disabled={isPending}
                className="w-full flex items-center gap-2.5 px-3 py-2 hover:bg-black/[0.03] text-left transition-colors disabled:opacity-50"
              >
                <span className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 border transition-colors ${playlist.hasSong ? "bg-[#7c3aed] border-[#7c3aed]" : "border-black/20"}`}>
                  {playlist.hasSong && (
                    <svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
                    </svg>
                  )}
                </span>
                <span className="text-sm truncate text-gray-800">{playlist.name}</span>
              </button>
            ))}
          </div>
          <div className="border-t border-black/[0.06]">
            {creating ? (
              <div className="px-3 py-2.5 flex items-center gap-2">
                <input
                  ref={inputRef}
                  value={newName}
                  onChange={(e) => setNewName(e.target.value)}
                  onKeyDown={onKeyDown}
                  placeholder="Playlist name…"
                  maxLength={100}
                  className="flex-1 min-w-0 text-sm outline-none placeholder-gray-300 text-gray-900"
                />
                <button
                  onClick={handleCreate}
                  disabled={!newName.trim() || isPending}
                  className="flex-shrink-0 text-xs font-semibold text-white bg-[#7c3aed] px-2.5 py-1 rounded-full disabled:opacity-40 whitespace-nowrap"
                >
                  Add
                </button>
              </div>
            ) : (
              <button
                onClick={() => setCreating(true)}
                className="w-full flex items-center gap-2 px-3 py-2.5 hover:bg-black/[0.03] transition-colors text-sm text-[#7c3aed] font-medium rounded-b-xl"
              >
                <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 4v16m8-8H4" />
                </svg>
                New playlist
              </button>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

SongList holds sharedPlaylists in state and re-derives hasSong for every song whenever a button creates a playlist or toggles membership. Go to src/app/a/[handle]/ and create a file called SongList.tsx:

SongList.tsx
"use client";

import { useState } from "react";
import Image from "next/image";
import { AudioPlayer } from "./AudioPlayer";
import { UnlockButton } from "./UnlockButton";
import { AddToPlaylistButton } from "./AddToPlaylistButton";

interface Song {
  id: string; title: string; description: string | null;
  coverUrl: string | null; audioUrl: string; previewUrl: string | null;
  duration: number; isPremium: boolean; price: number;
}

interface SharedPlaylist {
  id: string;
  name: string;
  songIds: string[];
}

interface Props {
  songs: Song[];
  artist: { id: string; displayName: string; whopCompanyId: string | null };
  userId: string | null;
  unlockedSongId: string | null;
  initialPlaylists: SharedPlaylist[];
}

export function SongList({ songs, artist, userId, unlockedSongId, initialPlaylists }: Props) {
  const [sharedPlaylists, setSharedPlaylists] = useState<SharedPlaylist[]>(initialPlaylists);

  function handlePlaylistCreated(playlist: { id: string; name: string }, songId: string) {
    setSharedPlaylists((prev) => [
      ...prev,
      { id: playlist.id, name: playlist.name, songIds: [songId] },
    ]);
  }

  function handleToggle(playlistId: string, songId: string, added: boolean) {
    setSharedPlaylists((prev) =>
      prev.map((p) =>
        p.id === playlistId
          ? { ...p, songIds: added ? [...p.songIds, songId] : p.songIds.filter((id) => id !== songId) }
          : p
      )
    );
  }

  // ... song card rendering, passing per-song playlists to AddToPlaylistButton
}

The library page lists every playlist for the logged-in user. Each card shows a 2x2 mosaic of the first four song covers; the rendering and delete button live in a PlaylistList client component. Go to src/app/library/ and create a file called page.tsx:

page.tsx
import { redirect } from "next/navigation";
import { getCurrentUserId } from "@/lib/session";
import { prisma } from "@/lib/prisma";
import { AppShell } from "@/app/components/AppShell";
import { PlaylistList } from "./PlaylistList";

export default async function LibraryPage() {
  const userId = await getCurrentUserId();
  if (!userId) redirect("/api/auth/login");

  const playlists = await prisma.userPlaylist.findMany({
    where: { userId },
    include: {
      songs: {
        include: { song: { select: { coverUrl: true, title: true } } },
        orderBy: { position: "asc" },
        take: 4,
      },
      _count: { select: { songs: true } },
    },
    orderBy: { createdAt: "desc" },
  });

  return (
    <AppShell userId={userId} activeHref="/library">
      <div className="px-8 py-8 max-w-2xl">
        <div className="mb-8">
          <h1
            className="text-2xl font-extrabold tracking-tight"
            style={{ fontFamily: "var(--font-bricolage)" }}
          >
            My Library
          </h1>
          <p className="text-sm mt-1" style={{ color: "rgba(255,255,255,0.4)" }}>
            {playlists.length} playlist{playlists.length !== 1 ? "s" : ""}
          </p>
        </div>
        <PlaylistList
          initialPlaylists={playlists.map((p) => ({
            id: p.id,
            name: p.name,
            songCount: p._count.songs,
            covers: p.songs.map((s) => s.song.coverUrl),
          }))}
        />
      </div>
    </AppShell>
  );
}

PlaylistList optimistically removes a deleted playlist from local state and snaps back if deletePlaylist rejects. Go to src/app/library/ and create a file called PlaylistList.tsx:

PlaylistList.tsx
"use client";

import { useState, useTransition } from "react";
import Link from "next/link";
import { deletePlaylist } from "@/app/actions/playlists";

interface Playlist {
  id: string;
  name: string;
  songCount: number;
  covers: (string | null)[];
}

export function PlaylistList({ initialPlaylists }: { initialPlaylists: Playlist[] }) {
  const [playlists, setPlaylists] = useState(initialPlaylists);
  const [, startTransition] = useTransition();

  function handleDelete(playlistId: string) {
    setPlaylists((prev) => prev.filter((p) => p.id !== playlistId));
    startTransition(async () => {
      const result = await deletePlaylist(playlistId);
      if ("error" in result) setPlaylists(initialPlaylists);
    });
  }

  // Render playlist cards using playlists state, calling handleDelete on the trash icon
}

The playlist detail page verifies playlist.userId === userId (others get a 404) and renders songs in position order. Premium songs link back to the artist page, where the payment-redirect verification lives. Go to src/app/library/[id]/ and create a file called page.tsx:

page.tsx
import { notFound, redirect } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { getCurrentUserId } from "@/lib/session";
import { prisma } from "@/lib/prisma";
import { AppShell } from "@/app/components/AppShell";
import { AudioPlayer } from "@/app/a/[handle]/AudioPlayer";

interface PageProps {
  params: Promise<{ id: string }>;
}

function formatDuration(seconds: number) {
  if (seconds === 0) return "—";
  const m = Math.floor(seconds / 60);
  const s = seconds % 60;
  return `${m}:${s.toString().padStart(2, "0")}`;
}

export default async function PlaylistPage({ params }: PageProps) {
  const { id } = await params;
  const userId = await getCurrentUserId();
  if (!userId) redirect("/api/auth/login");

  const playlist = await prisma.userPlaylist.findUnique({
    where: { id },
    include: {
      songs: {
        include: {
          song: {
            include: { artist: { select: { handle: true, displayName: true } } },
          },
        },
        orderBy: { position: "asc" },
      },
    },
  });

  if (!playlist || playlist.userId !== userId) notFound();

  return (
    <AppShell userId={userId} activeHref="/library">
      <div className="px-8 py-8 max-w-2xl">
        <div className="mb-8">
          <Link
            href="/library"
            className="inline-flex items-center gap-1.5 text-sm mb-4 transition-colors"
            style={{ color: "rgba(255,255,255,0.4)" }}
          >
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
            </svg>
            Back to Library
          </Link>
          <h1 className="text-2xl font-extrabold tracking-tight" style={{ fontFamily: "var(--font-bricolage)" }}>
            {playlist.name}
          </h1>
          <p className="text-sm mt-1" style={{ color: "rgba(255,255,255,0.4)" }}>
            {playlist.songs.length} song{playlist.songs.length !== 1 ? "s" : ""}
          </p>
        </div>

        {playlist.songs.length === 0 ? (
          <div
            className="rounded-xl p-10 text-center"
            style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)" }}
          >
            <svg className="w-8 h-8 mx-auto mb-3" fill="rgba(255,255,255,0.1)" viewBox="0 0 24 24">
              <path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z" />
            </svg>
            <p className="text-sm" style={{ color: "rgba(255,255,255,0.4)" }}>No songs in this playlist yet.</p>
          </div>
        ) : (
          <div className="space-y-3">
            {playlist.songs.map(({ song }) => {
              const canPlay = !song.isPremium;
              return (
                <div
                  key={song.id}
                  className="rounded-xl p-5"
                  style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)" }}
                >
                  <div className="flex items-start gap-4">
                    <div
                      className="w-14 h-14 rounded-xl flex-shrink-0 overflow-hidden flex items-center justify-center"
                      style={{ background: "rgba(124,58,237,0.3)" }}
                    >
                      {song.coverUrl ? (
                        <Image src={song.coverUrl} alt={song.title} width={56} height={56} className="w-full h-full object-cover" />
                      ) : (
                        <svg className="w-6 h-6" fill="rgba(255,255,255,0.4)" viewBox="0 0 24 24">
                          <path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z" />
                        </svg>
                      )}
                    </div>
                    <div className="flex-1 min-w-0">
                      <div className="flex items-start justify-between gap-2">
                        <div className="min-w-0">
                          <p className="font-semibold text-white leading-tight truncate">{song.title}</p>
                          <Link
                            href={`/a/${song.artist.handle}`}
                            className="text-xs mt-0.5 inline-block transition-colors hover:text-white"
                            style={{ color: "rgba(255,255,255,0.4)" }}
                          >
                            {song.artist.displayName}
                          </Link>
                        </div>
                        {song.isPremium && (
                          <span
                            className="flex-shrink-0 text-xs font-semibold text-white px-2.5 py-1 rounded-full"
                            style={{ background: "#7c3aed" }}
                          >
                            ${(song.price / 100).toFixed(2)}
                          </span>
                        )}
                      </div>
                      <p className="text-xs mt-1" style={{ color: "rgba(255,255,255,0.3)" }}>
                        {formatDuration(song.duration)}
                      </p>
                    </div>
                  </div>

                  {canPlay ? (
                    <AudioPlayer src={song.audioUrl} title={song.title} artist={song.artist.displayName} />
                  ) : (
                    <div className="mt-3 flex items-center justify-between">
                      <div className="flex items-center gap-2 text-sm" style={{ color: "rgba(255,255,255,0.4)" }}>
                        <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
                        </svg>
                        Premium track
                      </div>
                      <Link
                        href={`/a/${song.artist.handle}`}
                        className="text-xs font-semibold flex items-center gap-1 hover:underline"
                        style={{ color: "#a78bfa" }}
                      >
                        Unlock on artist page
                        <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
                        </svg>
                      </Link>
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        )}
      </div>
    </AppShell>
  );
}

Checkpoint

  1. The + button on any song row opens a dropdown with existing playlists and a "New playlist" input.
  2. Typing a name and pressing Enter creates the playlist and the song is added to it in the same database write.
  3. A playlist created in one song's dropdown appears immediately in every other song's dropdown without a page reload.
  4. /library lists every playlist as a card with a 2x2 cover mosaic.
  5. Clicking a playlist opens /library/<id>; free songs play inline, premium songs link back to the artist page to unlock.
  6. Hitting /library/<id> with another user's playlist ID returns a 404.

How it all fits together

Here's the full flow from artist signup to a listener paying for a song.
Artist onboarding:

  1. Artist visits the landing page, clicks "Start sharing music"
  2. OAuth runs: Whop login page, PKCE code exchange, session cookie set
  3. Artist fills in handle and display name → saveProfile creates the Artist record
  4. Artist uploads a song → browser uploads audio to Supabase, server action stores the URL
  5. Artist clicks "Enable Earnings" → Whop company created under the platform, KYC onboarding starts
  6. Artist completes Whop onboarding → /api/earnings/complete sets payoutEnabled: true, payout portal appears

Listener purchase:

  1. Listener visits /a/[handle], sees a premium track with a price badge
  2. Clicks "Unlock for $X.XX" → createCheckout creates a PENDING Unlock record, redirects to Whop
  3. Listener pays on Whop's hosted checkout page
  4. Whop redirects back to /a/[handle]?checkout_status=success&payment_id=pay_xxx&unlocked=xxx&song=xxx
  5. Page server verifies the payment ID with Whop, marks Unlock as PAID
  6. Audio player appears for that song
  7. Whop fires payment.succeeded webhook → handler marks it PAID if the redirect didn't already

The redirect and webhook both try to update the same Unlock record. The whopPaymentId unique constraint prevents double-processing. Whichever one runs first wins; the second finds the record already updated and skips it.

Listener playlists:

  1. Listener visits /a/[handle], logged in via Whop OAuth
  2. Clicks + on any song → dropdown opens showing existing playlists
  3. Clicks "New playlist" → types a name, presses Enter → createPlaylist runs, song is added in the same write, new playlist appears instantly in every other song's dropdown on the page
  4. Toggles a song into an existing playlist → addSongToPlaylist or removeSongFromPlaylist runs, checkbox updates immediately
  5. Clicks "My Library" in the nav → /library shows all playlists as cards with mosaic covers
  6. Clicks a playlist → /library/[id] shows the songs; free songs play inline, premium songs link back to the artist page to unlock

Deploying

Add all environment variables to the hosting platform. For production, update .env:

  • Remove WHOP_OAUTH_BASE and WHOP_BASE_URL entirely so the SDK defaults to the live Whop API.
  • Set NEXT_PUBLIC_WHOP_ENV="production".
  • Update NEXT_PUBLIC_APP_URL to the real domain.
  • Update WHOP_REDIRECT_URI to the real domain's callback URL.

In the Whop developer dashboard, update the OAuth redirect URI and webhook URL to match the production domain. The OAuth redirect URI must match WHOP_REDIRECT_URI exactly; any mismatch and logins will fail.

Run the production migration before deploying:

Terminal
npx prisma migrate deploy

Supabase bucket settings carry over automatically since they're configured in the Supabase dashboard, not in code.

Build your next platform with Whop

We now have a complete music monetisation platform: artist profiles, direct-to-Supabase file uploads, premium song gating, one-time payments via Whop with platform fees, and an embedded payout portal.

But this isn't the only platform you can build using the Whop infrastructure, you can build everything from an AI chatbot SaaS to a Substack clone easier than ever. If you want to learn more about the Whop infrastructure and how it can help you, check out our other guides and the Whop developer documentation.