You can build a Linktree clone using Next.js and the Whop infrastructure. In this guide, we're going to walk you through building such clone with user authentication, payments system integration, customizable link pages, and more.

Key takeaways

  • Whop consolidates payments, auth, KYC, and payouts into a single integration, removing the need to stitch together multiple vendors.
  • The tutorial walks through building a full Linktree clone with free and premium gated links, platform fees, and creator payout portals.
  • The stack uses Next.js App Router, Prisma with PostgreSQL, Tailwind, and the Whop SDK to ship a production-ready creator monetization platform.
Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

Most creators need four things to monetize their audience: a way to share links, a way to gate premium content, a payment processor, and a way to get paid. Building that stack usually means stitching together a payment provider, an auth provider, a KYC vendor, and a payouts service each with its own SDK, webhook, and compliance overhead. This tutorial shows you how to build the whole thing with a single integration.

We'll build a fully working Linktree clone where creators can publish free links and gate premium ones behind a one-time payment.

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

You can preview the finished product demo here and find the full codebase in this GitHub repository.

What you'll build

  • A marketing homepage with a hero that demos the product live (four sample creator pages drift behind the headline) and a handle picker that shows visitors what their URL will look like before they sign up
  • Creator sign-up and login via Whop OAuth
  • A dashboard for managing links, premium and free links, hiding individual links, and reordering them with drag-and-drop
  • A live preview pane that shows exactly what visitors will see while you edit
  • Per-creator accent colors from
  • Connected account enrollment (Whop handles KYC for creators)
  • A public profile page at /u/[handle] with free links visible and premium links locked behind a price chip
  • A one-time payment checkout via Whop Direct Charges with platform application fees
  • Instant post-payment unlock with Whop redirect verification and webhooks
  • An embedded payout portal for creators to manage withdrawals

Tech stack

  • Next.js (App Router, TypeScript)
  • Prisma + PostgreSQL
  • Tailwind CSS
  • Whop SDK (@whop/sdk, @whop/embedded-components-react-js)
  • iron-session for session management
  • @dnd-kit for drag-and-drop link reordering

Prerequisites

  • Node.js 18+
  • A PostgreSQL database (local, Neon, Supabase, or Railway)
  • 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 the project's dependencies by running this command and answering the questions on your terminal:

Terminal
npx create-next-app@latest whop-linktree-clone --typescript --tailwind --app --src-dir
cd whop-linktree-clone
npm install -D prisma
npm install @prisma/client iron-session zod
npm install @whop/sdk @whop/embedded-components-react-js @whop/embedded-components-vanilla-js
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm install simple-icons @vercel/blob

This creates a folder whop-linktree-clone with the default Next.js installation and the dependencies needed for the project: Tailwind, Prisma, TypeScript, Whop SDK, etc.

Set up the database schema by running this command in your terminal:

Terminal
npx prisma init

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

schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id         String   @id @default(cuid())
  whopUserId String   @unique
  email      String?
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
  creator    Creator?
}

model Creator {
  id             String   @id @default(cuid())
  userId         String   @unique
  user           User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  handle         String   @unique
  title          String   @default("")
  bio            String   @default("")
  avatarUrl      String?
  unlockPrice    Int      @default(500)       // cents
  accentColor    String   @default("violet")  // preset key OR raw hex; resolved in src/lib/theme.ts
  cardStyle      String   @default("default") // preset key; controls border-radius/shadow on link cards
  bgKind         String   @default("auto")    // "auto" | "solid" | "gradient" | "preset"
  bgValue        String?                       // hex when solid, gradient CSS when gradient, preset key otherwise
  textColor      String   @default("auto")    // "auto" | hex; auto picks based on bg luminance
  applicationFee Int      @default(50)        // cents - flat platform fee per sale
  whopCompanyId  String?
  payoutEnabled  Boolean  @default(false)     // true after KYC onboarding is complete
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
  links          Link[]
  socials        SocialLink[]
  unlocks        Unlock[]
}

model Link {
  id        String   @id @default(cuid())
  creatorId String
  creator   Creator  @relation(fields: [creatorId], references: [id], onDelete: Cascade)
  title     String
  url       String
  isPremium Boolean  @default(false)
  isVisible Boolean  @default(true)
  sortOrder Int      @default(0)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Unlock {
  id              String       @id @default(cuid())
  creatorId       String
  creator         Creator      @relation(fields: [creatorId], references: [id], onDelete: Cascade)
  buyerEmail      String?
  buyerWhopUserId String?
  status          UnlockStatus @default(PENDING)
  whopPaymentId   String?      @unique
  createdAt       DateTime     @default(now())
  updatedAt       DateTime     @updatedAt
}

enum UnlockStatus {
  PENDING
  PAID
  FAILED
  REFUNDED
}

model WebhookEvent {
  id         String   @id  // Whop event ID, used as the idempotency key
  type       String
  receivedAt DateTime @default(now())
}

model SocialLink {
  id         String   @id @default(cuid())
  creatorId  String
  creator    Creator  @relation(fields: [creatorId], references: [id], onDelete: Cascade)
  platform   String   // platform key, must match src/lib/socials.ts
  url        String
  color      String?  // optional override (hex). When null, the platform's brand color is used.
  sortOrder  Int      @default(0)
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
}

Create a database for the project, then update DATABASE_URL in your .env file with the correct details: your PostgreSQL username, password, and database name.

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

Every time you save a file in development, Next.js restarts parts of your app. Each restart creates a new database connection, and after enough saves, you'd have so many open connections that your database starts rejecting them.

The singleton pattern solves this by keeping one shared client alive for the entire lifetime of the dev server, reusing it on every reload instead of creating a new one. In production, this isn't an issue because the app starts only once, so the extra caching logic is skipped entirely.

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

prisma.ts
import { PrismaClient } from "@prisma/client";

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

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
  });

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

Run the migration:

Terminal
node_modules/.bin/prisma migrate dev --name init

Color tokens and hero animations

Before we complete the landing page, we'll add some cool design elements to capture the attention of visitors. Open src/app/globals.css and replace its contents:

globals.css
@import "tailwindcss";

:root {
  --background: #fafaf9;
  --background-alt: #ffffff;
  --foreground: #0a0a0c;
  --foreground-muted: #525258;
  --foreground-subtle: #8a8a92;
  --border: #e7e5e4;
  --border-muted: #f1efee;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --font-sans: var(--font-geist-sans);
  --font-mono: var(--font-geist-mono);
}

body {
  background: var(--background);
  color: var(--foreground);
  font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont,
    "Segoe UI", Roboto, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

@keyframes hero-drift-a {
  0%, 100% { transform: translate3d(0, 0, 0) rotate(-4deg); }
  50%      { transform: translate3d(0, -14px, 0) rotate(-2deg); }
}
@keyframes hero-drift-b {
  0%, 100% { transform: translate3d(0, 0, 0) rotate(3deg); }
  50%      { transform: translate3d(0, 12px, 0) rotate(5deg); }
}
@keyframes hero-drift-c {
  0%, 100% { transform: translate3d(0, 0, 0) rotate(-6deg); }
  50%      { transform: translate3d(0, -18px, 0) rotate(-8deg); }
}
@keyframes hero-drift-d {
  0%, 100% { transform: translate3d(0, 0, 0) rotate(5deg); }
  50%      { transform: translate3d(0, 16px, 0) rotate(7deg); }
}

@media (prefers-reduced-motion: reduce) {
  .hero-drift-a,
  .hero-drift-b,
  .hero-drift-c,
  .hero-drift-d {
    animation: none !important;
  }
}

Hero mockup card

The homepage shows four sample creator profiles drifting behind the headline so the visitor can see what they'll be making before reading anything. Go to src/components/ and create a file called HeroProfileCard.tsx with the content:

HeroProfileCard.tsx
import { resolveAccent, accentVars } from "@/lib/theme";

export interface HeroProfileCardProps {
  name: string;
  handle: string;
  bio: string;
  accent: string;
  initial?: string;
  freeLinks: readonly string[];
  premium?: { title: string; price: number };
}

export function HeroProfileCard({
  name,
  handle,
  bio,
  accent,
  initial,
  freeLinks,
  premium,
}: HeroProfileCardProps) {
  const a = resolveAccent(accent);
  const firstChar = (initial ?? name).charAt(0).toUpperCase();

  return (
    <div
      className="w-[260px] rounded-2xl border border-neutral-200/80 bg-white px-5 py-6 shadow-[0_18px_50px_-22px_rgba(15,15,18,0.20),0_2px_4px_rgba(15,15,18,0.04)]"
      style={accentVars(a)}
    >
      <div className="text-center">
        <div
          className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full text-base font-semibold text-white"
          style={{ background: "var(--accent)" }}
          aria-hidden
        >
          {firstChar}
        </div>
        <p className="text-[15px] font-semibold tracking-tight text-neutral-900">
          {name}
        </p>
        <p className="mt-0.5 text-[11px] text-neutral-400">/u/{handle}</p>
        <p className="mt-2 text-[12px] leading-snug text-neutral-500 line-clamp-2">
          {bio}
        </p>
      </div>

      <div className="mt-4 space-y-1.5">
        {freeLinks.map((title) => (
          <div
            key={title}
            className="flex items-center justify-center rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[12px] font-medium text-neutral-900"
          >
            {title}
          </div>
        ))}
        {premium && (
          <div className="flex items-center justify-between rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[12px] font-medium">
            <span className="flex-1 text-center text-neutral-400">
              {premium.title}
            </span>
            <span
              className="ml-2 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-tight"
              style={{
                background: "var(--accent-bg)",
                color: "var(--accent)",
              }}
            >
              ${premium.price}
            </span>
          </div>
        )}
      </div>
    </div>
  );
}

Floating cards container

Now let's build the cards themselves. Go to src/components/ and create a file called HeroFloatingCards.tsx with the content:

HeroFloatingCards.tsx
import { HeroProfileCard } from "./HeroProfileCard";

const SAMPLES = [
  {
    name: "Maya Chen",
    handle: "mayachen",
    bio: "Photographer. Light, color, and people you'll never meet.",
    accent: "crimson",
    freeLinks: ["Latest gallery", "Behind the scenes"],
    premium: { title: "Lightroom presets pack", price: 12 },
  },
  {
    name: "Daniel Park",
    handle: "danielpark",
    bio: "Producer making gentle electronics in Brooklyn.",
    accent: "indigo",
    freeLinks: ["Listen on Spotify", "Tour dates"],
    premium: { title: "Album rough mixes", price: 8 },
  },
  {
    name: "Lila Reyes",
    handle: "lilakitchen",
    bio: "Weeknight recipes that actually take 30 minutes.",
    accent: "tangerine",
    freeLinks: ["This week's recipe", "Newsletter"],
    premium: { title: "30-day meal plan", price: 15 },
  },
  {
    name: "Theo Brown",
    handle: "theobrown",
    bio: "Engineer. Writing about systems, simplicity, and side projects.",
    accent: "forest",
    freeLinks: ["GitHub", "Blog"],
    premium: { title: "Refactoring legacy course", price: 25 },
  },
] as const;

const POSITIONS = [
  {
    className: "left-[6%] top-[14%] hidden md:block hero-drift-a",
    style: { animation: "hero-drift-a 9s ease-in-out infinite" },
  },
  {
    className: "right-[8%] top-[8%] hero-drift-b",
    style: { animation: "hero-drift-b 11s ease-in-out infinite" },
  },
  {
    className: "left-[12%] bottom-[8%] hidden lg:block hero-drift-c",
    style: { animation: "hero-drift-c 10s ease-in-out infinite" },
  },
  {
    className: "right-[4%] bottom-[12%] hidden md:block hero-drift-d",
    style: { animation: "hero-drift-d 12s ease-in-out infinite" },
  },
] as const;

export function HeroFloatingCards() {
  return (
    <div
      className="pointer-events-none absolute inset-0 overflow-hidden"
      aria-hidden
    >
      {SAMPLES.map((sample, i) => {
        const pos = POSITIONS[i];
        return (
          <div
            key={sample.handle}
            className={`absolute opacity-90 ${pos.className}`}
            style={pos.style}
          >
            <HeroProfileCard {...sample} />
          </div>
        );
      })}
    </div>
  );
}

Handle picker CTA

The hero CTA is a handle input with a live URL preview. Go to src/components/ and create a file called SignupHandleInput.tsx with the content:

SignupHandleInput.tsx
"use client";

import { useState } from "react";

const HANDLE_PATTERN = /^[a-z0-9_-]{0,32}$/;

function sanitize(input: string) {
  return input
    .toLowerCase()
    .replace(/\s+/g, "-")
    .replace(/[^a-z0-9_-]/g, "")
    .slice(0, 32);
}

export function SignupHandleInput({
  urlHost,
  compact = false,
}: {
  urlHost: string;
  compact?: boolean;
}) {
  const [handle, setHandle] = useState("");

  const cleaned = sanitize(handle);
  const showError = handle.length > 0 && !HANDLE_PATTERN.test(handle);
  const inlinePrefix = compact ? "/u/" : `${urlHost}/u/`;

    function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const target = cleaned
      ? `/api/auth/login?handle=${encodeURIComponent(cleaned)}`
      : `/api/auth/login`;
    window.location.assign(target);
  }

  return (
    <form
      action="/api/auth/login"
      method="GET"
      className="w-full max-w-md"
      onSubmit={handleSubmit}
    >
      <label htmlFor="hero-handle" className="sr-only">
        Pick your handle
      </label>
      <div className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
        <div className="flex flex-1 items-stretch overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-[0_1px_2px_rgba(15,15,18,0.04)] focus-within:border-neutral-900 focus-within:ring-2 focus-within:ring-neutral-900/10 transition-colors">
          <span className="hidden items-center border-r border-neutral-200 bg-neutral-50 px-3 font-mono text-xs text-neutral-500 sm:inline-flex">
            {inlinePrefix}
          </span>
          <input
            id="hero-handle"
            name="handle"
            type="text"
            autoComplete="off"
            spellCheck={false}
            placeholder="yourname"
            value={handle}
            onChange={(e) => setHandle(e.target.value)}
            className="flex-1 bg-white px-4 py-3 font-mono text-sm text-neutral-900 outline-none placeholder:text-neutral-300"
            aria-describedby="hero-handle-help"
          />
        </div>
        <button
          type="submit"
          className="inline-flex items-center justify-center rounded-xl bg-neutral-900 px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-neutral-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/30"
        >
          Get my link
        </button>
      </div>
      <p
        id="hero-handle-help"
        className="mt-2 text-center text-xs text-neutral-500 sm:text-left"
      >
        {showError ? (
          <span className="text-red-600">
            Handle can only contain lowercase letters, numbers, underscores,
            and dashes.
          </span>
        ) : cleaned ? (
          <>
            Your URL: <span className="font-mono text-neutral-700">{urlHost}/u/{cleaned}</span>
          </>
        ) : (
          <>Free to start. Sign in with Whop on the next screen.</>
        )}
      </p>
    </form>
  );
}

Landing page

Now the let's build the actual page. Go to src/app/ and create a file called page.tsx with the content:

