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.
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/librarypage 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 flipspayoutEnabled/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
- Artist clicks "Enable Earnings" and the app creates a Whop company under the platform's parent, then redirects to Whop's hosted KYC flow.
- Artist publishes a premium song with a price; no Whop product is created upfront.
- Listener clicks "Unlock for $X.XX"; the app creates a
PENDINGUnlockrow and a Whop checkout configuration that targets the artist's company with an application fee for the platform. - Listener pays through Whop's hosted checkout and is redirected back to
/a/[handle]?payment_id=...; the page verifies the payment withwhop.payments.retrieveand flips the unlock toPAID. - Whop fires a
payment.succeededwebhook as a fallback that processes the unlock if the redirect didn't complete. - 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:
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:
npx prisma init
This creates prisma/schema.prisma and a .env file. Add this code to 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:
Userstores the Whop identity after loginArtistis the creator profile with the public handle and the Whop company ID used for payoutsSongholds the audio/cover URLs and the premium flagUnlockis the payment record: it startsPENDINGwhen checkout begins and flips toPAIDwhen payment completesUserPlaylistis a listener-owned playlist, andUserPlaylistSongis the junction table that links songs to playlists with apositioninteger for ordering and a unique constraint on[playlistId, songId]to prevent duplicatesapplicationFeedefaults 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:
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:
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:
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:
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:
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:
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:
npx ngrok http 3000
Copy the https:// URL ngrok prints; we'll need it in the next step.
Checkpoint
npm run devboots the Next.js app athttp://localhost:3000.- ngrok prints a public
https://*.ngrok-free.appURL that loads the same homepage. npx prisma migrate dev --name initcreates the database tables without errors.prisma/schema.prismadefinesUser,Artist,Song,UserPlaylist,UserPlaylistSong, andUnlock.- 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:
- Go to whop.com/developer and create a new app.
- Under OAuth / Redirect URIs, add the ngrok callback URL:
https://your-ngrok-url.ngrok-free.app/api/auth/callback. - Copy Client ID into
WHOP_CLIENT_ID. - Copy Client Secret into
WHOP_CLIENT_SECRET. - Create an API Key with all permissions enabled and copy it into
WHOP_API_KEY. - From the company dashboard, go to Settings and copy the Company ID (starts with
biz_) intoWHOP_PARENT_COMPANY_ID. - Under Webhooks, add
https://your-ngrok-url.ngrok-free.app/api/webhooks/whop, enablepayment.succeededandpayment.failed, and copy the signing secret intoWHOP_WEBHOOK_SECRET. - Generate a session secret with
openssl rand -base64 32and copy it intoSESSION_SECRET.
Replace the placeholders in the .env file with the real values from above:
# 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
- The Whop developer app is created and the OAuth redirect URI matches the ngrok callback URL exactly.
.envcontains values forWHOP_CLIENT_ID,WHOP_CLIENT_SECRET,WHOP_API_KEY,WHOP_PARENT_COMPANY_ID,WHOP_WEBHOOK_SECRET, andSESSION_SECRET.WHOP_BASE_URLandWHOP_OAUTH_BASEpoint atsandbox-api.whop.comandNEXT_PUBLIC_WHOP_ENVissandbox.- The three Supabase buckets
songs,covers, andpreviewsexist and are marked public. SUPABASE_URLandSUPABASE_SERVICE_ROLE_KEYare filled in.
Step 3: Set up the Whop SDK client
Create src/lib/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
src/lib/whop.tsexists and exports bothwhopandwhopAsUser.- Importing
whopfrom any server file does not throw at module load. - The platform-level
WHOP_API_KEYis 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:
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:
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:
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:
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:
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
- Hitting
/api/auth/loginredirects to Whop's sandbox login page. - Logging in returns to
/dashboardand thesnd_sessioncookie is set in the browser's devtools. - A row appears in the
Usertable with the Whop user ID. - Hitting
/dashboardwhile logged out redirects home. - 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:
"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:
"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:
"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:
"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:
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:
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
/dashboardshows the Profile, Songs, and Earnings sections (Songs and Earnings only appear after a profile exists).- Submitting the profile form with a unique handle creates an
Artistrow. - Submitting again with a handle already taken by another user shows the "Handle already taken" inline error.
- The "View artist page" link in the dashboard header opens
/a/<handle>in a new tab. - 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:
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:
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:
"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
- Uploading an audio file from the dashboard lands the file in the
songsbucket in Supabase Storage. - A new
Songrow is created withaudioUrlpointing at the public Supabase URL. - Uploading a cover image lands it in the
coversbucket andcoverUrlis set on the row. - The dashboard "Songs" section lists the new track with title, price, and the toggle/delete buttons.
- Toggling premium/free flips
isPremiumon 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:
"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:
"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:
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
/a/<handle>loads and shows the artist's display name, bio, and song count.- Free songs render with the inline
<audio>player. - Premium songs show a price badge and an "Unlock for $X.XX" button.
- If the artist has not yet enabled earnings, premium songs show "coming soon" instead of the unlock button.
- 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:
"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:
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
- Clicking "Unlock for $X.XX" on a premium song creates an
Unlockrow withstatus: PENDINGand redirects to a Whop checkout URL. - Paying with the sandbox test card
4242 4242 4242 4242redirects back to/a/<handle>?checkout_status=success&payment_id=.... - The Unlock row flips to
status: PAIDand thewhopPaymentIdis filled in. - The audio player replaces the unlock button on the artist page for the buyer.
- The webhook endpoint at
/api/webhooks/whopreceives apayment.succeededevent and returns{ received: true }; the ngrok inspector atlocalhost:4040shows a 200. - Replaying the same webhook does not double-process: the second attempt finds the row already
PAIDand 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:
"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:
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:
"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
- Clicking "Enable Earnings" creates a Whop company under the platform's parent and saves its ID on the artist row.
- The flow redirects to Whop's hosted onboarding form.
- Completing onboarding (or hitting the sandbox skip) returns to
/api/earnings/completeand flipspayoutEnabledtotrue. - The dashboard re-renders with the "Earnings enabled" status and the Payout Portal section becomes visible.
- 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:
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:
"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
- The "Payout Portal" section renders on the dashboard once
payoutEnabledis true. - The balance, verification status, withdraw button, and withdrawal history all load (the
/api/payout-tokenendpoint returns a non-empty token). - The portal pulls live data from the Whop sandbox: a successful sale shows the artist's net amount (price minus the platform application fee).
- 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:
"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:
"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:
"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:
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:
"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:
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
- The
+button on any song row opens a dropdown with existing playlists and a "New playlist" input. - Typing a name and pressing Enter creates the playlist and the song is added to it in the same database write.
- A playlist created in one song's dropdown appears immediately in every other song's dropdown without a page reload.
/librarylists every playlist as a card with a 2x2 cover mosaic.- Clicking a playlist opens
/library/<id>; free songs play inline, premium songs link back to the artist page to unlock. - 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:
- Artist visits the landing page, clicks "Start sharing music"
- OAuth runs: Whop login page, PKCE code exchange, session cookie set
- Artist fills in handle and display name →
saveProfilecreates the Artist record - Artist uploads a song → browser uploads audio to Supabase, server action stores the URL
- Artist clicks "Enable Earnings" → Whop company created under the platform, KYC onboarding starts
- Artist completes Whop onboarding →
/api/earnings/completesetspayoutEnabled: true, payout portal appears
Listener purchase:
- Listener visits
/a/[handle], sees a premium track with a price badge - Clicks "Unlock for $X.XX" →
createCheckoutcreates aPENDINGUnlock record, redirects to Whop - Listener pays on Whop's hosted checkout page
- Whop redirects back to
/a/[handle]?checkout_status=success&payment_id=pay_xxx&unlocked=xxx&song=xxx - Page server verifies the payment ID with Whop, marks Unlock as
PAID - Audio player appears for that song
- Whop fires
payment.succeededwebhook → handler marks itPAIDif 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:
- Listener visits
/a/[handle], logged in via Whop OAuth - Clicks
+on any song → dropdown opens showing existing playlists - Clicks "New playlist" → types a name, presses Enter →
createPlaylistruns, song is added in the same write, new playlist appears instantly in every other song's dropdown on the page - Toggles a song into an existing playlist →
addSongToPlaylistorremoveSongFromPlaylistruns, checkbox updates immediately - Clicks "My Library" in the nav →
/libraryshows all playlists as cards with mosaic covers - 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_BASEandWHOP_BASE_URLentirely so the SDK defaults to the live Whop API. - Set
NEXT_PUBLIC_WHOP_ENV="production". - Update
NEXT_PUBLIC_APP_URLto the real domain. - Update
WHOP_REDIRECT_URIto 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:
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.