page.tsx
import { getCurrentUserId } from "@/lib/session";
import { redirect } from "next/navigation";
import { HeroFloatingCards } from "@/components/HeroFloatingCards";
import { SignupHandleInput } from "@/components/SignupHandleInput";

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

  const appUrl =
    process.env.NEXT_PUBLIC_APP_URL ?? "https://your-app.vercel.app";
  const urlHost = appUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");

  return (
    <div className="flex min-h-screen flex-col bg-[var(--background)] text-[var(--foreground)]">
      <header className="relative z-20 flex items-center justify-between border-b border-[var(--border-muted)] bg-[var(--background)]/80 px-6 py-4 backdrop-blur">
        <span className="text-sm font-semibold tracking-tight">Linkstacks</span>
        <a
          href="/api/auth/login"
          className="text-sm text-neutral-500 transition-colors hover:text-neutral-900"
        >
          Sign in
        </a>
      </header>

      <main className="relative flex-1">
        <section className="relative isolate overflow-hidden">
          <HeroFloatingCards />

          <div
            className="absolute inset-0 bg-gradient-to-b from-[var(--background)]/60 via-[var(--background)]/85 to-[var(--background)] pointer-events-none"
            aria-hidden
          />

          <div className="relative z-10 mx-auto flex max-w-3xl flex-col items-center px-6 py-24 text-center sm:py-32">
            <h1 className="text-4xl font-semibold leading-[1.05] tracking-tight text-neutral-900 sm:text-5xl md:text-6xl">
              One link
              <br />
              Everything you make
            </h1>

            <p className="mt-6 max-w-md text-base leading-relaxed text-neutral-600 sm:text-lg">
              Share your links and charge for premium content from a single
              page.
            </p>

            <div className="mt-10 w-full">
              <div className="flex justify-center">
                <SignupHandleInput urlHost={urlHost} compact />
              </div>
            </div>
          </div>
        </section>

        <section className="relative z-10 border-t border-[var(--border-muted)] bg-[var(--background-alt)]">
          <div className="mx-auto max-w-5xl px-6 py-20">
            <p className="text-center text-xs font-semibold uppercase tracking-widest text-neutral-500">
              How it works
            </p>
            <h2 className="mt-3 text-center text-2xl font-semibold tracking-tight text-neutral-900 sm:text-3xl">
              Three steps from signup to first sale
            </h2>

            <div className="mt-12 grid gap-10 sm:grid-cols-3">
              {STEPS.map((step) => (
                <div key={step.title}>
                  <div className="flex items-center gap-3">
                    <span className="flex h-7 w-7 items-center justify-center rounded-full border border-neutral-200 text-xs font-semibold text-neutral-700">
                      {step.n}
                    </span>
                    <h3 className="text-base font-semibold tracking-tight text-neutral-900">
                      {step.title}
                    </h3>
                  </div>
                  <p className="mt-3 text-sm leading-relaxed text-neutral-600">
                    {step.body}
                  </p>
                </div>
              ))}
            </div>
          </div>
        </section>

        <section className="relative z-10 border-t border-[var(--border-muted)]">
          <div className="mx-auto max-w-3xl px-6 py-20 text-center">
            <h2 className="text-2xl font-semibold tracking-tight text-neutral-900 sm:text-3xl">
              Claim your link
            </h2>
            <p className="mt-3 text-sm text-neutral-500 sm:text-base">
              Five minutes from sign-in to a live page that takes payments.
            </p>
            <div className="mt-8 flex justify-center">
              <SignupHandleInput urlHost={urlHost} compact />
            </div>
          </div>
        </section>
      </main>

      <footer className="border-t border-[var(--border-muted)] px-6 py-5 text-center text-xs text-neutral-400">
        Built on Whop
      </footer>
    </div>
  );
}

const STEPS = [
  {
    n: 1,
    title: "Sign in with Whop",
    body: "One click using OAuth. Whop handles your account, identity, and billing rails so you don't have to.",
  },
  {
    n: 2,
    title: "Customize your page",
    body: "Pick a handle and an accent color, add your links, mark a few as premium, drag to reorder. The live preview tracks every edit.",
  },
  {
    n: 3,
    title: "Get paid",
    body: "Premium links unlock through a hosted checkout. The platform takes a small fee; the rest lands in your connected payout account.",
  },
] as const;

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 your local dev server:

Terminal
npx ngrok http 3000

Copy the https:// URL ngrok gives you. You'll need it in the next step.

Step 2: Setting up Whop

Now, let's get some secret keys from Whop by following the steps below:

  1. Go to whop.com/developer and create a new app
  2. Under OAuth, add your ngrok callback URL: https://your-ngrok-url.ngrok-free.app/api/auth/callback
  3. Copy App ID (starts with app_) → WHOP_APP_ID and NEXT_PUBLIC_WHOP_APP_ID
  4. Copy Client IDWHOP_CLIENT_ID (same value as App ID)
  5. Copy Client SecretWHOP_CLIENT_SECRET
  6. Create a Company API Key with all permissions enabled → WHOP_API_KEY
  7. Go to your company dashboard → Settings → copy your Company ID (starts with biz_) → WHOP_PARENT_COMPANY_ID
  8. Under Webhooks, create a webhook pointing to https://your-ngrok-url.ngrok-free.app/api/webhooks/whop with payment.succeeded and payment.failed events selected. Enable "Connected account events" so webhooks fire on payments to your creators' connected accounts. Copy the signing secret → WHOP_WEBHOOK_SECRET
  9. Generate a session secret: openssl rand -base64 32SESSION_SECRET

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

.env
# Whop OAuth — from https://whop.com/developer → your app → OAuth settings
WHOP_APP_ID="app_xxxxxxxxxxxx"
NEXT_PUBLIC_WHOP_APP_ID="app_xxxxxxxxxxxx"
WHOP_CLIENT_ID="app_xxxxxxxxxxxx"
WHOP_CLIENT_SECRET="apik_xxxxxxxxxxxx"
WHOP_REDIRECT_URI="https://yourdomain.com/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="ws_xxxxxxxxxxxx"

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

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

# App URL
NEXT_PUBLIC_APP_URL="https://yourdomain.com"
Sandbox mode: Whop provides a full sandbox environment at sandbox-api.whop.com. Use it for all local development. When you're ready to go live, remove the WHOP_BASE_URL and WHOPOAUTH_BASE lines and set NEXTPUBLIC_WHOP_ENV=production.

Step 3: Set up the Whop SDK client

Go to src/lib/ and create a file called whop.ts with the content:

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

const globalForWhop = globalThis as unknown as { whop: Whop };

export const whop =
  globalForWhop.whop ??
  new Whop({
    apiKey: process.env.WHOP_API_KEY!,
    appID: process.env.WHOP_APP_ID,
    webhookKey: Buffer.from(
      process.env.WHOP_WEBHOOK_SECRET ?? ""
    ).toString("base64"),
  });

if (process.env.NODE_ENV !== "production") globalForWhop.whop = whop;

export function whopAsUser(oauthToken: string): Whop {
  return new Whop({ apiKey: `Bearer ${oauthToken}` });
}

This follows the same singleton pattern as Prisma. Whop is the platform-level client used for most operations, creating companies, generating checkout configurations, and verifying payments.

whopAsUser creates a separate client scoped to a specific user's OAuth token, used when you need to make API calls on behalf of a logged-in creator rather than as the platform itself.

The SDK automatically reads WHOP_API_KEY and WHOP_BASE_URL from your .env file, so ensure the values are set correctly.

Webhook secret encoding: Whop's webhook signing follows the Standard Webhooks spec, which expects the secret as base64.

Step 4: Implement Whop OAuth

Whop uses OAuth 2.0 with PKCE (Proof Key for Code Exchange), a security extension that prevents authorization code interception attacks. Before redirecting the user to Whop's login page, your server generates a random secret called a code_verifier, hashes it into a code_challenge, and sends the hash to Whop.

When the user returns with an authorization code, your server sends the original verifier to exchange it for tokens. Whop hashes it, checks it matches the challenge it received earlier, and only then hands over the tokens. If someone intercepted the authorization code in transit, they couldn't use it cause they don't have the verifier.

OAuth is a round trip between your server, the user's browser, and Whop's servers. Each of the three routes below handles one leg of that journey.

Session helper

The server has no memory of who you are or whether you logged in. To fix this, we're going to store the user's ID in a cookie after the user authentication is completed. iron-session helps us by encrypting the cookie with a secret key, making it impossible to read or forge from the client side.

The SESSION_SECRET environment variable is that key, keep it private and generate it with openssl rand -base64 32. Go to src/lib/ and create a file called session.ts with the content:

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

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

if (!process.env.SESSION_SECRET) {
  throw new Error(
    "SESSION_SECRET is required. Generate one with `openssl rand -base64 32` and add it to your environment."
  );
}

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

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

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

Login route

The login route starts the authorization flow by generating PKCE values, storing the verifier in a cookie, and redirecting the user to Whop's authorize endpoint.

It also accepts an optional ?handle= query param from the homepage CTA so we can round-trip the visitor's chosen handle through OAuth and pre-fill it on the dashboard. Go to src/app/api/auth/login/ and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { randomBytes, createHash } from "crypto";

const HANDLE_PATTERN = /^[a-z0-9_-]{2,32}$/;

function generateCodeVerifier() {
  return randomBytes(32).toString("base64url");
}

function generateCodeChallenge(verifier: string) {
  return createHash("sha256").update(verifier).digest("base64url");
}

export async function GET(req: NextRequest) {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = generateCodeChallenge(codeVerifier);
  const state = randomBytes(16).toString("hex");
  const nonce = randomBytes(16).toString("hex");

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

  const whopBase = process.env.WHOP_OAUTH_BASE ?? "https://api.whop.com";
  const res = NextResponse.redirect(`${whopBase}/oauth/authorize?${params}`);

  const isProd = process.env.NODE_ENV === "production";
  const cookieOpts = {
    httpOnly: true,
    secure: isProd,
    sameSite: "lax" as const,
    path: "/",
    maxAge: 600,
  };
  res.cookies.set("pkce_verifier", codeVerifier, cookieOpts);
  res.cookies.set("oauth_state", state, cookieOpts);

  // Optional: a handle hint passed from the homepage CTA. We don't trust
  // it for auth purposes, we just round-trip it as a non-httpOnly cookie
  // so the dashboard can pre-fill the handle input after the user comes
  // back from Whop. The pattern check matches our profile schema.
  const handleHint = req.nextUrl.searchParams.get("handle");
  if (handleHint && HANDLE_PATTERN.test(handleHint)) {
    res.cookies.set("intended_handle", handleHint, {
      httpOnly: false,
      secure: isProd,
      sameSite: "lax",
      path: "/",
      maxAge: 600,
    });
  }

  return res;
}

Callback route

After the user logs in on Whop's site, they get sent back to this route with a temporary code in the URL. The server swaps that code for real credentials by making a request directly to Whop behind the scenes. Whop verifies everything and hands back a token containing the user's identity.

We use that to create or look up their account in the database, save their ID into an encrypted session cookie so the app remembers who they are, and redirect them to the dashboard. Go to src/app/api/auth/callback/ and create a file called route.ts with the content:

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

const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

export async function GET(req: NextRequest) {
  const code = req.nextUrl.searchParams.get("code");
  const returnedState = req.nextUrl.searchParams.get("state");
  const codeVerifier = req.cookies.get("pkce_verifier")?.value;
  const expectedState = req.cookies.get("oauth_state")?.value;

  if (!code || !codeVerifier || returnedState !== expectedState) {
    return NextResponse.redirect(`${APP_URL}/?error=invalid_oauth`);
  }

  const whopBase = process.env.WHOP_OAUTH_BASE ?? "https://api.whop.com";
  const tokenRes = await fetch(`${whopBase}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      code,
      client_id: process.env.WHOP_CLIENT_ID!,
      client_secret: process.env.WHOP_CLIENT_SECRET!,
      redirect_uri: process.env.WHOP_REDIRECT_URI!,
      code_verifier: codeVerifier,
    }),
  });

  if (!tokenRes.ok) {
    return NextResponse.redirect(`${APP_URL}/?error=token_exchange_failed`);
  }

  const tokenData = await tokenRes.json();
  const idToken: string = tokenData.id_token;
  const payload = JSON.parse(
    Buffer.from(idToken.split(".")[1], "base64url").toString("utf-8")
  );
  const whopUserId: string = payload.sub;
  const email: string | null = payload.email ?? null;

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

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

  const res = NextResponse.redirect(`${APP_URL}/dashboard`);
  res.cookies.delete("pkce_verifier");
  res.cookies.delete("oauth_state");
  return res;
}

Logout route

Destroys the session cookie and redirects to the home page. Go to src/app/api/auth/logout/ and create a file called route.ts with the content:

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

const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

export async function GET() {
  const session = await getSession();
  session.destroy();
  return NextResponse.redirect(`${APP_URL}/`);
}

Dashboard layout

This is the auth guard for the entire dashboard. If there's no session cookie, the user gets redirected to the homepage before any dashboard page loads. Go to src/app/dashboard/ and create a file called layout.tsx with the content:

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}</>;
}

At this point, visiting /api/auth/login should take you through the full Whop OAuth flow and land you on /dashboard.

Common errors

  • nonce is required - ensure the nonce param is included in your URLSearchParams in the login route.
  • Client secret required - Whop requires both PKCE and client secret. Make sure WHOP_CLIENT_SECRET is set and included in the token exchange body.
  • oauth:tokenexchange permission error - your API key needs this permission. Regenerate it with all permissions enabled in the Whop dashboard.

Step 5: Set up the theme system

Before building the dashboard, let's make sure we let each creator customize the look of their link page. We're going to do this with a curated palette of six accent colors, seven card-style presets, nine background presets (a mix of solid neutrals and gradients), and an auto-contrast text color that flips based on the background's brightness.

Every theme value is either a preset key or a hex color, so we never store anything that hasn't been audited for contrast. Go to src/lib/ and create a file called theme.ts with the content:

theme.ts
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">theme.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export type AccentKey =
  | &quot;violet&quot;
  | &quot;indigo&quot;
  | &quot;forest&quot;
  | &quot;crimson&quot;
  | &quot;slate&quot;
  | &quot;tangerine&quot;;

export interface Accent {
  key: AccentKey | string;
  label: string;
  hex: string;
  contrastOnWhite: number;
}

export const ACCENTS: readonly Accent[] = [
  { key: &quot;violet&quot;, label: &quot;Violet&quot;, hex: &quot;#7c3aed&quot;, contrastOnWhite: 5.93 },
  { key: &quot;indigo&quot;, label: &quot;Indigo&quot;, hex: &quot;#4338ca&quot;, contrastOnWhite: 8.73 },
  { key: &quot;forest&quot;, label: &quot;Forest&quot;, hex: &quot;#15803d&quot;, contrastOnWhite: 4.69 },
  { key: &quot;crimson&quot;, label: &quot;Crimson&quot;, hex: &quot;#be123c&quot;, contrastOnWhite: 6.49 },
  { key: &quot;slate&quot;, label: &quot;Slate&quot;, hex: &quot;#1e293b&quot;, contrastOnWhite: 15.59 },
  {
    key: &quot;tangerine&quot;,
    label: &quot;Tangerine&quot;,
    hex: &quot;#c2410c&quot;,
    contrastOnWhite: 4.97,
  },
] as const;

const ACCENT_BY_KEY: Record&lt;string, Accent&gt; = Object.fromEntries(
  ACCENTS.map((a) =&gt; [a.key, a])
);

export const DEFAULT_ACCENT_KEY: AccentKey = &quot;violet&quot;;

const HEX_REGEX = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/;

export function isHexColor(value: unknown): value is string {
  return typeof value === &quot;string&quot; &amp;&amp; HEX_REGEX.test(value.trim());
}

export function resolveAccent(key: string | null | undefined): Accent {
  if (!key) return ACCENT_BY_KEY[DEFAULT_ACCENT_KEY];
  if (ACCENT_BY_KEY[key]) return ACCENT_BY_KEY[key];
  if (isHexColor(key)) {
    return { key, label: &quot;Custom&quot;, hex: key, contrastOnWhite: 0 };
  }
  return ACCENT_BY_KEY[DEFAULT_ACCENT_KEY];
}

export function accentVars(accent: Accent): React.CSSProperties {
  return {
    &quot;--accent&quot;: accent.hex,
    &quot;--accent-bg&quot;: `${accent.hex}14`,
    &quot;--accent-border&quot;: `${accent.hex}33`,
  } as React.CSSProperties;
}

export type CardStyleKey =
  | &quot;default&quot;
  | &quot;pill&quot;
  | &quot;square&quot;
  | &quot;soft&quot;
  | &quot;outline&quot;
  | &quot;elevated&quot;
  | &quot;wave&quot;;

export interface CardStyle {
  key: CardStyleKey;
  label: string;
}

export const CARD_STYLES: readonly CardStyle[] = [
  { key: &quot;default&quot;, label: &quot;Rounded&quot; },
  { key: &quot;pill&quot;, label: &quot;Pill&quot; },
  { key: &quot;soft&quot;, label: &quot;Soft&quot; },
  { key: &quot;square&quot;, label: &quot;Square&quot; },
  { key: &quot;outline&quot;, label: &quot;Outline&quot; },
  { key: &quot;elevated&quot;, label: &quot;Elevated&quot; },
  { key: &quot;wave&quot;, label: &quot;Wave&quot; },
] as const;

export const DEFAULT_CARD_STYLE: CardStyleKey = &quot;default&quot;;

export function isCardStyleKey(value: unknown): value is CardStyleKey {
  return CARD_STYLES.some((s) =&gt; s.key === value);
}

export type BgKind = &quot;auto&quot; | &quot;solid&quot; | &quot;gradient&quot; | &quot;preset&quot;;

export interface BgPreset {
  key: string;
  label: string;
  css: string;
  isDark: boolean;
}

export const BG_PRESETS: readonly BgPreset[] = [
  { key: &quot;white&quot;, label: &quot;White&quot;, css: &quot;#ffffff&quot;, isDark: false },
  { key: &quot;cream&quot;, label: &quot;Cream&quot;, css: &quot;#fafaf9&quot;, isDark: false },
  { key: &quot;stone&quot;, label: &quot;Stone&quot;, css: &quot;#e7e5e4&quot;, isDark: false },
  { key: &quot;ink&quot;, label: &quot;Ink&quot;, css: &quot;#0a0a0c&quot;, isDark: true },
  {
    key: &quot;lavender&quot;,
    label: &quot;Lavender&quot;,
    css: &quot;linear-gradient(135deg, #f3e8ff 0%, #fdf2f8 100%)&quot;,
    isDark: false,
  },
  {
    key: &quot;peach&quot;,
    label: &quot;Peach&quot;,
    css: &quot;linear-gradient(135deg, #fff7ed 0%, #fee2e2 100%)&quot;,
    isDark: false,
  },
  {
    key: &quot;mint&quot;,
    label: &quot;Mint&quot;,
    css: &quot;linear-gradient(135deg, #ecfdf5 0%, #e0f2fe 100%)&quot;,
    isDark: false,
  },
  {
    key: &quot;dusk&quot;,
    label: &quot;Dusk&quot;,
    css: &quot;linear-gradient(135deg, #0f172a 0%, #312e81 100%)&quot;,
    isDark: true,
  },
  {
    key: &quot;horizon&quot;,
    label: &quot;Horizon&quot;,
    css: &quot;linear-gradient(135deg, #fef3c7 0%, #fda4af 60%, #c4b5fd 100%)&quot;,
    isDark: false,
  },
] as const;

const BG_PRESET_BY_KEY: Record&lt;string, BgPreset&gt; = Object.fromEntries(
  BG_PRESETS.map((b) =&gt; [b.key, b])
);

export function getBgPreset(key: string | null | undefined): BgPreset | null {
  if (!key) return null;
  return BG_PRESET_BY_KEY[key] ?? null;
}

export interface ResolvedBackground {
  css: string;
  isDark: boolean;
}

export function resolveBackground(
  kind: string | null | undefined,
  value: string | null | undefined
): ResolvedBackground {
  if (kind === &quot;preset&quot; &amp;&amp; value) {
    const preset = getBgPreset(value);
    if (preset) return { css: preset.css, isDark: preset.isDark };
  }
  if (kind === &quot;solid&quot; &amp;&amp; isHexColor(value)) {
    return { css: value as string, isDark: isHexDark(value as string) };
  }
  if (kind === &quot;gradient&quot; &amp;&amp; typeof value === &quot;string&quot; &amp;&amp; value.length &gt; 0) {
    return { css: value, isDark: false };
  }
  return { css: &quot;transparent&quot;, isDark: false };
}

export interface ResolvedText {
  color: string;
  muted: string;
}

const DARK_TEXT: ResolvedText = { color: &quot;#0a0a0c&quot;, muted: &quot;#525258&quot; };
const LIGHT_TEXT: ResolvedText = { color: &quot;#fafafa&quot;, muted: &quot;#cbd5e1&quot; };

export function resolveTextColor(
  stored: string | null | undefined,
  bgIsDark: boolean
): ResolvedText {
  if (stored &amp;&amp; isHexColor(stored)) {
    return { color: stored, muted: hexWithAlpha(stored, 0.65) };
  }
  return bgIsDark ? LIGHT_TEXT : DARK_TEXT;
}

export function isHexDark(hex: string): boolean {
  const value = hex.replace(&quot;#&quot;, &quot;&quot;);
  if (value.length !== 6 &amp;&amp; value.length !== 3) return false;
  const expand = (s: string) =&gt;
    s.length === 3
      ? s.split(&quot;&quot;).map((c) =&gt; c + c).join(&quot;&quot;)
      : s;
  const v = expand(value);
  const r = parseInt(v.slice(0, 2), 16);
  const g = parseInt(v.slice(2, 4), 16);
  const b = parseInt(v.slice(4, 6), 16);
  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
  return luminance &lt; 0.55;
}

function hexWithAlpha(hex: string, alpha: number): string {
  const a = Math.round(alpha * 255).toString(16).padStart(2, &quot;0&quot;);
  return `${hex}${a}`;
}</code></pre>
  </div>
</div>

Social platform registry

Now, let's build a system where users can add social links to their profiles. Go to src/lib and create a file called socials.ts with the content:

socials.ts
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">socials.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import {
  siX,
  siInstagram,
  siYoutube,
  siTiktok,
  siGithub,
  siDiscord,
  siBluesky,
  siThreads,
  siSpotify,
  siTwitch,
  siFacebook,
  siGmail,
  siSubstack,
} from &quot;simple-icons&quot;;

export interface SocialPlatform {
  key: string;
  label: string;
  brandColor: string;
  path: string;
  hrefBuilder?: (url: string) =&gt; string;
}

const websiteSvgPath =
  &quot;M12 2a10 10 0 100 20 10 10 0 000-20zm6.93 6h-2.95a15.65 15.65 0 00-1.38-3.56A8.03 8.03 0 0118.93 8zM12 4c.83 1.2 1.48 2.53 1.91 3.94H10.1A11.5 11.5 0 0112 4zM4.26 14a7.86 7.86 0 010-4H7.6a16.6 16.6 0 000 4zm.81 2h2.95c.32 1.25.78 2.45 1.38 3.56A7.99 7.99 0 015.07 16zm2.95-8H5.07a7.99 7.99 0 014.33-3.56A15.65 15.65 0 008.02 8zM12 20c-.83-1.2-1.48-2.53-1.91-3.94h3.82A11.5 11.5 0 0112 20zm2.34-5.94H9.66a14.5 14.5 0 010-4h4.68a14.5 14.5 0 010 4zm.32 5.5c.6-1.11 1.06-2.31 1.38-3.56h2.95a7.99 7.99 0 01-4.33 3.56zM16.4 14a16.6 16.6 0 000-4h3.34a7.86 7.86 0 010 4z&quot;;

export const SOCIALS: readonly SocialPlatform[] = [
  { key: &quot;x&quot;, label: &quot;X&quot;, brandColor: `#${siX.hex}`, path: siX.path },
  { key: &quot;instagram&quot;, label: &quot;Instagram&quot;, brandColor: `#${siInstagram.hex}`, path: siInstagram.path },
  { key: &quot;youtube&quot;, label: &quot;YouTube&quot;, brandColor: `#${siYoutube.hex}`, path: siYoutube.path },
  { key: &quot;tiktok&quot;, label: &quot;TikTok&quot;, brandColor: `#${siTiktok.hex}`, path: siTiktok.path },
  { key: &quot;github&quot;, label: &quot;GitHub&quot;, brandColor: `#${siGithub.hex}`, path: siGithub.path },
  { key: &quot;discord&quot;, label: &quot;Discord&quot;, brandColor: `#${siDiscord.hex}`, path: siDiscord.path },
  { key: &quot;bluesky&quot;, label: &quot;Bluesky&quot;, brandColor: `#${siBluesky.hex}`, path: siBluesky.path },
  { key: &quot;threads&quot;, label: &quot;Threads&quot;, brandColor: `#${siThreads.hex}`, path: siThreads.path },
  { key: &quot;spotify&quot;, label: &quot;Spotify&quot;, brandColor: `#${siSpotify.hex}`, path: siSpotify.path },
  { key: &quot;twitch&quot;, label: &quot;Twitch&quot;, brandColor: `#${siTwitch.hex}`, path: siTwitch.path },
  { key: &quot;facebook&quot;, label: &quot;Facebook&quot;, brandColor: `#${siFacebook.hex}`, path: siFacebook.path },
  { key: &quot;substack&quot;, label: &quot;Substack&quot;, brandColor: `#${siSubstack.hex}`, path: siSubstack.path },
  {
    key: &quot;email&quot;,
    label: &quot;Email&quot;,
    brandColor: `#${siGmail.hex}`,
    path: siGmail.path,
    hrefBuilder: (url) =&gt; (url.startsWith(&quot;mailto:&quot;) ? url : `mailto:${url}`),
  },
  { key: &quot;website&quot;, label: &quot;Website&quot;, brandColor: &quot;#0a0a0c&quot;, path: websiteSvgPath },
] as const;

const SOCIAL_BY_KEY: Record&lt;string, SocialPlatform&gt; = Object.fromEntries(
  SOCIALS.map((s) =&gt; [s.key, s])
);

export function getSocialPlatform(key: string): SocialPlatform | null {
  return SOCIAL_BY_KEY[key] ?? null;
}

export function isSocialPlatformKey(value: unknown): value is string {
  return typeof value === &quot;string&quot; &amp;&amp; value in SOCIAL_BY_KEY;
}</code></pre>
  </div>
</div>

Step 6: Build the shared profile renderer

Since users can customize the look of their link pages, let's show them a preview of what their page looks like.

Go to src/components/ and create a file called ProfileRender.tsx with the content:

ProfileRender.ts
import type {
  Creator,
  Link as DbLink,
  SocialLink as DbSocialLink,
} from "@prisma/client";
import {
  accentVars,
  resolveAccent,
  resolveBackground,
  resolveTextColor,
} from "@/lib/theme";
import { getSocialPlatform } from "@/lib/socials";
import { ReactNode } from "react";

export interface ProfileRenderProps {
  creator: Pick<
    Creator,
    | "handle"
    | "title"
    | "bio"
    | "avatarUrl"
    | "accentColor"
    | "unlockPrice"
    | "cardStyle"
    | "bgKind"
    | "bgValue"
    | "textColor"
  >;
  links: Array<
    Pick<DbLink, "id" | "title" | "url" | "isPremium" | "isVisible">
  >;
  socials?: Array<Pick<DbSocialLink, "id" | "platform" | "url" | "color">>;
  hasPaidUnlock: boolean;
  hasEarnings: boolean;
  unlockSlot?: ReactNode;
  scale?: "full" | "preview";
}

const PRICE_FORMATTER = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

const CARD_STYLE_CLASSES: Record<string, string> = {
  default:
    "rounded-xl border border-[var(--card-border)] bg-[var(--card-bg)]",
  pill: "rounded-full border border-[var(--card-border)] bg-[var(--card-bg)]",
  soft: "rounded-md border border-[var(--card-border)] bg-[var(--card-bg)]",
  square: "rounded-none border border-[var(--card-border)] bg-[var(--card-bg)]",
  outline:
    "rounded-xl border-2 border-[var(--card-border)] bg-transparent",
  elevated:
    "rounded-xl bg-[var(--card-bg)] shadow-[0_8px_24px_-6px_rgba(15,15,18,0.18),0_2px_4px_rgba(15,15,18,0.05)]",
  // Wavy edges. The SVG mask carves a wave shape into the bottom edge of
  // every card. Falls back to the default rounded style if mask-image
  // is unsupported.
  wave: "rounded-xl border border-[var(--card-border)] bg-[var(--card-bg)] [mask-image:radial-gradient(circle_8px_at_50%_100%,transparent_98%,#000_100%)]",
};

export function ProfileRender({
  creator,
  links,
  socials = [],
  hasPaidUnlock,
  hasEarnings,
  unlockSlot,
  scale = "full",
}: ProfileRenderProps) {
  const accent = resolveAccent(creator.accentColor);
  const bg = resolveBackground(creator.bgKind, creator.bgValue);
  const text = resolveTextColor(creator.textColor, bg.isDark);

  const visibleLinks = links.filter((l) => l.isVisible);
  const freeLinks = visibleLinks.filter((l) => !l.isPremium);
  const premiumLinks = visibleLinks.filter((l) => l.isPremium);

  const displayName = creator.title || creator.handle;
  const initial = displayName.charAt(0).toUpperCase();
  const priceLabel = PRICE_FORMATTER.format(creator.unlockPrice / 100);

  const cardClass =
    CARD_STYLE_CLASSES[creator.cardStyle] ?? CARD_STYLE_CLASSES.default;

  const containerPad = scale === "preview" ? "px-5 py-8" : "px-6 py-12";
  const avatarSize =
    scale === "preview" ? "w-16 h-16 text-xl" : "w-20 h-20 text-2xl";
  const nameSize = scale === "preview" ? "text-xl" : "text-2xl";

  const wrapperStyle: React.CSSProperties = {
    ...accentVars(accent),
    background: bg.css !== "transparent" ? bg.css : undefined,
    color: text.color,
    "--card-bg": bg.isDark ? "rgba(255,255,255,0.95)" : "#ffffff",
    "--card-border": bg.isDark ? "rgba(0,0,0,0.08)" : "#e7e5e4",
    "--text-color": text.color,
    "--text-muted": text.muted,
  } as React.CSSProperties;

  return (
    <div
      className="min-h-full font-sans transition-colors"
      style={wrapperStyle}
    >
      <div className={`mx-auto w-full max-w-md ${containerPad}`}>
        <header className="text-center mb-8">
          {creator.avatarUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={creator.avatarUrl}
              alt={displayName}
              className={`${avatarSize} rounded-full mx-auto mb-4 object-cover`}
            />
          ) : (
            <div
              className={`${avatarSize} rounded-full mx-auto mb-4 flex items-center justify-center font-semibold tracking-tight text-white`}
              style={{ background: "var(--accent)" }}
              aria-hidden
            >
              {initial}
            </div>
          )}

          <h1
            className={`${nameSize} font-semibold tracking-tight`}
            style={{ color: "var(--text-color)" }}
          >
            {displayName}
          </h1>

          {creator.bio && (
            <p
              className="mt-2 text-sm leading-relaxed max-w-xs mx-auto"
              style={{ color: "var(--text-muted)" }}
            >
              {creator.bio}
            </p>
          )}

          {socials.length > 0 && (
            <div className="mt-5 flex items-center justify-center gap-3">
              {socials.map((s) => {
                const platform = getSocialPlatform(s.platform);
                if (!platform) return null;
                const color = s.color ?? platform.brandColor;
                const href = platform.hrefBuilder
                  ? platform.hrefBuilder(s.url)
                  : s.url;
                const Tag = scale === "full" ? "a" : "div";
                const props =
                  scale === "full"
                    ? { href, target: "_blank", rel: "noopener noreferrer" }
                    : {};
                return (
                  <Tag
                    key={s.id}
                    {...props}
                    aria-label={platform.label}
                    className="inline-flex h-9 w-9 items-center justify-center rounded-full transition-opacity hover:opacity-80"
                    style={{ color }}
                  >
                    <svg
                      role="img"
                      viewBox="0 0 24 24"
                      aria-hidden="true"
                      width="20"
                      height="20"
                      fill="currentColor"
                    >
                      <path d={platform.path} />
                    </svg>
                  </Tag>
                );
              })}
            </div>
          )}
        </header>

        <div className="space-y-2.5">
          {freeLinks.map((link) => (
            <LinkCard
              key={link.id}
              link={link}
              interactive={scale === "full"}
              cardClass={cardClass}
            />
          ))}
        </div>

        {premiumLinks.length > 0 && (
          <div className="mt-8 pt-8 border-t border-[var(--card-border)]">
            {hasPaidUnlock ? (
              <div className="space-y-2.5">
                {premiumLinks.map((link) => (
                  <LinkCard
                    key={link.id}
                    link={link}
                    interactive={scale === "full"}
                    cardClass={cardClass}
                  />
                ))}
              </div>
            ) : (
              <>
                <div className="space-y-2.5 mb-5">
                  {premiumLinks.map((link) => (
                    <PremiumPlaceholderCard
                      key={link.id}
                      title={link.title}
                      priceLabel={priceLabel}
                      cardClass={cardClass}
                    />
                  ))}
                </div>

                {hasEarnings ? (
                  unlockSlot ?? <UnlockPreviewButton priceLabel={priceLabel} />
                ) : (
                  <p
                    className="text-xs text-center"
                    style={{ color: "var(--text-muted)" }}
                  >
                    Premium links coming soon.
                  </p>
                )}
              </>
            )}
          </div>
        )}

        {visibleLinks.length === 0 && (
          <p
            className="text-sm text-center mt-8"
            style={{ color: "var(--text-muted)" }}
          >
            No links yet.
          </p>
        )}
      </div>
    </div>
  );
}

function LinkCard({
  link,
  interactive,
  cardClass,
}: {
  link: { id: string; title: string; url: string; isPremium: boolean };
  interactive: boolean;
  cardClass: string;
}) {
  const Tag = interactive ? "a" : "div";
  const props: Record<string, string> = interactive
    ? { href: link.url, target: "_blank", rel: "noopener noreferrer" }
    : {};

  return (
    <Tag
      {...props}
      className={`flex items-center justify-between px-5 py-3.5 text-sm font-medium transition-colors hover:border-[color:var(--accent)] hover:bg-[color:var(--accent-bg)] ${cardClass}`}
      style={{ color: "var(--text-color)" }}
    >
      <span className="flex-1 text-center">{link.title}</span>
    </Tag>
  );
}

function PremiumPlaceholderCard({
  title,
  priceLabel,
  cardClass,
}: {
  title: string;
  priceLabel: string;
  cardClass: string;
}) {
  return (
    <div
      className={`flex items-center justify-between px-5 py-3.5 text-sm font-medium ${cardClass}`}
    >
      <span
        className="flex-1 text-center"
        style={{ color: "var(--text-muted)" }}
      >
        {title}
      </span>
      <span
        className="ml-3 inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold tracking-tight"
        style={{ background: "var(--accent-bg)", color: "var(--accent)" }}
      >
        {priceLabel}
      </span>
    </div>
  );
}

function UnlockPreviewButton({ priceLabel }: { priceLabel: string }) {
  return (
    <div
      className="w-full rounded-xl py-3 px-4 text-sm font-semibold text-white text-center"
      style={{ background: "var(--accent)" }}
    >
      Unlock premium for {priceLabel}
    </div>
  );
}

Step 7: Build the creator dashboard

Now that authentication is working, we need somewhere for creators to manage their presence. The dashboard has four sections:

  • Profile (handle, bio, unlock price, accent color)
  • Links (add, delete, hide, drag-reorder, toggle free/premium)
  • Earnings (connect to Whop for payouts)
  • Payouts (the embedded Whop payout portal, only visible after KYC completes)

Rather than a traditional API route + fetch setup, we'll use Next.js Server Actions. On wide screens the dashboard splits into two columns with the editor on the left, and a sticky live preview.

Profile server action

Go to src/app/actions/ and create a file called creator.ts with the content:

creator.ts
"use server";

import { prisma } from "@/lib/prisma";
import { getCurrentUserId } from "@/lib/session";
import {
  ACCENTS,
  BG_PRESETS,
  CARD_STYLES,
  isCardStyleKey,
  isHexColor,
  type AccentKey,
} from "@/lib/theme";
import { revalidatePath } from "next/cache";
import { z } from "zod";

const ProfileSchema = z.object({
  handle: z
    .string()
    .min(2)
    .max(32)
    .regex(/^[a-z0-9_-]+$/, "Only lowercase letters, numbers, - and _"),
  title: z.string().max(80),
  bio: z.string().max(300),
  unlockPrice: z.coerce.number().min(1).max(1000), // dollars, $1 to $1000
});

const ACCENT_KEYS = ACCENTS.map((a) => a.key) as [AccentKey, ...AccentKey[]];
const BG_PRESET_KEYS = BG_PRESETS.map((b) => b.key);
const CARD_STYLE_KEYS = CARD_STYLES.map((s) => s.key);

export type ActionResult = { error?: string; success?: boolean };

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

  const raw = {
    handle: formData.get("handle"),
    title: formData.get("title"),
    bio: formData.get("bio"),
    unlockPrice: formData.get("unlockPrice"),
  };

  const parsed = ProfileSchema.safeParse(raw);
  if (!parsed.success) {
    return { error: parsed.error.issues[0].message };
  }

  const { handle, title, bio, unlockPrice: unlockPriceDollars } = parsed.data;
  const unlockPrice = Math.round(unlockPriceDollars * 100); // convert to cents

  const existing = await prisma.creator.findUnique({ where: { handle } });
  const myCreator = await prisma.creator.findUnique({ where: { userId } });

  if (existing && existing.userId !== userId) {
    return { error: "Handle is already taken" };
  }

  if (myCreator) {
    await prisma.creator.update({
      where: { userId },
      data: { handle, title, bio, unlockPrice },
    });
  } else {
    await prisma.creator.create({
      data: { userId, handle, title, bio, unlockPrice },
    });
  }

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

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

  const raw = formData.get("accent");
  let value: string;
  if (typeof raw === "string" && ACCENT_KEYS.includes(raw as AccentKey)) {
    value = raw;
  } else if (isHexColor(raw)) {
    value = (raw as string).toLowerCase();
  } else {
    return { error: "Invalid accent color" };
  }

  const creator = await prisma.creator.findUnique({ where: { userId } });
  if (!creator) return { error: "Save your profile first" };

  await prisma.creator.update({
    where: { userId },
    data: { accentColor: value },
  });

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

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

  const raw = formData.get("cardStyle");
  if (!isCardStyleKey(raw)) return { error: "Invalid card style" };

  const creator = await prisma.creator.findUnique({ where: { userId } });
  if (!creator) return { error: "Save your profile first" };

  await prisma.creator.update({
    where: { userId },
    data: { cardStyle: raw },
  });

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

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

  const kind = formData.get("kind");
  const value = formData.get("value");

  let storedKind: string;
  let storedValue: string | null = null;

  if (kind === "auto") {
    storedKind = "auto";
    storedValue = null;
  } else if (kind === "preset") {
    if (typeof value !== "string" || !BG_PRESET_KEYS.includes(value)) {
      return { error: "Unknown background preset" };
    }
    storedKind = "preset";
    storedValue = value;
  } else if (kind === "solid") {
    if (!isHexColor(value)) return { error: "Invalid background color" };
    storedKind = "solid";
    storedValue = (value as string).toLowerCase();
  } else if (kind === "gradient") {
    if (
      typeof value !== "string" ||
      value.length > 500 ||
      !/^linear-gradient\(/.test(value)
    ) {
      return { error: "Invalid gradient" };
    }
    storedKind = "gradient";
    storedValue = value;
  } else {
    return { error: "Invalid background kind" };
  }

  const creator = await prisma.creator.findUnique({ where: { userId } });
  if (!creator) return { error: "Save your profile first" };

  await prisma.creator.update({
    where: { userId },
    data: { bgKind: storedKind, bgValue: storedValue },
  });

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

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

  const raw = formData.get("textColor");
  let value: string;
  if (raw === "auto") {
    value = "auto";
  } else if (isHexColor(raw)) {
    value = (raw as string).toLowerCase();
  } else {
    return { error: "Invalid text color" };
  }

  const creator = await prisma.creator.findUnique({ where: { userId } });
  if (!creator) return { error: "Save your profile first" };

  await prisma.creator.update({
    where: { userId },
    data: { textColor: value },
  });

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

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

  const raw = formData.get("avatarUrl");
  let avatarUrl: string | null = null;

  if (raw === "" || raw === null) {
    avatarUrl = null;
  } else if (
    typeof raw === "string" &&
    /^https:\/\/[^/]+\.public\.blob\.vercel-storage\.com\//.test(raw)
  ) {
    avatarUrl = raw;
  } else {
    return { error: "Invalid avatar URL" };
  }

  const creator = await prisma.creator.findUnique({ where: { userId } });
  if (!creator) return { error: "Save your profile first" };

  await prisma.creator.update({
    where: { userId },
    data: { avatarUrl },
  });

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

Socials server actions

The social-link actions mirror the regular link actions but reference the platform registry from socials.ts. We sanitize the URL on insert (auto-prepending https:// for non-email platforms) so the user doesn't have to remember it.

Go to src/app/actions and create a file called socials.ts with the content:

socials.ts
"use server";

import { prisma } from "@/lib/prisma";
import { getCurrentUserId } from "@/lib/session";
import { isSocialPlatformKey } from "@/lib/socials";
import { isHexColor } from "@/lib/theme";
import { revalidatePath } from "next/cache";

type ActionResult = { error?: string; success?: boolean };

async function getCreatorForUser() {
  const userId = await getCurrentUserId();
  if (!userId) return null;
  return prisma.creator.findUnique({ where: { userId } });
}

export async function addSocialLink(
  _prev: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const creator = await getCreatorForUser();
  if (!creator) return { error: "Save your profile first" };

  const platform = formData.get("platform");
  const url = formData.get("url");

  if (!isSocialPlatformKey(platform)) {
    return { error: "Pick a platform from the list" };
  }
  if (typeof url !== "string" || url.trim().length === 0) {
    return { error: "Enter a URL" };
  }

  let normalized = url.trim();
  if (platform === "email") {
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalized.replace(/^mailto:/, ""))) {
      return { error: "Enter a valid email address" };
    }
  } else if (!/^https?:\/\//.test(normalized)) {
    normalized = `https://${normalized}`;
  }

  const count = await prisma.socialLink.count({
    where: { creatorId: creator.id },
  });

  await prisma.socialLink.create({
    data: {
      creatorId: creator.id,
      platform,
      url: normalized,
      sortOrder: count,
    },
  });

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

export async function deleteSocialLink(
  _prev: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const creator = await getCreatorForUser();
  if (!creator) return { error: "Not authenticated" };

  const id = formData.get("id");
  if (typeof id !== "string") return { error: "Missing id" };

  const social = await prisma.socialLink.findUnique({ where: { id } });
  if (!social || social.creatorId !== creator.id) {
    return { error: "Not found" };
  }

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

export async function setSocialColor(
  _prev: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const creator = await getCreatorForUser();
  if (!creator) return { error: "Not authenticated" };

  const id = formData.get("id");
  const color = formData.get("color");

  if (typeof id !== "string") return { error: "Missing id" };

  const social = await prisma.socialLink.findUnique({ where: { id } });
  if (!social || social.creatorId !== creator.id) {
    return { error: "Not found" };
  }

  let storedColor: string | null = null;
  if (color === "" || color === null) {
    storedColor = null;
  } else if (isHexColor(color)) {
    storedColor = (color as string).toLowerCase();
  } else {
    return { error: "Invalid color" };
  }

  await prisma.socialLink.update({
    where: { id },
    data: { color: storedColor },
  });

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

export async function reorderSocialLinks(
  orderedIds: string[]
): Promise<ActionResult> {
  const creator = await getCreatorForUser();
  if (!creator) return { error: "Not authenticated" };

  const owned = await prisma.socialLink.findMany({
    where: { creatorId: creator.id, id: { in: orderedIds } },
    select: { id: true },
  });
  if (owned.length !== orderedIds.length) {
    return { error: "One or more socials could not be found" };
  }

  await prisma.$transaction(
    orderedIds.map((id, sortOrder) =>
      prisma.socialLink.update({ where: { id }, data: { sortOrder } })
    )
  );

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

Now, let's build five actions: adding links, deleting them, selecting if the link is paid or not, toggling their visibility, and adjusting their order. Go to src/app/actions/ and create a file called links.ts with the content:

links.ts
"use server";

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

const LinkSchema = z.object({
  title: z.string().min(1).max(100),
  url: z.string().url("Must be a valid URL"),
  isPremium: z.coerce.boolean(),
});

type ActionResult = { error?: string; success?: boolean };

async function getCreatorForUser() {
  const userId = await getCurrentUserId();
  if (!userId) return null;
  return prisma.creator.findUnique({ where: { userId } });
}

export async function addLink(
  _prev: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const creator = await getCreatorForUser();
  if (!creator) return { error: "No creator profile found" };

  const parsed = LinkSchema.safeParse({
    title: formData.get("title"),
    url: formData.get("url"),
    isPremium: formData.get("isPremium") === "on",
  });
  if (!parsed.success) return { error: parsed.error.issues[0].message };

  const count = await prisma.link.count({ where: { creatorId: creator.id } });

  await prisma.link.create({
    data: {
      creatorId: creator.id,
      title: parsed.data.title,
      url: parsed.data.url,
      isPremium: parsed.data.isPremium,
      sortOrder: count,
    },
  });

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

export async function deleteLink(
  _prev: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const creator = await getCreatorForUser();
  if (!creator) return { error: "Not authenticated" };

  const linkId = formData.get("linkId") as string;
  const link = await prisma.link.findUnique({ where: { id: linkId } });

  if (!link || link.creatorId !== creator.id) {
    return { error: "Link not found" };
  }

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

export async function togglePremium(
  _prev: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const creator = await getCreatorForUser();
  if (!creator) return { error: "Not authenticated" };

  const linkId = formData.get("linkId") as string;
  const link = await prisma.link.findUnique({ where: { id: linkId } });

  if (!link || link.creatorId !== creator.id) {
    return { error: "Link not found" };
  }

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

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

export async function toggleVisibility(
  _prev: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const creator = await getCreatorForUser();
  if (!creator) return { error: "Not authenticated" };

  const linkId = formData.get("linkId") as string;
  const link = await prisma.link.findUnique({ where: { id: linkId } });

  if (!link || link.creatorId !== creator.id) {
    return { error: "Link not found" };
  }

  await prisma.link.update({
    where: { id: linkId },
    data: { isVisible: !link.isVisible },
  });

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

export async function reorderLinks(orderedIds: string[]): Promise<ActionResult> {
  const creator = await getCreatorForUser();
  if (!creator) return { error: "Not authenticated" };

  const owned = await prisma.link.findMany({
    where: { creatorId: creator.id, id: { in: orderedIds } },
    select: { id: true },
  });
  if (owned.length !== orderedIds.length) {
    return { error: "One or more links could not be found" };
  }

  await prisma.$transaction(
    orderedIds.map((id, sortOrder) =>
      prisma.link.update({ where: { id }, data: { sortOrder } })
    )
  );

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

Profile form and accent picker

The profile form handles the basic creator metadata (handle, display name, bio, unlock price). Theme controls (accent, card style, background, text color) live in a separate ThemePicker component we'll build next, so this file stays focused on text inputs.

Go to src/app/dashboard and create a file called ProfileForm.tsx with the content:

ProfileForm.tsx
"use client";

import { useActionState } from "react";
import { saveProfile } from "@/app/actions/creator";
import type { Creator } from "@prisma/client";

const inputClass =
  "w-full border border-neutral-200 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-neutral-900 focus:ring-1 focus:ring-neutral-900 transition-colors bg-white placeholder:text-neutral-300";

const labelClass =
  "block text-xs font-semibold uppercase tracking-widest text-neutral-500 mb-1.5";

export function ProfileForm({
  creator,
  intendedHandle,
}: {
  creator: Creator | null;
  intendedHandle?: string;
}) {
  const [state, action, pending] = useActionState(saveProfile, {});
  const handleDefault = creator?.handle ?? intendedHandle ?? "";

  return (
    <form action={action} className="space-y-4">
      <div>
        <label className={labelClass}>Handle</label>
        <div className="flex items-center border border-neutral-200 rounded-lg overflow-hidden focus-within:border-neutral-900 focus-within:ring-1 focus-within:ring-neutral-900 transition-colors">
          <span className="px-3 py-2.5 text-sm text-neutral-400 bg-neutral-50 border-r border-neutral-200 select-none">
            /u/
          </span>
          <input
            name="handle"
            defaultValue={handleDefault}
            placeholder="yourname"
            className="flex-1 px-3 py-2.5 text-sm outline-none bg-white"
            required
          />
        </div>
      </div>

      <div>
        <label className={labelClass}>Display name</label>
        <input
          name="title"
          defaultValue={creator?.title ?? ""}
          placeholder="Your name"
          className={inputClass}
        />
      </div>

      <div>
        <label className={labelClass}>Bio</label>
        <textarea
          name="bio"
          defaultValue={creator?.bio ?? ""}
          placeholder="A short bio"
          rows={3}
          className={`${inputClass} resize-none`}
        />
      </div>

      <div>
        <label className={labelClass}>Premium unlock price (USD)</label>
        <div className="flex items-center border border-neutral-200 rounded-lg overflow-hidden focus-within:border-neutral-900 focus-within:ring-1 focus-within:ring-neutral-900 transition-colors">
          <span className="px-3 py-2.5 text-sm text-neutral-400 bg-neutral-50 border-r border-neutral-200 select-none">
            $
          </span>
          <input
            name="unlockPrice"
            type="number"
            min="1"
            max="1000"
            step="1"
            defaultValue={creator ? (creator.unlockPrice / 100).toString() : "5"}
            className="flex-1 px-3 py-2.5 text-sm outline-none bg-white"
            required
          />
        </div>
        <p className="text-xs text-neutral-400 mt-1.5">Minimum $1.</p>
      </div>

      {state.error && (
        <p className="text-sm text-red-700 border border-red-100 bg-red-50 rounded-lg px-3 py-2.5">
          {state.error}
        </p>
      )}
      {state.success && (
        <p className="text-sm text-green-700 border border-green-100 bg-green-50 rounded-lg px-3 py-2.5">
          Profile saved.
        </p>
      )}

      <button
        type="submit"
        disabled={pending}
        className="w-full rounded-lg py-2.5 px-4 text-sm font-semibold text-white bg-neutral-900 hover:bg-neutral-800 disabled:opacity-50 transition-colors"
      >
        {pending ? "Saving..." : "Save profile"}
      </button>
    </form>
  );
}

// The accent picker now lives inside `ThemePicker.tsx` alongside the card
// style, background, and text color controls.

Theme picker

ThemePicker is one client component split into four sub-sections (accent/button color, card style, background, text color). Each section uses useTransition so the live preview pane on the right updates immediately on click. Go to src/app/dashboard and create a file called ThemePicker.tsx with the content:

ThemePicker.tsx
"use client";

import { useState, useTransition } from "react";
import {
  setAccent,
  setBackground,
  setCardStyle,
  setTextColor,
} from "@/app/actions/creator";
import {
  ACCENTS,
  BG_PRESETS,
  CARD_STYLES,
  DEFAULT_ACCENT_KEY,
  type CardStyleKey,
} from "@/lib/theme";

const labelClass =
  "block text-xs font-semibold uppercase tracking-widest text-neutral-500 mb-2";

interface Props {
  hasProfile: boolean;
  accentColor: string;
  cardStyle: CardStyleKey | string;
  bgKind: string;
  bgValue: string | null;
  textColor: string;
}

export function ThemePicker(props: Props) {
  return (
    <div className="space-y-6">
      <AccentSection
        current={props.accentColor}
        hasProfile={props.hasProfile}
      />
      <CardStyleSection
        current={props.cardStyle}
        hasProfile={props.hasProfile}
      />
      <BackgroundSection
        kind={props.bgKind}
        value={props.bgValue}
        hasProfile={props.hasProfile}
      />
      <TextColorSection
        current={props.textColor}
        hasProfile={props.hasProfile}
      />
    </div>
  );
}

function AccentSection({
  current,
  hasProfile,
}: {
  current: string;
  hasProfile: boolean;
}) {
  const [pending, startTransition] = useTransition();
  const [optimistic, setOptimistic] = useState(current);
  const [error, setError] = useState<string | null>(null);

  const isHex = /^#[0-9a-fA-F]{6}$/.test(optimistic);
  const customValue = isHex ? optimistic : "#000000";

  function update(next: string) {
    if (!hasProfile) {
      setError("Save your profile first.");
      return;
    }
    setError(null);
    setOptimistic(next);
    startTransition(async () => {
      const fd = new FormData();
      fd.append("accent", next);
      const res = await setAccent({}, fd);
      if (res.error) {
        setError(res.error);
        setOptimistic(current);
      }
    });
  }

  return (
    <div>
      <p className={labelClass}>Accent / button color</p>
      <div className="flex flex-wrap items-center gap-2.5">
        {ACCENTS.map((a) => {
          const active = optimistic === a.key;
          return (
            <button
              key={a.key}
              type="button"
              onClick={() => update(a.key)}
              disabled={pending || !hasProfile}
              aria-label={a.label}
              aria-pressed={active}
              className="relative w-9 h-9 rounded-full transition-transform hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-neutral-900 disabled:opacity-40 disabled:cursor-not-allowed"
              style={{ background: a.hex }}
            >
              {active && (
                <span className="absolute inset-0 rounded-full ring-2 ring-offset-2 ring-neutral-900" />
              )}
            </button>
          );
        })}
        <label
          className={`relative inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-dashed border-neutral-300 text-xs font-semibold text-neutral-500 transition-colors hover:border-neutral-900 hover:text-neutral-900 ${
            hasProfile ? "" : "pointer-events-none opacity-40"
          }`}
          aria-label="Custom accent color"
          style={isHex ? { background: customValue } : undefined}
        >
          {!isHex && <span aria-hidden>+</span>}
          <input
            type="color"
            className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
            value={customValue}
            onChange={(e) => update(e.target.value.toLowerCase())}
            disabled={!hasProfile || pending}
          />
        </label>
      </div>
      {error && <p className="text-xs text-red-600 mt-2">{error}</p>}
    </div>
  );
}

function CardStyleSection({
  current,
  hasProfile,
}: {
  current: CardStyleKey | string;
  hasProfile: boolean;
}) {
  const [pending, startTransition] = useTransition();
  const [optimistic, setOptimistic] = useState(current);
  const [error, setError] = useState<string | null>(null);

  function update(next: string) {
    if (!hasProfile) {
      setError("Save your profile first.");
      return;
    }
    setError(null);
    setOptimistic(next);
    startTransition(async () => {
      const fd = new FormData();
      fd.append("cardStyle", next);
      const res = await setCardStyle({}, fd);
      if (res.error) {
        setError(res.error);
        setOptimistic(current);
      }
    });
  }

  return (
    <div>
      <p className={labelClass}>Card style</p>
      <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
        {CARD_STYLES.map((s) => {
          const active = optimistic === s.key;
          return (
            <button
              key={s.key}
              type="button"
              onClick={() => update(s.key)}
              disabled={pending || !hasProfile}
              aria-pressed={active}
              className={`flex flex-col items-center gap-2 rounded-lg border bg-white p-2.5 text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
                active
                  ? "border-neutral-900"
                  : "border-neutral-200 hover:border-neutral-400"
              }`}
            >
              <CardStylePreview kind={s.key} />
              <span>{s.label}</span>
            </button>
          );
        })}
      </div>
      {error && <p className="text-xs text-red-600 mt-2">{error}</p>}
    </div>
  );
}

function CardStylePreview({ kind }: { kind: string }) {
  // Tiny illustration of the card style so users can scan options visually.
  const base =
    "h-3.5 w-full bg-white border border-neutral-300 flex items-center justify-center";
  const map: Record<string, string> = {
    default: `${base} rounded-md`,
    pill: `${base} rounded-full`,
    soft: `${base} rounded-sm`,
    square: `${base} rounded-none`,
    outline: `h-3.5 w-full bg-transparent border-2 border-neutral-400 rounded-md`,
    elevated: `${base} rounded-md shadow-md`,
    wave: `${base} rounded-md`,
  };
  return <span className={map[kind] ?? map.default} />;
}

function BackgroundSection({
  kind,
  value,
  hasProfile,
}: {
  kind: string;
  value: string | null;
  hasProfile: boolean;
}) {
  const [pending, startTransition] = useTransition();
  const [optKind, setOptKind] = useState(kind);
  const [optValue, setOptValue] = useState<string | null>(value);
  const [error, setError] = useState<string | null>(null);

  const customHex =
    optKind === "solid" && optValue && /^#[0-9a-fA-F]{6}$/.test(optValue)
      ? optValue
      : "#fafaf9";

  function submit(nextKind: string, nextValue: string | null) {
    if (!hasProfile) {
      setError("Save your profile first.");
      return;
    }
    setError(null);
    setOptKind(nextKind);
    setOptValue(nextValue);
    startTransition(async () => {
      const fd = new FormData();
      fd.append("kind", nextKind);
      if (nextValue !== null) fd.append("value", nextValue);
      const res = await setBackground({}, fd);
      if (res.error) {
        setError(res.error);
        setOptKind(kind);
        setOptValue(value);
      }
    });
  }

  return (
    <div>
      <p className={labelClass}>Background</p>
      <div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
        <SwatchButton
          label="Auto"
          background="repeating-linear-gradient(45deg, #f5f5f4 0 6px, #e7e5e4 6px 12px)"
          active={optKind === "auto"}
          disabled={!hasProfile || pending}
          onClick={() => submit("auto", null)}
        />
        {BG_PRESETS.map((p) => (
          <SwatchButton
            key={p.key}
            label={p.label}
            background={p.css}
            active={optKind === "preset" && optValue === p.key}
            disabled={!hasProfile || pending}
            onClick={() => submit("preset", p.key)}
          />
        ))}
      </div>
      <div className="mt-3 flex items-center gap-3">
        <label
          className={`inline-flex items-center gap-2 cursor-pointer text-xs text-neutral-600 ${
            hasProfile ? "" : "pointer-events-none opacity-40"
          }`}
        >
          <span
            className="inline-block h-6 w-6 rounded border border-neutral-200"
            style={{ background: customHex }}
            aria-hidden
          />
          <span>Custom solid color</span>
          <input
            type="color"
            className="sr-only"
            value={customHex}
            onChange={(e) => submit("solid", e.target.value.toLowerCase())}
            disabled={!hasProfile || pending}
          />
        </label>
      </div>
      {error && <p className="text-xs text-red-600 mt-2">{error}</p>}
    </div>
  );
}

function SwatchButton({
  label,
  background,
  active,
  disabled,
  onClick,
}: {
  label: string;
  background: string;
  active: boolean;
  disabled: boolean;
  onClick: () => void;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      aria-pressed={active}
      className={`flex flex-col items-center gap-1.5 rounded-lg border bg-white p-1.5 text-[11px] font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
        active
          ? "border-neutral-900"
          : "border-neutral-200 hover:border-neutral-400"
      }`}
    >
      <span
        className="h-7 w-full rounded-md border border-neutral-200"
        style={{ background }}
        aria-hidden
      />
      <span className="text-neutral-700">{label}</span>
    </button>
  );
}

function TextColorSection({
  current,
  hasProfile,
}: {
  current: string;
  hasProfile: boolean;
}) {
  const [pending, startTransition] = useTransition();
  const [optimistic, setOptimistic] = useState(current);
  const [error, setError] = useState<string | null>(null);

  const isHex = /^#[0-9a-fA-F]{6}$/.test(optimistic);
  const customValue = isHex ? optimistic : "#0a0a0c";

  function update(next: string) {
    if (!hasProfile) {
      setError("Save your profile first.");
      return;
    }
    setError(null);
    setOptimistic(next);
    startTransition(async () => {
      const fd = new FormData();
      fd.append("textColor", next);
      const res = await setTextColor({}, fd);
      if (res.error) {
        setError(res.error);
        setOptimistic(current);
      }
    });
  }

  const presets: { key: string; label: string; preview: string }[] = [
    { key: "auto", label: "Auto", preview: "linear-gradient(135deg,#0a0a0c 50%,#fafafa 50%)" },
    { key: "#0a0a0c", label: "Black", preview: "#0a0a0c" },
    { key: "#fafafa", label: "White", preview: "#fafafa" },
    { key: "#525258", label: "Gray", preview: "#525258" },
  ];

  return (
    <div>
      <p className={labelClass}>Text color</p>
      <div className="flex items-center gap-2.5">
        {presets.map((p) => {
          const active = optimistic.toLowerCase() === p.key.toLowerCase();
          return (
            <button
              key={p.key}
              type="button"
              onClick={() => update(p.key)}
              disabled={pending || !hasProfile}
              aria-pressed={active}
              className={`flex items-center gap-2 rounded-lg border bg-white px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
                active
                  ? "border-neutral-900"
                  : "border-neutral-200 hover:border-neutral-400"
              }`}
            >
              <span
                className="inline-block h-4 w-4 rounded-full border border-neutral-200"
                style={{ background: p.preview }}
                aria-hidden
              />
              <span>{p.label}</span>
            </button>
          );
        })}
        <label
          className={`relative inline-flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 bg-white px-3 py-1.5 text-xs font-medium cursor-pointer transition-colors hover:border-neutral-900 ${
            hasProfile ? "" : "pointer-events-none opacity-40"
          }`}
          aria-label="Custom text color"
        >
          <span
            className="inline-block h-4 w-4 rounded-full border border-neutral-200"
            style={isHex ? { background: customValue } : undefined}
            aria-hidden
          />
          <span>Custom</span>
          <input
            type="color"
            className="sr-only"
            value={customValue}
            onChange={(e) => update(e.target.value.toLowerCase())}
            disabled={!hasProfile || pending}
          />
        </label>
      </div>
      {error && <p className="text-xs text-red-600 mt-2">{error}</p>}
    </div>
  );
}

We want two link components, a link create form, and a list of existing links that users can reoder. Go to src/app/dashboard/ and create a file called LinkForm.tsx with the content:

LinkForm.tsx
"use client";

import { useActionState, useEffect, useState, useTransition } from "react";
import {
  addLink,
  deleteLink,
  togglePremium,
  toggleVisibility,
  reorderLinks,
} from "@/app/actions/links";
import type { Link as DbLink } from "@prisma/client";
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

const labelClass =
  "block text-xs font-semibold uppercase tracking-widest text-neutral-500 mb-1.5";
const inputClass =
  "w-full border border-neutral-200 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-neutral-900 focus:ring-1 focus:ring-neutral-900 transition-colors bg-white placeholder:text-neutral-300";

export function AddLinkForm() {
  const [state, action, pending] = useActionState(addLink, {});
  const [resetKey, setResetKey] = useState(0);

  useEffect(() => {
    if (state.success) setResetKey((k) => k + 1);
  }, [state.success]);

  return (
    <form
      key={resetKey}
      action={action}
      className="space-y-3 border border-dashed border-neutral-200 rounded-lg p-4"
    >
      <p className={labelClass}>Add link</p>

      <input
        name="title"
        placeholder="Link title"
        required
        className={inputClass}
      />
      <input
        name="url"
        type="url"
        placeholder="https://example.com"
        required
        className={inputClass}
      />

      <label className="flex items-center gap-2 text-sm text-neutral-600 cursor-pointer">
        <input
          type="checkbox"
          name="isPremium"
          className="w-4 h-4 accent-neutral-900"
        />
        Mark as premium (locked behind unlock price)
      </label>

      {state.error && <p className="text-sm text-red-600">{state.error}</p>}

      <button
        type="submit"
        disabled={pending}
        className="w-full rounded-lg py-2 px-4 text-sm font-semibold text-white bg-neutral-900 hover:bg-neutral-800 disabled:opacity-50 transition-colors"
      >
        {pending ? "Adding..." : "Add link"}
      </button>
    </form>
  );
}

export function LinksList({ links }: { links: DbLink[] }) {
  const [order, setOrder] = useState<string[]>(() => links.map((l) => l.id));
  const [, startTransition] = useTransition();

  useEffect(() => {
    setOrder(links.map((l) => l.id));
  }, [links]);

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  );

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    if (!over || active.id === over.id) return;
    const oldIndex = order.indexOf(active.id as string);
    const newIndex = order.indexOf(over.id as string);
    const next = arrayMove(order, oldIndex, newIndex);
    setOrder(next);
    startTransition(() => {
      void reorderLinks(next);
    });
  }

  if (links.length === 0) {
    return (
      <p className="text-sm text-neutral-400 mb-4">
        No links yet. Add one below.
      </p>
    );
  }

  const linksById = new Map(links.map((l) => [l.id, l]));
  const sorted = order
    .map((id) => linksById.get(id))
    .filter(Boolean) as DbLink[];

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={handleDragEnd}
    >
      <SortableContext items={order} strategy={verticalListSortingStrategy}>
        <div className="space-y-2 mb-4">
          {sorted.map((link) => (
            <SortableLinkRow key={link.id} link={link} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}

function SortableLinkRow({ link }: { link: DbLink }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: link.id });

  const [delState, delAction, delPending] = useActionState(deleteLink, {});
  const [toggleState, toggleAction, togglePending] = useActionState(
    togglePremium,
    {}
  );
  const [visibilityState, visibilityAction, visibilityPending] = useActionState(
    toggleVisibility,
    {}
  );

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    zIndex: isDragging ? 50 : "auto",
  } as React.CSSProperties;

  const dimmed = !link.isVisible;

  return (
    <div
      ref={setNodeRef}
      style={style}
      className={`relative flex items-center gap-2 rounded-lg border bg-white px-3 py-2.5 ${
        isDragging
          ? "border-neutral-900 shadow-lg"
          : "border-neutral-200"
      } ${dimmed ? "opacity-60" : ""}`}
    >
      <button
        type="button"
        aria-label="Drag to reorder"
        className="touch-none cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500 transition-colors px-1"
        {...attributes}
        {...listeners}
      >
        <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
          <circle cx="4" cy="3" r="1.2" />
          <circle cx="10" cy="3" r="1.2" />
          <circle cx="4" cy="7" r="1.2" />
          <circle cx="10" cy="7" r="1.2" />
          <circle cx="4" cy="11" r="1.2" />
          <circle cx="10" cy="11" r="1.2" />
        </svg>
      </button>

      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium text-neutral-900 truncate">
          {link.title}
        </p>
        <p className="text-xs text-neutral-400 truncate">{link.url}</p>
      </div>

      <form action={visibilityAction} className="flex">
        <input type="hidden" name="linkId" value={link.id} />
        <button
          type="submit"
          disabled={visibilityPending}
          aria-label={link.isVisible ? "Hide link" : "Show link"}
          aria-pressed={!link.isVisible}
          className="text-neutral-400 hover:text-neutral-900 transition-colors disabled:opacity-50 px-1.5"
        >
          {link.isVisible ? <EyeIcon /> : <EyeOffIcon />}
        </button>
      </form>

      <form action={toggleAction}>
        <input type="hidden" name="linkId" value={link.id} />
        <button
          type="submit"
          disabled={togglePending}
          className={`text-xs font-semibold px-2 py-1 rounded-md border transition-colors ${
            link.isPremium
              ? "bg-neutral-900 text-white border-neutral-900"
              : "bg-white text-neutral-600 border-neutral-200 hover:border-neutral-400"
          }`}
        >
          {link.isPremium ? "Premium" : "Free"}
        </button>
      </form>

      <form action={delAction}>
        <input type="hidden" name="linkId" value={link.id} />
        <button
          type="submit"
          disabled={delPending}
          aria-label="Delete link"
          className="text-neutral-300 hover:text-red-600 transition-colors disabled:opacity-50 px-1.5"
        >
          <TrashIcon />
        </button>
      </form>

      {(delState.error || toggleState.error || visibilityState.error) && (
        <p className="absolute right-3 -bottom-5 text-xs text-red-600">
          {delState.error ?? toggleState.error ?? visibilityState.error}
        </p>
      )}
    </div>
  );
}

function EyeIcon() {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5}>
      <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" />
      <circle cx="8" cy="8" r="2" />
    </svg>
  );
}

function EyeOffIcon() {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5}>
      <path d="M2 2l12 12" strokeLinecap="round" />
      <path d="M6.5 4.2C7 4.1 7.5 4 8 4c4 0 6.5 4 6.5 4s-.7 1.1-2 2.3" />
      <path d="M11 11.4C10.1 12 9.1 12.5 8 12.5c-4 0-6.5-4.5-6.5-4.5s.9-1.6 2.6-2.9" />
    </svg>
  );
}

function TrashIcon() {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5}>
      <path d="M2.5 4h11" strokeLinecap="round" />
      <path d="M6 4V2.5h4V4" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M3.5 4l.7 9.3a1 1 0 001 .9h5.6a1 1 0 001-.9L12.5 4" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

Socials manager

SocialsManager is the dashboard equivalent of LinksList. Each row pairs a platform dropdown, the URL input, a drag handle, an optional custom-color picker (defaults to the platform's brand color), and a delete button.

Go to src/app/dashboard and create a file called SocialsManager.tsx with the content:

SocialsManager.tsx
"use client";

import { useActionState, useEffect, useState, useTransition } from "react";
import {
  addSocialLink,
  deleteSocialLink,
  reorderSocialLinks,
  setSocialColor,
} from "@/app/actions/socials";
import { SOCIALS, getSocialPlatform } from "@/lib/socials";
import type { SocialLink as DbSocialLink } from "@prisma/client";
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

const labelClass =
  "block text-xs font-semibold uppercase tracking-widest text-neutral-500 mb-2";
const inputClass =
  "w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-neutral-900 focus:ring-1 focus:ring-neutral-900 transition-colors bg-white placeholder:text-neutral-300";

export function SocialsManager({
  socials,
  hasProfile,
}: {
  socials: DbSocialLink[];
  hasProfile: boolean;
}) {
  return (
    <div className="space-y-4">
      {hasProfile ? (
        <>
          <SocialsList socials={socials} />
          <AddSocialForm />
        </>
      ) : (
        <p className="text-sm text-neutral-400">
          Save your profile first to add social links.
        </p>
      )}
    </div>
  );
}

function AddSocialForm() {
  const [state, action, pending] = useActionState(addSocialLink, {});
  const [resetKey, setResetKey] = useState(0);
  const [platform, setPlatform] = useState(SOCIALS[0].key);

  useEffect(() => {
    if (state.success) setResetKey((k) => k + 1);
  }, [state.success]);

  return (
    <form
      key={resetKey}
      action={action}
      className="rounded-lg border border-dashed border-neutral-200 p-3 space-y-2"
    >
      <p className={labelClass}>Add social</p>
      <div className="flex flex-col gap-2 sm:flex-row">
        <select
          name="platform"
          value={platform}
          onChange={(e) => setPlatform(e.target.value)}
          className={`${inputClass} sm:max-w-[160px]`}
        >
          {SOCIALS.map((s) => (
            <option key={s.key} value={s.key}>
              {s.label}
            </option>
          ))}
        </select>
        <input
          name="url"
          type="text"
          placeholder={platform === "email" ? "you@example.com" : "https://..."}
          required
          className={`${inputClass} flex-1`}
        />
        <button
          type="submit"
          disabled={pending}
          className="rounded-lg bg-neutral-900 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-800 disabled:opacity-50 transition-colors"
        >
          {pending ? "Adding..." : "Add"}
        </button>
      </div>
      {state.error && <p className="text-xs text-red-600">{state.error}</p>}
    </form>
  );
}

function SocialsList({ socials }: { socials: DbSocialLink[] }) {
  const [order, setOrder] = useState<string[]>(() => socials.map((s) => s.id));
  const [, startTransition] = useTransition();

  useEffect(() => {
    setOrder(socials.map((s) => s.id));
  }, [socials]);

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  );

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    if (!over || active.id === over.id) return;
    const oldIndex = order.indexOf(active.id as string);
    const newIndex = order.indexOf(over.id as string);
    const next = arrayMove(order, oldIndex, newIndex);
    setOrder(next);
    startTransition(() => {
      void reorderSocialLinks(next);
    });
  }

  if (socials.length === 0) {
    return (
      <p className="text-sm text-neutral-400">
        No socials yet. Add your first one below.
      </p>
    );
  }

  const byId = new Map(socials.map((s) => [s.id, s]));
  const sorted = order.map((id) => byId.get(id)).filter(Boolean) as DbSocialLink[];

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={handleDragEnd}
    >
      <SortableContext items={order} strategy={verticalListSortingStrategy}>
        <div className="space-y-2">
          {sorted.map((s) => (
            <SortableSocialRow key={s.id} social={s} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}

function SortableSocialRow({ social }: { social: DbSocialLink }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: social.id });

  const platform = getSocialPlatform(social.platform);
  const [delState, delAction, delPending] = useActionState(deleteSocialLink, {});
  const [colorState, colorAction, colorPending] = useActionState(
    setSocialColor,
    {}
  );
  const [optimisticColor, setOptimisticColor] = useState<string | null>(
    social.color
  );

  if (!platform) return null;

  const swatchColor = optimisticColor ?? platform.brandColor;

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    zIndex: isDragging ? 50 : "auto",
  } as React.CSSProperties;

  return (
    <div
      ref={setNodeRef}
      style={style}
      className={`flex items-center gap-2 rounded-lg border bg-white px-3 py-2 ${
        isDragging ? "border-neutral-900 shadow-lg" : "border-neutral-200"
      }`}
    >
      <button
        type="button"
        aria-label="Drag to reorder"
        className="touch-none cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500 transition-colors px-1"
        {...attributes}
        {...listeners}
      >
        <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
          <circle cx="4" cy="3" r="1.2" />
          <circle cx="10" cy="3" r="1.2" />
          <circle cx="4" cy="7" r="1.2" />
          <circle cx="10" cy="7" r="1.2" />
          <circle cx="4" cy="11" r="1.2" />
          <circle cx="10" cy="11" r="1.2" />
        </svg>
      </button>

      <span
        className="inline-flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full"
        style={{ background: `${swatchColor}1f`, color: swatchColor }}
        aria-hidden
      >
        <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
          <path d={platform.path} />
        </svg>
      </span>

      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium text-neutral-900 truncate">
          {platform.label}
        </p>
        <p className="text-xs text-neutral-400 truncate">{social.url}</p>
      </div>

      <form action={colorAction} className="flex items-center">
        <input type="hidden" name="id" value={social.id} />
        <label
          className="relative inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-neutral-200 hover:border-neutral-400"
          aria-label="Pick custom icon color"
          style={{ background: swatchColor }}
        >
          <input
            type="color"
            name="color"
            className="sr-only"
            value={
              /^#[0-9a-fA-F]{6}$/.test(swatchColor) ? swatchColor : "#000000"
            }
            disabled={colorPending}
            onChange={(e) => {
              const next = e.target.value.toLowerCase();
              setOptimisticColor(next);
              const fd = new FormData();
              fd.append("id", social.id);
              fd.append("color", next);
              void colorAction(fd);
            }}
          />
        </label>
        {social.color && (
          <button
            type="button"
            onClick={() => {
              setOptimisticColor(null);
              const fd = new FormData();
              fd.append("id", social.id);
              fd.append("color", "");
              void colorAction(fd);
            }}
            disabled={colorPending}
            className="ml-1 text-[10px] text-neutral-400 hover:text-neutral-700"
            aria-label="Reset to brand color"
          >
            reset
          </button>
        )}
      </form>

      <form action={delAction}>
        <input type="hidden" name="id" value={social.id} />
        <button
          type="submit"
          disabled={delPending}
          aria-label="Delete social"
          className="text-neutral-300 hover:text-red-600 transition-colors disabled:opacity-50 px-1.5"
        >
          <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5}>
            <path d="M2.5 4h11" strokeLinecap="round" />
            <path d="M6 4V2.5h4V4" strokeLinecap="round" strokeLinejoin="round" />
            <path d="M3.5 4l.7 9.3a1 1 0 001 .9h5.6a1 1 0 001-.9L12.5 4" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        </button>
      </form>

      {(delState.error || colorState.error) && (
        <p className="absolute right-3 -bottom-5 text-xs text-red-600">
          {delState.error ?? colorState.error}
        </p>
      )}
    </div>
  );
}

Avatar upload

The avatar widget posts a single file to /api/avatar (we'll build the route in the next step) and writes the resulting blob URL to Creator.avatarUrl. Go to src/app/dashboard and create a file called AvatarUpload.tsx with the content:

AvatarUpload.tsx
"use client";

import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";

export function AvatarUpload({
  current,
  hasProfile,
  displayName,
}: {
  current: string | null;
  hasProfile: boolean;
  displayName: string;
}) {
  const router = useRouter();
  const inputRef = useRef<HTMLInputElement>(null);
  const [pending, startTransition] = useTransition();
  const [error, setError] = useState<string | null>(null);
  const [preview, setPreview] = useState<string | null>(current);

  const initial = (displayName || "?").charAt(0).toUpperCase();

  async function upload(file: File) {
    setError(null);
    const localUrl = URL.createObjectURL(file);
    setPreview(localUrl);

    const fd = new FormData();
    fd.append("file", file);
    try {
      const res = await fetch("/api/avatar", { method: "POST", body: fd });
      if (!res.ok) {
        const data = (await res.json().catch(() => ({}))) as {
          error?: string;
        };
        throw new Error(data.error ?? "Upload failed");
      }
      const data = (await res.json()) as { url: string };
      setPreview(data.url);
      startTransition(() => router.refresh());
    } catch (err) {
      setPreview(current);
      setError(err instanceof Error ? err.message : "Upload failed");
    } finally {
      URL.revokeObjectURL(localUrl);
    }
  }

  async function clear() {
    setError(null);
    try {
      const res = await fetch("/api/avatar", { method: "DELETE" });
      if (!res.ok) throw new Error("Couldn't remove avatar");
      setPreview(null);
      startTransition(() => router.refresh());
    } catch (err) {
      setError(err instanceof Error ? err.message : "Couldn't remove avatar");
    }
  }

  return (
    <div className="flex items-center gap-4">
      {preview ? (
        <img
          src={preview}
          alt={displayName}
          className="h-16 w-16 rounded-full object-cover border border-neutral-200"
        />
      ) : (
        <div className="flex h-16 w-16 items-center justify-center rounded-full bg-neutral-100 text-xl font-semibold text-neutral-500 border border-neutral-200">
          {initial}
        </div>
      )}

      <div className="flex flex-col items-start gap-1.5">
        <div className="flex items-center gap-2">
          <button
            type="button"
            onClick={() => inputRef.current?.click()}
            disabled={!hasProfile || pending}
            className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-900 transition-colors hover:border-neutral-400 disabled:opacity-40 disabled:cursor-not-allowed"
          >
            {preview ? "Replace" : "Upload avatar"}
          </button>
          {preview && (
            <button
              type="button"
              onClick={clear}
              disabled={!hasProfile || pending}
              className="text-xs font-medium text-neutral-500 hover:text-red-600 disabled:opacity-40 disabled:cursor-not-allowed"
            >
              Remove
            </button>
          )}
        </div>
        <p className="text-[11px] text-neutral-400">JPG, PNG, WEBP, or GIF. 4 MB max.</p>
        <input
          ref={inputRef}
          type="file"
          accept="image/jpeg,image/png,image/webp,image/gif"
          className="sr-only"
          disabled={!hasProfile || pending}
          onChange={(e) => {
            const file = e.target.files?.[0];
            if (file) upload(file);
            e.target.value = "";
          }}
        />
        {error && <p className="text-xs text-red-600">{error}</p>}
      </div>
    </div>
  );
}

Avatar upload route

The route handler accepts the multipart file, validates session + MIME type + size, and pipes the file straight to Vercel Blob using put() with addRandomSuffix: true so re-uploads never collide with cached URLs.

The randomized suffix means each new upload gets a fresh URL, which sidesteps CDN caches that would otherwise serve the old image. Go to src/app/api/avatar and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { put } from "@vercel/blob";
import { getCurrentUserId } from "@/lib/session";
import { prisma } from "@/lib/prisma";

const MAX_BYTES = 4 * 1024 * 1024; // 4 MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];

export const dynamic = "force-dynamic";

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

  const creator = await prisma.creator.findUnique({ where: { userId } });
  if (!creator) {
    return NextResponse.json(
      { error: "Save your profile first" },
      { status: 400 }
    );
  }

  const formData = await req.formData();
  const file = formData.get("file");

  if (!(file instanceof File)) {
    return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
  }
  if (file.size > MAX_BYTES) {
    return NextResponse.json(
      { error: "Image must be smaller than 4 MB" },
      { status: 413 }
    );
  }
  if (!ALLOWED_TYPES.includes(file.type)) {
    return NextResponse.json(
      { error: "Use a JPG, PNG, WEBP, or GIF image" },
      { status: 415 }
    );
  }

  const ext = file.name.split(".").pop() || "jpg";
  const blob = await put(`avatars/${creator.id}.${ext}`, file, {
    access: "public",
    addRandomSuffix: true,
    contentType: file.type,
  });

  await prisma.creator.update({
    where: { id: creator.id },
    data: { avatarUrl: blob.url },
  });

  return NextResponse.json({ url: blob.url });
}

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

  const creator = await prisma.creator.findUnique({ where: { userId } });
  if (!creator) {
    return NextResponse.json(
      { error: "Save your profile first" },
      { status: 400 }
    );
  }

  await prisma.creator.update({
    where: { id: creator.id },
    data: { avatarUrl: null },
  });

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

Dashboard page

The dashboard page is a React Server Component that fetches the creator's data and renders the editor on the left.

On large screens, a sticky live preview shows what visitors will see at the profile. Go to src/app/dashboard/ and create a file called page.tsx with the content:

page.tsx
import { getCurrentUserId } from "@/lib/session";
import { prisma } from "@/lib/prisma";
import { ProfileForm } from "./ProfileForm";
import { AddLinkForm, LinksList } from "./LinkForm";
import { EarningsButton } from "./EarningsButton";
import { PayoutPortal } from "./PayoutPortal";
import { ThemePicker } from "./ThemePicker";
import { SocialsManager } from "./SocialsManager";
import { AvatarUpload } from "./AvatarUpload";
import { ProfileRender } from "@/components/ProfileRender";
import {
  resolveAccent,
  type CardStyleKey,
  DEFAULT_CARD_STYLE,
} from "@/lib/theme";
import { cookies } from "next/headers";

export default async function DashboardPage() {
  const userId = await getCurrentUserId();
  const creator = await prisma.creator.findUnique({
    where: { userId: userId! },
    include: {
      links: { orderBy: { sortOrder: "asc" } },
      socials: { orderBy: { sortOrder: "asc" } },
    },
  });

  const cookieStore = await cookies();
  const intendedHandle = cookieStore.get("intended_handle")?.value;

  const accent = resolveAccent(creator?.accentColor);

  const previewCreator =
    creator ?? {
      handle: "yourname",
      title: "Your name",
      bio: "Save your profile to start customizing your page.",
      avatarUrl: null,
      accentColor: accent.key,
      unlockPrice: 500,
      cardStyle: DEFAULT_CARD_STYLE,
      bgKind: "auto",
      bgValue: null,
      textColor: "auto",
    };
  const previewLinks = creator?.links ?? [];
  const previewSocials = creator?.socials ?? [];

  const displayName = creator?.title || creator?.handle || "your name";

  return (
    <div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
      <header className="sticky top-0 z-30 flex items-center justify-between border-b border-[var(--border-muted)] bg-[var(--background)]/85 px-6 py-4 backdrop-blur">
        <div className="flex min-w-0 items-center gap-2">
          <span className="text-sm font-semibold tracking-tight">
            Linkstacks
          </span>
          <span className="select-none text-sm text-neutral-300">/</span>
          <span className="text-sm text-neutral-500">Dashboard</span>
        </div>

        <div className="flex items-center gap-4">
          {creator && (
            <a
              href={`/u/${creator.handle}`}
              target="_blank"
              className="text-xs text-neutral-500 transition-colors hover:text-neutral-900"
            >
              View page
            </a>
          )}
          <a
            href="/api/auth/logout"
            className="text-xs text-neutral-400 transition-colors hover:text-neutral-900"
          >
            Log out
          </a>
        </div>
      </header>

      <main className="mx-auto max-w-6xl px-6 py-10 grid gap-12 lg:grid-cols-[minmax(0,1fr)_360px]">
        <div className="space-y-12 min-w-0">
          <Section title="Profile">
            <div className="mb-5">
              <AvatarUpload
                current={creator?.avatarUrl ?? null}
                hasProfile={!!creator}
                displayName={displayName}
              />
            </div>
            <ProfileForm creator={creator} intendedHandle={intendedHandle} />
          </Section>

          <Section title="Theme">
            <ThemePicker
              hasProfile={!!creator}
              accentColor={creator?.accentColor ?? "violet"}
              cardStyle={(creator?.cardStyle as CardStyleKey) ?? "default"}
              bgKind={creator?.bgKind ?? "auto"}
              bgValue={creator?.bgValue ?? null}
              textColor={creator?.textColor ?? "auto"}
            />
          </Section>

          <Section title="Links">
            {!creator ? (
              <p className="text-sm text-neutral-400 mb-4">
                Save your profile first to start adding links.
              </p>
            ) : (
              <>
                <LinksList links={creator.links} />
                <AddLinkForm />
              </>
            )}
          </Section>

          <Section title="Socials">
            <SocialsManager
              socials={creator?.socials ?? []}
              hasProfile={!!creator}
            />
          </Section>

          <Section title="Earnings">
            {creator ? (
              <EarningsButton
                enrolled={!!creator.whopCompanyId}
                payoutEnabled={creator.payoutEnabled}
              />
            ) : (
              <p className="text-sm text-neutral-400">
                Save your profile first to enable earnings.
              </p>
            )}
          </Section>

          {creator?.payoutEnabled && (
            <Section title="Payouts">
              <PayoutPortal companyId={creator.whopCompanyId!} />
            </Section>
          )}
        </div>

        <aside className="hidden lg:block">
          <div className="sticky top-24 space-y-3">
            <p className="text-xs font-semibold uppercase tracking-widest text-neutral-500">
              Live preview
            </p>
            <div className="rounded-2xl border border-neutral-200 overflow-hidden shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
              <ProfileRender
                creator={previewCreator}
                links={previewLinks}
                socials={previewSocials}
                hasPaidUnlock={false}
                hasEarnings={!!creator?.whopCompanyId}
                scale="preview"
              />
            </div>
            {creator && (
              <p className="text-xs text-neutral-400 text-center">
                This is what visitors see at{" "}
                <a
                  href={`/u/${creator.handle}`}
                  target="_blank"
                  className="underline hover:text-neutral-900"
                >
                  /u/{creator.handle}
                </a>
              </p>
            )}
          </div>
        </aside>
      </main>
    </div>
  );
}

function Section({
  title,
  children,
}: {
  title: string;
  children: React.ReactNode;
}) {
  return (
    <section>
      <h2 className="text-xs font-semibold uppercase tracking-widest text-neutral-500 mb-4">
        {title}
      </h2>
      {children}
    </section>
  );
}

Step 8: Enroll creators as connected accounts

When a creator clicks Enable Earnings, the platform creates a connected account company under your parent company using the Whop SDK. This is what allows Whop to run KYC on the creator and route payments to them.

Earnings server action

The action has two modes. In production it creates the connected company and redirects the creator to Whop's hosted KYC flow. However, since we're using Whop's sandbox environment in the development phase, we don't have to force everyone to complete their KYC.

So, in development, it skips the redirect entirely and flips payoutEnabled on directly. Go to src/app/actions/ and create a file called earnings.ts with the content:

earnings.ts
"use server";

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

const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const IS_SANDBOX = process.env.NEXT_PUBLIC_WHOP_ENV === "sandbox";

export type EnableEarningsResult = {
  error?: string;
  success?: boolean;
};

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

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

  if (!creator) return { error: "Create a profile first" };

  let companyId = creator.whopCompanyId;

  if (!companyId) {
    if (!creator.user.email) {
      return {
        error:
          "Your Whop account has no email address. Please add one at whop.com/settings before enabling earnings.",
      };
    }

    const company = await whop.companies.create({
      title: creator.title || creator.handle,
      parent_company_id: process.env.WHOP_PARENT_COMPANY_ID!,
      email: creator.user.email,
    });

    companyId = company.id;

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

  // Sandbox bypass: skip Whop's hosted KYC flow and mark the creator as
  // payout-ready immediately. Whop's sandbox doesn't enforce real KYC, so
  // the hosted onboarding screen would just auto-complete with placeholder
  // data anyway. The client-side button confirms this with a popup before
  // calling.
  if (IS_SANDBOX) {
    if (!creator.payoutEnabled) {
      await prisma.creator.update({
        where: { id: creator.id },
        data: { payoutEnabled: true },
      });
    }
    revalidatePath("/dashboard");
    return { success: true };
  }

  // Production flow: send the creator to Whop's hosted KYC onboarding.
  const accountLink = await whop.accountLinks.create({
    company_id: companyId,
    use_case: "account_onboarding",
    return_url: `${APP_URL}/api/earnings/complete`,
    refresh_url: `${APP_URL}/dashboard?refresh=true`,
  });

  redirect(accountLink.url);
}
Skipping KYC in the demo:
Our demo runs in sandbox, so clicking Enable earnings shows an explanation popup and flips the flag instantly. You should expect the standard KYC redirect described above for production.

Earnings button

Shows a setup prompt if the creator hasn't enrolled yet, or a link to manage their KYC if they have. In sandbox mode, the click is intercepted to open a confirmation modal that explains the bypass before calling the server action.

The modal closes itself on success and supports click-outside-to-close. Go to src/app/dashboard/ and create a file called EarningsButton.tsx with the content:

EarningsButton.tsx
"use client";

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

const IS_SANDBOX = process.env.NEXT_PUBLIC_WHOP_ENV === "sandbox";

const primaryBtn =
  "rounded-lg py-2.5 px-6 text-sm font-semibold text-white bg-neutral-900 hover:bg-neutral-800 disabled:opacity-50 transition-colors";

const ghostBtn =
  "rounded-lg px-5 py-2.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50 transition-colors";

export function EarningsButton({
  enrolled,
  payoutEnabled,
}: {
  enrolled: boolean;
  payoutEnabled: boolean;
}) {
  const [state, action, pending] = useActionState(enableEarnings, {
    error: "",
  });
  const [showSandboxNotice, setShowSandboxNotice] = useState(false);

  function handleSubmit(e: React.MouseEvent<HTMLButtonElement>) {
    if (!IS_SANDBOX) return;
    if (showSandboxNotice) return;
    e.preventDefault();
    setShowSandboxNotice(true);
  }

  useEffect(() => {
    if (state?.success) setShowSandboxNotice(false);
  }, [state?.success]);

  const renderForm = (label: string, pendingLabel: string) => (
    <form action={action}>
      <button
        type="submit"
        onClick={handleSubmit}
        disabled={pending}
        className={primaryBtn}
      >
        {pending ? pendingLabel : label}
      </button>
    </form>
  );

  return (
    <div className="rounded-xl border border-dashed border-neutral-200 p-6">
      {payoutEnabled ? (
        <div className="space-y-3">
          <div className="flex items-center gap-2">
            <span
              className="inline-flex items-center justify-center w-5 h-5 rounded-full text-white text-xs flex-shrink-0 bg-neutral-900"
              aria-hidden
            >
              ✓
            </span>
            <p className="text-sm font-medium text-neutral-900">
              Earnings enabled. Your connected account is set up.
            </p>
          </div>
          <form action={action}>
            <button
              type="submit"
              onClick={handleSubmit}
              disabled={pending}
              className="text-sm text-neutral-500 hover:text-neutral-900 transition-colors disabled:opacity-50 underline underline-offset-4"
            >
              {pending ? "Opening..." : "Manage account onboarding"}
            </button>
          </form>
        </div>
      ) : enrolled ? (
        <div className="space-y-4">
          <div className="flex items-start gap-2">
            <span
              className="text-amber-500 text-sm leading-none mt-0.5"
              aria-hidden
            >
              ⚠
            </span>
            <p className="text-sm text-amber-700 font-medium">
              Account created but KYC not complete. Finish onboarding to accept
              payments.
            </p>
          </div>
          {renderForm("Complete onboarding", "Opening...")}
        </div>
      ) : (
        <div className="text-center space-y-4">
          <p className="text-sm text-neutral-600">
            Enable earnings to accept payments for premium links.
          </p>
          {renderForm("Enable earnings", "Setting up...")}
        </div>
      )}

      {state?.error && (
        <p className="text-sm text-red-600 mt-3">{state.error}</p>
      )}

      {showSandboxNotice && (
        <SandboxBypassModal
          onCancel={() => setShowSandboxNotice(false)}
          formAction={action}
          pending={pending}
        />
      )}
    </div>
  );
}

function SandboxBypassModal({
  onCancel,
  formAction,
  pending,
}: {
  onCancel: () => void;
  formAction: (formData: FormData) => void;
  pending: boolean;
}) {
  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-900/30 backdrop-blur-sm px-4"
      role="dialog"
      aria-modal="true"
      aria-labelledby="sandbox-bypass-title"
      onClick={(e) => {
        if (e.target === e.currentTarget) onCancel();
      }}
    >
      <div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl border border-neutral-200">
        <h3
          id="sandbox-bypass-title"
          className="text-lg font-semibold text-neutral-900 mb-2 tracking-tight"
        >
          Sandbox demo. KYC skipped.
        </h3>
        <p className="text-sm text-neutral-600 leading-relaxed mb-3">
          On the production version of this project, clicking{" "}
          <span className="font-medium text-neutral-900">Enable earnings</span>{" "}
          would send you to Whop&apos;s hosted KYC flow to verify your identity
          and connect a payout method.
        </p>
        <p className="text-sm text-neutral-600 leading-relaxed mb-6">
          Since this demo runs against the Whop sandbox, no real KYC is needed.
          Continuing will mark your account as payout-ready instantly so you
          can test the rest of the flow.
        </p>
        <div className="flex justify-end gap-2">
          <button type="button" onClick={onCancel} className={ghostBtn}>
            Cancel
          </button>
          <form action={formAction}>
            <button type="submit" disabled={pending} className={primaryBtn}>
              {pending ? "Enabling..." : "Continue"}
            </button>
          </form>
        </div>
      </div>
    </div>
  );
}

KYC completion route

When the creator finishes KYC, Whop redirects them to this route. Since a logged-in user could navigate directly here without going through onboarding, we ask Whop whether the creator’s connected company actually has a payout method on file, and only flip payoutEnabled if it does.

Go to src/app/api/earnings/complete/ and create a file called route.ts with the content:

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

const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

export async function GET() {
  const userId = await getCurrentUserId();
  if (!userId) return NextResponse.redirect(`${APP_URL}/`);

  const creator = await prisma.creator.findUnique({ where: { userId } });

  if (!creator?.whopCompanyId) {
    return NextResponse.redirect(`${APP_URL}/dashboard`);
  }

  let payoutReady = false;
  try {
    const methods = await whop.payoutMethods.list({
      company_id: creator.whopCompanyId,
    });
    for await (const method of methods) {
      if (method.destination) {
        payoutReady = true;
        break;
      }
    }
  } catch (err) {
    console.error("[earnings/complete] payout-methods lookup failed:", err);
  }

  if (payoutReady && !creator.payoutEnabled) {
    await prisma.creator.update({
      where: { userId },
      data: { payoutEnabled: true },
    });
  }

  const status = payoutReady ? "enrolled=true" : "kyc_incomplete=true";
  return NextResponse.redirect(`${APP_URL}/dashboard?${status}`);
}

The three states this creates in the UI:

  • No whopCompanyId - "Enable earnings" button (creator hasn't started)
  • whopCompanyId set, payoutEnabled false - yellow warning, "Complete onboarding" button (started but not finished KYC). The dashboard also lands here with ?kyc_incomplete=true if the creator returned from Whop's KYC flow without actually adding a payout method.
  • payoutEnabled true - green checkmark + payout portal visible

Key points:

  • parent_company_id is your platform's company. This is what makes it a connected account
  • The account_onboarding use case sends the creator through Whop's hosted KYC flow
  • After KYC, Whop redirects to /api/earnings/complete which verifies the payout method against whop.payoutMethods.list before flipping payoutEnabled = true
  • The whopCompanyId stored on the Creator is used for all future payment and payout operations

Required permissions

  • company:create
  • company:basic:read
  • payout:withdraw_funds
Common error:
If you see a payout:withdraw_funds permission error when generating the account link, your API key is missing that scope. Create a new API key with the permissions below enabled and update WHOP_API_KEY in your .env.

Step 9: Build the public profile page

The public page at /u/[handle] is intentionally a thin shell. All the layout work is done by the profile render component from Step 6, so the page just loads the creator and their links, looks up the unlock if there's an ?unlocked= query parameter, and hands everything to the renderer.

Go to src/app/u/[handle]/ and create a file called page.tsx with the content:

page.tsx
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { ProfileRender } from "@/components/ProfileRender";
import { UnlockButton } from "./UnlockButton";

interface Props {
  params: Promise<{ handle: string }>;
  searchParams: Promise<{ unlocked?: string }>;
}

export default async function PublicProfilePage({ params, searchParams }: Props) {
  const { handle } = await params;
  const { unlocked } = await searchParams;

  const creator = await prisma.creator.findUnique({
    where: { handle },
    include: {
      links: { orderBy: { sortOrder: "asc" } },
      socials: { orderBy: { sortOrder: "asc" } },
    },
  });

  if (!creator) notFound();

  let hasPaidUnlock = false;
  if (unlocked) {
    const unlock = await prisma.unlock.findUnique({ where: { id: unlocked } });
    hasPaidUnlock = unlock?.creatorId === creator.id && unlock?.status === "PAID";
  }

  return (
    <div className="min-h-screen bg-white">
      <ProfileRender
        creator={creator}
        links={creator.links}
        socials={creator.socials}
        hasPaidUnlock={hasPaidUnlock}
        hasEarnings={!!creator.whopCompanyId}
        unlockSlot={
          <UnlockButton
            creatorId={creator.id}
            priceInCents={creator.unlockPrice}
          />
        }
        scale="full"
      />

      <footer className="text-center text-xs text-neutral-300 py-6">
        <a href="/" className="hover:text-neutral-500 transition-colors">
          Built with Linkstacks
        </a>
      </footer>
    </div>
  );
}

Step 10: Create the unlock checkout

When a visitor clicks the Unlock Premium Links button, we create a Whop checkout configuration with an inline one-time payment plan.

Checkout server action

To build the checkout server action, go to src/app/actions/ and create a file called checkout.ts with the content:

checkout.ts
"use server";

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

const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

export async function createCheckout(
  creatorId: string
): Promise<{ error: string }> {
  const creator = await prisma.creator.findUnique({
    where: { id: creatorId },
  });

  if (!creator) return { error: "Creator not found" };
  if (!creator.whopCompanyId) return { error: "Creator has not enabled earnings" };

  const priceInDollars = creator.unlockPrice / 100;
  const feeInDollars = creator.applicationFee / 100;

  const unlock = await prisma.unlock.create({
    data: {
      creatorId: creator.id,
      status: "PENDING",
    },
  });

  const checkout = await whop.checkoutConfigurations.create({
    plan: {
      company_id: creator.whopCompanyId,         // creator's connected account
      currency: "usd",
      plan_type: "one_time",
      initial_price: priceInDollars,
      application_fee_amount: feeInDollars,     // platform cut
    },
    redirect_url: `${APP_URL}/api/checkout/verify?handle=${encodeURIComponent(
      creator.handle
    )}&unlock_id=${unlock.id}`,
    metadata: { unlock_id: unlock.id, creator_id: creator.id },
  });

  redirect(checkout.purchase_url);
}

Checkout verification route

Whop redirects back to redirect_url with payment_id, and checkout_status=success. We use this route as the page users see after they complete a payment. It asks Whop to confirm the payment, marks the unlock as PAID if the payment looks good, then forwards the buyer to the unlocked profile page.

Failed verifications fall through to the same destination, since the webhook handler we'll write next is the authoritative source of truth and will catch up shortly. Go to src/app/api/checkout/verify/ and create a file called route.ts with the content:

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

const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

export async function GET(req: NextRequest) {
  const handle = req.nextUrl.searchParams.get("handle");
  const unlockId = req.nextUrl.searchParams.get("unlock_id");
  const paymentId = req.nextUrl.searchParams.get("payment_id");
  const checkoutStatus = req.nextUrl.searchParams.get("checkout_status");

  if (!handle) {
    return NextResponse.redirect(`${APP_URL}/`);
  }

  const profileUrl = `${APP_URL}/u/${handle}`;

  if (checkoutStatus !== "success" || !unlockId || !paymentId) {
    return NextResponse.redirect(profileUrl);
  }

  try {
    const payment = await whop.payments.retrieve(paymentId);
    if (payment.status === "paid") {
      const unlock = await prisma.unlock.findUnique({
        where: { id: unlockId },
        include: { creator: true },
      });

      if (unlock && unlock.creator.handle === handle) {
        await prisma.unlock.updateMany({
          where: { id: unlockId, status: "PENDING" },
          data: { status: "PAID", whopPaymentId: paymentId },
        });
      }
    }
  } catch (err) {
    console.error("[checkout/verify] payment retrieve failed:", err);
  }

  return NextResponse.redirect(`${profileUrl}?unlocked=${unlockId}`);
}

Unlock button

Here we bind the creator ID to the checkout action and show the price. While the server action runs, the button shows a redirecting state so the user knows something is happening.

Go to src/app/u/[handle]/ and create a file called UnlockButton.tsx with the content:

UnlockButton.tsx
"use client";

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

export function UnlockButton({
  creatorId,
  priceInCents,
}: {
  creatorId: string;
  priceInCents: number;
}) {
  const action = createCheckout.bind(null, creatorId);
  const [state, formAction, pending] = useActionState(action, { error: "" });

  const dollars = (priceInCents / 100).toFixed(2);

  return (
    <div>
      <form action={formAction}>
        <button
          type="submit"
          disabled={pending}
          className="w-full rounded-xl py-3 px-4 text-sm font-semibold text-white transition-opacity disabled:opacity-50"
          style={{ background: "var(--accent)" }}
        >
          {pending
            ? "Redirecting to checkout..."
            : `Unlock premium for $${dollars}`}
        </button>
      </form>
      {state?.error && (
        <p className="text-sm text-red-600 mt-2 text-center">{state.error}</p>
      )}
    </div>
  );
}

The purchase_url returned by Whop is a hosted checkout page. After the buyer completes payment, Whop redirects them to your redirect_url with payment_id, checkout_status=success, and your original unlocked parameter appended.

Required permissions

  • checkout_configuration:create
  • checkout_configuration:basic:read
  • plan:create
  • plan:basic:read
  • access_pass:create
  • access_pass:update
  • access_pass:basic:read
Common error:
If you see "test mode card in live mode", set WHOP_BASE_URL=https://sandbox-api.whop.com/api/v1, WHOP_OAUTH_BASE=https://sandbox-api.whop.com, and NEXT_PUBLIC_WHOP_ENV=sandbox in your .env, then restart the dev server.

Step 11: Handle payment webhooks

The redirect-based verification in Step 10 covers most cases, but it has one weakness: if the buyer closes the tab before the redirect completes, your database never gets updated. Webhooks solve this.

Whop sends a server-to-server POST request to your endpoint the moment a payment succeeds or fails, no browser involved. Together, the redirect and the webhook give you two independent paths to the same outcome, so an unlock is never missed.

Whop retries webhook deliveries on any non-2xx response, and even successful deliveries occasionally arrive twice during network hiccups.

We use the WebhookEvent model from Step 1 as an idempotency store: insert the event ID first, and let any duplicate trip a unique-constraint violation that we catch and treat as a "no-op, already processed" signal. That way every replay is safe.

Webhook route

The Whop SDK's webhooks.unwrap() method verifies the signature and returns a typed event object. It uses the webhookKey we passed to the SDK constructor in Step 3 (the base64-encoded WHOP_WEBHOOK_SECRET).

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

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

// Next.js must not parse the body. We need the raw string for signature verification.
export const dynamic = "force-dynamic";

type WhopEventEnvelope = {
  id: string;
  type: string;
  data: { id?: unknown; metadata?: unknown } & Record<string, unknown>;
};

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: WhopEventEnvelope;
  try {
    event = whop.webhooks.unwrap(rawBody, {
      headers,
    }) as unknown as WhopEventEnvelope;
  } catch (err) {
    console.error("[webhook] signature verification failed:", err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  // Idempotency. Whop retries on non-2xx, so we must short-circuit replays.
  try {
    await prisma.webhookEvent.create({
      data: { id: event.id, type: event.type },
    });
  } catch (err) {
    if (
      err instanceof Prisma.PrismaClientKnownRequestError &&
      err.code === "P2002"
    ) {
      console.log(`[webhook] duplicate ${event.id}, skipping`);
      return NextResponse.json({ received: true, deduped: true });
    }
    throw err;
  }

  console.log(`[webhook] received: ${event.type} (${event.id})`);

  if (event.type === "payment.succeeded") {
    const paymentId = event.data.id as string;
    await handlePaymentSucceeded(paymentId);
  }

  if (event.type === "payment.failed") {
    const paymentId = event.data.id as string;
    await handlePaymentFailed(paymentId);
  }

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

async function handlePaymentSucceeded(paymentId: string) {
  const existing = await prisma.unlock.findUnique({
    where: { whopPaymentId: paymentId },
  });

  if (existing) {
    if (existing.status !== "PAID") {
      await prisma.unlock.update({
        where: { id: existing.id },
        data: { status: "PAID" },
      });
      console.log(`[webhook] unlock ${existing.id} marked PAID`);
    }
    return;
  }

  // No unlock matched by payment ID yet. Find a PENDING unlock without a payment ID.
  const payment = await whop.payments.retrieve(paymentId);
  const unlockId = (payment.metadata as Record<string, string> | null)
    ?.unlock_id;

  if (unlockId) {
    await prisma.unlock.updateMany({
      where: { id: unlockId, status: "PENDING" },
      data: { status: "PAID", whopPaymentId: paymentId },
    });
    console.log(`[webhook] unlock ${unlockId} marked PAID via metadata`);
  } else {
    console.warn(
      `[webhook] payment.succeeded: no unlock found for payment ${paymentId}`
    );
  }
}

async function handlePaymentFailed(paymentId: string) {
  const existing = await prisma.unlock.findUnique({
    where: { whopPaymentId: paymentId },
  });

  if (existing) {
    await prisma.unlock.update({
      where: { id: existing.id },
      data: { status: "FAILED" },
    });
    console.log(`[webhook] unlock ${existing.id} marked FAILED`);
  }
}

Configure the webhook in the Whop dashboard:

  1. Go to the Developer page of your whop and create a webhook with the endpoint https://your-ngrok-url.ngrok-free.app/api/webhooks/whop
  2. Subscribe to: payment.succeeded, payment.failed
  3. Enable the "Connected account events" option
  4. Copy the signing secret → WHOP_WEBHOOK_SECRET

Common errors

  • If signature verification fails with a 401, double-check the base64 encoding of WHOP_WEBHOOK_SECRET in your lib/whop.ts constructor. The internal library expects the key as base64; passing the raw ws_xxx value silently fails on every delivery.
  • Trailing newlines or whitespace in WHOP_WEBHOOK_SECRET also produce 401s with no useful error. Paste carefully when adding the value to Vercel.
  • If you're reading the raw body with req.text(), make sure you do that before any JSON parsing. Parsing first corrupts the signature that webhooks.unwrap() checks against.

Step 12: Add security headers

Whop's hosted checkout, payout portal, and embedded components load scripts and iframes from whop.com. By default, Next.js doesn't ship a Content-Security-Policy, so these resources work without one, but anything else also works without one.

Open next.config.ts (created for you by create-next-app) and replace its contents with:

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

const isSandbox = process.env.NEXT_PUBLIC_WHOP_ENV === "sandbox";

const whopFrame = isSandbox
  ? "https://*.whop.com https://sandbox-js.whop.com"
  : "https://*.whop.com";
const whopScript = isSandbox
  ? "https://js.whop.com https://sandbox-js.whop.com"
  : "https://js.whop.com";

const csp = [
  "default-src 'self'",
  `script-src 'self' 'unsafe-inline' 'unsafe-eval' ${whopScript}`,
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: https:",
  "font-src 'self' data:",
  "connect-src 'self' https://*.whop.com https://sandbox-api.whop.com",
  `frame-src ${whopFrame}`,
  "frame-ancestors 'none'",
  "base-uri 'self'",
  "form-action 'self'",
].join("; ");

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          { key: "Content-Security-Policy", value: csp },
          { key: "X-Frame-Options", value: "DENY" },
          { key: "X-Content-Type-Options", value: "nosniff" },
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
          {
            key: "Permissions-Policy",
            value: "camera=(), microphone=(), geolocation=()",
          },
        ],
      },
    ];
  },
};

export default nextConfig;

Step 13: Embed the payout portal

The payout portal is where creators manage their Whop balance, complete KYC if they haven't, and withdraw earnings. Whop provides pre-built React components that embed directly into your dashboard.

Token endpoint

The embedded components need a short-lived access token scoped to the creator's connected company. Create one server-side on demand.

Go to src/app/api/payout-token/ and create a file called route.ts with the content:

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 creator = await prisma.creator.findUnique({ where: { userId } });
  if (!creator?.whopCompanyId) {
    return NextResponse.json({ error: "No connected account" }, { status: 400 });
  }

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

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

Payout portal component

The Elements component accepts a promise directly, so we use useMemo to create the loadWhopElements promise once and pass it straight in. The environment is resolved at module level (safe and it only reads an env var, not window).

The redirectUrl guard handles SSR where window is undefined. The CSP we added in Step 12 already allows js.whop.com and *.whop.com so these embedded components load without changes. Go to src/app/dashboard/ and create a file called PayoutPortal.tsx with the content:

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";

const environment =
  process.env.NEXT_PUBLIC_WHOP_ENV === "sandbox" ? "sandbox" : "production";

async function fetchPayoutToken(): Promise<string | null> {
  const res = await fetch("/api/payout-token");
  if (!res.ok) return null;
  return (await res.json()).token ?? null;
}

export function PayoutPortal({ companyId }: { companyId: string }) {
  const elementsPromise = useMemo(() => loadWhopElements({ environment }), []);

  return (
    <Elements elements={elementsPromise}>
      <PayoutsSession
        companyId={companyId}
        token={fetchPayoutToken}
        currency="usd"
        redirectUrl={typeof window !== "undefined" ? window.location.href : "/dashboard"}
      >
        <div className="space-y-4">
          <StatusBannerElement />
          <VerifyElement />
          <BalanceElement />
          <WithdrawButtonElement />
          <WithdrawalsElement />
        </div>
      </PayoutsSession>
    </Elements>
  );
}

The token is passed as a function (not a string), so the SDK automatically refreshes it before it expires. No manual token rotation is needed on our end.

Common error:
If the payout portal shows an environment error or fails to load, make sure NEXT_PUBLIC_WHOP_ENV is set in your .env file (not just in your shell). This variable must be prefixed with NEXT_PUBLIC_ to be available in the browser.

Step 14: Test the full flow

Before you ship, run through the complete user journey end to end. Make sure your dev server and ngrok tunnel are running, then work through each step:

  1. Sign in with Whop OAuth - Go to your app's homepage and click Sign in. You should be redirected to Whop's login page and land on /dashboard with your session set.
  2. Set up your creator profile - Fill in a display name, handle, bio, and unlock price, then save. Pick an accent color from the swatches; the live preview pane on the right should recolor immediately. Your public page at /u/your-handle should now be live.
  3. Add links - Add a mix of free and premium links. Toggle some to premium. Hide one with the eye icon and confirm it disappears from the live preview and from /u/your-handle. Drag a row by its handle to a new position; refresh the dashboard and confirm the order is persisted.
  4. Enable earnings - Click "Enable earnings" on the dashboard. You'll be redirected to Whop to complete KYC (or, in sandbox, you'll see the bypass modal that lets you continue without the redirect). Finish that flow and come back. Your dashboard should now show the Payouts section.
  5. Buy a premium unlock (sandbox) - Open your public profile in a fresh browser tab or incognito window. Click the unlock button and complete checkout using the Whop test card (4242 4242 4242 4242, any future expiry, any CVC). After the redirect through /api/checkout/verify, the premium links should be visible without the price chip.
  6. Verify the webhook fired - Check your server logs for the payment.succeeded event. The unlock record in your database should have status PAID. If you trigger the same webhook twice (the Whop dashboard has a "redeliver" option on each event), the second delivery should log duplicate <event_id>, skipping from the idempotency check.
  7. Check the payout portal - Back on the dashboard, the Payouts section should show your balance and withdrawal options from the Whop embedded component.

Step 15: Deploy to Vercel

Now, let's deploy our project to Vercel by following the steps below:

  1. Push your repo to GitHub.
  2. Import the project at vercel.com/new.
  3. Add all environment variables from .env.example with production values. Remove the sandbox WHOP_BASE_URL and WHOP_OAUTH_BASE lines, set NEXT_PUBLIC_WHOP_ENV=production, and double-check that WHOP_APP_ID, NEXT_PUBLIC_WHOP_APP_ID, and WHOP_WEBHOOK_SECRET are all present.
  4. Run your production migration: node_modules/.bin/prisma migrate deploy.
  5. In the Whop dashboard, register the production app's redirect URI and webhook URL:
    • OAuth redirect URI → https://yourapp.vercel.app/api/auth/callback
    • Webhook endpoint → https://yourapp.vercel.app/api/webhooks/whop (with Connected account events enabled, same as in development)
  6. Switch over to your production Whop API keys. The production company API key, app API key, app client secret, and webhook signing secret are all distinct values from the sandbox versions. Generate fresh keys against your live company and update Vercel.

Provision a Vercel Blob store for avatar uploads. With the Vercel CLI logged in (vercel login) and inside the project directory, run:

page.tsx
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">vercel blob create-store linktree-avatars --access public --yes --environment production --environment preview --environment development</code></pre>
  </div>
</div>

Make sure your package.json build script generates the Prisma client before running Next's build. Vercel's build cache can serve a stale client without this:

package.json
"scripts": {
  "build": "prisma generate && next build"
}

Build your dream platform with Whop

In this project, we've used the Whop Payments Network to create connected accounts for our members, but this isn't the only way you can use the Whop infrastructure to build your dream platform.

Using the Whop Payments Network, you can create projects like a Substack clone, a Udemy clone, or a Gumroad clone. If don't want a platform, but a SaaS, you can use the Whop API to create projects like an AI writing tool or an AI chatbot SaaS.

If you want to learn more about how the Whop Payments Network and the Whop API can help you, check out the Whop developer documentation.