You can build a video platform like YouTube with channels, playlists, memberships, tips, and more with Next.js and Whop. Learn how to in this guide.

Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

You can build a video platform like YouTube using Next.js and Whop. The hard parts like charging users, taking platform fees, paying creators out, and the KYC are all handled by Whop.

In this tutorial we're going to build such platform and call it Wavora. Anyone can sign in with Whop, start a channel, and upload videos. Users watch, subscribe, like, comment, save to playlists, and scroll Waves (our take on Shorts).

Creators turn on memberships, collect tips, and withdraw their earnings without ever leaving the app.

We build all the pages, the video upload, and the database ourselves with Next.js, TypeScript, and Prisma. Whop handles sign-in and every payment, which are the hard parts.

You can try the live demo here, and the full code is on GitHub.

Project overview

Our project has three aspects: the public site where people watch, the studio where creators upload and manage videos, and the money layer that connects the two. By the end of this tutorial, the app has:

  • Channels with subscribers, a public page, and tabs for videos, Waves, and an about section.
  • Video upload straight from the browser, a watch page, a home feed, search, and a vertical Waves feed.
  • Likes, threaded comments with creator moderation, watch history with resume, Watch Later, Liked, and playlists.
  • Channel memberships that renew monthly and one-time Cheers tips, both paid on the creator's own Whop account.
  • An embedded payout portal with KYC built in, an earnings view, and an in-app notification inbox.
  • Original dark and light themes.

Why Whop

There are four things this project needs that are normally hard: charge users, take a platform fee, pay creators out with KYC, and turn payment events into database rows. That usually means building four separate systems. Whop handles all four in one SDK:

  • Whop OAuth for sign-in.
  • Connected accounts and embedded checkout for charging users.
  • Signed webhooks for recording what was paid.
  • An embedded payout portal with KYC for paying creators.

Tech stack

  • Next.js (App Router, Turbopack) for routing, server components, and a Vercel deploy. The middleware file is now proxy.ts.
  • React for server components and the studio's client pieces.
  • Tailwind CSS with CSS-first @theme, no config file.
  • Prisma with the pg driver adapter, against Neon Postgres through the Vercel integration.
  • iron-session for an encrypted-cookie session, no session table.
  • Zod for validation at every boundary, including the environment variables.
  • Whop SDK (@whop/sdk), the checkout embed (@whop/checkout), and the embedded payout components.
  • Vercel Blob for video storage, and Vercel for hosting.

Pages

  • / home feed
  • /results search results
  • /watch watch page
  • /shorts vertical Waves (Shorts) feed
  • /@handle a channel, with /videos, /shorts, /about, and /membership
  • /feed/subscriptions, /feed/history, /feed/you, /feed/playlists
  • /playlist a playlist (Watch Later, Liked, or a custom one)
  • /explore/trending and /explore/[category]
  • /create-channel and /sign-in
  • /studio/videos, /studio/upload, /studio/video/[id], /studio/customize, /studio/monetization

API routes

  • Auth: /api/auth/login, /api/auth/callback, /api/auth/logout
  • Upload: /api/blob/upload, /api/whop/upload
  • Channel: /api/handle-check
  • Money: /api/payout-token, /api/webhooks/whop
  • Notifications: /api/notifications

Part 1: Foundation, deployment, and authentication

In this first part, we're going to build the foundation of our project and by the end, we'll have a public URL, and users will be able to sign in via their Whop accounts. We'll scaffold a new Next.js project and deploy it to Vercel firsthand, add a Neon database.

Scaffold the Next.js app

Let's create the project. Run the command below at where you want the project to live:

Terminal
npx create-next-app@latest wavora --typescript --tailwind --app --src-dir --turbopack --eslint
cd wavora

Now let's install everything we'll use with the command below, so no later part has to stop and add a package:

Terminal
npm install @whop/sdk @whop/checkout @whop/embedded-components-react-js @whop/embedded-components-vanilla-js \
  @prisma/client @prisma/adapter-pg pg iron-session zod @vercel/blob \
  clsx tailwind-merge lucide-react
npm install -D prisma @types/pg

Next 16 renamed middleware.ts to proxy.ts and made cookies(), params, and searchParams async. So, let's make sure our project complies with 16th version. Open next.config.ts and replace its contents:

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

const nextConfig: NextConfig = {
  turbopack: {
    root: __dirname,
  },
};

export default nextConfig;

Set up your Whop app

A Whop app is going to help us with user authentication, so let's create a Whop app now.

We'll use the sandbox environment of Whop at Sandbox.Whop.com, which helps us simulate payments without moving real money. We'll switch to the production environment at Whop.com at the end of the tutorial.

Now, go to Sandbox.Whop.com, create a Whop account, and a new whop. Once you're viewing your whop, follow the steps below to create an app:

  1. Click on the Dashboard button at the left navigation sidebar of your whop
  2. Select the Developer page at the left sidebar of your whop dashboard
  3. In the Developer page, find the Apps section and click the Create app button under it
  4. Give your app a name and click Create. This will take you to the details page of your new app
  5. Go to the Permissions tab of your app and add the oauth:token_exchange permissions
  6. Then, go to the OAuth tab of your app and copy down the Client ID and Client secret details of the app, we'll use them later

We'll come back to the OAuth tab later to add the redirect URI for the user authentication.

Before moving on, there are two secrets we'll get from the Developer page. Go back to the Developer page of your whop and create an API key under Company API keys section with Owner permissions (since we're in sandbox.) and copy the API key down.

Then, look at the URL of your dashboard and find your company ID which starts with biz_..., and copy it down as well.

Environment variables

Now let's build the environment variable validation method so that when one fails, we get a clear message instead of breaking deep inside a request. The check waits until a value is actually used, so a build step that never needs a secret won't trip on it. Create src/lib/env.ts:

env.ts
import "server-only";
import { z } from "zod";

const schema = z.object({
  WHOP_CLIENT_ID: z.string().min(1),
  WHOP_CLIENT_SECRET: z.string().min(1),
  WHOP_COMPANY_API_KEY: z.string().min(1),
  WHOP_PLATFORM_COMPANY_ID: z.string().min(1),
  SESSION_SECRET: z.string().min(32, "SESSION_SECRET must be at least 32 characters"),
  NEXT_PUBLIC_APP_URL: z.url(),
  WHOP_SANDBOX: z.enum(["true", "false"]).default("false"),
  WHOP_WEBHOOK_SECRET: z.string().optional(),
  DATABASE_URL: z.string().optional(),
});

type Env = z.infer<typeof schema>;

let cached: Env | null = null;

function load(): Env {
  if (cached) return cached;
  const parsed = schema.safeParse(process.env);
  if (!parsed.success) {
    const issues = JSON.stringify(parsed.error.flatten().fieldErrors, null, 2);
    throw new Error(`Invalid environment variables:\n${issues}`);
  }
  cached = parsed.data;
  return cached;
}

export const env = new Proxy({} as Env, {
  get(_target, key: string) {
    return load()[key as keyof Env];
  },
});

export const isSandbox = () => env.WHOP_SANDBOX === "true";

export const devRoutesEnabled = () => process.env.NODE_ENV !== "production";

Then, let's create .env.example:

.env.example
# Whop OAuth (app): Whop dashboard > Developer > Apps > [App] > OAuth
WHOP_CLIENT_ID=app_xxxxxxxxxxxx
WHOP_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxx

# Whop platform (company): Whop dashboard > Developer > Company API Keys
WHOP_COMPANY_API_KEY=apik_xxxxxxxxxxxx
WHOP_PLATFORM_COMPANY_ID=biz_xxxxxxxxxxxx

# Webhook signing secret: added in Part 4 (Whop dashboard > Developer > Apps > Webhooks)
WHOP_WEBHOOK_SECRET=

# Session: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=

# App URL: http://localhost:3000 in dev; stable Vercel URL in prod
NEXT_PUBLIC_APP_URL=http://localhost:3000

# Database (Neon): added when provisioned
DATABASE_URL=
DATABASE_URL_UNPOOLED=

# Vercel Blob (video): Vercel dashboard > Storage > Blob (auto-adds this)
BLOB_READ_WRITE_TOKEN=

# Mode: "true" for the Whop sandbox during development
WHOP_SANDBOX=true
# Sandbox apps authenticate via the sandbox OAuth host. Leave unset in
# production (defaults to https://api.whop.com).
WHOP_OAUTH_BASE_URL=https://sandbox-api.whop.com

Deploy to Vercel and set up Neon

And let's deploy the app now to get a real URL since Whop redirects back to it after sign-in and that URL cannot be localhost. We add the database while we are here.

Install the Vercel CLI and link the project:

Terminal
npm install -g vercel
vercel link

Vercel needs to know this is a Next.js app, not a folder of plain files. Create vercel.json:

vercel.json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "framework": "nextjs"
}

Deploy once to get your production URL:

Terminal
vercel deploy --prod

Now add the database and the rest of the configuration in the Vercel dashboard. Under Storage, add Neon, which writes DATABASE_URL into the project for you automatically.

Then, under Settings, then Environment Variables, add the Whop credentials from the last section, a SESSION_SECRET (any random string of 32 or more characters), NEXT_PUBLIC_APP_URL set to your new production URL, WHOP_SANDBOX set to true, and WHOP_OAUTH_BASE_URL set to https://sandbox-api.whop.com.

Then pull it all down to a local file using the command:

Terminal
vercel env pull .env.local
Set every variable in Vercel, not just locally, because the deployed app reads them from there. Secret values pull back blank, so paste anything a local script needs into .env.local by hand.

Finally, go to the OAuth tab of the Whop app you created in the previous steps, then register <your-production-url>/api/auth/callback as a redirect URI, so sign-in can return to your app.

The data model

We set the entire database now including the tables we're not going to use until Part 4 so we don't keep coming back to this file.

A user in our model owns one channel, a channel owns videos, and the rest tracks activity like views, watch history, likes, comments, etc. Create prisma/schema.prisma:

schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

enum Visibility {
  PUBLIC
  UNLISTED
  PRIVATE
}

enum VideoStatus {
  UPLOADING
  PROCESSING
  READY
  ERRORED
}

enum VideoCategory {
  MUSIC
  GAMING
  NEWS
  SPORTS
  COMEDY
  EDUCATION
  ENTERTAINMENT
  TECH
  PODCASTS
  COOKING
  OTHER
}

enum ReactionType {
  LIKE
  DISLIKE
}

enum CommentStatus {
  PUBLISHED
  HELD
  REMOVED
  DELETED
}

enum MemberStatus {
  ACTIVE
  INACTIVE
}

enum LedgerSource {
  MEMBERSHIP
  SUPER_THANKS
}

enum NotificationType {
  NEW_SUBSCRIBER
  NEW_MEMBER
  SUPER_THANKS
  NEW_UPLOAD
  COMMENT_REPLY
}

enum NotifyLevel {
  ALL
  PERSONALIZED
  NONE
}

model User {
  id         String   @id @default(cuid())
  whopUserId String   @unique
  username   String
  name          String?
  email         String?
  avatarUrl     String?
  historyPaused Boolean  @default(false)
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  channel          Channel?
  subscriptions    Subscription[]
  reactions        Reaction[]
  comments         Comment[]
  commentReactions CommentReaction[]
  watchHistory     WatchHistory[]
  views            View[]
  watchLater       WatchLater[]
  playlists        Playlist[]
  memberships      ChannelMember[]
  tips             Tip[]             @relation("TipSupporter")
  notifications    Notification[]
}

model Channel {
  id                 String   @id @default(cuid())
  userId             String   @unique
  handle             String   @unique
  name               String
  description        String?
  avatarUrl          String?
  bannerUrl          String?
  whopCompanyId      String?
  payoutEnabled      Boolean  @default(false)
  superThanksEnabled Boolean  @default(false)
  membershipsEnabled Boolean  @default(false)
  createdAt          DateTime @default(now())
  updatedAt          DateTime @updatedAt

  user            User             @relation(fields: [userId], references: [id], onDelete: Cascade)
  videos          Video[]
  subscribers     Subscription[]
  membershipTiers MembershipTier[]
  members         ChannelMember[]
  tips            Tip[]
  earnings        EarningsLedger[]
}

model Video {
  id              String        @id @default(cuid())
  channelId       String
  title           String
  description     String?
  category        VideoCategory @default(OTHER)
  visibility      Visibility    @default(PUBLIC)
  status          VideoStatus   @default(UPLOADING)
  isShort         Boolean       @default(false)
  membersOnly     Boolean       @default(false)
  commentsEnabled Boolean       @default(true)

  videoUrl        String?
  videoPathname   String?
  durationSeconds Int     @default(0)
  thumbnailUrl    String?

  viewCount   Int       @default(0)
  publishedAt DateTime?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  channel       Channel        @relation(fields: [channelId], references: [id], onDelete: Cascade)
  reactions     Reaction[]
  comments      Comment[]
  watchHistory  WatchHistory[]
  views         View[]
  watchLater    WatchLater[]
  playlistItems PlaylistItem[]
  tips          Tip[]

  @@index([channelId])
  @@index([visibility, status, publishedAt])
  @@index([category])
}

model Subscription {
  id           String      @id @default(cuid())
  subscriberId String
  channelId    String
  notify       NotifyLevel @default(ALL)
  createdAt    DateTime    @default(now())

  subscriber User    @relation(fields: [subscriberId], references: [id], onDelete: Cascade)
  channel    Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)

  @@unique([subscriberId, channelId])
  @@index([channelId])
}

model Reaction {
  id        String       @id @default(cuid())
  userId    String
  videoId   String
  type      ReactionType
  createdAt DateTime     @default(now())

  user  User  @relation(fields: [userId], references: [id], onDelete: Cascade)
  video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)

  @@unique([userId, videoId])
  @@index([videoId, type])
}

model Comment {
  id                String        @id @default(cuid())
  videoId           String
  authorId          String
  parentId          String?
  body              String
  status            CommentStatus @default(PUBLISHED)
  isPinned          Boolean       @default(false)
  heartedByCreator  Boolean       @default(false)
  isSuperThanks     Boolean       @default(false)
  superThanksAmount Int?
  createdAt         DateTime      @default(now())
  updatedAt         DateTime      @updatedAt

  video     Video             @relation(fields: [videoId], references: [id], onDelete: Cascade)
  author    User              @relation(fields: [authorId], references: [id], onDelete: Cascade)
  parent    Comment?          @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
  replies   Comment[]         @relation("CommentReplies")
  reactions CommentReaction[]

  @@index([videoId, status, createdAt])
  @@index([parentId])
}

model CommentReaction {
  id        String   @id @default(cuid())
  userId    String
  commentId String
  createdAt DateTime @default(now())

  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)

  @@unique([userId, commentId])
}

model WatchHistory {
  id              String   @id @default(cuid())
  userId          String
  videoId         String
  positionSeconds Int      @default(0)
  completed       Boolean  @default(false)
  lastWatchedAt   DateTime @default(now())

  user  User  @relation(fields: [userId], references: [id], onDelete: Cascade)
  video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)

  @@unique([userId, videoId])
  @@index([userId, lastWatchedAt])
}

model View {
  id         String   @id @default(cuid())
  videoId    String
  userId     String?
  sessionKey String
  createdAt  DateTime @default(now())

  video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
  user  User? @relation(fields: [userId], references: [id], onDelete: SetNull)

  @@unique([videoId, sessionKey])
  @@index([videoId])
  @@index([createdAt])
}

model WatchLater {
  id      String   @id @default(cuid())
  userId  String
  videoId String
  addedAt DateTime @default(now())

  user  User  @relation(fields: [userId], references: [id], onDelete: Cascade)
  video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)

  @@unique([userId, videoId])
}

model Playlist {
  id          String     @id @default(cuid())
  ownerId     String
  title       String
  description String?
  visibility  Visibility @default(PRIVATE)
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt

  owner User           @relation(fields: [ownerId], references: [id], onDelete: Cascade)
  items PlaylistItem[]

  @@index([ownerId])
}

model PlaylistItem {
  id         String   @id @default(cuid())
  playlistId String
  videoId    String
  position   Int
  addedAt    DateTime @default(now())

  playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
  video    Video    @relation(fields: [videoId], references: [id], onDelete: Cascade)

  @@unique([playlistId, videoId])
  @@index([playlistId, position])
}

model MembershipTier {
  id            String   @id @default(cuid())
  channelId     String
  name          String
  description   String?
  priceCents    Int
  whopPlanId    String?
  whopProductId String?
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  channel Channel         @relation(fields: [channelId], references: [id], onDelete: Cascade)
  members ChannelMember[]

  @@index([channelId])
}

model ChannelMember {
  id               String       @id @default(cuid())
  userId           String
  channelId        String
  tierId           String?
  status           MemberStatus @default(ACTIVE)
  whopMembershipId String?      @unique
  startedAt        DateTime     @default(now())
  updatedAt        DateTime     @updatedAt

  user    User            @relation(fields: [userId], references: [id], onDelete: Cascade)
  channel Channel         @relation(fields: [channelId], references: [id], onDelete: Cascade)
  tier    MembershipTier? @relation(fields: [tierId], references: [id], onDelete: SetNull)

  @@unique([userId, channelId])
  @@index([channelId, status])
}

model Tip {
  id            String   @id @default(cuid())
  supporterId   String
  channelId     String
  videoId       String?
  amountCents   Int
  feeCents      Int
  netCents      Int
  currency      String   @default("usd")
  message       String?
  whopPaymentId String   @unique
  createdAt     DateTime @default(now())

  supporter User    @relation("TipSupporter", fields: [supporterId], references: [id], onDelete: Cascade)
  channel   Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
  video     Video?  @relation(fields: [videoId], references: [id], onDelete: SetNull)

  @@index([channelId])
}

model EarningsLedger {
  id            String       @id @default(cuid())
  channelId     String
  source        LedgerSource
  grossCents    Int
  feeCents      Int
  netCents      Int
  currency      String       @default("usd")
  whopPaymentId String       @unique
  videoId       String?
  createdAt     DateTime     @default(now())

  channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)

  @@index([channelId, createdAt])
}

model Notification {
  id          String           @id @default(cuid())
  recipientId String
  type        NotificationType
  title       String
  body        String?
  data        Json?
  readAt      DateTime?
  createdAt   DateTime         @default(now())

  recipient User @relation(fields: [recipientId], references: [id], onDelete: Cascade)

  @@index([recipientId, createdAt])
}

model WebhookEvent {
  id          String   @id
  source      String
  processedAt DateTime @default(now())

  @@index([processedAt])
}

Prisma reads its settings from a TypeScript file. Create prisma.config.ts:

prisma.config.ts
import path from "node:path";
import { defineConfig } from "prisma/config";

try {
  process.loadEnvFile(".env.local");
} catch {}

export default defineConfig({
  schema: path.join("prisma", "schema.prisma"),
  datasource: {
    url: process.env.DATABASE_URL_UNPOOLED ?? process.env.DATABASE_URL,
  },
});

Push the schema to Neon and generate the client:

Terminal
npx prisma db push
npx prisma generate

Vercel reruns npm install on every deploy but not prisma generate, so the generated client would be missing from the build.

We're going to use a postinstall script to fix that. Open package.json and make sure its scripts block includes these:

package.json
"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "eslint",
  "postinstall": "prisma generate",
  "db:push": "prisma db push",
  "db:generate": "prisma generate"
}

We connect to Neon using the pooled connection string and keep a single client instance so developer-mode reloads don't create a new pool on every change. Create src/lib/prisma.ts:

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

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

function createPrismaClient() {
  const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
  return new PrismaClient({ adapter });
}

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

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

Signing in with Whop

In the sign-in flow, we send users to Whop, they approve the connection there, and come back to our app with a code that we exchange for their identity. A few small files handle it.

First, the helpers that create the random codes the flow needs. Create src/lib/pkce.ts:

pkce.ts
export function randomToken(bytes = 32): string {
  const arr = new Uint8Array(bytes);
  crypto.getRandomValues(arr);
  return Buffer.from(arr).toString("base64url");
}

export async function pkceChallenge(verifier: string): Promise<string> {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return Buffer.from(new Uint8Array(digest)).toString("base64url");
}

Whop's sign-in has four things we need to get right: a nonce whenever the scope includes openid, the client_secret on the token exchange, a JSON request body, and an app in the same environment as the OAuth host, which is sandbox here. Create src/lib/whop-oauth.ts:

whop-oauth.ts
import "server-only";
import { env } from "./env";

const OAUTH_BASE = (
  process.env.WHOP_OAUTH_BASE_URL ?? "https://api.whop.com"
).replace(/\/$/, "");

export const REDIRECT_URI = `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`;
export const OAUTH_SCOPE = "openid profile email";

export function buildAuthorizeUrl(params: {
  state: string;
  nonce: string;
  codeChallenge: string;
}): string {
  const url = new URL(`${OAUTH_BASE}/oauth/authorize`);
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: env.WHOP_CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: OAUTH_SCOPE,
    state: params.state,
    nonce: params.nonce,
    code_challenge: params.codeChallenge,
    code_challenge_method: "S256",
  }).toString();
  return url.toString();
}

type TokenResponse = {
  access_token: string;
  refresh_token?: string;
  id_token?: string;
  token_type?: string;
  expires_in?: number;
};

export async function exchangeCodeForTokens(
  code: string,
  codeVerifier: string,
): Promise<TokenResponse> {
  const res = await fetch(`${OAUTH_BASE}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      code,
      redirect_uri: REDIRECT_URI,
      client_id: env.WHOP_CLIENT_ID,
      client_secret: env.WHOP_CLIENT_SECRET,
      code_verifier: codeVerifier,
    }),
  });
  if (!res.ok) {
    throw new Error(`Token exchange failed (${res.status}): ${await res.text()}`);
  }
  return (await res.json()) as TokenResponse;
}

type UserInfo = {
  sub: string;
  preferred_username?: string;
  name?: string;
  email?: string;
  picture?: string;
};

export async function fetchUserInfo(accessToken: string): Promise<UserInfo> {
  const res = await fetch(`${OAUTH_BASE}/oauth/userinfo`, {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  if (!res.ok) {
    throw new Error(`userinfo failed (${res.status}): ${await res.text()}`);
  }
  return (await res.json()) as UserInfo;
}

The login session and the temporary sign-in code live in two separate cookies, because setting a cookie while redirecting can drop it. Create src/lib/session-config.ts:

config.ts
export const SESSION_COOKIE = "wavora_session";
export const PKCE_COOKIE = "wavora_pkce";

export const sessionCookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax" as const,
  path: "/",
  maxAge: 60 * 60 * 24 * 7,
};

export const PROTECTED_PREFIXES = [
  "/studio",
  "/feed/history",
  "/feed/subscriptions",
  "/feed/you",
];

Create src/lib/session.ts:

session.ts
import "server-only";
import { cache } from "react";
import { getIronSession, sealData } from "iron-session";
import { cookies } from "next/headers";
import type { NextResponse } from "next/server";
import { prisma } from "./prisma";
import { env } from "./env";
import { SESSION_COOKIE, sessionCookieOptions } from "./session-config";

export type SessionUser = {
  id: string;
  whopUserId: string;
  username: string;
  name: string | null;
  email: string | null;
  avatarUrl: string | null;
};

export interface SessionData {
  user?: SessionUser;
  accessToken?: string;
}

const sessionOptions = {
  password: env.SESSION_SECRET,
  cookieName: SESSION_COOKIE,
  cookieOptions: sessionCookieOptions,
};

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

export const getCurrentUser = cache(async (): Promise<SessionUser | null> => {
  const user = (await getSession()).user;
  if (!user || typeof user.id !== "string" || user.id.length === 0) return null;
  const exists = await prisma.user.findUnique({
    where: { id: user.id },
    select: { id: true },
  });
  return exists ? user : null;
});

export async function writeSessionCookie(res: NextResponse, data: SessionData) {
  const sealed = await sealData(data, { password: env.SESSION_SECRET });
  res.cookies.set(SESSION_COOKIE, sealed, sessionCookieOptions);
}

And now let's create the auth function for our routes. Create src/lib/auth.ts:

auth.ts
import "server-only";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { getCurrentUser } from "./session";

export async function requireUser() {
  const user = await getCurrentUser();
  if (!user) redirect("/sign-in");
  return user;
}

export async function getMyChannel() {
  const user = await getCurrentUser();
  if (!user) return null;
  return prisma.channel.findUnique({ where: { userId: user.id } });
}

export async function requireChannel() {
  const user = await requireUser();
  const channel = await prisma.channel.findUnique({
    where: { userId: user.id },
  });
  if (!channel) redirect("/create-channel");
  return { user, channel };
}

The auth routes

We'll have three routes that direct the flow. Login creates the codes and sends the user to Whop. Create src/app/api/auth/login/route.ts:

route.ts
import { type NextRequest, NextResponse } from "next/server";
import { randomToken, pkceChallenge } from "@/lib/pkce";
import { buildAuthorizeUrl } from "@/lib/whop-oauth";
import { PKCE_COOKIE, sessionCookieOptions } from "@/lib/session-config";

export function safeNext(raw: string | null | undefined): string | undefined {
  if (!raw || !raw.startsWith("/")) return undefined;
  try {
    const resolved = new URL(raw, "http://internal");
    if (resolved.origin !== "http://internal") return undefined;
    return resolved.pathname + resolved.search;
  } catch {
    return undefined;
  }
}

export async function GET(request: NextRequest) {
  const verifier = randomToken(32);
  const state = randomToken(16);
  const nonce = randomToken(16);
  const next = safeNext(request.nextUrl.searchParams.get("next"));
  const codeChallenge = await pkceChallenge(verifier);

  const res = NextResponse.redirect(
    buildAuthorizeUrl({ state, nonce, codeChallenge }),
  );

  res.cookies.set(
    PKCE_COOKIE,
    JSON.stringify({ verifier, state, nonce, next }),
    { ...sessionCookieOptions, maxAge: 600 },
  );

  return res;
}
safeNext validates the resolved URL instead of the raw string. Browsers strip tabs and newlines from URLs and treat backslashes as slashes, so a plain startsWith check can be tricked into redirecting to another site.

The callback checks the response, swaps the code for the user's details, saves them, and signs them in. Create src/app/api/auth/callback/route.ts:

route.ts
import { type NextRequest, NextResponse } from "next/server";
import { exchangeCodeForTokens, fetchUserInfo } from "@/lib/whop-oauth";
import { safeNext } from "../login/route";
import { writeSessionCookie } from "@/lib/session";
import { PKCE_COOKIE } from "@/lib/session-config";
import { prisma } from "@/lib/prisma";

export async function GET(request: NextRequest) {
  const { searchParams, origin } = request.nextUrl;
  const code = searchParams.get("code");
  const state = searchParams.get("state");
  const oauthError = searchParams.get("error");

  const fail = (reason: string) =>
    NextResponse.redirect(new URL(`/sign-in?error=${reason}`, origin));

  if (oauthError) return fail(oauthError);
  if (!code || !state) return fail("missing_code");

  const pkceRaw = request.cookies.get(PKCE_COOKIE)?.value;
  if (!pkceRaw) return fail("missing_pkce");

  let pkce: { verifier: string; state: string; nonce: string; next?: string };
  try {
    pkce = JSON.parse(pkceRaw);
  } catch {
    return fail("bad_pkce");
  }
  if (pkce.state !== state) return fail("state_mismatch");

  try {
    const tokens = await exchangeCodeForTokens(code, pkce.verifier);
    const info = await fetchUserInfo(tokens.access_token);

    const profile = {
      username: info.preferred_username ?? info.sub,
      name: info.name ?? null,
      email: info.email ?? null,
      avatarUrl: info.picture ?? null,
    };
    const user = await prisma.user.upsert({
      where: { whopUserId: info.sub },
      create: { whopUserId: info.sub, ...profile },
      update: profile,
    });

    const res = NextResponse.redirect(new URL(safeNext(pkce.next) ?? "/", origin));
    await writeSessionCookie(res, {
      user: {
        id: user.id,
        whopUserId: user.whopUserId,
        username: user.username,
        name: user.name,
        email: user.email,
        avatarUrl: user.avatarUrl,
      },
      accessToken: tokens.access_token,
    });
    res.cookies.delete(PKCE_COOKIE);
    return res;
  } catch (err) {
    console.error("OAuth callback failed:", err);
    return fail("token_exchange_failed");
  }
}

Logout clears the session. Create src/app/api/auth/logout/route.ts:

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

function destroy(url: string) {
  const res = NextResponse.redirect(new URL("/", new URL(url).origin));
  res.cookies.delete(SESSION_COOKIE);
  return res;
}

export async function POST(request: Request) {
  return destroy(request.url);
}

export async function GET(request: Request) {
  return destroy(request.url);
}

The route guard and sign-in page

The proxy blocks users who are not logged in from accessing the studio and library before the page loads.

It only checks the session cookie; since the actual validation runs again on the server for each protected page, a fake cookie will not work. Create src/proxy.ts:

proxy.ts
import { type NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE, PROTECTED_PREFIXES } from "@/lib/session-config";

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;

  const isProtected = PROTECTED_PREFIXES.some((p) => pathname.startsWith(p));
  if (!isProtected) return NextResponse.next();

  if (request.cookies.has(SESSION_COOKIE)) return NextResponse.next();

  const signIn = new URL("/sign-in", request.url);
  signIn.searchParams.set("next", pathname);
  return NextResponse.redirect(signIn);
}

export const config = {
  matcher: [
    "/studio/:path*",
    "/feed/history/:path*",
    "/feed/subscriptions/:path*",
    "/feed/you/:path*",
  ],
};

The sign-in page is one button that points at the login route (for now). Create src/app/sign-in/page.tsx:

page.tsx
import Link from "next/link";
import { WavoraLogo } from "@/components/ui/wavora-logo";

const ERRORS: Record<string, string> = {
  token_exchange_failed: "We couldn't complete sign-in. Please try again.",
  state_mismatch: "Your sign-in session expired. Please try again.",
  missing_pkce: "Your sign-in session expired. Please try again.",
  missing_code: "Sign-in didn't complete. Please try again.",
  bad_pkce: "Your sign-in session expired. Please try again.",
  access_denied: "Sign-in was cancelled.",
};

export default async function SignInPage({
  searchParams,
}: {
  searchParams: Promise<{ error?: string; next?: string }>;
}) {
  const { error, next } = await searchParams;
  const loginHref =
    next && next.startsWith("/") && !next.startsWith("//")
      ? `/api/auth/login?next=${encodeURIComponent(next)}`
      : "/api/auth/login";

  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-8 bg-canvas px-4">
      <Link href="/" aria-label="Wavora home">
        <WavoraLogo className="scale-125" />
      </Link>

      <div className="w-full max-w-sm rounded-2xl border border-border bg-surface p-8 text-center">
        <h1 className="text-xl font-semibold">Sign in to Wavora</h1>
        <p className="mt-2 text-sm text-fg-muted">
          Watch, subscribe, comment, and support the creators you love.
        </p>

        {error ? (
          <p className="mt-4 rounded-lg bg-brand/10 px-3 py-2 text-sm text-brand">
            {ERRORS[error] ?? "Something went wrong. Please try again."}
          </p>
        ) : null}

        <a
          href={loginHref}
          className="mt-6 flex h-11 w-full items-center justify-center rounded-full bg-accent font-medium text-accent-fg transition hover:opacity-90"
        >
          Continue with Whop
        </a>

        <p className="mt-4 text-xs text-fg-muted">
          We use Whop to securely handle your account.
        </p>
      </div>
    </main>
  );
}

The design system and app shell

It's important to look like YouTube since we're creating a clone, so we'll do the interface now: light and dark color themes, a top bar, and a sidebar.

Since almost everything uses these, here are two little helpers to get you started.

Create src/lib/utils.ts:

utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Create src/lib/constants.ts:

constants.ts
import {
  Home,
  Clapperboard,
  MonitorPlay,
  History,
  ListVideo,
  Clock,
  ThumbsUp,
  Video,
  Flame,
  Music2,
  Gamepad2,
  Newspaper,
  Trophy,
  type LucideIcon,
} from "lucide-react";

export type NavItem = {
  label: string;
  href: string;
  icon: LucideIcon;
};

export const PRIMARY_NAV: NavItem[] = [
  { label: "Home", href: "/", icon: Home },
  { label: "Waves", href: "/waves", icon: Clapperboard },
  { label: "Subscriptions", href: "/feed/subscriptions", icon: MonitorPlay },
];

export const YOU_NAV: NavItem[] = [
  { label: "History", href: "/feed/history", icon: History },
  { label: "Playlists", href: "/feed/playlists", icon: ListVideo },
  { label: "Your videos", href: "/studio/videos", icon: Video },
  { label: "Watch later", href: "/playlist?list=WL", icon: Clock },
  { label: "Liked videos", href: "/playlist?list=LL", icon: ThumbsUp },
];

export const EXPLORE_NAV: NavItem[] = [
  { label: "Trending", href: "/explore/trending", icon: Flame },
  { label: "Music", href: "/explore/music", icon: Music2 },
  { label: "Gaming", href: "/explore/gaming", icon: Gamepad2 },
  { label: "News", href: "/explore/news", icon: Newspaper },
  { label: "Sports", href: "/explore/sports", icon: Trophy },
];

export const MINI_NAV: NavItem[] = [
  { label: "Home", href: "/", icon: Home },
  { label: "Waves", href: "/waves", icon: Clapperboard },
  { label: "Subscriptions", href: "/feed/subscriptions", icon: MonitorPlay },
  { label: "You", href: "/feed/you", icon: History },
];

The theme is a set of CSS variables that swap with a .dark class on the page. Open src/app/globals.css and replace its contents:

globals.css
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

:root {

  --canvas: #faf8f3;
  --surface: #ffffff;
  --hover: #f0ece3;
  --hover-strong: #e7e2d6;
  --fg: #16140f;
  --fg-muted: #5f5b52;
  --border: #e7e2d6;
  --accent: #065fd4;
  --accent-fg: #ffffff;
  --brand: #cc0000;
  --chip: #efebe2;
  --chip-active: #16140f;
  --chip-active-fg: #ffffff;

  --header-h: 56px;
  --sidebar-w: 240px;
  --sidebar-mini-w: 72px;
}

.dark {

  --canvas: #0f0f0f;
  --surface: #212121;
  --hover: #272727;
  --hover-strong: #3f3f3f;
  --fg: #f1f1f1;
  --fg-muted: #aaaaaa;
  --border: #303030;
  --accent: #3ea6ff;
  --accent-fg: #0f0f0f;
  --brand: #ff0000;
  --chip: #272727;
  --chip-active: #f1f1f1;
  --chip-active-fg: #0f0f0f;
}

@theme inline {
  --color-canvas: var(--canvas);
  --color-surface: var(--surface);
  --color-hover: var(--hover);
  --color-hover-strong: var(--hover-strong);
  --color-fg: var(--fg);
  --color-fg-muted: var(--fg-muted);
  --color-border: var(--border);
  --color-accent: var(--accent);
  --color-accent-fg: var(--accent-fg);
  --color-brand: var(--brand);
  --color-chip: var(--chip);
  --color-chip-active: var(--chip-active);
  --color-chip-active-fg: var(--chip-active-fg);

  --font-sans: var(--font-roboto);

  --spacing-header: var(--header-h);
  --spacing-sidebar: var(--sidebar-w);
  --spacing-sidebar-mini: var(--sidebar-mini-w);
}

@theme {
  --radius-lg: 0.75rem;
  --radius-xl: 1rem;
  --radius-2xl: 1.25rem;
}

body {
  background: var(--canvas);
  color: var(--fg);
  font-family: var(--font-roboto), Roboto, Arial, sans-serif;
  font-size: 14px;
  -webkit-font-smoothing: antialiased;
}

* {
  scrollbar-width: thin;
  scrollbar-color: var(--fg-muted) transparent;
}
*::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}
*::-webkit-scrollbar-thumb {
  background: color-mix(in srgb, var(--fg-muted) 60%, transparent);
  border-radius: 9999px;
}

::selection {
  background: var(--accent);
  color: #fff;
}

@keyframes dot-bounce {
  0%,
  80%,
  100% {
    transform: translateY(0);
    opacity: 0.45;
  }
  40% {
    transform: translateY(-5px);
    opacity: 1;
  }
}

The root system installs the Roboto font and sets the saved theme before anything is rendered. This prevents screen flickering and wraps the app in our theme provider. Open src/app/layout.tsx and replace its contents:

layout.tsx
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";

const roboto = Roboto({
  variable: "--font-roboto",
  subsets: ["latin"],
  weight: ["400", "500", "700"],
  display: "swap",
});

export const metadata: Metadata = {
  title: "Wavora",
  description: "Watch, share, and support the creators you love on Wavora.",
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html
      lang="en"
      className={`${roboto.variable} h-full`}
      suppressHydrationWarning
    >
      <body className="min-h-full bg-canvas text-fg antialiased">
        {}
        <script
          dangerouslySetInnerHTML={{
            __html:
              "try{var t=localStorage.getItem('theme')||'dark';document.documentElement.classList.toggle('dark',t==='dark')}catch(e){document.documentElement.classList.add('dark')}",
          }}
        />
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

The provider remembers whether "light" or "dark" was selected and changes the class. Create src/components/theme-provider.tsx:

theme-provider.tsx
"use client";

import {
  createContext,
  useContext,
  useEffect,
  useState,
  type ReactNode,
} from "react";

type Theme = "dark" | "light";

type ThemeContextValue = {
  theme: Theme;
  setTheme: (t: Theme) => void;
  toggle: () => void;
};

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setThemeState] = useState<Theme>("dark");

  useEffect(() => {
    setThemeState(
      document.documentElement.classList.contains("dark") ? "dark" : "light",
    );
  }, []);

  const setTheme = (t: Theme) => {
    setThemeState(t);
    document.documentElement.classList.toggle("dark", t === "dark");
    try {
      localStorage.setItem("theme", t);
    } catch {}
  };

  return (
    <ThemeContext.Provider
      value={{
        theme,
        setTheme,
        toggle: () => setTheme(theme === "dark" ? "light" : "dark"),
      }}
    >
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
  return ctx;
}

Create src/components/theme-toggle.tsx:

theme-toggle.tsx
"use client";

import { Sun, Moon } from "lucide-react";
import { useTheme } from "@/components/theme-provider";
import { cn } from "@/lib/utils";

export function ThemeToggle({ className }: { className?: string }) {
  const { theme, toggle } = useTheme();
  const isDark = theme === "dark";

  return (
    <button
      type="button"
      aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
      onClick={toggle}
      className={cn(
        "grid h-10 w-10 place-items-center rounded-full hover:bg-hover",
        className,
      )}
    >
      {isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
    </button>
  );
}

The logo is our own, never YouTube's. Create src/components/ui/wavora-logo.tsx:

wavora-logo.tsx
import { cn } from "@/lib/utils";

export function WavoraMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 48 48" aria-hidden className={className}>
      <path
        d="M15 10 L15 32 L34 21 Z"
        fill="currentColor"
        stroke="currentColor"
        strokeWidth={4}
        strokeLinejoin="round"
      />
      <path
        d="M12 36 q6 -9 12 0 t12 0"
        fill="none"
        stroke="currentColor"
        strokeWidth={4.5}
        strokeLinecap="round"
      />
    </svg>
  );
}

export function WavoraLogo({ className }: { className?: string }) {
  return (
    <span className={cn("flex select-none items-center gap-1.5", className)}>
      <WavoraMark className="h-6 w-6 text-brand" />
      <span className="text-[1.3rem] font-bold leading-none tracking-[-0.04em] text-fg">
        Wavora
      </span>
    </span>
  );
}

The top bar and sidebar share the sidebar's pinned state via React context. Create src/components/layout/sidebar-context.tsx:

sidebar-context.tsx
"use client";

import { createContext, useContext, useState, type ReactNode } from "react";

type SidebarState = {
  pinned: boolean;
  toggle: () => void;
  mobileOpen: boolean;
  setMobileOpen: (open: boolean) => void;
};

const SidebarContext = createContext<SidebarState | null>(null);

export function SidebarProvider({ children }: { children: ReactNode }) {
  const [pinned, setPinned] = useState(false);
  const [mobileOpen, setMobileOpen] = useState(false);

  return (
    <SidebarContext.Provider
      value={{
        pinned,
        toggle: () => setPinned((c) => !c),
        mobileOpen,
        setMobileOpen,
      }}
    >
      {children}
    </SidebarContext.Provider>
  );
}

export function useSidebar() {
  const ctx = useContext(SidebarContext);
  if (!ctx) throw new Error("useSidebar must be used within a SidebarProvider");
  return ctx;
}

Create src/components/layout/mobile-nav.tsx:

mobile-nav.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { MINI_NAV } from "@/lib/constants";
import { cn } from "@/lib/utils";

export function MobileBottomNav() {
  const pathname = usePathname();

  return (
    <nav className="fixed inset-x-0 bottom-0 z-50 flex h-12 items-stretch border-t border-border bg-canvas lg:hidden">
      {MINI_NAV.map((item) => {
        const Icon = item.icon;
        const active =
          item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
        return (
          <Link
            key={item.label}
            href={item.href}
            className={cn(
              "flex flex-1 flex-col items-center justify-center gap-1 text-[10px]",
              active ? "text-fg" : "text-fg-muted",
            )}
          >
            <Icon className="h-5 w-5" strokeWidth={1.8} />
            {item.label}
          </Link>
        );
      })}
    </nav>
  );
}

The sidebar receives the channels the user is subscribed to as a prop. For now, we're passing an empty list. We'll populate it in Part 3 when the subscriptions are ready. Create src/components/layout/guide-sidebar.tsx:

guide-sidebar.tsx
"use client";

import { useState } from "react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import {
  PRIMARY_NAV,
  YOU_NAV,
  EXPLORE_NAV,
  MINI_NAV,
  type NavItem,
} from "@/lib/constants";
import { Menu, User } from "lucide-react";
import { WavoraLogo } from "@/components/ui/wavora-logo";
import { useSidebar } from "./sidebar-context";
import { cn } from "@/lib/utils";

export type GuideChannel = {
  handle: string;
  name: string;
  avatarUrl: string | null;
};

function isActive(pathname: string, search: URLSearchParams, href: string) {
  const [base, query] = href.split("?");
  const baseMatch = base === "/" ? pathname === "/" : pathname.startsWith(base);
  if (!baseMatch) return false;
  if (!query) return true;
  return [...new URLSearchParams(query)].every(([k, v]) => search.get(k) === v);
}

function GuideLink({
  item,
  active,
  onNavigate,
}: {
  item: NavItem;
  active: boolean;
  onNavigate?: () => void;
}) {
  const Icon = item.icon;
  return (
    <Link
      href={item.href}
      onClick={onNavigate}
      className={cn(
        "flex h-10 items-center gap-6 rounded-lg px-3 hover:bg-hover",
        active && "bg-hover font-medium",
      )}
    >
      <Icon className="h-6 w-6 shrink-0" strokeWidth={active ? 2 : 1.6} />
      <span className="truncate text-sm">{item.label}</span>
    </Link>
  );
}

function Section({
  title,
  items,
  pathname,
  search,
  onNavigate,
}: {
  title?: string;
  items: NavItem[];
  pathname: string;
  search: URLSearchParams;
  onNavigate?: () => void;
}) {
  return (
    <div className="border-b border-border py-3">
      {title && <h3 className="mb-1 px-3 text-base font-medium">{title}</h3>}
      <nav className="flex flex-col gap-0.5">
        {items.map((i) => (
          <GuideLink
            key={i.label}
            item={i}
            active={isActive(pathname, search, i.href)}
            onNavigate={onNavigate}
          />
        ))}
      </nav>
    </div>
  );
}

function MiniRail({
  pathname,
  search,
}: {
  pathname: string;
  search: URLSearchParams;
}) {
  return (
    <nav className="flex flex-col items-center gap-1 py-1">
      {MINI_NAV.map((i) => {
        const Icon = i.icon;
        const active = isActive(pathname, search, i.href);
        return (
          <Link
            key={i.label}
            href={i.href}
            className={cn(
              "flex w-16 flex-col items-center gap-1 rounded-lg py-4 hover:bg-hover",
              active && "bg-hover",
            )}
          >
            <Icon className="h-6 w-6" strokeWidth={1.6} />
            <span className="text-[10px] leading-none">{i.label}</span>
          </Link>
        );
      })}
    </nav>
  );
}

function SubscriptionsSection({
  channels,
  onNavigate,
}: {
  channels: GuideChannel[];
  onNavigate?: () => void;
}) {
  if (channels.length === 0) return null;
  return (
    <div className="border-b border-border py-3">
      <h3 className="mb-1 px-3 text-base font-medium">Subscriptions</h3>
      <nav className="flex flex-col gap-0.5">
        {channels.map((c) => (
          <Link
            key={c.handle}
            href={`/@${c.handle}`}
            onClick={onNavigate}
            className="flex h-10 items-center gap-3 rounded-lg px-3 hover:bg-hover"
          >
            <span className="grid h-6 w-6 shrink-0 place-items-center overflow-hidden rounded-full bg-hover">
              {c.avatarUrl ? (
                // eslint-disable-next-line @next/next/no-img-element
                <img
                  src={c.avatarUrl}
                  alt=""
                  className="h-full w-full object-cover"
                />
              ) : (
                <User className="h-4 w-4 text-fg-muted" />
              )}
            </span>
            <span className="truncate text-sm">{c.name}</span>
          </Link>
        ))}
      </nav>
    </div>
  );
}

export function GuideSidebar({
  subscriptions,
}: {
  subscriptions: GuideChannel[];
}) {
  const { pinned } = useSidebar();
  const [hovered, setHovered] = useState(false);
  const pathname = usePathname();
  const search = useSearchParams();
  const expanded = pinned || hovered;

  return (
    <aside
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      className={cn(
        "fixed left-0 top-14 z-40 hidden h-[calc(100vh-3.5rem)] bg-canvas transition-[width] duration-150 lg:block",
        expanded ? "w-60 overflow-y-auto px-3 pb-6" : "w-[72px] overflow-hidden",
        hovered && !pinned && "border-r border-border shadow-xl",
      )}
    >
      {expanded ? (
        <>
          <Section items={PRIMARY_NAV} pathname={pathname} search={search} />
          <Section title="You" items={YOU_NAV} pathname={pathname} search={search} />
          <SubscriptionsSection channels={subscriptions} />
          <Section title="Explore" items={EXPLORE_NAV} pathname={pathname} search={search} />
          <p className="px-3 pt-4 text-xs text-fg-muted">© 2026 Wavora</p>
        </>
      ) : (
        <MiniRail pathname={pathname} search={search} />
      )}
    </aside>
  );
}

export function MobileDrawer({
  subscriptions,
}: {
  subscriptions: GuideChannel[];
}) {
  const { mobileOpen, setMobileOpen } = useSidebar();
  const pathname = usePathname();
  const search = useSearchParams();
  const close = () => setMobileOpen(false);

  if (!mobileOpen) return null;

  return (
    <div className="fixed inset-0 z-[60] lg:hidden">
      <div className="absolute inset-0 bg-black/50" onClick={close} />
      <aside className="absolute left-0 top-0 h-full w-60 max-w-[80vw] overflow-y-auto bg-canvas px-3 pb-6 shadow-xl">
        <div className="mb-1 flex h-14 items-center gap-3 px-1">
          <button
            type="button"
            aria-label="Close menu"
            onClick={close}
            className="grid h-10 w-10 place-items-center rounded-full hover:bg-hover"
          >
            <Menu className="h-6 w-6" />
          </button>
          <Link href="/" onClick={close} aria-label="Wavora home">
            <WavoraLogo />
          </Link>
        </div>
        <Section items={PRIMARY_NAV} pathname={pathname} search={search} onNavigate={close} />
        <Section
          title="You"
          items={YOU_NAV}
          pathname={pathname}
          search={search}
          onNavigate={close}
        />
        <SubscriptionsSection channels={subscriptions} onNavigate={close} />
        <Section
          title="Explore"
          items={EXPLORE_NAV}
          pathname={pathname}
          search={search}
          onNavigate={close}
        />
      </aside>
    </div>
  );
}

The top bar expects a notification bell; we'll create this in Part 4. For now, it doesn't display anything. Create src/components/layout/notification-bell.tsx:

notification-bell.tsx
"use client";

export function NotificationBell() {
  return null;
}

Now, the top bar: menu button, logo, search box, and sign-in or avatar on the right. Create src/components/layout/top-bar.tsx:

top-bar.tsx
"use client";

import { useState } from "react";
import Link from "next/link";
import { ArrowLeft, Menu, Search, Video, User } from "lucide-react";
import type { SessionUser } from "@/lib/session";
import { WavoraLogo } from "@/components/ui/wavora-logo";
import { ThemeToggle } from "@/components/theme-toggle";
import { NotificationBell } from "./notification-bell";
import { useSidebar } from "./sidebar-context";

export function TopBar({ user }: { user: SessionUser | null }) {
  const { toggle, setMobileOpen } = useSidebar();
  const [showSearch, setShowSearch] = useState(false);

  return (
    <header className="fixed inset-x-0 top-0 z-50 flex h-14 items-center gap-2 bg-canvas px-4">
      {showSearch ? (
        <form
          role="search"
          action="/results"
          className="flex h-10 w-full items-center gap-2"
        >
          <button
            type="button"
            aria-label="Close search"
            onClick={() => setShowSearch(false)}
            className="grid h-10 w-10 shrink-0 place-items-center rounded-full hover:bg-hover"
          >
            <ArrowLeft className="h-5 w-5" />
          </button>
          <div className="flex h-10 flex-1 items-center gap-2 rounded-full border border-border bg-surface px-4 focus-within:border-accent">
            <Search className="h-4 w-4 shrink-0 text-fg-muted" />
            <input
              type="text"
              name="search_query"
              placeholder="Search Wavora"
              autoFocus
              autoComplete="off"
              className="h-full w-full bg-transparent outline-none placeholder:text-fg-muted"
            />
          </div>
        </form>
      ) : (
        <>
          {}
          <div className="flex shrink-0 items-center gap-1">
            <button
              type="button"
              aria-label="Menu"
              onClick={() => {
                if (window.matchMedia("(min-width: 1024px)").matches) toggle();
                else setMobileOpen(true);
              }}
              className="grid h-10 w-10 place-items-center rounded-full hover:bg-hover"
            >
              <Menu className="h-6 w-6" />
            </button>
            <Link href="/" aria-label="Wavora home" className="ml-1">
              <WavoraLogo />
            </Link>
          </div>

          {}
          <form
            role="search"
            action="/results"
            className="ml-4 hidden h-10 w-full max-w-[440px] items-center gap-2 rounded-full border border-border bg-surface px-4 transition-[max-width,box-shadow] duration-200 focus-within:max-w-[520px] focus-within:border-accent focus-within:shadow-sm sm:flex"
          >
            <Search className="h-4 w-4 shrink-0 text-fg-muted" />
            <input
              type="text"
              name="search_query"
              placeholder="Search Wavora"
              autoComplete="off"
              className="h-full w-full bg-transparent text-sm outline-none placeholder:text-fg-muted"
            />
          </form>

          {}
          <div className="ml-auto flex shrink-0 items-center gap-0.5">
            <button
              type="button"
              aria-label="Search"
              onClick={() => setShowSearch(true)}
              className="grid h-10 w-10 place-items-center rounded-full hover:bg-hover sm:hidden"
            >
              <Search className="h-5 w-5" />
            </button>
            <Link
              href="/studio/upload"
              className="mr-1 hidden items-center gap-2 rounded-full bg-hover px-3.5 py-2 text-sm font-medium hover:bg-hover-strong sm:flex"
            >
              <Video className="h-5 w-5" />
              Create
            </Link>
            {user ? <NotificationBell /> : null}
            <ThemeToggle />

            {user ? (
              <Link
                href="/studio/videos"
                aria-label={`Account: ${user.username}`}
                className="ml-1 grid h-8 w-8 place-items-center overflow-hidden rounded-full bg-hover"
              >
                {user.avatarUrl ? (
                  // eslint-disable-next-line @next/next/no-img-element
                  <img
                    src={user.avatarUrl}
                    alt=""
                    className="h-8 w-8 object-cover"
                  />
                ) : (
                  <User className="h-5 w-5 text-fg-muted" />
                )}
              </Link>
            ) : (
              <Link
                href="/sign-in"
                className="ml-2 flex items-center gap-2 rounded-full border border-border px-3 py-1.5 text-sm font-medium text-accent hover:bg-accent/10"
              >
                <User className="h-5 w-5" />
                Sign in
              </Link>
            )}
          </div>
        </>
      )}
    </header>
  );
}

The shell places the top bar, sidebar, and mobile navigation around the page. Create src/components/layout/app-shell.tsx:

app-shell.tsx
"use client";

import type { ReactNode } from "react";
import type { SessionUser } from "@/lib/session";
import { SidebarProvider, useSidebar } from "./sidebar-context";
import { TopBar } from "./top-bar";
import { GuideSidebar, MobileDrawer, type GuideChannel } from "./guide-sidebar";
import { MobileBottomNav } from "./mobile-nav";
import { cn } from "@/lib/utils";

function Main({ children }: { children: ReactNode }) {
  const { pinned } = useSidebar();
  return (
    <main
      className={cn(
        "min-h-[calc(100vh-3.5rem)] pt-14 pb-16 transition-[margin] duration-150 lg:pb-0",
        pinned ? "lg:ml-60" : "lg:ml-[72px]",
      )}
    >
      <div className="px-4 py-4 sm:px-6">{children}</div>
    </main>
  );
}

export function AppShell({
  children,
  user,
  subscriptions,
}: {
  children: ReactNode;
  user: SessionUser | null;
  subscriptions: GuideChannel[];
}) {
  return (
    <SidebarProvider>
      <TopBar user={user} />
      <GuideSidebar subscriptions={subscriptions} />
      <MobileDrawer subscriptions={subscriptions} />
      <Main>{children}</Main>
      <MobileBottomNav />
    </SidebarProvider>
  );
}

Finally, the layout of the home pages loads the current user and renders the shell. Part 3 replaces this with a version that also loads subscriptions. Create src/app/(main)/layout.tsx:

layout.tsx
import type { ReactNode } from "react";
import { AppShell } from "@/components/layout/app-shell";
import { getCurrentUser } from "@/lib/session";

export default async function MainLayout({ children }: { children: ReactNode }) {
  const user = await getCurrentUser();

  return (
    <AppShell user={user} subscriptions={[]}>
      {children}
    </AppShell>
  );
}

One last thing: the starter project put a demo home page at src/app/page.tsx. Delete it, then create src/app/(main)/page.tsx so the home route renders inside our shell. We replace this with the real video feed in Part 2:

page.tsx
export default function HomePage() {
  return (
    <div className="py-24 text-center text-sm text-fg-muted">
      Your feed will appear here once channels start uploading.
    </div>
  );
}

Checkpoint

Before moving on, confirm:

  • npm run dev starts with no type errors and the home page shows the top bar and sidebar.
  • The theme toggle switches the whole app between dark and light, and the choice survives a refresh.
  • vercel deploy --prod succeeds and the production URL serves the same shell.
  • https://<your-app>.vercel.app/api/auth/callback is registered as a redirect URI in your Whop app's settings.
  • Clicking sign in and continuing with Whop returns you to the app with your avatar in the top bar.
  • Visiting /studio/videos while signed out sends you to /sign-in.
  • npx prisma studio shows your user row, created on first sign-in.

Part 2: Channels and video

In this part, we create the systems that lets users create a channel, upload videos in their browser, video-watching, the home feed, search, and channel pages. Of course, creators get a basic studio to manage what they've posted.

Shared validators and formatters

There are two files we're going to use everywhere in our project, the validator. The first validates form and API input with Zod; the second formats numbers and dates the way YouTube does, like 1.2M views or 3 days ago.

Create src/lib/validators.ts:

validators.ts
import { z } from "zod";

export const VISIBILITIES = ["PUBLIC", "UNLISTED", "PRIVATE"] as const;
export const CATEGORIES = [
  "MUSIC",
  "GAMING",
  "NEWS",
  "SPORTS",
  "COMEDY",
  "EDUCATION",
  "ENTERTAINMENT",
  "TECH",
  "PODCASTS",
  "COOKING",
  "OTHER",
] as const;

export const handleSchema = z
  .string()
  .trim()
  .transform((v) => v.replace(/^@/, "").toLowerCase())
  .pipe(
    z
      .string()
      .min(3, "Handle must be at least 3 characters")
      .max(30, "Handle must be 30 characters or fewer")
      .regex(
        /^[a-z0-9_.-]+$/,
        "Use only letters, numbers, and . _ -",
      ),
  );

export const createChannelSchema = z.object({
  name: z.string().trim().min(1, "Add a channel name").max(50),
  handle: handleSchema,
});

export const updateChannelSchema = z.object({
  name: z.string().trim().min(1, "Add a channel name").max(50),
  description: z.string().trim().max(1000).optional().or(z.literal("")),
  handle: handleSchema,
  avatarUrl: z.url().optional().or(z.literal("")),
  bannerUrl: z.url().optional().or(z.literal("")),
});

export const videoMetaSchema = z.object({
  title: z.string().trim().min(1, "Add a title").max(100),
  description: z.string().trim().max(5000).optional().or(z.literal("")),
  visibility: z.enum(VISIBILITIES).default("PUBLIC"),
  category: z.enum(CATEGORIES).default("OTHER"),
  membersOnly: z.boolean().default(false),
  isShort: z.boolean().default(false),
});

const blobUrl = z
  .url()
  .refine(
    (u) => u.includes(".blob.vercel-storage.com"),
    "Expected a Vercel Blob URL",
  );

export const createVideoSchema = videoMetaSchema.extend({
  videoUrl: blobUrl,
  videoPathname: z.string().min(1),
  thumbnailUrl: blobUrl.optional(),
  durationSeconds: z.number().min(0).max(60 * 60 * 12),
});

export const updateVideoSchema = videoMetaSchema.extend({
  id: z.string().min(1),
});

export const commentBodySchema = z
  .string()
  .trim()
  .min(1, "Write something")
  .max(10000);

export type CreateChannelInput = z.infer<typeof createChannelSchema>;
export type UpdateChannelInput = z.infer<typeof updateChannelSchema>;
export type CreateVideoInput = z.infer<typeof createVideoSchema>;
export type UpdateVideoInput = z.infer<typeof updateVideoSchema>;

Create src/lib/format.ts:

format.ts
const compact = new Intl.NumberFormat("en", {
  notation: "compact",
  maximumFractionDigits: 1,
});

export function formatCompact(n: number): string {
  if (n < 1000) return String(n);
  return compact.format(n);
}

export function formatViews(n: number): string {
  if (n === 0) return "No views";
  if (n === 1) return "1 view";
  return `${formatCompact(n)} views`;
}

export function formatSubscribers(n: number): string {
  if (n === 0) return "No subscribers";
  if (n === 1) return "1 subscriber";
  return `${formatCompact(n)} subscribers`;
}

const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });

const DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
  { amount: 60, unit: "seconds" },
  { amount: 60, unit: "minutes" },
  { amount: 24, unit: "hours" },
  { amount: 7, unit: "days" },
  { amount: 4.34524, unit: "weeks" },
  { amount: 12, unit: "months" },
  { amount: Number.POSITIVE_INFINITY, unit: "years" },
];

export function formatTimeAgo(date: Date | string): string {
  const value = typeof date === "string" ? new Date(date) : date;
  let duration = (value.getTime() - Date.now()) / 1000;
  for (const division of DIVISIONS) {
    if (Math.abs(duration) < division.amount) {
      return relative.format(Math.round(duration), division.unit);
    }
    duration /= division.amount;
  }
  return relative.format(Math.round(duration), "years");
}

export function formatDuration(totalSeconds: number): string {
  const s = Math.max(0, Math.floor(totalSeconds));
  const hours = Math.floor(s / 3600);
  const minutes = Math.floor((s % 3600) / 60);
  const seconds = s % 60;
  const mm = hours > 0 ? String(minutes).padStart(2, "0") : String(minutes);
  const ss = String(seconds).padStart(2, "0");
  return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
}

Become a channel

To publish a video, the user must first create a channel with a unique @handle. This handle becomes the channel's URL, such as /@nova. There is one routing detail to note: Next.js reads a folder named @[handle] as a parallel-route slot, so the segment is defined only as [handle], and the @ symbol is preserved at runtime.

The server action validates the name and handle and creates the channel. Create src/app/(main)/create-channel/actions.ts:

actions.ts
"use server";

import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { createChannelSchema } from "@/lib/validators";

export type CreateChannelState = { error?: string };

export async function createChannel(
  _prev: CreateChannelState,
  formData: FormData,
): Promise<CreateChannelState> {
  const user = await getCurrentUser();
  if (!user) return { error: "Please sign in first." };

  const parsed = createChannelSchema.safeParse({
    name: formData.get("name"),
    handle: formData.get("handle"),
  });
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Invalid input." };
  }

  const existing = await prisma.channel.findUnique({
    where: { userId: user.id },
  });
  if (existing) redirect("/studio/videos");

  const taken = await prisma.channel.findUnique({
    where: { handle: parsed.data.handle },
  });
  if (taken) return { error: "That handle is already taken." };

  try {
    await prisma.channel.create({
      data: {
        userId: user.id,
        handle: parsed.data.handle,
        name: parsed.data.name,
        avatarUrl: user.avatarUrl,
      },
    });
  } catch (e) {
    if (
      e &&
      typeof e === "object" &&
      "code" in e &&
      (e as { code?: string }).code === "P2002"
    ) {
      return { error: "That handle is already taken." };
    }
    throw e;
  }

  redirect("/studio/videos");
}

Create src/app/(main)/create-channel/page.tsx:

page.tsx
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { CreateChannelForm } from "./create-channel-form";

export const metadata = { title: "Create channel - Wavora" };

export default async function CreateChannelPage() {
  const user = await getCurrentUser();
  if (!user) redirect("/sign-in");

  const channel = await prisma.channel.findUnique({
    where: { userId: user.id },
  });
  if (channel) redirect("/studio/videos");

  return (
    <CreateChannelForm
      defaultName={user.name ?? user.username}
      defaultHandle={user.username}
      avatarUrl={user.avatarUrl}
    />
  );
}

The form uses debounce to validate the handle as the user types. This way, the user knows the handle has been accepted before submitting the form. Create src/app/(main)/create-channel/create-channel-form.tsx:

create-channel-form.tsx
"use client";

import { useActionState, useEffect, useState } from "react";
import { User, Check, X } from "lucide-react";
import { createChannel, type CreateChannelState } from "./actions";

type CheckStatus = "idle" | "checking" | "available" | "taken" | "invalid";

function LoadingDots() {
  return (
    <span
      className="flex items-center gap-1"
      role="status"
      aria-label="Checking availability"
    >
      {[0, 1, 2].map((i) => (
        <span
          key={i}
          className="block h-1.5 w-1.5 rounded-full bg-fg-muted"
          style={{
            animation: "dot-bounce 1.2s ease-in-out infinite",
            animationDelay: `${(i - 2) * 0.16}s`,
          }}
        />
      ))}
    </span>
  );
}

export function CreateChannelForm({
  defaultName,
  defaultHandle,
  avatarUrl,
}: {
  defaultName: string;
  defaultHandle: string;
  avatarUrl: string | null;
}) {
  const [state, formAction, pending] = useActionState<
    CreateChannelState,
    FormData
  >(createChannel, {});
  const [handle, setHandle] = useState(defaultHandle);
  const [check, setCheck] = useState<{ status: CheckStatus; message?: string }>({
    status: "idle",
  });

  useEffect(() => {
    const h = handle.trim();
    if (h.length === 0) {
      setCheck({ status: "idle" });
      return;
    }
    if (h.length < 3) {
      setCheck({
        status: "invalid",
        message: "Handle must be at least 3 characters.",
      });
      return;
    }

    setCheck({ status: "checking" });
    const controller = new AbortController();
    const timer = setTimeout(async () => {
      try {
        const res = await fetch(
          `/api/handle-check?handle=${encodeURIComponent(h)}`,
          { signal: controller.signal },
        );
        if (!res.ok) {
          setCheck({ status: "idle" });
          return;
        }
        const data = (await res.json()) as {
          available: boolean;
          reason?: string;
          message?: string;
        };
        if (data.available) {
          setCheck({ status: "available" });
        } else if (data.reason === "invalid") {
          setCheck({ status: "invalid", message: data.message ?? "Invalid handle." });
        } else {
          setCheck({ status: "taken", message: "That handle is already taken." });
        }
      } catch (err) {
        if ((err as Error).name !== "AbortError") setCheck({ status: "idle" });
      }
    }, 450);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [handle]);

  const isError = check.status === "taken" || check.status === "invalid";
  const canSubmit = check.status === "available" && !pending;

  return (
    <div className="mx-auto max-w-md py-10">
      <h1 className="text-center text-2xl font-bold">How you'll appear</h1>

      <form action={formAction} className="mt-8 flex flex-col items-center gap-6">
        <div className="grid h-28 w-28 place-items-center overflow-hidden rounded-full bg-hover">
          {avatarUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img src={avatarUrl} alt="" className="h-full w-full object-cover" />
          ) : (
            <User className="h-12 w-12 text-fg-muted" />
          )}
        </div>

        <label className="w-full">
          <span className="mb-1 block text-xs text-fg-muted">Name</span>
          <input
            name="name"
            defaultValue={defaultName}
            required
            maxLength={50}
            className="w-full rounded-lg border border-border bg-transparent px-3 py-2.5 outline-none focus:border-accent"
          />
        </label>

        <label className="w-full">
          <span className="mb-1 block text-xs text-fg-muted">Handle</span>
          <div
            className={`flex items-center rounded-lg border ${
              isError
                ? "border-red-500"
                : check.status === "available"
                  ? "border-green-500"
                  : "border-border focus-within:border-accent"
            }`}
          >
            <span className="pl-3 text-fg-muted">@</span>
            <input
              name="handle"
              value={handle}
              onChange={(e) => setHandle(e.target.value)}
              required
              maxLength={30}
              autoComplete="off"
              autoCapitalize="none"
              spellCheck={false}
              aria-invalid={isError}
              className="min-w-0 flex-1 bg-transparent px-1 py-2.5 outline-none"
            />
            <span className="grid w-9 place-items-center">
              {check.status === "checking" ? <LoadingDots /> : null}
              {check.status === "available" ? (
                <Check className="h-5 w-5 text-green-500" />
              ) : null}
              {isError ? <X className="h-5 w-5 text-red-500" /> : null}
            </span>
          </div>
          {isError && check.message ? (
            <span className="mt-1 block text-xs text-red-500">{check.message}</span>
          ) : check.status === "available" ? (
            <span className="mt-1 block text-xs text-green-500">
              Handle is available.
            </span>
          ) : (
            <span className="mt-1 block text-xs text-fg-muted">
              Letters, numbers, and . _ - - at least 3 characters.
            </span>
          )}
        </label>

        {state.error ? (
          <p className="w-full text-sm text-red-500">{state.error}</p>
        ) : null}

        <p className="text-center text-xs text-fg-muted">
          By creating a channel, you agree to Wavora's Terms. Your name and
          handle can be changed later.
        </p>

        <button
          type="submit"
          disabled={!canSubmit}
          className="w-full rounded-full bg-accent py-2.5 font-medium text-accent-fg hover:opacity-90 disabled:opacity-60"
        >
          {pending ? "Creating…" : "Create channel"}
        </button>
      </form>
    </div>
  );
}

This validation sends a request to a small route that checks the handle. We'll add rate limiting to this in Part 5. Create src/app/api/handle-check/route.ts:

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

export async function GET(request: Request): Promise<NextResponse> {
  const user = await getCurrentUser();
  if (!user) {
    return NextResponse.json({ available: false, reason: "auth" }, { status: 401 });
  }

  const raw = new URL(request.url).searchParams.get("handle") ?? "";
  const parsed = handleSchema.safeParse(raw);
  if (!parsed.success) {
    return NextResponse.json({
      available: false,
      reason: "invalid",
      message: parsed.error.issues[0]?.message ?? "Invalid handle.",
    });
  }

  const taken = await prisma.channel.findUnique({
    where: { handle: parsed.data },
    select: { id: true },
  });

  return NextResponse.json({
    available: !taken,
    reason: taken ? "taken" : "ok",
    handle: parsed.data,
  });
}

Upload a video

Since video files are often large, the browser uploads them directly to Vercel Blob rather than through our server. Our server only generates a short-lived upload token. The browser also reads the video's duration and retrieves a poster frame before uploading. We store these along with the video.

The token route authorizes the upload and signs the token. Create src/app/api/blob/upload/route.ts:

route.ts
import { handleUpload, type HandleUploadBody } from "@vercel/blob/client";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";

const MAX_BYTES = 512 * 1024 * 1024;
const ALLOWED_CONTENT_TYPES = [
  "video/mp4",
  "video/webm",
  "video/quicktime",
  "image/jpeg",
  "image/png",
  "image/webp",
];

export async function POST(request: Request): Promise<NextResponse> {
  try {
    const body = (await request.json()) as HandleUploadBody;
    const json = await handleUpload({
      body,
      request,
      onBeforeGenerateToken: async () => {
        const user = await getCurrentUser();
        if (!user) throw new Error("You must be signed in to upload.");
        const channel = await prisma.channel.findUnique({
          where: { userId: user.id },
          select: { id: true },
        });
        if (!channel) throw new Error("Create a channel before uploading.");

        return {
          allowedContentTypes: ALLOWED_CONTENT_TYPES,
          maximumSizeInBytes: MAX_BYTES,
          addRandomSuffix: true,
          tokenPayload: JSON.stringify({ channelId: channel.id }),
        };
      },
      onUploadCompleted: async () => {},
    });

    return NextResponse.json(json);
  } catch (err) {
    return NextResponse.json(
      { error: (err as Error).message },
      { status: 400 },
    );
  }
}

Once the file is in Blob, this action saves the video row. Create src/app/studio/actions.ts:

actions.ts
"use server";

import { del } from "@vercel/blob";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import {
  createVideoSchema,
  updateVideoSchema,
  type CreateVideoInput,
} from "@/lib/validators";

export type CreateVideoResult = { id?: string; error?: string };
export type UpdateVideoResult = { ok?: boolean; error?: string };

async function ownedVideo(videoId: string) {
  const user = await getCurrentUser();
  if (!user) return null;
  const video = await prisma.video.findUnique({
    where: { id: videoId },
    select: {
      id: true,
      videoUrl: true,
      thumbnailUrl: true,
      publishedAt: true,
      channel: { select: { userId: true, membershipsEnabled: true } },
    },
  });
  if (!video || video.channel.userId !== user.id) return null;
  return video;
}

export async function createVideo(
  input: CreateVideoInput,
): Promise<CreateVideoResult> {
  const user = await getCurrentUser();
  if (!user) return { error: "Please sign in." };

  const channel = await prisma.channel.findUnique({
    where: { userId: user.id },
    select: { id: true, name: true, membershipsEnabled: true },
  });
  if (!channel) return { error: "Create a channel first." };

  const parsed = createVideoSchema.safeParse(input);
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Invalid video data." };
  }
  const d = parsed.data;

  const video = await prisma.video.create({
    data: {
      channelId: channel.id,
      title: d.title,
      description: d.description ? d.description : null,
      visibility: d.visibility,
      category: d.category,
      status: "READY",
      videoUrl: d.videoUrl,
      videoPathname: d.videoPathname,
      thumbnailUrl: d.thumbnailUrl ?? null,
      durationSeconds: Math.round(d.durationSeconds),
      isShort: d.isShort,
      publishedAt: d.visibility === "PRIVATE" ? null : new Date(),
    },
    select: { id: true },
  });

  revalidatePath("/");
  revalidatePath("/studio/videos");
  return { id: video.id };
}

export async function updateVideo(
  _prev: UpdateVideoResult,
  formData: FormData,
): Promise<UpdateVideoResult> {
  const parsed = updateVideoSchema.safeParse({
    id: formData.get("id"),
    title: formData.get("title"),
    description: formData.get("description"),
    visibility: formData.get("visibility"),
    category: formData.get("category"),
    membersOnly: formData.get("membersOnly") === "on",
    isShort: formData.get("isShort") === "on",
  });
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Invalid input." };
  }

  const owned = await ownedVideo(parsed.data.id);
  if (!owned) return { error: "Video not found." };

  await prisma.video.update({
    where: { id: parsed.data.id },
    data: {
      title: parsed.data.title,
      description: parsed.data.description ? parsed.data.description : null,
      visibility: parsed.data.visibility,
      category: parsed.data.category,
      isShort: parsed.data.isShort,
      publishedAt:
        parsed.data.visibility === "PRIVATE"
          ? null
          : owned.publishedAt
            ? undefined
            : new Date(),
    },
  });

  revalidatePath("/");
  revalidatePath("/studio/videos");
  revalidatePath("/watch");
  return { ok: true };
}

export async function deleteVideo(formData: FormData): Promise<void> {
  const id = String(formData.get("id") ?? "");
  const owned = await ownedVideo(id);
  if (!owned) return;

  const blobUrls = [owned.videoUrl, owned.thumbnailUrl].filter(
    (u): u is string => !!u && u.includes(".blob.vercel-storage.com"),
  );
  if (blobUrls.length > 0) {
    try {
      await del(blobUrls);
    } catch {}
  }

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

  revalidatePath("/");
  revalidatePath("/studio/videos");
  redirect("/studio/videos");
}

Create src/app/studio/upload/page.tsx:

page.tsx
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { requireChannel } from "@/lib/auth";
import { Uploader } from "./uploader";

export const metadata = { title: "Upload - Wavora Studio" };

export default async function UploadPage() {
  await requireChannel();

  return (
    <div>
      <Link
        href="/studio/videos"
        className="mb-4 inline-flex items-center gap-1 text-sm text-fg-muted hover:text-fg"
      >
        <ChevronLeft className="h-4 w-4" />
        Back to content
      </Link>
      <h1 className="mb-8 text-2xl font-bold">Upload video</h1>
      <Uploader />
    </div>
  );
}

The uploader is the client-side component: it reads the duration and poster from the file, uploads it to Blob, and then calls the action. Create src/app/studio/upload/uploader.tsx:

uploader.tsx
"use client";

import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { upload } from "@vercel/blob/client";
import { ImagePlus, UploadCloud } from "lucide-react";
import { CATEGORIES, VISIBILITIES } from "@/lib/validators";
import { cn } from "@/lib/utils";
import { createVideo } from "../actions";

type Captured = {
  durationSeconds: number;
  width: number;
  height: number;
  posters: Blob[];
};

function captureFromVideo(file: File): Promise<Captured> {
  return new Promise((resolve) => {
    const url = URL.createObjectURL(file);
    const video = document.createElement("video");
    video.preload = "auto";
    video.muted = true;
    video.src = url;

    let settled = false;
    let w = 0;
    let h = 0;
    let duration = 0;
    const posters: Blob[] = [];

    const finish = () => {
      if (settled) return;
      settled = true;
      clearTimeout(timeout);
      URL.revokeObjectURL(url);
      resolve({ durationSeconds: duration, width: w, height: h, posters });
    };
    const timeout = setTimeout(finish, 12000);

    const seekAndShoot = (t: number) =>
      new Promise<void>((res) => {
        const onSeeked = () => {
          video.removeEventListener("seeked", onSeeked);
          try {
            const maxW = 1280;
            const scale = video.videoWidth > maxW ? maxW / video.videoWidth : 1;
            const canvas = document.createElement("canvas");
            canvas.width = Math.max(1, Math.round(video.videoWidth * scale));
            canvas.height = Math.max(1, Math.round(video.videoHeight * scale));
            const ctx = canvas.getContext("2d");
            if (!ctx) return res();
            ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
            canvas.toBlob(
              (blob) => {
                if (blob) posters.push(blob);
                res();
              },
              "image/jpeg",
              0.82,
            );
          } catch {
            res();
          }
        };
        video.addEventListener("seeked", onSeeked);
        try {
          video.currentTime = t;
        } catch {
          video.removeEventListener("seeked", onSeeked);
          res();
        }
      });

    video.onloadedmetadata = async () => {
      w = video.videoWidth;
      h = video.videoHeight;
      duration = Number.isFinite(video.duration) ? video.duration : 0;
      const fractions = duration > 0 ? [0.15, 0.5, 0.85] : [0];
      for (const f of fractions) {
        const t =
          duration > 0 ? Math.min(duration * f, Math.max(0, duration - 0.1)) : 0;
        await seekAndShoot(t);
      }
      finish();
    };
    video.onerror = () => finish();
  });
}

function sanitize(name: string): string {
  return name.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 80) || "video";
}

export function Uploader() {
  const router = useRouter();
  const [file, setFile] = useState<File | null>(null);
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [visibility, setVisibility] =
    useState<(typeof VISIBILITIES)[number]>("PUBLIC");
  const [category, setCategory] =
    useState<(typeof CATEGORIES)[number]>("OTHER");
  const [membersOnly, setMembersOnly] = useState(false);
  const [detectedShort, setDetectedShort] = useState(false);

  const [posterUrls, setPosterUrls] = useState<string[]>([]);
  const [selectedPoster, setSelectedPoster] = useState(0);
  const [customThumb, setCustomThumb] = useState<File | null>(null);
  const [customThumbUrl, setCustomThumbUrl] = useState<string | null>(null);

  const [progress, setProgress] = useState(0);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const captured = useRef<Captured | null>(null);

  async function onPick(picked: File) {
    setFile(picked);
    setTitle(picked.name.replace(/\.[^.]+$/, "").slice(0, 100));
    setError(null);
    captured.current = null;
    setPosterUrls([]);
    setSelectedPoster(0);
    setCustomThumb(null);
    setCustomThumbUrl(null);

    const result = await captureFromVideo(picked);
    captured.current = result;
    setDetectedShort(
      result.height > result.width && result.durationSeconds <= 180,
    );
    setPosterUrls(result.posters.map((p) => URL.createObjectURL(p)));
  }

  function onCustomThumb(picked: File) {
    setCustomThumb(picked);
    setCustomThumbUrl(URL.createObjectURL(picked));
    setSelectedPoster(-1);
  }

  function chosenThumb(): Blob | null {
    if (selectedPoster === -1) return customThumb;
    return captured.current?.posters[selectedPoster] ?? null;
  }

  const selectedUrl =
    selectedPoster === -1 ? customThumbUrl : (posterUrls[selectedPoster] ?? null);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!file || busy) return;
    setBusy(true);
    setError(null);
    try {
      const base = sanitize(file.name);
      const videoBlob = await upload(`videos/${base}`, file, {
        access: "public",
        handleUploadUrl: "/api/blob/upload",
        contentType: file.type,
        onUploadProgress: (p) => setProgress(p.percentage),
      });

      let thumbnailUrl: string | undefined;
      const poster = chosenThumb();
      if (poster) {
        const thumb = await upload(
          `thumbnails/${base}.jpg`,
          new File([poster], `${base}.jpg`, { type: "image/jpeg" }),
          {
            access: "public",
            handleUploadUrl: "/api/blob/upload",
            contentType: "image/jpeg",
          },
        );
        thumbnailUrl = thumb.url;
      }

      const res = await createVideo({
        title,
        description,
        visibility,
        category,
        membersOnly,
        isShort: detectedShort,
        videoUrl: videoBlob.url,
        videoPathname: videoBlob.pathname,
        thumbnailUrl,
        durationSeconds: captured.current?.durationSeconds ?? 0,
      });

      if (res.error || !res.id) {
        setError(res.error ?? "Something went wrong.");
        setBusy(false);
        return;
      }
      router.push(`/watch?v=${res.id}`);
    } catch (err) {
      setError((err as Error).message || "Upload failed.");
      setBusy(false);
    }
  }

  if (!file) {
    return (
      <label className="mx-auto flex max-w-lg cursor-pointer flex-col items-center gap-4 rounded-2xl border border-dashed border-border px-6 py-16 text-center hover:bg-hover">
        <span className="grid h-20 w-20 place-items-center rounded-full bg-hover">
          <UploadCloud className="h-9 w-9 text-fg-muted" />
        </span>
        <span className="text-base font-medium">Select a video to upload</span>
        <span className="text-sm text-fg-muted">
          MP4 or WebM, up to 512 MB. It plays as-is (no transcoding) in this demo.
        </span>
        <input
          type="file"
          accept="video/mp4,video/webm,video/quicktime"
          className="hidden"
          onChange={(e) => {
            const f = e.target.files?.[0];
            if (f) onPick(f);
          }}
        />
      </label>
    );
  }

  return (
    <form onSubmit={onSubmit} className="mx-auto grid max-w-3xl gap-6 md:grid-cols-[1fr_300px]">
      <div className="flex flex-col gap-4">
        <label className="block">
          <span className="mb-1 block text-xs text-fg-muted">Title (required)</span>
          <input
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            required
            maxLength={100}
            className="w-full rounded-lg border border-border bg-transparent px-3 py-2.5 outline-none focus:border-accent"
          />
        </label>

        <label className="block">
          <span className="mb-1 block text-xs text-fg-muted">Description</span>
          <textarea
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            rows={5}
            maxLength={5000}
            className="w-full resize-y rounded-lg border border-border bg-transparent px-3 py-2.5 outline-none focus:border-accent"
          />
        </label>

        <div className="grid grid-cols-2 gap-4">
          <label className="block">
            <span className="mb-1 block text-xs text-fg-muted">Visibility</span>
            <select
              value={visibility}
              onChange={(e) =>
                setVisibility(e.target.value as (typeof VISIBILITIES)[number])
              }
              className="w-full rounded-lg border border-border bg-canvas px-3 py-2.5 outline-none focus:border-accent"
            >
              {VISIBILITIES.map((v) => (
                <option key={v} value={v}>
                  {v.charAt(0) + v.slice(1).toLowerCase()}
                </option>
              ))}
            </select>
          </label>

          <label className="block">
            <span className="mb-1 block text-xs text-fg-muted">Category</span>
            <select
              value={category}
              onChange={(e) =>
                setCategory(e.target.value as (typeof CATEGORIES)[number])
              }
              className="w-full rounded-lg border border-border bg-canvas px-3 py-2.5 outline-none focus:border-accent"
            >
              {CATEGORIES.map((c) => (
                <option key={c} value={c}>
                  {c.charAt(0) + c.slice(1).toLowerCase()}
                </option>
              ))}
            </select>
          </label>
        </div>

        {detectedShort ? (
          <p className="rounded-lg bg-brand/10 px-3 py-2 text-sm text-brand">
            Vertical video detected - this will publish as a Wave.
          </p>
        ) : null}

        <label className="flex cursor-pointer items-start gap-2.5 rounded-lg border border-border p-3">
          <input
            type="checkbox"
            checked={membersOnly}
            onChange={(e) => setMembersOnly(e.target.checked)}
            className="mt-0.5 h-4 w-4 accent-accent"
          />
          <span className="text-sm">
            Members-only
            <span className="block text-xs text-fg-muted">
              Only your channel members can watch. Requires memberships enabled.
            </span>
          </span>
        </label>

        {error ? <p className="text-sm text-red-500">{error}</p> : null}

        {busy ? (
          <div className="h-1.5 w-full overflow-hidden rounded-full bg-hover">
            <div
              className="h-full bg-accent transition-[width]"
              style={{ width: `${Math.max(5, progress)}%` }}
            />
          </div>
        ) : null}

        <div className="flex gap-3">
          <button
            type="submit"
            disabled={busy || !title.trim()}
            className="rounded-full bg-accent px-5 py-2.5 font-medium text-accent-fg hover:opacity-90 disabled:opacity-60"
          >
            {busy ? `Uploading… ${Math.round(progress)}%` : "Publish"}
          </button>
          <button
            type="button"
            disabled={busy}
            onClick={() => {
              setFile(null);
              setProgress(0);
            }}
            className="rounded-full border border-border px-5 py-2.5 font-medium hover:bg-hover disabled:opacity-60"
          >
            Cancel
          </button>
        </div>
      </div>

      <aside className="flex flex-col gap-3">
        <span className="text-xs text-fg-muted">Thumbnail</span>
        <div className="aspect-video w-full overflow-hidden rounded-xl bg-hover">
          {selectedUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={selectedUrl}
              alt=""
              className="h-full w-full object-cover"
            />
          ) : (
            <div className="grid h-full place-items-center text-xs text-fg-muted">
              Generating thumbnails…
            </div>
          )}
        </div>

        {posterUrls.length > 0 ? (
          <div className="grid grid-cols-3 gap-2">
            {posterUrls.map((u, i) => (
              <button
                type="button"
                key={u}
                onClick={() => setSelectedPoster(i)}
                className={cn(
                  "aspect-video overflow-hidden rounded-lg border-2",
                  selectedPoster === i ? "border-accent" : "border-transparent",
                )}
              >
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img src={u} alt="" className="h-full w-full object-cover" />
              </button>
            ))}
          </div>
        ) : null}

        <label
          className={cn(
            "flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed px-3 py-2 text-xs hover:bg-hover",
            selectedPoster === -1
              ? "border-accent text-accent"
              : "border-border text-fg-muted",
          )}
        >
          <ImagePlus className="h-4 w-4" />
          {customThumbUrl ? "Custom thumbnail selected" : "Upload custom thumbnail"}
          <input
            type="file"
            accept="image/jpeg,image/png,image/webp"
            className="hidden"
            onChange={(e) => {
              const f = e.target.files?.[0];
              if (f) onCustomThumb(f);
            }}
          />
        </label>

        <p className="truncate text-xs text-fg-muted">{file.name}</p>
      </aside>
    </form>
  );
}

The watch page

The watch page plays the video, displays its title and channel, and lists related videos on the side. We also increment a view count-limited to once per user per day-when the page loads.

First the read queries for the feed and the related rail. Create src/lib/videos.ts:

videos.ts
import "server-only";
import { prisma } from "./prisma";
import type { VideoCategory } from "@/generated/prisma/client";
import type { FeedVideo } from "@/components/feed/video-card";

const channelSelect = { handle: true, name: true, avatarUrl: true } as const;
const cardSelect = {
  id: true,
  title: true,
  thumbnailUrl: true,
  durationSeconds: true,
  viewCount: true,
  publishedAt: true,
  channel: { select: channelSelect },
} as const;

export async function getFeedVideos(
  take = 24,
  category?: VideoCategory,
): Promise<FeedVideo[]> {
  return prisma.video.findMany({
    where: {
      visibility: "PUBLIC",
      status: "READY",
      isShort: false,
      ...(category ? { category } : {}),
    },
    orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
    take,
    select: cardSelect,
  });
}

export async function getRelatedVideos(
  videoId: string,
  take = 16,
): Promise<FeedVideo[]> {
  return prisma.video.findMany({
    where: {
      visibility: "PUBLIC",
      status: "READY",
      isShort: false,
      id: { not: videoId },
    },
    orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
    take,
    select: cardSelect,
  });
}

The player currently works as a native HTML5 video. In Part 3, we'll add support for resuming playback from where it left off. Create src/components/watch/watch-player.tsx:

watch-player.tsx
"use client";

export function WatchPlayer({
  src,
  poster,
}: {
  src: string;
  poster?: string;
}) {
  return (
    // eslint-disable-next-line jsx-a11y/media-has-caption
    <video
      src={src}
      poster={poster}
      controls
      playsInline
      className="aspect-video max-h-[80vh] w-full"
    />
  );
}

Create src/app/(main)/watch/page.tsx:

page.tsx
import { notFound } from "next/navigation";
import Link from "next/link";
import { User } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { getRelatedVideos } from "@/lib/videos";
import { formatDuration, formatTimeAgo, formatViews } from "@/lib/format";
import type { FeedVideo } from "@/components/feed/video-card";
import { WatchPlayer } from "@/components/watch/watch-player";
import { ViewTracker } from "./view-tracker";

type SearchParams = Promise<{ v?: string | string[] }>;

export default async function WatchPage({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const { v: rawV } = await searchParams;
  const v = Array.isArray(rawV) ? rawV[0] : rawV;
  if (!v) notFound();

  const video = await prisma.video.findUnique({
    where: { id: v },
    include: {
      channel: {
        select: { handle: true, name: true, avatarUrl: true, userId: true },
      },
    },
  });
  if (!video || video.status !== "READY" || !video.videoUrl) notFound();

  const user = await getCurrentUser();
  if (video.visibility === "PRIVATE" && user?.id !== video.channel.userId) {
    notFound();
  }

  const related = await getRelatedVideos(video.id);

  return (
    <div className="mx-auto flex max-w-[1700px] flex-col gap-6 lg:flex-row">
      <div className="min-w-0 flex-1">
        <div className="overflow-hidden rounded-xl bg-black">
          <WatchPlayer
            key={video.id}
            src={video.videoUrl}
            poster={video.thumbnailUrl ?? undefined}
          />
        </div>
        <ViewTracker videoId={video.id} />

        <h1 className="mt-3 text-xl font-bold leading-tight">{video.title}</h1>

        <div className="mt-3 flex items-center gap-3">
          <Link
            href={`/@${video.channel.handle}`}
            className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-full bg-hover"
          >
            {video.channel.avatarUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={video.channel.avatarUrl}
                alt=""
                className="h-full w-full object-cover"
              />
            ) : (
              <User className="h-5 w-5 text-fg-muted" />
            )}
          </Link>
          <Link href={`/@${video.channel.handle}`} className="font-medium">
            {video.channel.name}
          </Link>
        </div>

        <div className="mt-4 rounded-xl bg-hover p-3 text-sm">
          <p className="font-medium">
            {formatViews(video.viewCount)}
            {video.publishedAt
              ? ` • ${formatTimeAgo(video.publishedAt)}`
              : " • Unpublished"}
          </p>
          {video.description ? (
            <p className="mt-2 whitespace-pre-wrap text-fg">
              {video.description}
            </p>
          ) : null}
        </div>
      </div>

      <aside className="w-full shrink-0 lg:w-[402px]">
        <div className="flex flex-col gap-2">
          {related.map((r) => (
            <RelatedRow key={r.id} video={r} />
          ))}
        </div>
      </aside>
    </div>
  );
}

function RelatedRow({ video }: { video: FeedVideo }) {
  return (
    <Link href={`/watch?v=${video.id}`} className="flex gap-2">
      <div className="relative aspect-video w-40 shrink-0 overflow-hidden rounded-lg bg-hover">
        {video.thumbnailUrl ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={video.thumbnailUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        ) : null}
        {video.durationSeconds > 0 ? (
          <span className="absolute bottom-1 right-1 rounded bg-black/80 px-1 text-xs text-white">
            {formatDuration(video.durationSeconds)}
          </span>
        ) : null}
      </div>
      <div className="min-w-0 flex-1">
        <h3 className="line-clamp-2 text-sm font-medium leading-5">
          {video.title}
        </h3>
        <p className="mt-1 truncate text-xs text-fg-muted">
          {video.channel.name}
        </p>
        <p className="truncate text-xs text-fg-muted">
          {formatViews(video.viewCount)}
          {video.publishedAt ? ` • ${formatTimeAgo(video.publishedAt)}` : ""}
        </p>
      </div>
    </Link>
  );
}

A small client-side component logs the view via this action, which dedupes so one user counts once per day. Create src/app/(main)/watch/actions.ts:

actions.ts
"use server";

import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";

const ANON_COOKIE = "wv_anon";
const DAY_MS = 86_400_000;

export async function recordView(videoId: string): Promise<void> {
  if (!videoId) return;

  const user = await getCurrentUser();
  let who: string;
  if (user) {
    who = `u:${user.id}`;
  } else {
    const jar = await cookies();
    let anon = jar.get(ANON_COOKIE)?.value;
    if (!anon) {
      anon = crypto.randomUUID();
      jar.set(ANON_COOKIE, anon, {
        httpOnly: true,
        sameSite: "lax",
        path: "/",
        maxAge: 60 * 60 * 24 * 400,
      });
    }
    who = `a:${anon}`;
  }

  const dayBucket = Math.floor(Date.now() / DAY_MS);
  const sessionKey = `${who}:${dayBucket}`;

  try {
    await prisma.view.create({
      data: { videoId, userId: user?.id ?? null, sessionKey },
    });
    await prisma.video.update({
      where: { id: videoId },
      data: { viewCount: { increment: 1 } },
    });
  } catch {}
}

Create src/app/(main)/watch/view-tracker.tsx:

view-tracker.tsx
"use client";

import { useEffect, useRef } from "react";
import { recordView } from "./actions";

export function ViewTracker({ videoId }: { videoId: string }) {
  const lastRecorded = useRef<string | null>(null);
  useEffect(() => {
    if (lastRecorded.current === videoId) return;
    lastRecorded.current = videoId;
    recordView(videoId).catch(() => {});
  }, [videoId]);
  return null;
}

The home feed

The main feed is a grid view of the latest public videos, built from a single card component that we reuse throughout the app.

Create src/components/feed/video-card.tsx:

video-card.tsx
import Link from "next/link";
import { User } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDuration, formatTimeAgo, formatViews } from "@/lib/format";

export type FeedVideo = {
  id: string;
  title: string;
  thumbnailUrl: string | null;
  durationSeconds: number;
  viewCount: number;
  publishedAt: Date | string | null;
  channel: {
    handle: string;
    name: string;
    avatarUrl: string | null;
  };
};

export function VideoCard({
  video,
  className,
}: {
  video: FeedVideo;
  className?: string;
}) {
  const { channel } = video;
  const watchHref = `/watch?v=${video.id}`;
  const channelHref = `/@${channel.handle}`;

  return (
    <div className={cn("group flex flex-col gap-3", className)}>
      {}
      <Link
        href={watchHref}
        className="relative block aspect-video w-full overflow-hidden rounded-xl bg-hover"
      >
        {video.thumbnailUrl ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={video.thumbnailUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        ) : null}
        {video.durationSeconds > 0 ? (
          <span className="absolute bottom-1.5 right-1.5 rounded bg-black/80 px-1 py-0.5 text-xs font-medium text-white">
            {formatDuration(video.durationSeconds)}
          </span>
        ) : null}
      </Link>

      {}
      <div className="flex gap-3">
        <Link
          href={channelHref}
          aria-label={channel.name}
          className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-full bg-hover"
        >
          {channel.avatarUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={channel.avatarUrl}
              alt=""
              className="h-full w-full object-cover"
            />
          ) : (
            <User className="h-5 w-5 text-fg-muted" />
          )}
        </Link>

        <div className="min-w-0 flex-1">
          <Link href={watchHref}>
            <h3 className="line-clamp-2 text-sm font-medium leading-5 text-fg">
              {video.title}
            </h3>
          </Link>
          <Link
            href={channelHref}
            className="mt-1 block truncate text-sm text-fg-muted hover:text-fg"
          >
            {channel.name}
          </Link>
          <p className="truncate text-sm text-fg-muted">
            {formatViews(video.viewCount)}
            {video.publishedAt ? ` • ${formatTimeAgo(video.publishedAt)}` : ""}
          </p>
        </div>
      </div>
    </div>
  );
}

A placeholder card for loading states. Create src/components/feed/video-card-skeleton.tsx:

video-card-skeleton.tsx
export function VideoCardSkeleton() {
  return (
    <div className="flex flex-col gap-3">
      <div className="aspect-video w-full animate-pulse rounded-xl bg-hover" />
      <div className="flex gap-3">
        <div className="h-9 w-9 shrink-0 animate-pulse rounded-full bg-hover" />
        <div className="flex-1 space-y-2 pt-0.5">
          <div className="h-4 w-[90%] animate-pulse rounded bg-hover" />
          <div className="h-3 w-[55%] animate-pulse rounded bg-hover" />
          <div className="h-3 w-[40%] animate-pulse rounded bg-hover" />
        </div>
      </div>
    </div>
  );
}

A reusable grid wrapper. Create src/components/feed/video-grid.tsx:

video-grid.tsx
import { VideoCard, type FeedVideo } from "./video-card";

export function VideoGrid({ videos }: { videos: FeedVideo[] }) {
  return (
    <div className="grid grid-cols-1 gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
      {videos.map((video) => (
        <VideoCard key={video.id} video={video} />
      ))}
    </div>
  );
}

Now for the homepage itself. We'll add category chips and personalized shelves in later parts. Open src/app/(main)/page.tsx and replace its contents:

page.tsx
import Link from "next/link";
import { getFeedVideos } from "@/lib/videos";
import { VideoCard } from "@/components/feed/video-card";

export default async function HomePage() {
  const videos = await getFeedVideos(24);

  return (
    <div className="mx-auto max-w-[2400px]">
      {videos.length === 0 ? (
        <div className="flex flex-col items-center gap-3 py-24 text-center">
          <p className="text-lg font-medium">No videos yet</p>
          <p className="max-w-sm text-sm text-fg-muted">
            Be the first to upload. Create a channel and publish a video, and it
            shows up right here.
          </p>
          <Link
            href="/studio/upload"
            className="mt-2 rounded-full bg-accent px-5 py-2.5 font-medium text-accent-fg hover:opacity-90"
          >
            Upload a video
          </Link>
        </div>
      ) : (
        <div className="grid grid-cols-1 gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
          {videos.map((video) => (
            <VideoCard key={video.id} video={video} />
          ))}
        </div>
      )}
    </div>
  );
}

The channel page

Each channel has a public page at the /@handle URL, containing its header and a grid view of its videos. We'll split this into tabs in Part 3. For now, it remains a single page.

First the channel queries. Create src/lib/channels.ts:

channels.ts
import "server-only";
import { notFound } from "next/navigation";
import { prisma } from "./prisma";
import type { FeedVideo } from "@/components/feed/video-card";

const cardSelect = {
  id: true,
  title: true,
  thumbnailUrl: true,
  durationSeconds: true,
  viewCount: true,
  publishedAt: true,
  channel: { select: { handle: true, name: true, avatarUrl: true } },
} as const;

export async function resolveChannel(raw: string) {
  const decoded = decodeURIComponent(raw);
  if (!decoded.startsWith("@")) notFound();
  const channel = await prisma.channel.findUnique({
    where: { handle: decoded.slice(1).toLowerCase() },
    select: {
      id: true,
      userId: true,
      name: true,
      handle: true,
      avatarUrl: true,
      bannerUrl: true,
      description: true,
      createdAt: true,
      membershipsEnabled: true,
    },
  });
  if (!channel) notFound();
  return channel;
}

export async function getChannelVideos(channelId: string): Promise<FeedVideo[]> {
  return prisma.video.findMany({
    where: { channelId, visibility: "PUBLIC", status: "READY", isShort: false },
    orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
    take: 30,
    select: cardSelect,
  });
}

export async function getChannelVideoCount(channelId: string): Promise<number> {
  return prisma.video.count({
    where: { channelId, visibility: "PUBLIC", status: "READY", isShort: false },
  });
}

Create src/app/(main)/[handle]/page.tsx:

page.tsx
import { User } from "lucide-react";
import { getChannelVideos, resolveChannel } from "@/lib/channels";
import { formatCompact } from "@/lib/format";
import { VideoGrid } from "@/components/feed/video-grid";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ handle: string }>;
}) {
  const { handle } = await params;
  const channel = await resolveChannel(handle);
  return { title: `${channel.name} - Wavora` };
}

export default async function ChannelPage({
  params,
}: {
  params: Promise<{ handle: string }>;
}) {
  const { handle } = await params;
  const channel = await resolveChannel(handle);
  const videos = await getChannelVideos(channel.id);

  return (
    <div className="mx-auto max-w-[1280px]">
      {channel.bannerUrl ? (
        <div className="mb-4 aspect-[6/1] w-full overflow-hidden rounded-xl bg-hover">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={channel.bannerUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        </div>
      ) : null}

      <div className="flex flex-col items-center gap-4 py-4 sm:flex-row sm:items-end">
        <div className="grid h-28 w-28 shrink-0 place-items-center overflow-hidden rounded-full bg-hover sm:h-40 sm:w-40">
          {channel.avatarUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={channel.avatarUrl}
              alt=""
              className="h-full w-full object-cover"
            />
          ) : (
            <User className="h-16 w-16 text-fg-muted" />
          )}
        </div>

        <div className="min-w-0 text-center sm:text-left">
          <h1 className="text-2xl font-bold sm:text-3xl">{channel.name}</h1>
          <p className="mt-1 text-sm text-fg-muted">
            <span className="font-medium text-fg">@{channel.handle}</span>
            {" • "}
            {formatCompact(videos.length)} video{videos.length === 1 ? "" : "s"}
          </p>
          {channel.description ? (
            <p className="mt-2 line-clamp-1 max-w-xl text-sm text-fg-muted">
              {channel.description}
            </p>
          ) : null}
        </div>
      </div>

      {videos.length === 0 ? (
        <p className="py-16 text-center text-sm text-fg-muted">
          This channel hasn&apos;t published any videos yet.
        </p>
      ) : (
        <VideoGrid videos={videos} />
      )}
    </div>
  );
}

The search box on the top bar is a simple GET form that redirects to the /results URL. This page matches the query against video titles, channel names, and handles, and lists the results.

Create src/app/(main)/results/page.tsx:

page.tsx
import Link from "next/link";
import { User } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { formatDuration, formatTimeAgo, formatViews } from "@/lib/format";

type SearchParams = Promise<{ search_query?: string | string[] }>;

const queryOf = (search_query: string | string[] | undefined) =>
  (Array.isArray(search_query) ? search_query[0] : (search_query ?? "")).trim();

export async function generateMetadata({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const q = queryOf((await searchParams).search_query);
  return { title: q ? `${q} - Wavora` : "Search - Wavora" };
}

export default async function ResultsPage({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const q = queryOf((await searchParams).search_query);

  const videos = q
    ? await prisma.video.findMany({
        where: {
          visibility: "PUBLIC",
          status: "READY",
          OR: [
            { title: { contains: q, mode: "insensitive" } },
            { channel: { name: { contains: q, mode: "insensitive" } } },
            { channel: { handle: { contains: q, mode: "insensitive" } } },
          ],
        },
        orderBy: [{ viewCount: "desc" }, { publishedAt: "desc" }],
        take: 40,
        select: {
          id: true,
          title: true,
          description: true,
          thumbnailUrl: true,
          durationSeconds: true,
          viewCount: true,
          publishedAt: true,
          channel: { select: { handle: true, name: true, avatarUrl: true } },
        },
      })
    : [];

  if (!q) {
    return (
      <p className="py-16 text-center text-sm text-fg-muted">
        Type a search to find videos.
      </p>
    );
  }

  if (videos.length === 0) {
    return (
      <div className="py-16 text-center">
        <p className="font-medium">No results found</p>
        <p className="mt-1 text-sm text-fg-muted">
          Try different keywords for &ldquo;{q}&rdquo;.
        </p>
      </div>
    );
  }

  return (
    <div className="mx-auto flex max-w-[1100px] flex-col gap-4">
      {videos.map((v) => (
        <Link
          key={v.id}
          href={`/watch?v=${v.id}`}
          className="flex flex-col gap-3 sm:flex-row"
        >
          <div className="relative aspect-video w-full shrink-0 overflow-hidden rounded-xl bg-hover sm:w-[360px]">
            {v.thumbnailUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={v.thumbnailUrl}
                alt=""
                className="h-full w-full object-cover"
              />
            ) : null}
            {v.durationSeconds > 0 ? (
              <span className="absolute bottom-1.5 right-1.5 rounded bg-black/80 px-1 py-0.5 text-xs font-medium text-white">
                {formatDuration(v.durationSeconds)}
              </span>
            ) : null}
          </div>

          <div className="min-w-0 flex-1">
            <h3 className="line-clamp-2 text-lg leading-6">{v.title}</h3>
            <p className="mt-1 text-xs text-fg-muted">
              {formatViews(v.viewCount)}
              {v.publishedAt ? ` • ${formatTimeAgo(v.publishedAt)}` : ""}
            </p>
            <div className="mt-3 flex items-center gap-2">
              <span className="grid h-6 w-6 place-items-center overflow-hidden rounded-full bg-hover">
                {v.channel.avatarUrl ? (
                  // eslint-disable-next-line @next/next/no-img-element
                  <img
                    src={v.channel.avatarUrl}
                    alt=""
                    className="h-full w-full object-cover"
                  />
                ) : (
                  <User className="h-3.5 w-3.5 text-fg-muted" />
                )}
              </span>
              <span className="text-xs text-fg-muted">{v.channel.name}</span>
            </div>
            {v.description ? (
              <p className="mt-2 line-clamp-2 text-xs text-fg-muted">
                {v.description}
              </p>
            ) : null}
          </div>
        </Link>
      ))}
    </div>
  );
}

The studio

Studio is where content creators manage their videos. It consists of a shell with its own navigation, the channel's video list, and an edit page for each video.

Create src/app/studio/layout.tsx:

layout.tsx
import type { ReactNode } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { User } from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { WavoraLogo } from "@/components/ui/wavora-logo";

export default async function StudioLayout({
  children,
}: {
  children: ReactNode;
}) {
  const user = await getCurrentUser();
  if (!user) redirect("/sign-in");

  return (
    <div className="min-h-dvh bg-canvas">
      <header className="fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-4 border-b border-border bg-canvas px-4">
        <div className="flex items-center gap-2">
          <Link href="/" aria-label="Wavora home">
            <WavoraLogo />
          </Link>
          <span className="text-sm font-medium text-fg-muted">Studio</span>
          <nav className="ml-3 hidden items-center gap-1 sm:flex">
            <Link
              href="/studio/videos"
              className="rounded-lg px-3 py-1.5 text-sm hover:bg-hover"
            >
              Content
            </Link>
          </nav>
        </div>
        <div className="flex items-center gap-4">
          <Link href="/" className="text-sm text-fg-muted hover:text-fg">
            View site
          </Link>
          <span className="grid h-8 w-8 place-items-center overflow-hidden rounded-full bg-hover">
            {user.avatarUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={user.avatarUrl}
                alt=""
                className="h-full w-full object-cover"
              />
            ) : (
              <User className="h-5 w-5 text-fg-muted" />
            )}
          </span>
        </div>
      </header>

      <main className="mx-auto max-w-screen-xl px-6 pb-16 pt-20">{children}</main>
    </div>
  );
}

Create src/app/studio/videos/page.tsx:

page.tsx
import Link from "next/link";
import { Upload } from "lucide-react";
import { getMyChannel } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { formatDuration, formatTimeAgo, formatViews } from "@/lib/format";

export const metadata = { title: "Content - Wavora Studio" };

const VISIBILITY_LABEL: Record<string, string> = {
  PUBLIC: "Public",
  UNLISTED: "Unlisted",
  PRIVATE: "Private",
};

export default async function StudioVideosPage() {
  const channel = await getMyChannel();

  if (!channel) {
    return (
      <div className="mx-auto max-w-md rounded-2xl border border-border p-10 text-center">
        <h1 className="text-xl font-semibold">Create a channel</h1>
        <p className="mt-2 text-sm text-fg-muted">
          You need a channel before you can upload videos.
        </p>
        <Link
          href="/create-channel"
          className="mt-6 inline-block rounded-full bg-accent px-5 py-2.5 font-medium text-accent-fg hover:opacity-90"
        >
          Create channel
        </Link>
      </div>
    );
  }

  const videos = await prisma.video.findMany({
    where: { channelId: channel.id },
    orderBy: { createdAt: "desc" },
    select: {
      id: true,
      title: true,
      thumbnailUrl: true,
      durationSeconds: true,
      visibility: true,
      viewCount: true,
      createdAt: true,
    },
  });

  return (
    <div>
      <div className="mb-6 flex items-center justify-between">
        <h1 className="text-2xl font-bold">Channel content</h1>
        <Link
          href="/studio/upload"
          className="inline-flex items-center gap-2 rounded-full bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90"
        >
          <Upload className="h-4 w-4" />
          Upload
        </Link>
      </div>

      {videos.length === 0 ? (
        <div className="rounded-2xl border border-dashed border-border py-16 text-center text-sm text-fg-muted">
          No videos yet. Upload your first one to see it here and on the home feed.
        </div>
      ) : (
        <ul className="divide-y divide-border rounded-2xl border border-border">
          {videos.map((v) => (
            <li key={v.id} className="flex items-center gap-4 p-3">
              <Link
                href={`/watch?v=${v.id}`}
                className="relative aspect-video w-40 shrink-0 overflow-hidden rounded-lg bg-hover"
              >
                {v.thumbnailUrl ? (
                  // eslint-disable-next-line @next/next/no-img-element
                  <img
                    src={v.thumbnailUrl}
                    alt=""
                    className="h-full w-full object-cover"
                  />
                ) : null}
                {v.durationSeconds > 0 ? (
                  <span className="absolute bottom-1 right-1 rounded bg-black/80 px-1 text-xs text-white">
                    {formatDuration(v.durationSeconds)}
                  </span>
                ) : null}
              </Link>

              <div className="min-w-0 flex-1">
                <Link
                  href={`/watch?v=${v.id}`}
                  className="line-clamp-1 font-medium hover:text-accent"
                >
                  {v.title}
                </Link>
                <p className="mt-1 text-xs text-fg-muted">
                  {VISIBILITY_LABEL[v.visibility] ?? v.visibility} •{" "}
                  {formatViews(v.viewCount)} • {formatTimeAgo(v.createdAt)}
                </p>
              </div>
              <Link
                href={`/studio/video/${v.id}`}
                className="shrink-0 rounded-full px-3 py-1.5 text-sm text-fg-muted hover:bg-hover"
              >
                Details
              </Link>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

The edit page loads one video and hands it to a form. Create src/app/studio/video/[id]/page.tsx:

page.tsx
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { EditVideoForm } from "./edit-form";

export const metadata = { title: "Edit video - Wavora Studio" };

export default async function EditVideoPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const user = await getCurrentUser();
  if (!user) redirect("/sign-in");

  const video = await prisma.video.findUnique({
    where: { id },
    select: {
      id: true,
      title: true,
      description: true,
      visibility: true,
      category: true,
      membersOnly: true,
      isShort: true,
      channel: { select: { userId: true } },
    },
  });
  if (!video || video.channel.userId !== user.id) notFound();

  return (
    <div>
      <Link
        href="/studio/videos"
        className="mb-4 inline-flex items-center gap-1 text-sm text-fg-muted hover:text-fg"
      >
        <ChevronLeft className="h-4 w-4" />
        Back to content
      </Link>
      <h1 className="mb-8 text-2xl font-bold">Video details</h1>
      <EditVideoForm
        video={{
          id: video.id,
          title: video.title,
          description: video.description,
          visibility: video.visibility,
          category: video.category,
          membersOnly: video.membersOnly,
          isShort: video.isShort,
        }}
      />
    </div>
  );
}

The form saves edits and allows you to delete the video. Deleting the video also removes the file from the Blob. Create src/app/studio/video/[id]/edit-form.tsx:

edit-form.tsx
"use client";

import { useActionState } from "react";
import { CATEGORIES, VISIBILITIES } from "@/lib/validators";
import {
  deleteVideo,
  updateVideo,
  type UpdateVideoResult,
} from "../../actions";

type EditableVideo = {
  id: string;
  title: string;
  description: string | null;
  visibility: string;
  category: string;
  membersOnly: boolean;
  isShort: boolean;
};

export function EditVideoForm({ video }: { video: EditableVideo }) {
  const [state, formAction, pending] = useActionState<
    UpdateVideoResult,
    FormData
  >(updateVideo, {});

  return (
    <div className="max-w-2xl">
      <form action={formAction} className="flex flex-col gap-4">
        <input type="hidden" name="id" value={video.id} />

        <label className="block">
          <span className="mb-1 block text-xs text-fg-muted">Title</span>
          <input
            name="title"
            defaultValue={video.title}
            required
            maxLength={100}
            className="w-full rounded-lg border border-border bg-transparent px-3 py-2.5 outline-none focus:border-accent"
          />
        </label>

        <label className="block">
          <span className="mb-1 block text-xs text-fg-muted">Description</span>
          <textarea
            name="description"
            defaultValue={video.description ?? ""}
            rows={5}
            maxLength={5000}
            className="w-full resize-y rounded-lg border border-border bg-transparent px-3 py-2.5 outline-none focus:border-accent"
          />
        </label>

        <div className="grid grid-cols-2 gap-4">
          <label className="block">
            <span className="mb-1 block text-xs text-fg-muted">Visibility</span>
            <select
              name="visibility"
              defaultValue={video.visibility}
              className="w-full rounded-lg border border-border bg-canvas px-3 py-2.5 outline-none focus:border-accent"
            >
              {VISIBILITIES.map((v) => (
                <option key={v} value={v}>
                  {v.charAt(0) + v.slice(1).toLowerCase()}
                </option>
              ))}
            </select>
          </label>

          <label className="block">
            <span className="mb-1 block text-xs text-fg-muted">Category</span>
            <select
              name="category"
              defaultValue={video.category}
              className="w-full rounded-lg border border-border bg-canvas px-3 py-2.5 outline-none focus:border-accent"
            >
              {CATEGORIES.map((c) => (
                <option key={c} value={c}>
                  {c.charAt(0) + c.slice(1).toLowerCase()}
                </option>
              ))}
            </select>
          </label>
        </div>

        <label className="flex items-center gap-2">
          <input
            type="checkbox"
            name="membersOnly"
            defaultChecked={video.membersOnly}
            className="h-4 w-4 accent-accent"
          />
          <span className="text-sm">
            Members only - restrict playback to active channel members
          </span>
        </label>

        <label className="flex items-center gap-2">
          <input
            type="checkbox"
            name="isShort"
            defaultChecked={video.isShort}
            className="h-4 w-4 accent-accent"
          />
          <span className="text-sm">
            Wave - show this vertical video in the Waves feed
          </span>
        </label>

        {state.error ? (
          <p className="text-sm text-red-500">{state.error}</p>
        ) : null}
        {state.ok ? (
          <p className="text-sm text-green-500">Saved.</p>
        ) : null}

        <div>
          <button
            type="submit"
            disabled={pending}
            className="rounded-full bg-accent px-5 py-2.5 font-medium text-accent-fg hover:opacity-90 disabled:opacity-60"
          >
            {pending ? "Saving…" : "Save changes"}
          </button>
        </div>
      </form>

      <form
        action={deleteVideo}
        onSubmit={(e) => {
          if (!confirm("Delete this video permanently? This cannot be undone.")) {
            e.preventDefault();
          }
        }}
        className="mt-8 border-t border-border pt-6"
      >
        <input type="hidden" name="id" value={video.id} />
        <button
          type="submit"
          className="rounded-full border border-red-500/40 px-5 py-2.5 text-sm font-medium text-red-500 hover:bg-red-500/10"
        >
          Delete video
        </button>
      </form>
    </div>
  );
}

Checkpoint

Before moving on, confirm:

  • Visiting /create-channel while signed in lets you claim a handle, and the form warns you when a handle is taken.
  • After creating a channel, /@your-handle shows its page.
  • Uploading a video in the studio takes you to its watch page, where it plays.
  • The video appears on the home feed, in search results, and on your channel page.
  • Reloading the watch page does not double the view count for the same day.
  • The studio video list shows your upload; editing it updates the watch page, and deleting it removes it everywhere.

Part 3: Engagement and library

We have channels and videos and in this part we create the social aspect of the project. Users should be able to subscribe to channels, like and comment on videos, and the app should remember what they watched so they can pick up where they left off. We also add Watch Later, Liked, and a feed of the channels they follow.

Subscribe

A subscription is a row that connects a user to a channel in the project. Read operations come from a single helper, while write operations come from a server action where a small client button brings these together.

Create src/lib/social.ts:

social.ts
import "server-only";
import { prisma } from "./prisma";

export async function getChannelSocial(channelId: string, userId?: string) {
  const [subscriberCount, mine] = await Promise.all([
    prisma.subscription.count({ where: { channelId } }),
    userId
      ? prisma.subscription.findUnique({
          where: { subscriberId_channelId: { subscriberId: userId, channelId } },
          select: { notify: true },
        })
      : Promise.resolve(null),
  ]);
  return {
    subscriberCount,
    isSubscribed: Boolean(mine),
    notifyLevel: mine?.notify ?? null,
  };
}

export async function getVideoReactions(videoId: string, userId?: string) {
  const [likeCount, mine] = await Promise.all([
    prisma.reaction.count({ where: { videoId, type: "LIKE" } }),
    userId
      ? prisma.reaction.findUnique({
          where: { userId_videoId: { userId, videoId } },
          select: { type: true },
        })
      : Promise.resolve(null),
  ]);
  return { likeCount, myReaction: mine?.type ?? null };
}

Create src/lib/social-actions.ts:

social-actions.ts
"use server";

import { prisma } from "./prisma";
import { getCurrentUser } from "./session";
import type { NotifyLevel } from "@/generated/prisma/client";

function prismaCode(e: unknown): string | undefined {
  return e && typeof e === "object" && "code" in e
    ? (e as { code?: string }).code
    : undefined;
}

export type SubscribeResult =
  | { subscribed: boolean; count: number }
  | { error: "sign_in" | "own_channel" };

export async function toggleSubscribe(
  channelId: string,
): Promise<SubscribeResult> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };

  const channel = await prisma.channel.findUnique({
    where: { id: channelId },
    select: { userId: true },
  });
  if (!channel) return { error: "sign_in" };
  if (channel.userId === user.id) return { error: "own_channel" };

  const existing = await prisma.subscription.findUnique({
    where: {
      subscriberId_channelId: { subscriberId: user.id, channelId },
    },
    select: { id: true },
  });

  if (existing) {
    try {
      await prisma.subscription.delete({ where: { id: existing.id } });
    } catch (e) {
      if (prismaCode(e) !== "P2025") throw e;
    }
  } else {
    try {
      await prisma.subscription.create({
        data: { subscriberId: user.id, channelId },
      });
    } catch (e) {
      if (prismaCode(e) !== "P2002") throw e;
    }
  }

  const count = await prisma.subscription.count({ where: { channelId } });
  return { subscribed: !existing, count };
}

export async function setNotifyLevel(
  channelId: string,
  level: NotifyLevel,
): Promise<{ ok: true } | { error: "sign_in" | "not_subscribed" }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };

  const res = await prisma.subscription.updateMany({
    where: { subscriberId: user.id, channelId },
    data: { notify: level },
  });
  if (res.count === 0) return { error: "not_subscribed" };
  return { ok: true };
}

export type ReactionResult =
  | { reaction: "LIKE" | "DISLIKE" | null; likeCount: number }
  | { error: "sign_in" };

export async function toggleReaction(
  videoId: string,
  type: "LIKE" | "DISLIKE",
): Promise<ReactionResult> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };

  const existing = await prisma.reaction.findUnique({
    where: { userId_videoId: { userId: user.id, videoId } },
  });

  try {
    if (existing?.type === type) {
      await prisma.reaction.delete({ where: { id: existing.id } });
    } else if (existing) {
      await prisma.reaction.update({
        where: { id: existing.id },
        data: { type },
      });
    } else {
      try {
        await prisma.reaction.create({ data: { userId: user.id, videoId, type } });
      } catch (e) {
        if (prismaCode(e) !== "P2002") throw e;
        await prisma.reaction.update({
          where: { userId_videoId: { userId: user.id, videoId } },
          data: { type },
        });
      }
    }
  } catch (e) {
    if (prismaCode(e) !== "P2025") throw e;
  }

  const likeCount = await prisma.reaction.count({
    where: { videoId, type: "LIKE" },
  });
  const reaction = existing?.type === type ? null : type;
  return { reaction, likeCount };
}

A small hook closes menus on the Escape key, which a few components reuse. Create src/hooks/use-escape.ts:

use-escape.ts
"use client";

import { useEffect, useRef } from "react";

export function useEscape(active: boolean, onEscape: () => void): void {
  const handler = useRef(onEscape);
  handler.current = onEscape;

  useEffect(() => {
    if (!active) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") handler.current();
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [active]);
}

The Subscribe button toggles the subscription on and off and also carries the notification bell where the subscriber chooses how often they want to receive notifications about new uploads.

We'll build notifications that act on this preference in Part 4. Create src/components/watch/subscribe-button.tsx:

subscribe-button.tsx
"use client";

import { useState, useTransition } from "react";
import { Bell, BellOff, BellRing, Check, ChevronDown } from "lucide-react";
import {
  setNotifyLevel,
  toggleSubscribe,
} from "@/lib/social-actions";
import type { NotifyLevel } from "@/generated/prisma/client";
import { useEscape } from "@/hooks/use-escape";

function goSignIn() {
  window.location.href = `/sign-in?next=${encodeURIComponent(
    window.location.pathname + window.location.search,
  )}`;
}

const LEVELS: { value: NotifyLevel; label: string; icon: typeof Bell }[] = [
  { value: "ALL", label: "All", icon: BellRing },
  { value: "PERSONALIZED", label: "Personalized", icon: Bell },
  { value: "NONE", label: "None", icon: BellOff },
];

export function SubscribeButton({
  channelId,
  isOwner,
  isSignedIn,
  initialSubscribed,
  initialNotify,
}: {
  channelId: string;
  isOwner: boolean;
  isSignedIn: boolean;
  initialSubscribed: boolean;
  initialNotify?: NotifyLevel | null;
}) {
  const [subscribed, setSubscribed] = useState(initialSubscribed);
  const [notify, setNotify] = useState<NotifyLevel>(initialNotify ?? "ALL");
  const [open, setOpen] = useState(false);
  const [, startTransition] = useTransition();

  useEscape(open, () => setOpen(false));

  if (isOwner) return null;

  function subscribe() {
    if (!isSignedIn) return goSignIn();
    setSubscribed(true);
    setNotify("ALL");
    startTransition(async () => {
      const res = await toggleSubscribe(channelId);
      if ("error" in res) setSubscribed(false);
      else setSubscribed(res.subscribed);
    });
  }

  function unsubscribe() {
    setOpen(false);
    setSubscribed(false);
    startTransition(async () => {
      const res = await toggleSubscribe(channelId);
      if ("error" in res) setSubscribed(true);
      else setSubscribed(res.subscribed);
    });
  }

  function choose(level: NotifyLevel) {
    setNotify(level);
    setOpen(false);
    startTransition(async () => {
      await setNotifyLevel(channelId, level);
    });
  }

  if (!subscribed) {
    return (
      <button
        type="button"
        onClick={subscribe}
        className="rounded-full bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition hover:opacity-90"
      >
        Subscribe
      </button>
    );
  }

  const BellIcon =
    notify === "ALL" ? BellRing : notify === "NONE" ? BellOff : Bell;

  return (
    <div className="relative">
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        className="flex items-center gap-1.5 rounded-full bg-chip px-4 py-2 text-sm font-medium hover:bg-hover-strong"
      >
        <BellIcon className="h-4 w-4" />
        Subscribed
        <ChevronDown className="h-4 w-4" />
      </button>

      {open ? (
        <>
          <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
          <div className="absolute right-0 z-50 mt-2 w-48 rounded-xl border border-border bg-surface p-1 shadow-lg">
            {LEVELS.map((l) => {
              const Icon = l.icon;
              return (
                <button
                  key={l.value}
                  type="button"
                  onClick={() => choose(l.value)}
                  className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm hover:bg-hover"
                >
                  <Icon className="h-4 w-4" />
                  {l.label}
                  {notify === l.value ? (
                    <Check className="ml-auto h-4 w-4" />
                  ) : null}
                </button>
              );
            })}
            <div className="my-1 border-t border-border" />
            <button
              type="button"
              onClick={unsubscribe}
              className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm hover:bg-hover"
            >
              Unsubscribe
            </button>
          </div>
        </>
      ) : null}
    </div>
  );
}

Like and dislike

The "Like" and "Dislike" buttons are mutually exclusive. Just like YouTube, we never display the number of dislikes. The button works optimistically and redirects signed-out users to sign in.

Create src/components/watch/like-dislike.tsx:

like-dislike.tsx
"use client";

import { useState, useTransition } from "react";
import { ThumbsUp, ThumbsDown } from "lucide-react";
import { toggleReaction } from "@/lib/social-actions";
import { formatCompact } from "@/lib/format";
import { cn } from "@/lib/utils";

type Reaction = "LIKE" | "DISLIKE" | null;

function goSignIn() {
  window.location.href = `/sign-in?next=${encodeURIComponent(
    window.location.pathname + window.location.search,
  )}`;
}

export function LikeDislike({
  videoId,
  isSignedIn,
  initialReaction,
  initialLikeCount,
}: {
  videoId: string;
  isSignedIn: boolean;
  initialReaction: Reaction;
  initialLikeCount: number;
}) {
  const [reaction, setReaction] = useState<Reaction>(initialReaction);
  const [likeCount, setLikeCount] = useState(initialLikeCount);
  const [pending, startTransition] = useTransition();

  function react(type: "LIKE" | "DISLIKE") {
    if (!isSignedIn) return goSignIn();

    const prevReaction = reaction;
    const prevCount = likeCount;

    let nextReaction: Reaction;
    let nextCount = likeCount;
    if (reaction === type) {
      nextReaction = null;
      if (type === "LIKE") nextCount -= 1;
    } else {
      nextReaction = type;
      if (type === "LIKE") nextCount += 1;
      if (reaction === "LIKE") nextCount -= 1;
    }

    setReaction(nextReaction);
    setLikeCount(nextCount);

    startTransition(async () => {
      const res = await toggleReaction(videoId, type);
      if ("error" in res) {
        setReaction(prevReaction);
        setLikeCount(prevCount);
      } else {
        setReaction(res.reaction);
        setLikeCount(res.likeCount);
      }
    });
  }

  return (
    <div className="flex items-center rounded-full bg-chip">
      <button
        type="button"
        onClick={() => react("LIKE")}
        disabled={pending}
        aria-pressed={reaction === "LIKE"}
        className={cn(
          "flex items-center gap-2 rounded-l-full py-2 pl-4 pr-3 transition-colors disabled:opacity-60",
          reaction === "LIKE"
            ? "bg-accent text-accent-fg"
            : "hover:bg-hover-strong",
        )}
      >
        <ThumbsUp
          className={cn("h-5 w-5", reaction === "LIKE" && "fill-current")}
        />
        <span className="text-sm font-medium">
          {likeCount > 0 ? formatCompact(likeCount) : ""}
        </span>
      </button>
      <span className="h-6 w-px bg-border" />
      <button
        type="button"
        onClick={() => react("DISLIKE")}
        disabled={pending}
        aria-pressed={reaction === "DISLIKE"}
        aria-label="Dislike"
        className="rounded-r-full px-4 py-2 hover:bg-hover-strong disabled:opacity-60"
      >
        <ThumbsDown
          className={cn("h-5 w-5", reaction === "DISLIKE" && "fill-current")}
        />
      </button>
    </div>
  );
}

Comments

Comments follow a tree structure: top-level comments and replies are loaded on demand, "Top" and "Newest" sorting options, likes per comment, and creator-specific tools for likes, pinning, or removing comments.

Create src/lib/comments.ts:

comments.ts
import "server-only";
import { prisma } from "./prisma";

export type CommentDTO = {
  id: string;
  body: string;
  createdAt: string;
  author: {
    id: string;
    name: string | null;
    username: string;
    avatarUrl: string | null;
  };
  likeCount: number;
  myLiked: boolean;
  heartedByCreator: boolean;
  isPinned: boolean;
  replyCount: number;
  isOwn: boolean;
  authorIsMember: boolean;
  isSuperThanks: boolean;
  superThanksAmount: number | null;
};

const authorSelect = {
  id: true,
  name: true,
  username: true,
  avatarUrl: true,
} as const;

function commentInclude(currentUserId?: string) {
  return {
    author: { select: authorSelect },
    _count: { select: { reactions: true } },
    reactions: {
      where: { userId: currentUserId ?? "__no_user__" },
      select: { id: true },
    },
  };
}

type RawComment = {
  id: string;
  body: string;
  createdAt: Date;
  heartedByCreator: boolean;
  isPinned: boolean;
  isSuperThanks: boolean;
  superThanksAmount: number | null;
  author: {
    id: string;
    name: string | null;
    username: string;
    avatarUrl: string | null;
  };
  _count: { reactions: number };
  reactions: { id: string }[];
};

export function toCommentDTO(
  c: RawComment,
  currentUserId: string | undefined,
  replyCount: number,
  authorIsMember = false,
): CommentDTO {
  return {
    id: c.id,
    body: c.body,
    createdAt: c.createdAt.toISOString(),
    author: c.author,
    likeCount: c._count.reactions,
    myLiked: c.reactions.length > 0,
    heartedByCreator: c.heartedByCreator,
    isPinned: c.isPinned,
    replyCount,
    isOwn: Boolean(currentUserId) && c.author.id === currentUserId,
    authorIsMember,
    isSuperThanks: c.isSuperThanks,
    superThanksAmount: c.superThanksAmount,
  };
}

export async function getCommentsData(
  videoId: string,
  currentUserId: string | undefined,
  sort: "top" | "newest",
) {
  const video = await prisma.video.findUnique({
    where: { id: videoId },
    select: {
      commentsEnabled: true,
      channelId: true,
      channel: { select: { userId: true } },
    },
  });
  if (!video) return null;

  const isCreator =
    Boolean(currentUserId) && currentUserId === video.channel.userId;

  const [count, rows] = await Promise.all([
    prisma.comment.count({ where: { videoId, status: "PUBLISHED" } }),
    prisma.comment.findMany({
      where: { videoId, parentId: null, status: "PUBLISHED" },
      orderBy:
        sort === "top"
          ? [
              { isPinned: "desc" },
              { reactions: { _count: "desc" } },
              { createdAt: "desc" },
            ]
          : [{ isPinned: "desc" }, { createdAt: "desc" }],
      take: 50,
      include: commentInclude(currentUserId),
    }),
  ]);

  const ids = rows.map((r) => r.id);
  const replyGroups = ids.length
    ? await prisma.comment.groupBy({
        by: ["parentId"],
        where: { parentId: { in: ids }, status: "PUBLISHED" },
        _count: { _all: true },
      })
    : [];
  const replyCounts = new Map(
    replyGroups.map((g) => [g.parentId as string, g._count._all]),
  );

  return {
    enabled: video.commentsEnabled,
    isCreator,
    count,
    comments: rows.map((r) =>
      toCommentDTO(r, currentUserId, replyCounts.get(r.id) ?? 0),
    ),
  };
}

export async function getReplies(
  parentId: string,
  currentUserId: string | undefined,
): Promise<CommentDTO[]> {
  const rows = await prisma.comment.findMany({
    where: { parentId, status: "PUBLISHED" },
    orderBy: { createdAt: "asc" },
    take: 100,
    include: commentInclude(currentUserId),
  });
  return rows.map((r) => toCommentDTO(r, currentUserId, 0));
}

Create src/lib/comment-actions.ts:

comment-actions.ts
"use server";

import { prisma } from "./prisma";
import { getCurrentUser } from "./session";
import { commentBodySchema } from "./validators";
import {
  getCommentsData,
  getReplies,
  toCommentDTO,
  type CommentDTO,
} from "./comments";

function prismaCode(e: unknown): string | undefined {
  return e && typeof e === "object" && "code" in e
    ? (e as { code?: string }).code
    : undefined;
}

async function canReadComments(
  videoId: string,
  userId?: string,
): Promise<boolean> {
  const video = await prisma.video.findUnique({
    where: { id: videoId },
    select: {
      commentsEnabled: true,
      visibility: true,
      channel: { select: { userId: true } },
    },
  });
  if (!video || !video.commentsEnabled) return false;
  return video.visibility !== "PRIVATE" || video.channel.userId === userId;
}

async function assertCommentOwner(commentId: string, userId: string) {
  const c = await prisma.comment.findUnique({
    where: { id: commentId },
    select: {
      isPinned: true,
      heartedByCreator: true,
      videoId: true,
      parentId: true,
      video: { select: { channel: { select: { userId: true } } } },
    },
  });
  if (!c || c.video.channel.userId !== userId) return null;
  return c;
}

export async function postComment(
  videoId: string,
  body: string,
  parentId?: string,
): Promise<{ comment: CommentDTO } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };

  const parsed = commentBodySchema.safeParse(body);
  if (!parsed.success) return { error: "invalid" };

  if (!(await canReadComments(videoId, user.id))) return { error: "disabled" };

  let normalizedParent: string | null = null;
  if (parentId) {
    const parent = await prisma.comment.findUnique({
      where: { id: parentId },
      select: { id: true, parentId: true, videoId: true, status: true },
    });
    if (!parent || parent.videoId !== videoId || parent.status !== "PUBLISHED") {
      return { error: "bad_parent" };
    }
    normalizedParent = parent.parentId ?? parent.id;
  }

  const created = await prisma.comment.create({
    data: {
      videoId,
      authorId: user.id,
      parentId: normalizedParent,
      body: parsed.data,
      status: "PUBLISHED",
    },
    include: {
      author: {
        select: { id: true, name: true, username: true, avatarUrl: true },
      },
      _count: { select: { reactions: true } },
      reactions: { where: { userId: user.id }, select: { id: true } },
    },
  });

  return { comment: toCommentDTO(created, user.id, 0) };
}

export async function deleteComment(
  commentId: string,
): Promise<{ ok: true } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };
  const c = await prisma.comment.findUnique({
    where: { id: commentId },
    select: { authorId: true },
  });
  if (!c) return { ok: true };
  if (c.authorId !== user.id) return { error: "forbidden" };
  await prisma.comment.delete({ where: { id: commentId } });
  return { ok: true };
}

export async function toggleCommentLike(
  commentId: string,
): Promise<{ liked: boolean; count: number } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };
  const existing = await prisma.commentReaction.findUnique({
    where: { userId_commentId: { userId: user.id, commentId } },
    select: { id: true },
  });
  if (existing) {
    try {
      await prisma.commentReaction.delete({ where: { id: existing.id } });
    } catch (e) {
      if (prismaCode(e) !== "P2025") throw e;
    }
  } else {
    try {
      await prisma.commentReaction.create({
        data: { userId: user.id, commentId },
      });
    } catch (e) {
      const code = prismaCode(e);
      if (code === "P2003") return { error: "not_found" };
      if (code !== "P2002") throw e;
    }
  }
  const count = await prisma.commentReaction.count({ where: { commentId } });
  return { liked: !existing, count };
}

export async function heartComment(
  commentId: string,
): Promise<{ hearted: boolean } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };
  const c = await assertCommentOwner(commentId, user.id);
  if (!c) return { error: "forbidden" };
  await prisma.comment.update({
    where: { id: commentId },
    data: { heartedByCreator: !c.heartedByCreator },
  });
  return { hearted: !c.heartedByCreator };
}

export async function pinComment(
  commentId: string,
): Promise<{ pinned: boolean } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };
  const c = await assertCommentOwner(commentId, user.id);
  if (!c) return { error: "forbidden" };

  if (c.isPinned) {
    await prisma.comment.update({
      where: { id: commentId },
      data: { isPinned: false },
    });
    return { pinned: false };
  }
  await prisma.$transaction([
    prisma.comment.updateMany({
      where: { videoId: c.videoId, isPinned: true },
      data: { isPinned: false },
    }),
    prisma.comment.update({
      where: { id: commentId },
      data: { isPinned: true },
    }),
  ]);
  return { pinned: true };
}

export async function moderateComment(
  commentId: string,
  action: "remove" | "restore",
): Promise<{ ok: true } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };
  const c = await assertCommentOwner(commentId, user.id);
  if (!c) return { error: "forbidden" };

  const status = action === "remove" ? "REMOVED" : "PUBLISHED";
  await prisma.$transaction([
    prisma.comment.update({ where: { id: commentId }, data: { status } }),
    ...(c.parentId === null
      ? [
          prisma.comment.updateMany({
            where: { parentId: commentId },
            data: { status },
          }),
        ]
      : []),
  ]);
  return { ok: true };
}

export async function toggleComments(
  videoId: string,
  enabled: boolean,
): Promise<{ enabled: boolean } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };
  const v = await prisma.video.findUnique({
    where: { id: videoId },
    select: { channel: { select: { userId: true } } },
  });
  if (!v || v.channel.userId !== user.id) return { error: "forbidden" };
  await prisma.video.update({
    where: { id: videoId },
    data: { commentsEnabled: enabled },
  });
  return { enabled };
}

export async function fetchComments(
  videoId: string,
  sort: "top" | "newest",
): Promise<CommentDTO[]> {
  const user = await getCurrentUser();
  if (!(await canReadComments(videoId, user?.id))) return [];
  const data = await getCommentsData(videoId, user?.id, sort);
  return data?.comments ?? [];
}

export async function loadReplies(parentId: string): Promise<CommentDTO[]> {
  const user = await getCurrentUser();
  const parent = await prisma.comment.findUnique({
    where: { id: parentId },
    select: { videoId: true },
  });
  if (!parent || !(await canReadComments(parent.videoId, user?.id))) return [];
  return getReplies(parentId, user?.id);
}

Create src/components/comments/comments.tsx:

comments.tsx
"use client";

import { useState, useTransition } from "react";
import { ThumbsUp, MoreVertical, Heart, User } from "lucide-react";
import { formatCompact, formatTimeAgo } from "@/lib/format";
import { cn } from "@/lib/utils";
import {
  postComment,
  deleteComment,
  toggleCommentLike,
  heartComment,
  pinComment,
  moderateComment,
  toggleComments,
  fetchComments,
  loadReplies,
} from "@/lib/comment-actions";

type CommentDTO = {
  id: string;
  body: string;
  createdAt: string;
  author: {
    id: string;
    name: string | null;
    username: string;
    avatarUrl: string | null;
  };
  likeCount: number;
  myLiked: boolean;
  heartedByCreator: boolean;
  isPinned: boolean;
  replyCount: number;
  isOwn: boolean;
  authorIsMember: boolean;
  isSuperThanks: boolean;
  superThanksAmount: number | null;
};

function goSignIn() {
  window.location.href = `/sign-in?next=${encodeURIComponent(
    window.location.pathname + window.location.search,
  )}`;
}

function Avatar({ url, size = 40 }: { url: string | null; size?: number }) {
  return (
    <span
      className="grid shrink-0 place-items-center overflow-hidden rounded-full bg-hover"
      style={{ width: size, height: size }}
    >
      {url ? (
        // eslint-disable-next-line @next/next/no-img-element
        <img src={url} alt="" className="h-full w-full object-cover" />
      ) : (
        <User className="h-1/2 w-1/2 text-fg-muted" />
      )}
    </span>
  );
}

function Composer({
  isSignedIn,
  onSubmit,
  placeholder = "Add a comment...",
  autoFocus = false,
  onCancel,
}: {
  isSignedIn: boolean;
  onSubmit: (body: string) => Promise<boolean>;
  placeholder?: string;
  autoFocus?: boolean;
  onCancel?: () => void;
}) {
  const [body, setBody] = useState("");
  const [pending, startTransition] = useTransition();

  function submit() {
    if (!isSignedIn) return goSignIn();
    const text = body.trim();
    if (!text) return;
    startTransition(async () => {
      const ok = await onSubmit(text);
      if (ok) setBody("");
    });
  }

  return (
    <div className="min-w-0 flex-1">
      <textarea
        value={body}
        onChange={(e) => setBody(e.target.value)}
        placeholder={placeholder}
        rows={1}
        autoFocus={autoFocus}
        className="w-full resize-none border-b border-border bg-transparent pb-1 text-sm outline-none focus:border-fg"
      />
      <div className="mt-2 flex justify-end gap-2">
        {onCancel ? (
          <button
            type="button"
            onClick={onCancel}
            className="rounded-full px-3 py-1.5 text-sm hover:bg-hover"
          >
            Cancel
          </button>
        ) : null}
        <button
          type="button"
          onClick={submit}
          disabled={pending || !body.trim()}
          className="rounded-full bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg disabled:opacity-50"
        >
          Comment
        </button>
      </div>
    </div>
  );
}

function Kebab({
  items,
}: {
  items: { label: string; onClick: () => void; danger?: boolean }[];
}) {
  const [open, setOpen] = useState(false);
  if (items.length === 0) return null;
  return (
    <div className="relative">
      <button
        type="button"
        aria-label="Comment actions"
        onClick={() => setOpen((o) => !o)}
        className="grid h-8 w-8 place-items-center rounded-full hover:bg-hover"
      >
        <MoreVertical className="h-4 w-4" />
      </button>
      {open ? (
        <>
          <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
          <div className="absolute right-0 z-20 mt-1 w-44 overflow-hidden rounded-lg border border-border bg-surface py-1 shadow-lg">
            {items.map((it) => (
              <button
                key={it.label}
                type="button"
                onClick={() => {
                  setOpen(false);
                  it.onClick();
                }}
                className={cn(
                  "block w-full px-3 py-1.5 text-left text-sm hover:bg-hover",
                  it.danger && "text-red-500",
                )}
              >
                {it.label}
              </button>
            ))}
          </div>
        </>
      ) : null}
    </div>
  );
}

function CommentItem({
  comment,
  videoId,
  isSignedIn,
  isCreator,
  myAvatar,
  isReply = false,
  adjustCount,
  onRemoved,
}: {
  comment: CommentDTO;
  videoId: string;
  isSignedIn: boolean;
  isCreator: boolean;
  myAvatar: string | null;
  isReply?: boolean;
  adjustCount: (d: number) => void;
  onRemoved: (id: string, removedReplies: number) => void;
}) {
  const [liked, setLiked] = useState(comment.myLiked);
  const [likeCount, setLikeCount] = useState(comment.likeCount);
  const [hearted, setHearted] = useState(comment.heartedByCreator);
  const [pinned, setPinned] = useState(comment.isPinned);
  const [replying, setReplying] = useState(false);
  const [replies, setReplies] = useState<CommentDTO[]>([]);
  const [repliesOpen, setRepliesOpen] = useState(false);
  const [replyCount, setReplyCount] = useState(comment.replyCount);
  const [, startTransition] = useTransition();

  function like() {
    if (!isSignedIn) return goSignIn();
    const prevLiked = liked;
    const prevCount = likeCount;
    setLiked(!liked);
    setLikeCount(likeCount + (liked ? -1 : 1));
    startTransition(async () => {
      const res = await toggleCommentLike(comment.id);
      if ("error" in res) {
        setLiked(prevLiked);
        setLikeCount(prevCount);
      } else {
        setLiked(res.liked);
        setLikeCount(res.count);
      }
    });
  }

  async function showReplies() {
    setRepliesOpen(true);
    if (replies.length === 0) setReplies(await loadReplies(comment.id));
  }

  function removeReplyLocally(id: string) {
    setReplies((r) => r.filter((x) => x.id !== id));
    setReplyCount((n) => Math.max(0, n - 1));
    adjustCount(-1);
  }

  const items: { label: string; onClick: () => void; danger?: boolean }[] = [];
  if (comment.isOwn) {
    items.push({
      label: "Delete",
      danger: true,
      onClick: () => {
        if (!confirm("Delete this comment?")) return;
        startTransition(async () => {
          const res = await deleteComment(comment.id);
          if (!("error" in res)) onRemoved(comment.id, isReply ? 0 : replyCount);
        });
      },
    });
  }
  if (isCreator) {
    items.push({
      label: hearted ? "Remove heart" : "Heart",
      onClick: () =>
        startTransition(async () => {
          const res = await heartComment(comment.id);
          if (!("error" in res)) setHearted(res.hearted);
        }),
    });
    if (!isReply) {
      items.push({
        label: pinned ? "Unpin" : "Pin",
        onClick: () =>
          startTransition(async () => {
            const res = await pinComment(comment.id);
            if (!("error" in res)) setPinned(res.pinned);
          }),
      });
    }
    if (!comment.isOwn) {
      items.push({
        label: "Remove",
        danger: true,
        onClick: () =>
          startTransition(async () => {
            const res = await moderateComment(comment.id, "remove");
            if (!("error" in res))
              onRemoved(comment.id, isReply ? 0 : replyCount);
          }),
      });
    }
  }

  return (
    <div className="flex gap-3">
      <Avatar url={comment.author.avatarUrl} size={isReply ? 32 : 40} />
      <div className="min-w-0 flex-1">
        {pinned && !isReply ? (
          <p className="mb-0.5 text-xs text-fg-muted">Pinned</p>
        ) : null}
        <div className="flex items-center gap-2">
          <span className="text-[13px] font-medium">
            {comment.author.name ?? `@${comment.author.username}`}
          </span>
          {comment.authorIsMember ? (
            <span className="rounded-full bg-accent/15 px-1.5 py-0.5 text-[10px] font-medium text-accent">
              Member
            </span>
          ) : null}
          {comment.isSuperThanks && comment.superThanksAmount ? (
            <span className="rounded-full bg-brand px-2 py-0.5 text-[11px] font-semibold text-white">
              ${(comment.superThanksAmount / 100).toFixed(2)}
            </span>
          ) : null}
          <span className="text-xs text-fg-muted">
            {formatTimeAgo(comment.createdAt)}
          </span>
          {hearted ? (
            <Heart className="h-3.5 w-3.5 fill-red-500 text-red-500" />
          ) : null}
        </div>
        <p
          className={cn(
            "mt-0.5 whitespace-pre-wrap break-words text-sm",
            comment.isSuperThanks && "rounded-lg bg-brand/10 px-3 py-2",
          )}
        >
          {comment.body}
        </p>

        <div className="mt-1 flex items-center gap-3">
          <button
            type="button"
            onClick={like}
            className="flex items-center gap-1 text-fg-muted hover:text-fg"
          >
            <ThumbsUp className={cn("h-4 w-4", liked && "fill-current text-fg")} />
            {likeCount > 0 ? (
              <span className="text-xs">{formatCompact(likeCount)}</span>
            ) : null}
          </button>
          {!isReply ? (
            <button
              type="button"
              onClick={() => (isSignedIn ? setReplying(true) : goSignIn())}
              className="text-xs font-medium text-fg-muted hover:text-fg"
            >
              Reply
            </button>
          ) : null}
          <div className="ml-auto">
            <Kebab items={items} />
          </div>
        </div>

        {replying && !isReply ? (
          <div className="mt-2 flex gap-3">
            <Avatar url={myAvatar} size={32} />
            <Composer
              isSignedIn={isSignedIn}
              placeholder="Add a reply..."
              autoFocus
              onCancel={() => setReplying(false)}
              onSubmit={async (text) => {
                const res = await postComment(videoId, text, comment.id);
                if ("error" in res) return false;
                setReplying(false);
                adjustCount(1);
                setRepliesOpen(true);
                const fresh = await loadReplies(comment.id);
                setReplies(fresh);
                setReplyCount(fresh.length);
                return true;
              }}
            />
          </div>
        ) : null}

        {!isReply && replyCount > 0 ? (
          <div className="mt-2">
            {!repliesOpen ? (
              <button
                type="button"
                onClick={showReplies}
                className="text-sm font-medium text-accent hover:underline"
              >
                View {replyCount} {replyCount === 1 ? "reply" : "replies"}
              </button>
            ) : (
              <div className="mt-3 flex flex-col gap-4">
                {replies.map((r) => (
                  <CommentItem
                    key={r.id}
                    comment={r}
                    videoId={videoId}
                    isSignedIn={isSignedIn}
                    isCreator={isCreator}
                    myAvatar={myAvatar}
                    isReply
                    adjustCount={adjustCount}
                    onRemoved={(id) => removeReplyLocally(id)}
                  />
                ))}
              </div>
            )}
          </div>
        ) : null}
      </div>
    </div>
  );
}

export function Comments({
  videoId,
  isSignedIn,
  isCreator,
  myAvatar,
  initial,
}: {
  videoId: string;
  isSignedIn: boolean;
  isCreator: boolean;
  myAvatar: string | null;
  initial: { enabled: boolean; count: number; comments: CommentDTO[] };
}) {
  const [enabled, setEnabled] = useState(initial.enabled);
  const [comments, setComments] = useState(initial.comments);
  const [count, setCount] = useState(initial.count);
  const [sort, setSort] = useState<"top" | "newest">("top");
  const [, startTransition] = useTransition();

  const adjustCount = (d: number) => setCount((c) => Math.max(0, c + d));

  function changeSort(s: "top" | "newest") {
    if (s === sort) return;
    setSort(s);
    startTransition(async () => setComments(await fetchComments(videoId, s)));
  }

  function removeTop(id: string, removedReplies: number) {
    setComments((cs) => cs.filter((c) => c.id !== id));
    adjustCount(-(1 + removedReplies));
  }

  function toggleEnabled() {
    const next = !enabled;
    setEnabled(next);
    startTransition(async () => {
      const res = await toggleComments(videoId, next);
      if ("error" in res) setEnabled(!next);
    });
  }

  if (!enabled) {
    return (
      <div className="mt-6 rounded-xl bg-hover p-4 text-sm text-fg-muted">
        Comments are turned off.{" "}
        {isCreator ? (
          <button
            type="button"
            onClick={toggleEnabled}
            className="font-medium text-accent hover:underline"
          >
            Turn on
          </button>
        ) : null}
      </div>
    );
  }

  return (
    <div className="mt-6">
      <div className="flex items-center gap-6">
        <h2 className="text-lg font-bold">
          {formatCompact(count)} {count === 1 ? "Comment" : "Comments"}
        </h2>
        <div className="flex gap-4 text-sm">
          <button
            type="button"
            onClick={() => changeSort("top")}
            className={sort === "top" ? "font-medium text-fg" : "text-fg-muted"}
          >
            Top
          </button>
          <button
            type="button"
            onClick={() => changeSort("newest")}
            className={
              sort === "newest" ? "font-medium text-fg" : "text-fg-muted"
            }
          >
            Newest
          </button>
        </div>
        {isCreator ? (
          <button
            type="button"
            onClick={toggleEnabled}
            className="ml-auto text-sm text-fg-muted hover:text-fg"
          >
            Turn off comments
          </button>
        ) : null}
      </div>

      <div className="mt-4 flex gap-3">
        <Avatar url={myAvatar} size={40} />
        <Composer
          isSignedIn={isSignedIn}
          onSubmit={async (text) => {
            const res = await postComment(videoId, text);
            if ("error" in res) return false;
            setComments((cs) => [res.comment, ...cs]);
            adjustCount(1);
            return true;
          }}
        />
      </div>

      <div className="mt-6 flex flex-col gap-5">
        {comments.map((c) => (
          <CommentItem
            key={c.id}
            comment={c}
            videoId={videoId}
            isSignedIn={isSignedIn}
            isCreator={isCreator}
            myAvatar={myAvatar}
            adjustCount={adjustCount}
            onRemoved={removeTop}
          />
        ))}
      </div>
    </div>
  );
}

Watch history and resume

For each video, we track how far the user has progressed: at regular intervals while watching, and once more when they leave. This feature powers our History page, the progress bar on video cards, and the "Continue Watching" section on the home page.

First the history reads. Create src/lib/history.ts:

history.ts
import "server-only";
import { prisma } from "./prisma";
import type { Prisma } from "@/generated/prisma/client";
import type { FeedVideo } from "@/components/feed/video-card";

const cardSelect = {
  id: true,
  title: true,
  thumbnailUrl: true,
  durationSeconds: true,
  viewCount: true,
  publishedAt: true,
  channel: { select: { handle: true, name: true, avatarUrl: true } },
} as const;

export type HistoryItem = { video: FeedVideo; progressSeconds: number };

export async function getResumePosition(
  userId: string,
  videoId: string,
): Promise<number> {
  const h = await prisma.watchHistory.findUnique({
    where: { userId_videoId: { userId, videoId } },
    select: { positionSeconds: true, completed: true },
  });
  return h && !h.completed ? h.positionSeconds : 0;
}

const viewableBy = (userId: string): Prisma.VideoWhereInput => ({
  status: "READY",
  OR: [
    { visibility: { in: ["PUBLIC", "UNLISTED"] } },
    { channel: { userId } },
  ],
});

export async function getHistory(userId: string): Promise<HistoryItem[]> {
  const rows = await prisma.watchHistory.findMany({
    where: {
      userId,
      video: viewableBy(userId),
    },
    orderBy: { lastWatchedAt: "desc" },
    take: 100,
    select: {
      positionSeconds: true,
      completed: true,
      video: { select: cardSelect },
    },
  });
  return rows.map((r) => ({
    video: r.video,
    progressSeconds: r.completed ? r.video.durationSeconds : r.positionSeconds,
  }));
}

export async function getContinueWatching(
  userId: string,
): Promise<HistoryItem[]> {
  const rows = await prisma.watchHistory.findMany({
    where: {
      userId,
      completed: false,
      positionSeconds: { gt: 5 },
      video: viewableBy(userId),
    },
    orderBy: { lastWatchedAt: "desc" },
    take: 12,
    select: { positionSeconds: true, video: { select: cardSelect } },
  });
  return rows.map((r) => ({ video: r.video, progressSeconds: r.positionSeconds }));
}

export async function isHistoryPaused(userId: string): Promise<boolean> {
  const u = await prisma.user.findUnique({
    where: { id: userId },
    select: { historyPaused: true },
  });
  return Boolean(u?.historyPaused);
}

Then the writes, called from the player. Create src/lib/history-actions.ts:

history-actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { prisma } from "./prisma";
import { getCurrentUser } from "./session";

export async function recordWatchProgress(
  videoId: string,
  positionSeconds: number,
  durationSeconds: number,
): Promise<void> {
  const user = await getCurrentUser();
  if (!user || typeof videoId !== "string" || videoId.length === 0) return;

  if (!Number.isFinite(positionSeconds) || !Number.isFinite(durationSeconds)) return;
  const MAX_SECONDS = 12 * 60 * 60;
  const pos = Math.min(Math.max(0, Math.round(positionSeconds)), MAX_SECONDS);
  const dur = Math.min(Math.max(0, Math.round(durationSeconds)), MAX_SECONDS);

  const u = await prisma.user.findUnique({
    where: { id: user.id },
    select: { historyPaused: true },
  });
  if (u?.historyPaused) return;

  const completed = dur > 0 && pos >= dur * 0.95;

  await prisma.watchHistory.upsert({
    where: { userId_videoId: { userId: user.id, videoId } },
    create: { userId: user.id, videoId, positionSeconds: pos, completed },
    update: { positionSeconds: pos, completed, lastWatchedAt: new Date() },
  });
}

export async function clearHistory(): Promise<{ ok: true } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };
  await prisma.watchHistory.deleteMany({ where: { userId: user.id } });
  revalidatePath("/feed/history");
  revalidatePath("/");
  return { ok: true };
}

export async function setHistoryPaused(
  paused: boolean,
): Promise<{ paused: boolean } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };
  await prisma.user.update({
    where: { id: user.id },
    data: { historyPaused: paused },
  });
  return { paused };
}

The player now supports resuming playback. It jumps to the saved point and tracks progress during playback. Open src/components/watch/watch-player.tsx and replace its contents:

watch-player.tsx
"use client";

import { useEffect, useRef } from "react";
import { recordWatchProgress } from "@/lib/history-actions";

export function WatchPlayer({
  videoId,
  src,
  poster,
  resumeAt,
  isSignedIn,
}: {
  videoId: string;
  src: string;
  poster?: string;
  resumeAt: number;
  isSignedIn: boolean;
}) {
  const ref = useRef<HTMLVideoElement>(null);
  const lastSent = useRef(0);

  useEffect(() => {
    const video = ref.current;
    if (!video) return;

    if (resumeAt > 0) {
      const seek = () => {
        try {
          video.currentTime = resumeAt;
        } catch {}
      };
      if (video.readyState >= 1) seek();
      else video.addEventListener("loadedmetadata", seek, { once: true });
    }

    if (!isSignedIn) return;

    const send = () => {
      const pos = Math.floor(video.currentTime || 0);
      const dur = Math.floor(video.duration || 0);
      recordWatchProgress(videoId, pos, dur).catch(() => {});
    };
    const onTime = () => {
      const now = Date.now();
      if (now - lastSent.current > 5000) {
        lastSent.current = now;
        send();
      }
    };

    video.addEventListener("timeupdate", onTime);
    video.addEventListener("pause", send);
    video.addEventListener("ended", send);
    return () => {
      video.removeEventListener("timeupdate", onTime);
      video.removeEventListener("pause", send);
      video.removeEventListener("ended", send);
      send();
    };
  }, [videoId, resumeAt, isSignedIn]);

  return (
    // eslint-disable-next-line jsx-a11y/media-has-caption
    <video
      ref={ref}
      src={src}
      poster={poster}
      controls
      playsInline
      className="aspect-video max-h-[80vh] w-full"
    />
  );
}

Pause and clear controls for the History page. Create src/components/library/history-controls.tsx:

history-controls.tsx
"use client";

import { useState, useTransition } from "react";
import { Trash2, Pause, Play } from "lucide-react";
import { clearHistory, setHistoryPaused } from "@/lib/history-actions";

export function HistoryControls({
  initialPaused,
  hasHistory,
}: {
  initialPaused: boolean;
  hasHistory: boolean;
}) {
  const [paused, setPaused] = useState(initialPaused);
  const [, startTransition] = useTransition();

  function togglePause() {
    const next = !paused;
    setPaused(next);
    startTransition(async () => {
      const res = await setHistoryPaused(next);
      if ("error" in res) setPaused(!next);
    });
  }

  function onClear() {
    if (!confirm("Clear all watch history? This can't be undone.")) return;
    startTransition(async () => {
      await clearHistory();
    });
  }

  return (
    <div className="flex flex-col gap-2">
      {hasHistory ? (
        <button
          type="button"
          onClick={onClear}
          className="flex items-center gap-3 rounded-full px-3 py-2 text-sm font-medium hover:bg-hover"
        >
          <Trash2 className="h-5 w-5" />
          Clear all watch history
        </button>
      ) : null}
      <button
        type="button"
        onClick={togglePause}
        className="flex items-center gap-3 rounded-full px-3 py-2 text-sm font-medium hover:bg-hover"
      >
        {paused ? <Play className="h-5 w-5" /> : <Pause className="h-5 w-5" />}
        {paused ? "Turn on watch history" : "Pause watch history"}
      </button>
    </div>
  );
}

Create src/app/(main)/feed/history/page.tsx:

page.tsx
import { requireUser } from "@/lib/auth";
import { getHistory, isHistoryPaused } from "@/lib/history";
import { VideoCard } from "@/components/feed/video-card";
import { HistoryControls } from "@/components/library/history-controls";

export const metadata = { title: "History - Wavora" };

export default async function HistoryPage() {
  const user = await requireUser();
  const [items, paused] = await Promise.all([
    getHistory(user.id),
    isHistoryPaused(user.id),
  ]);

  return (
    <div className="mx-auto flex max-w-[1280px] flex-col gap-8 lg:flex-row-reverse">
      <aside className="lg:w-72 lg:shrink-0">
        <HistoryControls initialPaused={paused} hasHistory={items.length > 0} />
      </aside>

      <div className="min-w-0 flex-1">
        <h1 className="mb-6 text-2xl font-bold">Watch history</h1>
        {items.length > 0 ? (
          <div className="grid grid-cols-1 gap-x-4 gap-y-8 sm:grid-cols-2 xl:grid-cols-3">
            {items.map((it) => (
              <VideoCard
                key={it.video.id}
                video={it.video}
                progressSeconds={it.progressSeconds}
              />
            ))}
          </div>
        ) : (
          <p className="py-16 text-center text-sm text-fg-muted">
            {paused
              ? "Watch history is paused. Turn it back on to start tracking again."
              : "Videos you watch will show up here."}
          </p>
        )}
      </div>
    </div>
  );
}

The card gets a thin red resume bar. Open src/components/feed/video-card.tsx and replace its contents:

video-card.tsx
import Link from "next/link";
import { User } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDuration, formatTimeAgo, formatViews } from "@/lib/format";

export type FeedVideo = {
  id: string;
  title: string;
  thumbnailUrl: string | null;
  durationSeconds: number;
  viewCount: number;
  publishedAt: Date | string | null;
  channel: {
    handle: string;
    name: string;
    avatarUrl: string | null;
  };
};

export function VideoCard({
  video,
  className,
  progressSeconds,
}: {
  video: FeedVideo;
  className?: string;
  progressSeconds?: number;
}) {
  const { channel } = video;
  const watchHref = `/watch?v=${video.id}`;
  const channelHref = `/@${channel.handle}`;

  return (
    <div className={cn("group flex flex-col gap-3", className)}>
      {}
      <Link
        href={watchHref}
        className="relative block aspect-video w-full overflow-hidden rounded-xl bg-hover"
      >
        {video.thumbnailUrl ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={video.thumbnailUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        ) : null}
        {video.durationSeconds > 0 ? (
          <span className="absolute bottom-1.5 right-1.5 rounded bg-black/80 px-1 py-0.5 text-xs font-medium text-white">
            {formatDuration(video.durationSeconds)}
          </span>
        ) : null}
        {}
        {progressSeconds && progressSeconds > 0 && video.durationSeconds > 0 ? (
          <span className="absolute inset-x-0 bottom-0 h-1 bg-white/30">
            <span
              className="block h-full bg-brand"
              style={{
                width: `${Math.min(100, (progressSeconds / video.durationSeconds) * 100)}%`,
              }}
            />
          </span>
        ) : null}
      </Link>

      {}
      <div className="flex gap-3">
        <Link
          href={channelHref}
          aria-label={channel.name}
          className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-full bg-hover"
        >
          {channel.avatarUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={channel.avatarUrl}
              alt=""
              className="h-full w-full object-cover"
            />
          ) : (
            <User className="h-5 w-5 text-fg-muted" />
          )}
        </Link>

        <div className="min-w-0 flex-1">
          <Link href={watchHref}>
            <h3 className="line-clamp-2 text-sm font-medium leading-5 text-fg">
              {video.title}
            </h3>
          </Link>
          <Link
            href={channelHref}
            className="mt-1 block truncate text-sm text-fg-muted hover:text-fg"
          >
            {channel.name}
          </Link>
          <p className="truncate text-sm text-fg-muted">
            {formatViews(video.viewCount)}
            {video.publishedAt ? ` • ${formatTimeAgo(video.publishedAt)}` : ""}
          </p>
        </div>
      </div>
    </div>
  );
}

And the home page gets a "Continue" watching shelf. Open src/app/(main)/page.tsx and replace its contents:

page.tsx
import Link from "next/link";
import { getCurrentUser } from "@/lib/session";
import { getFeedVideos } from "@/lib/videos";
import { getContinueWatching } from "@/lib/history";
import { VideoCard } from "@/components/feed/video-card";

export default async function HomePage() {
  const user = await getCurrentUser();
  const [videos, continueWatching] = await Promise.all([
    getFeedVideos(24),
    user ? getContinueWatching(user.id) : Promise.resolve([]),
  ]);

  return (
    <div className="mx-auto max-w-[2400px]">
      {continueWatching.length > 0 ? (
        <section className="mb-8">
          <h2 className="mb-3 text-lg font-bold">Continue watching</h2>
          <div className="flex gap-4 overflow-x-auto pb-2 [scrollbar-width:none]">
            {continueWatching.map((it) => (
              <div key={it.video.id} className="w-72 shrink-0 sm:w-80">
                <VideoCard
                  video={it.video}
                  progressSeconds={it.progressSeconds}
                />
              </div>
            ))}
          </div>
        </section>
      ) : null}

      {videos.length === 0 ? (
        <div className="flex flex-col items-center gap-3 py-24 text-center">
          <p className="text-lg font-medium">No videos yet</p>
          <p className="max-w-sm text-sm text-fg-muted">
            Be the first to upload. Create a channel and publish a video, and it
            shows up right here.
          </p>
          <Link
            href="/studio/upload"
            className="mt-2 rounded-full bg-accent px-5 py-2.5 font-medium text-accent-fg hover:opacity-90"
          >
            Upload a video
          </Link>
        </div>
      ) : (
        <div className="grid grid-cols-1 gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
          {videos.map((video) => (
            <VideoCard key={video.id} video={video} />
          ))}
        </div>
      )}
    </div>
  );
}

Watch Later, Liked, and the subscriptions feed

"Watch Later" and "Favorites" are two system lists available to every user. The "Save" button toggles "Watch Later" on the watch page, and the subscription feed displays the latest uploads from the channels the user follows.

Create src/lib/library.ts:

library.ts
import "server-only";
import { prisma } from "./prisma";
import type { Prisma } from "@/generated/prisma/client";
import type { FeedVideo } from "@/components/feed/video-card";

const cardSelect = {
  id: true,
  title: true,
  thumbnailUrl: true,
  durationSeconds: true,
  viewCount: true,
  publishedAt: true,
  channel: { select: { handle: true, name: true, avatarUrl: true } },
} as const;

const viewableBy = (userId: string): Prisma.VideoWhereInput => ({
  status: "READY",
  OR: [
    { visibility: { in: ["PUBLIC", "UNLISTED"] } },
    { channel: { userId } },
  ],
});

export async function getWatchLater(userId: string): Promise<FeedVideo[]> {
  const rows = await prisma.watchLater.findMany({
    where: { userId, video: viewableBy(userId) },
    orderBy: { addedAt: "desc" },
    take: 100,
    select: { video: { select: cardSelect } },
  });
  return rows.map((r) => r.video);
}

export async function getLikedVideos(userId: string): Promise<FeedVideo[]> {
  const rows = await prisma.reaction.findMany({
    where: { userId, type: "LIKE", video: viewableBy(userId) },
    orderBy: { createdAt: "desc" },
    take: 100,
    select: { video: { select: cardSelect } },
  });
  return rows.map((r) => r.video);
}

export async function getSubscriptionsFeed(userId: string): Promise<FeedVideo[]> {
  return prisma.video.findMany({
    where: {
      visibility: "PUBLIC",
      status: "READY",
      isShort: false,
      channel: { subscribers: { some: { subscriberId: userId } } },
    },
    orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
    take: 50,
    select: cardSelect,
  });
}

export async function getSubscribedChannels(userId: string) {
  const rows = await prisma.subscription.findMany({
    where: { subscriberId: userId },
    orderBy: { createdAt: "desc" },
    take: 100,
    select: {
      channel: { select: { handle: true, name: true, avatarUrl: true } },
    },
  });
  return rows.map((r) => r.channel);
}

export async function isInWatchLater(
  userId: string,
  videoId: string,
): Promise<boolean> {
  const row = await prisma.watchLater.findUnique({
    where: { userId_videoId: { userId, videoId } },
    select: { id: true },
  });
  return Boolean(row);
}

Create src/lib/library-actions.ts:

library-actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { prisma } from "./prisma";
import { getCurrentUser } from "./session";

function prismaCode(e: unknown): string | undefined {
  return e && typeof e === "object" && "code" in e
    ? (e as { code?: string }).code
    : undefined;
}

export async function toggleWatchLater(
  videoId: string,
): Promise<{ saved: boolean } | { error: "sign_in" }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };

  const existing = await prisma.watchLater.findUnique({
    where: { userId_videoId: { userId: user.id, videoId } },
    select: { id: true },
  });
  if (existing) {
    try {
      await prisma.watchLater.delete({ where: { id: existing.id } });
    } catch (e) {
      if (prismaCode(e) !== "P2025") throw e;
    }
  } else {
    try {
      await prisma.watchLater.create({ data: { userId: user.id, videoId } });
    } catch (e) {
      const code = prismaCode(e);
      if (code === "P2003") return { saved: false };
      if (code !== "P2002") throw e;
    }
  }

  revalidatePath("/playlist");
  return { saved: !existing };
}

Create src/components/watch/save-button.tsx:

save-button.tsx
"use client";

import { useState, useTransition } from "react";
import { Bookmark, BookmarkCheck } from "lucide-react";
import { toggleWatchLater } from "@/lib/library-actions";
import { cn } from "@/lib/utils";

function goSignIn() {
  window.location.href = `/sign-in?next=${encodeURIComponent(
    window.location.pathname + window.location.search,
  )}`;
}

export function SaveButton({
  videoId,
  isSignedIn,
  initialSaved,
}: {
  videoId: string;
  isSignedIn: boolean;
  initialSaved: boolean;
}) {
  const [saved, setSaved] = useState(initialSaved);
  const [pending, startTransition] = useTransition();

  function onClick() {
    if (!isSignedIn) return goSignIn();
    const next = !saved;
    setSaved(next);
    startTransition(async () => {
      const res = await toggleWatchLater(videoId);
      if ("error" in res) setSaved(!next);
      else setSaved(res.saved);
    });
  }

  return (
    <button
      type="button"
      onClick={onClick}
      disabled={pending}
      className={cn(
        "flex items-center gap-2 rounded-full bg-chip px-4 py-2 text-sm font-medium hover:bg-hover-strong disabled:opacity-60",
        saved && "text-accent",
      )}
    >
      {saved ? (
        <BookmarkCheck className="h-5 w-5" />
      ) : (
        <Bookmark className="h-5 w-5" />
      )}
      {saved ? "Saved" : "Save"}
    </button>
  );
}

One page renders both system lists and we add custom playlists to it in Part 5. Create src/app/(main)/playlist/page.tsx:

page.tsx
import { notFound } from "next/navigation";
import { requireUser } from "@/lib/auth";
import { getLikedVideos, getWatchLater } from "@/lib/library";
import { VideoGrid } from "@/components/feed/video-grid";

type SearchParams = Promise<{ list?: string | string[] }>;

const listId = (list: string | string[] | undefined) =>
  Array.isArray(list) ? list[0] : list;

const SYSTEM = {
  WL: {
    title: "Watch later",
    load: getWatchLater,
    empty: "Videos you save will show up here.",
  },
  LL: {
    title: "Liked videos",
    load: getLikedVideos,
    empty: "Videos you like will show up here.",
  },
} as const;

export async function generateMetadata({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const list = listId((await searchParams).list);
  if (list === "WL" || list === "LL") {
    return { title: `${SYSTEM[list].title} - Wavora` };
  }
  return { title: "Playlist - Wavora" };
}

export default async function PlaylistPage({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const list = listId((await searchParams).list);

  if (list !== "WL" && list !== "LL") notFound();

  const meta = SYSTEM[list];
  const user = await requireUser();
  const videos = await meta.load(user.id);

  return (
    <div className="mx-auto max-w-[2400px]">
      <h1 className="mb-6 text-2xl font-bold">{meta.title}</h1>
      {videos.length > 0 ? (
        <VideoGrid videos={videos} />
      ) : (
        <p className="py-16 text-center text-sm text-fg-muted">{meta.empty}</p>
      )}
    </div>
  );
}

Create src/app/(main)/feed/subscriptions/page.tsx:

page.tsx
import { requireUser } from "@/lib/auth";
import { getSubscriptionsFeed } from "@/lib/library";
import { VideoGrid } from "@/components/feed/video-grid";

export const metadata = { title: "Subscriptions - Wavora" };

export default async function SubscriptionsPage() {
  const user = await requireUser();
  const videos = await getSubscriptionsFeed(user.id);

  return (
    <div className="mx-auto max-w-[2400px]">
      <h1 className="mb-6 text-2xl font-bold">Subscriptions</h1>
      {videos.length > 0 ? (
        <VideoGrid videos={videos} />
      ) : (
        <div className="py-16 text-center text-sm text-fg-muted">
          No videos yet. Subscribe to channels and their latest uploads show up
          here.
        </div>
      )}
    </div>
  );
}

The watch page, updated

Now we're integrating all of this into the watch page. The "Subscribe", "Like," "Dislike," and "Save" buttons, and the comments section. Open src/app/(main)/watch/page.tsx and replace its contents:

page.tsx
import { notFound } from "next/navigation";
import Link from "next/link";
import { User } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { getRelatedVideos } from "@/lib/videos";
import { getChannelSocial, getVideoReactions } from "@/lib/social";
import { getCommentsData } from "@/lib/comments";
import { isInWatchLater } from "@/lib/library";
import { getResumePosition } from "@/lib/history";
import {
  formatDuration,
  formatSubscribers,
  formatTimeAgo,
  formatViews,
} from "@/lib/format";
import type { FeedVideo } from "@/components/feed/video-card";
import { SubscribeButton } from "@/components/watch/subscribe-button";
import { LikeDislike } from "@/components/watch/like-dislike";
import { SaveButton } from "@/components/watch/save-button";
import { WatchPlayer } from "@/components/watch/watch-player";
import { Comments } from "@/components/comments/comments";
import { ViewTracker } from "./view-tracker";

type SearchParams = Promise<{ v?: string | string[] }>;

export default async function WatchPage({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const { v: rawV } = await searchParams;
  const v = Array.isArray(rawV) ? rawV[0] : rawV;
  if (!v) notFound();

  const video = await prisma.video.findUnique({
    where: { id: v },
    include: {
      channel: {
        select: {
          handle: true,
          name: true,
          avatarUrl: true,
          userId: true,
        },
      },
    },
  });
  if (!video || video.status !== "READY" || !video.videoUrl) notFound();

  const user = await getCurrentUser();

  if (video.visibility === "PRIVATE" && user?.id !== video.channel.userId) {
    notFound();
  }

  const [related, channelSocial, reactions, commentsData, savedToWatchLater, resumeAt] =
    await Promise.all([
      getRelatedVideos(video.id),
      getChannelSocial(video.channelId, user?.id),
      getVideoReactions(video.id, user?.id),
      getCommentsData(video.id, user?.id, "top"),
      user ? isInWatchLater(user.id, video.id) : Promise.resolve(false),
      user ? getResumePosition(user.id, video.id) : Promise.resolve(0),
    ]);

  return (
    <div className="mx-auto flex max-w-[1700px] flex-col gap-6 lg:flex-row">
      {}
      <div className="min-w-0 flex-1">
        <div className="overflow-hidden rounded-xl bg-black">
          <WatchPlayer
            key={video.id}
            videoId={video.id}
            src={video.videoUrl}
            poster={video.thumbnailUrl ?? undefined}
            resumeAt={resumeAt}
            isSignedIn={Boolean(user)}
          />
        </div>
        <ViewTracker videoId={video.id} />

        <h1 className="mt-3 text-xl font-bold leading-tight">{video.title}</h1>

        {}
        <div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
          <div className="flex items-center gap-3">
            <Link
              href={`/@${video.channel.handle}`}
              className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-full bg-hover"
            >
              {video.channel.avatarUrl ? (
                // eslint-disable-next-line @next/next/no-img-element
                <img
                  src={video.channel.avatarUrl}
                  alt=""
                  className="h-full w-full object-cover"
                />
              ) : (
                <User className="h-5 w-5 text-fg-muted" />
              )}
            </Link>
            <div className="min-w-0">
              <Link
                href={`/@${video.channel.handle}`}
                className="block truncate font-medium"
              >
                {video.channel.name}
              </Link>
              <p className="truncate text-xs text-fg-muted">
                {formatSubscribers(channelSocial.subscriberCount)}
              </p>
            </div>
            <SubscribeButton
              channelId={video.channelId}
              isOwner={user?.id === video.channel.userId}
              isSignedIn={Boolean(user)}
              initialSubscribed={channelSocial.isSubscribed}
              initialNotify={channelSocial.notifyLevel}
            />
          </div>

          <div className="flex items-center gap-2">
            <LikeDislike
              videoId={video.id}
              isSignedIn={Boolean(user)}
              initialReaction={reactions.myReaction}
              initialLikeCount={reactions.likeCount}
            />
            <SaveButton
              videoId={video.id}
              isSignedIn={Boolean(user)}
              initialSaved={savedToWatchLater}
            />
          </div>
        </div>

        {}
        <div className="mt-4 rounded-xl bg-hover p-3 text-sm">
          <p className="font-medium">
            {formatViews(video.viewCount)}
            {video.publishedAt
              ? ` • ${formatTimeAgo(video.publishedAt)}`
              : " • Unpublished"}
          </p>
          {video.description ? (
            <p className="mt-2 whitespace-pre-wrap text-fg">
              {video.description}
            </p>
          ) : null}
        </div>

        {commentsData ? (
          <Comments
            videoId={video.id}
            isSignedIn={Boolean(user)}
            isCreator={commentsData.isCreator}
            myAvatar={user?.avatarUrl ?? null}
            initial={{
              enabled: commentsData.enabled,
              count: commentsData.count,
              comments: commentsData.comments,
            }}
          />
        ) : null}
      </div>

      {}
      <aside className="w-full shrink-0 lg:w-[402px]">
        <div className="flex flex-col gap-2">
          {related.map((r) => (
            <RelatedRow key={r.id} video={r} />
          ))}
        </div>
      </aside>
    </div>
  );
}

function RelatedRow({ video }: { video: FeedVideo }) {
  return (
    <Link href={`/watch?v=${video.id}`} className="flex gap-2">
      <div className="relative aspect-video w-40 shrink-0 overflow-hidden rounded-lg bg-hover">
        {video.thumbnailUrl ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={video.thumbnailUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        ) : null}
        {video.durationSeconds > 0 ? (
          <span className="absolute bottom-1 right-1 rounded bg-black/80 px-1 text-xs text-white">
            {formatDuration(video.durationSeconds)}
          </span>
        ) : null}
      </div>
      <div className="min-w-0 flex-1">
        <h3 className="line-clamp-2 text-sm font-medium leading-5">
          {video.title}
        </h3>
        <p className="mt-1 truncate text-xs text-fg-muted">
          {video.channel.name}
        </p>
        <p className="truncate text-xs text-fg-muted">
          {formatViews(video.viewCount)}
          {video.publishedAt ? ` • ${formatTimeAgo(video.publishedAt)}` : ""}
        </p>
      </div>
    </Link>
  );
}

Channel tabs

And now we make the channel page a tabbed layout with a common header consisting of a banner, a "Subscribe" button, and a tab bar, with a separate page for each tab. The single page we created in Part 2 becomes the main page of the project, the "Home" tab.

The layout holds the header and tabs and wraps every tab. Create src/app/(main)/[handle]/layout.tsx:

layout.tsx
import type { ReactNode } from "react";
import { User } from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { getChannelSocial } from "@/lib/social";
import { getChannelVideoCount, resolveChannel } from "@/lib/channels";
import { SubscribeButton } from "@/components/watch/subscribe-button";
import { ChannelTabs } from "@/components/channel/channel-tabs";
import { formatCompact, formatSubscribers } from "@/lib/format";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ handle: string }>;
}) {
  const { handle } = await params;
  const channel = await resolveChannel(handle);
  return { title: `${channel.name} - Wavora` };
}

export default async function ChannelLayout({
  children,
  params,
}: {
  children: ReactNode;
  params: Promise<{ handle: string }>;
}) {
  const { handle: raw } = await params;
  const channel = await resolveChannel(raw);
  const user = await getCurrentUser();
  const [videoCount, social] = await Promise.all([
    getChannelVideoCount(channel.id),
    getChannelSocial(channel.id, user?.id),
  ]);

  return (
    <div className="mx-auto max-w-[1280px]">
      {channel.bannerUrl ? (
        <div className="mb-4 aspect-[6/1] w-full overflow-hidden rounded-xl bg-hover">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={channel.bannerUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        </div>
      ) : null}

      <div className="flex flex-col items-center gap-4 py-4 sm:flex-row sm:items-end">
        <div className="grid h-28 w-28 shrink-0 place-items-center overflow-hidden rounded-full bg-hover sm:h-40 sm:w-40">
          {channel.avatarUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={channel.avatarUrl}
              alt=""
              className="h-full w-full object-cover"
            />
          ) : (
            <User className="h-16 w-16 text-fg-muted" />
          )}
        </div>

        <div className="min-w-0 text-center sm:text-left">
          <h1 className="text-2xl font-bold sm:text-3xl">{channel.name}</h1>
          <p className="mt-1 text-sm text-fg-muted">
            <span className="font-medium text-fg">@{channel.handle}</span>
            {" • "}
            {formatSubscribers(social.subscriberCount)}
            {" • "}
            {formatCompact(videoCount)} video{videoCount === 1 ? "" : "s"}
          </p>
          {channel.description ? (
            <p className="mt-2 line-clamp-1 max-w-xl text-sm text-fg-muted">
              {channel.description}
            </p>
          ) : null}
          <div className="mt-4 flex justify-center gap-2 sm:justify-start">
            <SubscribeButton
              channelId={channel.id}
              isOwner={user?.id === channel.userId}
              isSignedIn={Boolean(user)}
              initialSubscribed={social.isSubscribed}
              initialNotify={social.notifyLevel}
            />
          </div>
        </div>
      </div>

      <ChannelTabs handle={channel.handle} showShorts={false} showMembership={false} />

      {children}
    </div>
  );
}

The tab strip is a small client component that highlights the active tab. Create src/components/channel/channel-tabs.tsx:

channel-tabs.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";

export function ChannelTabs({
  handle,
  showShorts,
  showMembership,
}: {
  handle: string;
  showShorts?: boolean;
  showMembership?: boolean;
}) {
  const pathname = decodeURIComponent(usePathname());
  const base = `/@${handle}`;
  const tabs = [
    { label: "Home", href: base },
    { label: "Videos", href: `${base}/videos` },
    ...(showShorts ? [{ label: "Waves", href: `${base}/waves` }] : []),
    ...(showMembership
      ? [{ label: "Membership", href: `${base}/membership` }]
      : []),
    { label: "About", href: `${base}/about` },
  ];

  return (
    <div className="sticky top-14 z-20 mb-4 flex gap-6 border-b border-border bg-canvas">
      {tabs.map((tab) => {
        const active = pathname === tab.href;
        return (
          <Link
            key={tab.label}
            href={tab.href}
            className={cn(
              "border-b-2 py-3 text-sm font-medium",
              active
                ? "border-fg text-fg"
                : "border-transparent text-fg-muted hover:text-fg",
            )}
          >
            {tab.label}
          </Link>
        );
      })}
    </div>
  );
}

Open src/app/(main)/[handle]/page.tsx and replace its contents with the Home tab:

page.tsx
import { getChannelVideos, resolveChannel } from "@/lib/channels";
import { VideoGrid } from "@/components/feed/video-grid";

export default async function ChannelHomePage({
  params,
}: {
  params: Promise<{ handle: string }>;
}) {
  const { handle } = await params;
  const channel = await resolveChannel(handle);
  const videos = await getChannelVideos(channel.id);

  if (videos.length === 0) {
    return (
      <p className="py-16 text-center text-sm text-fg-muted">
        This channel hasn&apos;t published any videos yet.
      </p>
    );
  }
  return <VideoGrid videos={videos} />;
}

Create src/app/(main)/[handle]/videos/page.tsx:

page.tsx
import { getChannelVideos, resolveChannel } from "@/lib/channels";
import { VideoGrid } from "@/components/feed/video-grid";

export default async function ChannelVideosPage({
  params,
}: {
  params: Promise<{ handle: string }>;
}) {
  const { handle } = await params;
  const channel = await resolveChannel(handle);
  const videos = await getChannelVideos(channel.id);

  if (videos.length === 0) {
    return (
      <p className="py-16 text-center text-sm text-fg-muted">
        This channel hasn&apos;t published any videos yet.
      </p>
    );
  }
  return <VideoGrid videos={videos} />;
}

Create src/app/(main)/[handle]/about/page.tsx:

page.tsx
import { getChannelVideoCount, resolveChannel } from "@/lib/channels";

export default async function ChannelAboutPage({
  params,
}: {
  params: Promise<{ handle: string }>;
}) {
  const { handle } = await params;
  const channel = await resolveChannel(handle);
  const count = await getChannelVideoCount(channel.id);
  const joined = new Intl.DateTimeFormat("en", { dateStyle: "long" }).format(
    channel.createdAt,
  );

  return (
    <div className="max-w-2xl">
      <h2 className="mb-3 text-lg font-bold">About</h2>
      {channel.description ? (
        <p className="whitespace-pre-wrap text-sm">{channel.description}</p>
      ) : (
        <p className="text-sm text-fg-muted">No description provided.</p>
      )}
      <dl className="mt-6 space-y-1.5 text-sm text-fg-muted">
        <div>Joined {joined}</div>
        <div>
          {count} video{count === 1 ? "" : "s"}
        </div>
      </dl>
    </div>
  );
}

Guide subscriptions and the library hub

Our sidebar can now list the channels the user follows just like how YouTube does. A single page brings together the History, Watch Later, and Favorites shelves. Open src/app/(main)/layout.tsx and replace its contents:

layout.tsx
import type { ReactNode } from "react";
import { AppShell } from "@/components/layout/app-shell";
import { getCurrentUser } from "@/lib/session";
import { getSubscribedChannels } from "@/lib/library";

export default async function MainLayout({ children }: { children: ReactNode }) {
  const user = await getCurrentUser();
  const subscriptions = user ? await getSubscribedChannels(user.id) : [];
  return (
    <AppShell user={user} subscriptions={subscriptions}>
      {children}
    </AppShell>
  );
}

Create src/app/(main)/feed/you/page.tsx:

page.tsx
import Link from "next/link";
import { requireUser } from "@/lib/auth";
import { getLikedVideos, getWatchLater } from "@/lib/library";
import { getHistory } from "@/lib/history";
import { VideoCard, type FeedVideo } from "@/components/feed/video-card";

export const metadata = { title: "You - Wavora" };

function Shelf({
  title,
  href,
  videos,
  progress,
}: {
  title: string;
  href: string;
  videos: FeedVideo[];
  progress?: Map<string, number>;
}) {
  if (videos.length === 0) return null;
  return (
    <section className="mb-8">
      <div className="mb-3 flex items-center justify-between">
        <h2 className="text-lg font-bold">{title}</h2>
        <Link
          href={href}
          className="text-sm font-medium text-accent hover:underline"
        >
          View all
        </Link>
      </div>
      <div className="flex gap-4 overflow-x-auto pb-2 [scrollbar-width:none]">
        {videos.map((v) => (
          <div key={v.id} className="w-72 shrink-0 sm:w-80">
            <VideoCard video={v} progressSeconds={progress?.get(v.id)} />
          </div>
        ))}
      </div>
    </section>
  );
}

export default async function YouPage() {
  const user = await requireUser();
  const [history, watchLater, liked] = await Promise.all([
    getHistory(user.id),
    getWatchLater(user.id),
    getLikedVideos(user.id),
  ]);

  const progress = new Map(history.map((h) => [h.video.id, h.progressSeconds]));
  const empty =
    history.length === 0 && watchLater.length === 0 && liked.length === 0;

  return (
    <div className="mx-auto max-w-[1400px]">
      <h1 className="mb-6 text-2xl font-bold">You</h1>
      {empty ? (
        <p className="py-16 text-center text-sm text-fg-muted">
          Watch, like, and save videos to build your library.
        </p>
      ) : (
        <>
          <Shelf
            title="History"
            href="/feed/history"
            videos={history.slice(0, 10).map((h) => h.video)}
            progress={progress}
          />
          <Shelf
            title="Watch later"
            href="/playlist?list=WL"
            videos={watchLater.slice(0, 10)}
          />
          <Shelf
            title="Liked videos"
            href="/playlist?list=LL"
            videos={liked.slice(0, 10)}
          />
        </>
      )}
    </div>
  );
}

Checkpoint

Before moving on, confirm:

  • Subscribing to a channel updates the button, and the channel shows up in the sidebar and the subscriptions feed.
  • Liking a video adds it to the Liked page, and the dislike count is never shown.
  • You can post a comment and a reply, sort by Top and Newest, and as the channel owner heart or pin a comment.
  • Watching part of a video and coming back resumes it, lists it on History, and shows it under Continue watching with a red bar on the card.
  • Saving a video from the watch page adds it to Watch Later.
  • The channel page now has Home, Videos, and About tabs.

Part 4: The creator economy

In this section, we'll build the creator economy, one of the project's main components. Content creators earn money from their viewers in two ways: through monthly channel memberships and "Cheers," which are one-time tips for videos.

We don't have to worry about the payment system, payment distribution, or compliance issues since Whop handles all of that.

How the money flows

We touched on this topic earlier in the article, but since it's relevant to this part, let's review the money flow again.

Every content creator has their own linked account on Whop. When a user makes a payment, the amount is processed directly into the content creator's account. Whop takes its platform fee, the remainder is transferred to the creator, and the money never passes through our hands.

After a payment is made, Whop sends us a webhook. This is how we learn about and verify when a subscription is purchased or a tip is received. When creators want to withdraw their earnings, they use an embedded payment portal directly within our app. Whop also manages KYC and payment transfers.

The Whop SDK clients

We talk to Whop through two clients. The first, the company client, creates the product, plan, checkout, and payment. The app client verifies the webhooks.

Create src/lib/whop.ts:

whop.ts
import "server-only";
import Whop from "@whop/sdk";
import { env, isSandbox } from "./env";

const sandboxOverride = isSandbox()
  ? { baseURL: "https://sandbox-api.whop.com/api/v1" }
  : {};

export const whopCompany = new Whop({
  apiKey: env.WHOP_COMPANY_API_KEY,
  ...sandboxOverride,
});

export const whopsdk = new Whop({
  apiKey: env.WHOP_CLIENT_SECRET,
  appID: env.WHOP_CLIENT_ID,
  webhookKey: Buffer.from(env.WHOP_WEBHOOK_SECRET ?? "").toString("base64"),
  ...sandboxOverride,
});

Create src/lib/money.ts:

money.ts
export const PLATFORM_FEE_RATE = 0.1;

export function platformFeeCents(amountCents: number): number {
  const fee = Math.round(amountCents * PLATFORM_FEE_RATE);
  return Math.min(Math.max(fee, 1), amountCents - 1);
}

export function toDollars(cents: number): number {
  return Math.round(cents) / 100;
}

export const TIP_PRESETS_CENTS = [200, 500, 1000, 5000] as const;

Make a channel payable

When a content creator enables monetization, a connected Whop company is created. From then on, all payments are processed through that company and disbursed from there. This action creates the associated company and registers its identity with the channel.

Let's create the action that creates the connected company and stores its ID on the channel. Create src/app/studio/monetization/actions.ts:

actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { whopCompany } from "@/lib/whop";
import { env, isSandbox } from "@/lib/env";

export async function enableMonetization(): Promise<
  { ok: true } | { error: string }
> {
  const user = await getCurrentUser();
  if (!user) return { error: "Please sign in." };

  const channel = await prisma.channel.findUnique({
    where: { userId: user.id },
    select: { id: true, name: true, handle: true, whopCompanyId: true },
  });
  if (!channel) return { error: "Create a channel first." };
  if (channel.whopCompanyId) return { ok: true };

  try {
    const company = await whopCompany.companies.create({
      title: channel.name,
      email: user.email ?? `${channel.handle}@wavora.app`,
      parent_company_id: env.WHOP_PLATFORM_COMPANY_ID,
      metadata: { channelId: channel.id, handle: channel.handle },
    });

    await prisma.channel.update({
      where: { id: channel.id },
      data: {
        whopCompanyId: company.id,
        payoutEnabled: isSandbox(),
        superThanksEnabled: true,
      },
    });
  } catch (err) {
    console.error("companies.create failed:", err);
    return { error: "Could not enable monetization. Please try again." };
  }

  revalidatePath("/studio/monetization");
  return { ok: true };
}

The panel is the on/off control in the studio. Create src/app/studio/monetization/monetization-panel.tsx:

monetization-panel.tsx
"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { CheckCircle2, Wallet } from "lucide-react";
import { enableMonetization } from "./actions";

export function MonetizationPanel({
  enrolled,
  whopCompanyId,
  payoutEnabled,
}: {
  enrolled: boolean;
  whopCompanyId: string | null;
  payoutEnabled: boolean;
}) {
  const router = useRouter();
  const [pending, startTransition] = useTransition();
  const [error, setError] = useState<string | null>(null);

  if (enrolled) {
    return (
      <div className="max-w-lg rounded-2xl border border-border p-6">
        <div className="flex items-center gap-2 font-medium text-green-500">
          <CheckCircle2 className="h-5 w-5" />
          Monetization enabled
        </div>
        <p className="mt-3 text-sm text-fg-muted">
          Connected account:{" "}
          <code className="rounded bg-hover px-1.5 py-0.5 text-fg">
            {whopCompanyId}
          </code>
        </p>
        <p className="mt-1 text-sm text-fg-muted">
          {payoutEnabled
            ? "Payout-ready (KYC is skipped in Whop's sandbox)."
            : "Finish identity verification to receive payouts."}
        </p>
      </div>
    );
  }

  function onEnable() {
    setError(null);
    startTransition(async () => {
      const res = await enableMonetization();
      if ("error" in res) setError(res.error);
      else router.refresh();
    });
  }

  return (
    <div className="max-w-lg rounded-2xl border border-border p-6">
      <Wallet className="h-8 w-8 text-accent" />
      <p className="mt-3 text-sm">
        Enable monetization to create your Whop connected account. Viewers can
        then join channel memberships and send Cheers; you withdraw your
        earnings (minus our platform fee) directly through Whop, as the merchant
        of record.
      </p>
      <button
        type="button"
        onClick={onEnable}
        disabled={pending}
        className="mt-5 rounded-full bg-accent px-5 py-2.5 font-medium text-accent-fg hover:opacity-90 disabled:opacity-60"
      >
        {pending ? "Enabling…" : "Enable monetization"}
      </button>
      {error ? <p className="mt-3 text-sm text-red-500">{error}</p> : null}
      <p className="mt-5 text-xs text-fg-muted">
        Sandbox note: Whop's sandbox skips KYC. In production you'd be redirected
        to Whop's hosted identity verification here before your first payout.
      </p>
    </div>
  );
}

Channel memberships

Membership tiers are recurring Whop plans within the content creator's connected company. When a tier is created, a Whop product and a plan are created once per channel, and their IDs are recorded. The app fee is not added here but is included in the checkout created when a user joins.

First the membership reads. Create src/lib/membership.ts:

membership.ts
import "server-only";
import { prisma } from "./prisma";

export type ChannelTier = {
  id: string;
  name: string;
  description: string | null;
  priceCents: number;
};

export async function getChannelTiers(channelId: string): Promise<ChannelTier[]> {
  return prisma.membershipTier.findMany({
    where: { channelId },
    orderBy: { priceCents: "asc" },
    select: { id: true, name: true, description: true, priceCents: true },
  });
}

export async function isActiveMember(
  userId: string,
  channelId: string,
): Promise<boolean> {
  const m = await prisma.channelMember.findUnique({
    where: { userId_channelId: { userId, channelId } },
    select: { status: true },
  });
  return m?.status === "ACTIVE";
}

export async function activeMemberIds(
  channelId: string,
  userIds: string[],
): Promise<Set<string>> {
  if (userIds.length === 0) return new Set();
  const rows = await prisma.channelMember.findMany({
    where: { channelId, status: "ACTIVE", userId: { in: userIds } },
    select: { userId: true },
  });
  return new Set(rows.map((r) => r.userId));
}

export async function getChannelMemberCount(channelId: string): Promise<number> {
  return prisma.channelMember.count({
    where: { channelId, status: "ACTIVE" },
  });
}

export type RecentMember = {
  id: string;
  startedAt: Date;
  user: { username: string; name: string | null; avatarUrl: string | null };
  tier: { name: string } | null;
};

export async function getRecentMembers(
  channelId: string,
  take = 8,
): Promise<RecentMember[]> {
  const rows = await prisma.channelMember.findMany({
    where: { channelId, status: "ACTIVE" },
    orderBy: { startedAt: "desc" },
    take,
    select: {
      id: true,
      startedAt: true,
      user: { select: { username: true, name: true, avatarUrl: true } },
      tier: { select: { name: true } },
    },
  });
  return rows;
}

The membership action that creates a tier. Create src/app/studio/monetization/membership-actions.ts:

membership-actions.ts
"use server";

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

const tierSchema = z.object({
  name: z.string().trim().min(1, "Add a tier name").max(50),
  description: z.string().trim().max(500).optional().or(z.literal("")),
  priceCents: z.number().int().min(100).max(100_000),
});

export type TierResult = { ok: true } | { error: string };

export async function createTier(input: {
  name: string;
  description?: string;
  priceCents: number;
}): Promise<TierResult> {
  const user = await getCurrentUser();
  if (!user) return { error: "Please sign in." };

  const channel = await prisma.channel.findUnique({
    where: { userId: user.id },
    select: {
      id: true,
      name: true,
      whopCompanyId: true,
      membershipsEnabled: true,
    },
  });
  if (!channel) return { error: "Create a channel first." };
  if (!channel.whopCompanyId) {
    return { error: "Enable monetization before adding tiers." };
  }

  const parsed = tierSchema.safeParse(input);
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Invalid tier." };
  }
  const { name, description, priceCents } = parsed.data;

  try {
    const existing = await prisma.membershipTier.findFirst({
      where: { channelId: channel.id, whopProductId: { not: null } },
      select: { whopProductId: true },
    });
    let productId = existing?.whopProductId ?? null;
    if (!productId) {
      const product = await whopCompany.products.create({
        company_id: channel.whopCompanyId,
        title: `${channel.name} Memberships`,
        visibility: "visible",
      });
      productId = product.id;
    }

    const dollars = priceCents / 100;
    const plan = await whopCompany.plans.create({
      company_id: channel.whopCompanyId,
      product_id: productId,
      plan_type: "renewal",
      initial_price: dollars,
      renewal_price: dollars,
      billing_period: 30,
      currency: "usd",
      visibility: "hidden",
      release_method: "buy_now",
    });

    await prisma.membershipTier.create({
      data: {
        channelId: channel.id,
        name,
        description: description ? description : null,
        priceCents,
        whopProductId: productId,
        whopPlanId: plan.id,
      },
    });

    if (!channel.membershipsEnabled) {
      await prisma.channel.update({
        where: { id: channel.id },
        data: { membershipsEnabled: true },
      });
    }
  } catch (err) {
    console.error("createTier failed:", err);
    return { error: "Could not create the tier. Please try again." };
  }

  revalidatePath("/studio/monetization");
  return { ok: true };
}

And now the panel where creators add tiers. Create src/app/studio/monetization/membership-panel.tsx:

membership-panel.tsx
"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { createTier } from "./membership-actions";

type Tier = {
  id: string;
  name: string;
  description: string | null;
  priceCents: number;
};

export function MembershipsPanel({ tiers }: { tiers: Tier[] }) {
  const router = useRouter();
  const [pending, startTransition] = useTransition();
  const [error, setError] = useState<string | null>(null);
  const [name, setName] = useState("");
  const [price, setPrice] = useState("");
  const [description, setDescription] = useState("");
  const [showForm, setShowForm] = useState(tiers.length === 0);

  function add() {
    setError(null);
    const dollars = Number.parseFloat(price);
    if (!name.trim() || !Number.isFinite(dollars) || dollars < 1) {
      setError("Add a tier name and a monthly price of at least $1.");
      return;
    }
    startTransition(async () => {
      const res = await createTier({
        name,
        description,
        priceCents: Math.round(dollars * 100),
      });
      if ("error" in res) {
        setError(res.error);
      } else {
        setName("");
        setPrice("");
        setDescription("");
        setShowForm(false);
        router.refresh();
      }
    });
  }

  const inputClass =
    "w-full rounded-lg border border-border bg-transparent px-3 py-2.5 text-sm outline-none focus:border-accent";

  return (
    <div className="max-w-lg rounded-2xl border border-border p-6">
      <h2 className="text-lg font-bold">Channel memberships</h2>
      <p className="mt-1 text-sm text-fg-muted">
        Offer monthly tiers. Viewers join through Whop; you&apos;re the merchant
        of record and we keep a small platform fee on each charge.
      </p>

      {tiers.length > 0 ? (
        <ul className="mt-4 divide-y divide-border rounded-xl border border-border">
          {tiers.map((t) => (
            <li key={t.id} className="flex items-center justify-between gap-3 p-3">
              <div className="min-w-0">
                <p className="font-medium">{t.name}</p>
                {t.description ? (
                  <p className="truncate text-xs text-fg-muted">
                    {t.description}
                  </p>
                ) : null}
              </div>
              <span className="shrink-0 text-sm font-medium">
                ${(t.priceCents / 100).toFixed(2)}/mo
              </span>
            </li>
          ))}
        </ul>
      ) : null}

      {showForm ? (
        <div className="mt-4 flex flex-col gap-3">
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
            placeholder="Tier name (e.g. Supporter)"
            maxLength={50}
            className={inputClass}
          />
          <input
            value={price}
            onChange={(e) => setPrice(e.target.value)}
            type="number"
            min="1"
            step="1"
            placeholder="Monthly price (USD)"
            className={inputClass}
          />
          <input
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            placeholder="Perks (optional)"
            maxLength={500}
            className={inputClass}
          />
          {error ? <p className="text-sm text-red-500">{error}</p> : null}
          <div className="flex gap-2">
            <button
              type="button"
              onClick={add}
              disabled={pending}
              className="rounded-full bg-accent px-5 py-2.5 text-sm font-medium text-accent-fg hover:opacity-90 disabled:opacity-60"
            >
              {pending ? "Creating…" : "Create tier"}
            </button>
            {tiers.length > 0 ? (
              <button
                type="button"
                onClick={() => setShowForm(false)}
                className="rounded-full border border-border px-5 py-2.5 text-sm font-medium hover:bg-hover"
              >
                Cancel
              </button>
            ) : null}
          </div>
        </div>
      ) : (
        <button
          type="button"
          onClick={() => setShowForm(true)}
          className="mt-4 inline-flex items-center gap-2 rounded-full border border-border px-4 py-2 text-sm font-medium hover:bg-hover"
        >
          <Plus className="h-4 w-4" />
          Add a tier
        </button>
      )}
    </div>
  );
}

The Join checkout

When a user joins, we create a checkout that uses the tier's plan and includes our app fee. The fee appears on the checkout, not on the plan. Also, it must be greater than zero and less than the total. Whop's embedded checkout handles the rest.

Both checkouts live in this one file: joining a membership and, in a moment, tipping. Create src/lib/checkout-actions.ts:

checkout-actions.ts
"use server";

import { prisma } from "./prisma";
import { getCurrentUser } from "./session";
import { whopCompany } from "./whop";
import { platformFeeCents, toDollars } from "./money";

export type CheckoutResult = { sessionId: string } | { error: string };

export async function createMembershipCheckout(
  tierId: string,
): Promise<CheckoutResult> {
  const user = await getCurrentUser();
  if (!user) return { error: "Please sign in." };

  const tier = await prisma.membershipTier.findUnique({
    where: { id: tierId },
    select: {
      id: true,
      priceCents: true,
      whopProductId: true,
      channel: {
        select: {
          id: true,
          userId: true,
          whopCompanyId: true,
          membershipsEnabled: true,
        },
      },
    },
  });
  if (
    !tier ||
    !tier.channel.membershipsEnabled ||
    !tier.channel.whopCompanyId ||
    !tier.whopProductId
  ) {
    return { error: "Memberships aren't available for this channel." };
  }
  if (tier.channel.userId === user.id) {
    return { error: "You can't join your own channel." };
  }

  const membership = await prisma.channelMember.findUnique({
    where: {
      userId_channelId: { userId: user.id, channelId: tier.channel.id },
    },
    select: { status: true },
  });
  if (membership?.status === "ACTIVE") {
    return { error: "You're already a member of this channel." };
  }

  const amountCents = tier.priceCents;
  const feeCents = platformFeeCents(amountCents);

  try {
    const config = await whopCompany.checkoutConfigurations.create({
      mode: "payment",
      metadata: {
        kind: "membership",
        channelId: tier.channel.id,
        tierId: tier.id,
        viewerUserId: user.id,
        amountCents: String(amountCents),
        feeCents: String(feeCents),
      },
      plan: {
        company_id: tier.channel.whopCompanyId,
        product_id: tier.whopProductId,
        plan_type: "renewal",
        initial_price: toDollars(amountCents),
        renewal_price: toDollars(amountCents),
        billing_period: 30,
        currency: "usd",
        visibility: "hidden",
        release_method: "buy_now",
        application_fee_amount: toDollars(feeCents),
      },
    });
    return { sessionId: config.id };
  } catch (err) {
    console.error("createMembershipCheckout failed:", err);
    return { error: "Could not start checkout. Please try again." };
  }
}

export async function createTipCheckout(
  videoId: string,
  amountCents: number,
  message: string,
): Promise<CheckoutResult> {
  const user = await getCurrentUser();
  if (!user) return { error: "Please sign in." };
  if (!Number.isInteger(amountCents) || amountCents < 100 || amountCents > 50_000) {
    return { error: "Pick a valid amount." };
  }

  const video = await prisma.video.findUnique({
    where: { id: videoId },
    select: {
      id: true,
      channel: {
        select: {
          id: true,
          userId: true,
          whopCompanyId: true,
          superThanksEnabled: true,
        },
      },
    },
  });
  if (
    !video ||
    !video.channel.whopCompanyId ||
    !video.channel.superThanksEnabled
  ) {
    return { error: "Cheers isn't available for this video." };
  }
  if (video.channel.userId === user.id) {
    return { error: "You can't tip your own video." };
  }

  const feeCents = platformFeeCents(amountCents);

  try {
    const config = await whopCompany.checkoutConfigurations.create({
      mode: "payment",
      metadata: {
        kind: "tip",
        channelId: video.channel.id,
        videoId: video.id,
        viewerUserId: user.id,
        message: message.trim().slice(0, 200),
        amountCents: String(amountCents),
        feeCents: String(feeCents),
      },
      plan: {
        company_id: video.channel.whopCompanyId,
        plan_type: "one_time",
        initial_price: toDollars(amountCents),
        currency: "usd",
        visibility: "hidden",
        release_method: "buy_now",
        application_fee_amount: toDollars(feeCents),
      },
    });
    return { sessionId: config.id };
  } catch (err) {
    console.error("createTipCheckout failed:", err);
    return { error: "Could not start checkout. Please try again." };
  }
}

A thin wrapper around Whop's embedded checkout. Create src/components/checkout/checkout-embed-panel.tsx:

checkout-embed-panel.tsx
"use client";

import { WhopCheckoutEmbed } from "@whop/checkout/react";

export function CheckoutEmbedPanel({
  sessionId,
  environment,
  onComplete,
}: {
  sessionId: string;
  environment: "sandbox" | "production";
  onComplete?: () => void;
}) {
  return (
    <div className="min-h-[420px]">
      <WhopCheckoutEmbed
        sessionId={sessionId}
        environment={environment}
        onComplete={() => onComplete?.()}
        themeOptions={{ accentColor: "blue" }}
      />
    </div>
  );
}

The Join button with the tier list. Create src/components/channel/join-membership.tsx:

join-membership.tsx
"use client";

import { useState, useTransition } from "react";
import { X } from "lucide-react";
import { createMembershipCheckout } from "@/lib/checkout-actions";
import { CheckoutEmbedPanel } from "@/components/checkout/checkout-embed-panel";
import { useEscape } from "@/hooks/use-escape";

type Tier = {
  id: string;
  name: string;
  description: string | null;
  priceCents: number;
};

export function JoinMembership({
  tiers,
  isSignedIn,
  isMember,
  environment,
}: {
  tiers: Tier[];
  isSignedIn: boolean;
  isMember: boolean;
  environment: "sandbox" | "production";
}) {
  const [open, setOpen] = useState(false);
  const [sessionId, setSessionId] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  useEscape(open, close);

  if (isMember) {
    return (
      <span className="rounded-full bg-chip px-4 py-2 text-sm font-medium">
        Member ✓
      </span>
    );
  }
  if (tiers.length === 0) return null;

  function pick(tierId: string) {
    if (!isSignedIn) {
      window.location.href = `/sign-in?next=${encodeURIComponent(
        window.location.pathname,
      )}`;
      return;
    }
    setError(null);
    startTransition(async () => {
      const res = await createMembershipCheckout(tierId);
      if ("error" in res) setError(res.error);
      else setSessionId(res.sessionId);
    });
  }

  function close() {
    setOpen(false);
    setSessionId(null);
    setError(null);
  }

  return (
    <>
      <button
        type="button"
        onClick={() => setOpen(true)}
        className="rounded-full bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90"
      >
        Join
      </button>
      {open ? (
        <div
          className="fixed inset-0 z-[100] grid place-items-center bg-black/70 p-4"
          onClick={close}
        >
          <div
            role="dialog"
            aria-modal="true"
            aria-label="Choose your membership"
            className="relative max-h-[92vh] w-full max-w-md overflow-auto rounded-2xl bg-canvas p-5"
            onClick={(e) => e.stopPropagation()}
          >
            <button
              type="button"
              onClick={close}
              aria-label="Close"
              className="absolute right-3 top-3 z-10 grid h-8 w-8 place-items-center rounded-full hover:bg-hover"
            >
              <X className="h-5 w-5" />
            </button>
            {sessionId ? (
              <div className="pt-6">
                <CheckoutEmbedPanel
                  sessionId={sessionId}
                  environment={environment}
                  onComplete={() => window.location.reload()}
                />
              </div>
            ) : (
              <div className="pt-2">
                <h3 className="text-lg font-bold">Choose your membership</h3>
                <div className="mt-4 flex flex-col gap-2">
                  {tiers.map((t) => (
                    <button
                      key={t.id}
                      type="button"
                      onClick={() => pick(t.id)}
                      disabled={pending}
                      className="flex items-center justify-between gap-3 rounded-xl border border-border p-3 text-left hover:border-accent disabled:opacity-60"
                    >
                      <span className="min-w-0">
                        <span className="block font-medium">{t.name}</span>
                        {t.description ? (
                          <span className="block truncate text-xs text-fg-muted">
                            {t.description}
                          </span>
                        ) : null}
                      </span>
                      <span className="shrink-0 font-medium">
                        ${(t.priceCents / 100).toFixed(2)}/mo
                      </span>
                    </button>
                  ))}
                </div>
                {error ? (
                  <p className="mt-3 text-sm text-red-500">{error}</p>
                ) : null}
                {pending ? (
                  <p className="mt-3 text-sm text-fg-muted">Starting checkout…</p>
                ) : null}
              </div>
            )}
          </div>
        </div>
      ) : null}
    </>
  );
}

Create src/app/(main)/[handle]/membership/page.tsx:

page.tsx
import { resolveChannel } from "@/lib/channels";
import { getChannelTiers, isActiveMember } from "@/lib/membership";
import { getCurrentUser } from "@/lib/session";
import { isSandbox } from "@/lib/env";
import { JoinMembership } from "@/components/channel/join-membership";

export default async function ChannelMembershipPage({
  params,
}: {
  params: Promise<{ handle: string }>;
}) {
  const { handle } = await params;
  const channel = await resolveChannel(handle);

  if (!channel.membershipsEnabled) {
    return (
      <p className="py-16 text-center text-sm text-fg-muted">
        This channel doesn&apos;t offer memberships yet.
      </p>
    );
  }

  const user = await getCurrentUser();
  const [tiers, member] = await Promise.all([
    getChannelTiers(channel.id),
    user ? isActiveMember(user.id, channel.id) : Promise.resolve(false),
  ]);

  return (
    <div className="max-w-2xl">
      <h2 className="mb-1 text-lg font-bold">Channel memberships</h2>
      <p className="mb-4 text-sm text-fg-muted">
        Support {channel.name} with a monthly membership and unlock members-only
        perks.
      </p>

      <div className="flex flex-col gap-3">
        {tiers.map((t) => (
          <div
            key={t.id}
            className="flex items-center justify-between gap-3 rounded-xl border border-border p-4"
          >
            <div className="min-w-0">
              <p className="font-medium">{t.name}</p>
              {t.description ? (
                <p className="text-sm text-fg-muted">{t.description}</p>
              ) : null}
            </div>
            <span className="shrink-0 font-medium">
              ${(t.priceCents / 100).toFixed(2)}/mo
            </span>
          </div>
        ))}
      </div>

      {user?.id !== channel.userId ? (
        <div className="mt-5">
          <JoinMembership
            tiers={tiers}
            isSignedIn={Boolean(user)}
            isMember={member}
            environment={isSandbox() ? "sandbox" : "production"}
          />
        </div>
      ) : null}
    </div>
  );
}

Webhooks

Webhooks are how Whop tells us what's happening, and implementing them correctly is important. There are two rules we must follow: verify each one and process each once. For verification, we use the app key, which ensures we only listen to events that actually come from Whop.

For idempotency, each handler records the event ID as the first action within the transaction. This way, a duplicate delivery triggers a constraint, and the entire handler is rolled back.

The dispatcher maps each event type to a handler. Create src/lib/webhooks.ts:

webhooks.ts
import "server-only";
import { prisma } from "./prisma";

export type WebhookEnvelope = {
  type: string;
  id: string;
  data: Record<string, unknown>;
};

function str(v: unknown): string | undefined {
  return typeof v === "string" ? v : undefined;
}

function int(v: unknown): number {
  const n = Number(v);
  return Number.isFinite(n) ? Math.round(n) : 0;
}

function meta(data: Record<string, unknown>): Record<string, unknown> {
  const m = data.metadata;
  return m && typeof m === "object" ? (m as Record<string, unknown>) : {};
}

function isUniqueViolation(e: unknown): boolean {
  return Boolean(
    e && typeof e === "object" && "code" in e && (e as { code?: string }).code === "P2002",
  );
}

const eventWrite = (id: string) =>
  prisma.webhookEvent.create({ data: { id, source: "whop" } });

export async function processWebhookEvent(event: WebhookEnvelope): Promise<void> {
  if (!event.id) return;

  const seen = await prisma.webhookEvent.findUnique({
    where: { id: event.id },
    select: { id: true },
  });
  if (seen) return;

  try {
    switch (event.type) {
      case "membership.activated":
        await onMembershipActivated(event.id, event.data);
        break;
      case "membership.deactivated":
        await onMembershipDeactivated(event.id, event.data);
        break;
      case "payment.succeeded":
        await onPaymentSucceeded(event.id, event.data);
        break;
      case "refund.created":
        await onRefundCreated(event.id, event.data);
        break;
      default:
        await eventWrite(event.id);
        break;
    }
  } catch (e) {
    if (isUniqueViolation(e)) return;
    throw e;
  }
}

async function onMembershipActivated(
  eventId: string,
  data: Record<string, unknown>,
): Promise<void> {
  const m = meta(data);
  const channelId = str(m.channelId);
  const viewerUserId = str(m.viewerUserId);
  const tierId = str(m.tierId) ?? null;
  const whopMembershipId = str(data.id) ?? null;
  if (!channelId || !viewerUserId) {
    await eventWrite(eventId);
    return;
  }

  const channel = await prisma.channel.findUnique({
    where: { id: channelId },
    select: { userId: true },
  });

  await prisma.$transaction([
    eventWrite(eventId),
    prisma.channelMember.upsert({
      where: { userId_channelId: { userId: viewerUserId, channelId } },
      create: {
        userId: viewerUserId,
        channelId,
        tierId,
        status: "ACTIVE",
        whopMembershipId,
      },
      update: { status: "ACTIVE", tierId, whopMembershipId },
    }),
    ...(channel
      ? [
          prisma.notification.create({
            data: {
              recipientId: channel.userId,
              type: "NEW_MEMBER",
              title: "New channel member",
              body: "Someone just joined your channel memberships.",
              data: { channelId },
            },
          }),
        ]
      : []),
  ]);
}

async function onMembershipDeactivated(
  eventId: string,
  data: Record<string, unknown>,
): Promise<void> {
  const m = meta(data);
  const whopMembershipId = str(data.id);
  const channelId = str(m.channelId);
  const viewerUserId = str(m.viewerUserId);

  await prisma.$transaction(async (tx) => {
    await tx.webhookEvent.create({ data: { id: eventId, source: "whop" } });
    if (whopMembershipId) {
      const res = await tx.channelMember.updateMany({
        where: { whopMembershipId },
        data: { status: "INACTIVE" },
      });
      if (res.count > 0) return;
    }
    if (channelId && viewerUserId) {
      await tx.channelMember.updateMany({
        where: { userId: viewerUserId, channelId },
        data: { status: "INACTIVE" },
      });
    }
  });
}

async function onPaymentSucceeded(
  eventId: string,
  data: Record<string, unknown>,
): Promise<void> {
  const m = meta(data);
  const kind = str(m.kind);
  const whopPaymentId = str(data.id);
  if (!whopPaymentId) {
    await eventWrite(eventId);
    return;
  }

  const channelId = str(m.channelId);
  const amountCents = int(m.amountCents);
  const feeCents = int(m.feeCents);
  const netCents = Math.max(0, amountCents - feeCents);

  if (kind === "tip") {
    const supporterUserId = str(m.viewerUserId);
    const message = str(m.message) || null;
    if (!channelId || !supporterUserId || amountCents <= 0) {
      await eventWrite(eventId);
      return;
    }

    const channel = await prisma.channel.findUnique({
      where: { id: channelId },
      select: { userId: true },
    });

    let videoId = str(m.videoId) ?? null;
    if (videoId) {
      const video = await prisma.video.findUnique({
        where: { id: videoId },
        select: { id: true },
      });
      if (!video) videoId = null;
    }

    await prisma.$transaction([
      eventWrite(eventId),
      prisma.tip.create({
        data: {
          supporterId: supporterUserId,
          channelId,
          videoId,
          amountCents,
          feeCents,
          netCents,
          message,
          whopPaymentId,
        },
      }),
      prisma.earningsLedger.create({
        data: {
          channelId,
          source: "SUPER_THANKS",
          grossCents: amountCents,
          feeCents,
          netCents,
          whopPaymentId,
          videoId,
        },
      }),
      ...(videoId
        ? [
            prisma.comment.create({
              data: {
                videoId,
                authorId: supporterUserId,
                body: message ?? "Cheers!",
                status: "PUBLISHED",
                isSuperThanks: true,
                superThanksAmount: amountCents,
              },
            }),
          ]
        : []),
      ...(channel
        ? [
            prisma.notification.create({
              data: {
                recipientId: channel.userId,
                type: "SUPER_THANKS",
                title: "Cheers received",
                body: `You received $${(amountCents / 100).toFixed(2)} in Cheers.`,
                data: { channelId, videoId },
              },
            }),
          ]
        : []),
    ]);
    return;
  }

  if (kind === "membership" && channelId && amountCents > 0) {
    await prisma.$transaction([
      eventWrite(eventId),
      prisma.earningsLedger.create({
        data: {
          channelId,
          source: "MEMBERSHIP",
          grossCents: amountCents,
          feeCents,
          netCents,
          whopPaymentId,
        },
      }),
    ]);
    return;
  }

  await eventWrite(eventId);
}

async function onRefundCreated(
  eventId: string,
  data: Record<string, unknown>,
): Promise<void> {
  const payment = data.payment;
  const originalPaymentId =
    (payment && typeof payment === "object"
      ? str((payment as Record<string, unknown>).id)
      : undefined) ?? str(data.payment_id);
  if (!originalPaymentId) {
    await eventWrite(eventId);
    return;
  }

  const original = await prisma.earningsLedger.findUnique({
    where: { whopPaymentId: originalPaymentId },
    select: {
      channelId: true,
      source: true,
      grossCents: true,
      feeCents: true,
      netCents: true,
      videoId: true,
    },
  });
  if (!original) {
    await eventWrite(eventId);
    return;
  }

  const refundKey = `refund_${originalPaymentId}`;
  await prisma.$transaction([
    eventWrite(eventId),
    prisma.earningsLedger.create({
      data: {
        channelId: original.channelId,
        source: original.source,
        grossCents: -original.grossCents,
        feeCents: -original.feeCents,
        netCents: -original.netCents,
        videoId: original.videoId,
        whopPaymentId: refundKey,
      },
    }),
  ]);
}

The route verifies the signature and hands the event to the dispatcher. Create src/app/api/webhooks/whop/route.ts:

route.ts
import type { NextRequest } from "next/server";
import { whopsdk } from "@/lib/whop";
import { processWebhookEvent } from "@/lib/webhooks";
import { env } from "@/lib/env";

export async function POST(request: NextRequest): Promise<Response> {
  if (!env.WHOP_WEBHOOK_SECRET) {
    return new Response("webhook not configured", { status: 503 });
  }
  const bodyText = await request.text();
  const headers = Object.fromEntries(request.headers);

  let event: { type: string; id: string; data: unknown };
  try {
    event = whopsdk.webhooks.unwrap(bodyText, { headers }) as typeof event;
  } catch (err) {
    console.error("Webhook verification failed:", err);
    return new Response("invalid signature", { status: 401 });
  }

  try {
    await processWebhookEvent({
      type: event.type,
      id: event.id,
      data: (event.data ?? {}) as Record<string, unknown>,
    });
  } catch (err) {
    console.error("Webhook processing failed:", err);
    return new Response("processing failed", { status: 500 });
  }

  return new Response("OK", { status: 200 });
}
Webhooks need a public URL and an endpoint registered in your Whop dashboard, so the full money flow only comes alive once we deploy in Part 5. Until then you can build and read every piece.

Cheers

Cheers is a one-time tip for a video. When the payment is successful, the webhook records the tip, posts the highlighted comment, and notifies the creator.

The tip button uses the createTipCheckout action we already wrote. Create src/components/watch/super-thanks.tsx:

super-thanks.tsx
"use client";

import { useState, useTransition } from "react";
import { Heart, X } from "lucide-react";
import { createTipCheckout } from "@/lib/checkout-actions";
import { CheckoutEmbedPanel } from "@/components/checkout/checkout-embed-panel";
import { TIP_PRESETS_CENTS } from "@/lib/money";
import { useEscape } from "@/hooks/use-escape";
import { cn } from "@/lib/utils";

export function SuperThanks({
  videoId,
  isSignedIn,
  environment,
}: {
  videoId: string;
  isSignedIn: boolean;
  environment: "sandbox" | "production";
}) {
  const [open, setOpen] = useState(false);
  const [amount, setAmount] = useState(500);
  const [message, setMessage] = useState("");
  const [sessionId, setSessionId] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  function begin() {
    if (!isSignedIn) {
      window.location.href = `/sign-in?next=${encodeURIComponent(
        window.location.pathname + window.location.search,
      )}`;
      return;
    }
    setError(null);
    startTransition(async () => {
      const res = await createTipCheckout(videoId, amount, message);
      if ("error" in res) setError(res.error);
      else setSessionId(res.sessionId);
    });
  }

  function close() {
    setOpen(false);
    setSessionId(null);
    setError(null);
  }

  useEscape(open, close);

  return (
    <>
      <button
        type="button"
        onClick={() => setOpen(true)}
        className="flex items-center gap-2 rounded-full bg-chip px-4 py-2 text-sm font-medium hover:bg-hover-strong"
      >
        <Heart className="h-5 w-5" />
        Cheers
      </button>
      {open ? (
        <div
          className="fixed inset-0 z-[100] grid place-items-center bg-black/70 p-4"
          onClick={close}
        >
          <div
            role="dialog"
            aria-modal="true"
            aria-label="Send Cheers"
            className="relative max-h-[92vh] w-full max-w-md overflow-auto rounded-2xl bg-canvas p-5"
            onClick={(e) => e.stopPropagation()}
          >
            <button
              type="button"
              onClick={close}
              aria-label="Close"
              className="absolute right-3 top-3 z-10 grid h-8 w-8 place-items-center rounded-full hover:bg-hover"
            >
              <X className="h-5 w-5" />
            </button>
            {sessionId ? (
              <div className="pt-6">
                <CheckoutEmbedPanel
                  sessionId={sessionId}
                  environment={environment}
                  onComplete={() => window.location.reload()}
                />
              </div>
            ) : (
              <div className="pt-2">
                <h3 className="text-lg font-bold">Send Cheers</h3>
                <p className="mt-1 text-sm text-fg-muted">
                  Show your support - the video stays free.
                </p>
                <div className="mt-4 grid grid-cols-4 gap-2">
                  {TIP_PRESETS_CENTS.map((c) => (
                    <button
                      key={c}
                      type="button"
                      onClick={() => setAmount(c)}
                      className={cn(
                        "rounded-full border px-2 py-2 text-sm font-medium",
                        amount === c
                          ? "border-accent bg-accent/10 text-accent"
                          : "border-border hover:bg-hover",
                      )}
                    >
                      ${c / 100}
                    </button>
                  ))}
                </div>
                <input
                  value={message}
                  onChange={(e) => setMessage(e.target.value)}
                  maxLength={200}
                  placeholder="Add a message (optional)"
                  className="mt-4 w-full rounded-lg border border-border bg-transparent px-3 py-2.5 text-sm outline-none focus:border-accent"
                />
                {error ? (
                  <p className="mt-3 text-sm text-red-500">{error}</p>
                ) : null}
                <button
                  type="button"
                  onClick={begin}
                  disabled={pending}
                  className="mt-4 w-full rounded-full bg-accent py-2.5 font-medium text-accent-fg hover:opacity-90 disabled:opacity-60"
                >
                  {pending ? "Starting…" : `Send $${(amount / 100).toFixed(2)}`}
                </button>
              </div>
            )}
          </div>
        </div>
      ) : null}
    </>
  );
}

Payouts and earnings

Content creators withdraw their earnings through an embedded payment portal. We generate a short-lived access token on the server, then render the Whop payment portal that manages KYC and payment transfers. We also calculate the creator's total earnings from the records written by the webhooks.

The token route. Create src/app/api/payout-token/route.ts:

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

export async function GET(): Promise<Response> {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });

  const channel = await prisma.channel.findUnique({
    where: { userId: user.id },
    select: { whopCompanyId: true },
  });
  if (!channel?.whopCompanyId) {
    return NextResponse.json({ error: "no connected account" }, { status: 400 });
  }

  try {
    const res = await whopCompany.accessTokens.create({
      company_id: channel.whopCompanyId,
    });
    return NextResponse.json({ token: res.token });
  } catch (err) {
    console.error("accessTokens.create failed:", err);
    return NextResponse.json({ error: "token failed" }, { status: 500 });
  }
}

The portal component. Create src/components/payouts/payout-portal.tsx:

payout-portal.tsx
"use client";

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

export function PayoutPortal({
  companyId,
  environment,
}: {
  companyId: string;
  environment: "sandbox" | "production";
}) {
  const elements = useMemo(() => loadWhopElements({ environment }), [environment]);
  const redirectUrl =
    typeof window !== "undefined"
      ? `${window.location.origin}/studio/monetization`
      : "/studio/monetization";

  return (
    <Elements elements={elements}>
      <PayoutsSession
        token={async () => {
          const r = await fetch("/api/payout-token");
          if (!r.ok) throw new Error("Could not start a payout session.");
          const d = (await r.json()) as { token?: unknown };
          if (typeof d.token !== "string" || !d.token) {
            throw new Error("No payout token returned.");
          }
          return d.token;
        }}
        companyId={companyId}
        redirectUrl={redirectUrl}
      >
        <div className="flex flex-col gap-4">
          <BalanceElement
            fallback={<div className="text-sm text-fg-muted">Loading balance…</div>}
          />
          <WithdrawButtonElement fallback={<div />} />
          <WithdrawalsElement fallback={<div />} />
        </div>
      </PayoutsSession>
    </Elements>
  );
}

The earnings totals. Create src/lib/earnings.ts:

earnings.ts
import "server-only";
import { prisma } from "./prisma";

export async function getEarnings(channelId: string) {
  const rows = await prisma.earningsLedger.groupBy({
    by: ["source"],
    where: { channelId },
    _sum: { netCents: true },
  });

  let membershipNetCents = 0;
  let tipNetCents = 0;
  for (const r of rows) {
    if (r.source === "MEMBERSHIP") membershipNetCents = r._sum.netCents ?? 0;
    else if (r.source === "SUPER_THANKS") tipNetCents = r._sum.netCents ?? 0;
  }

  return {
    membershipNetCents,
    tipNetCents,
    lifetimeNetCents: membershipNetCents + tipNetCents,
  };
}

The members list for the studio. Create src/app/studio/monetization/members-panel.tsx:

members-panel.tsx
import { User } from "lucide-react";
import { formatCompact, formatTimeAgo } from "@/lib/format";
import type { RecentMember } from "@/lib/membership";

export function MembersPanel({
  memberCount,
  recentMembers,
}: {
  memberCount: number;
  recentMembers: RecentMember[];
}) {
  return (
    <div className="max-w-lg rounded-2xl border border-border p-6">
      <h2 className="text-lg font-bold">Members</h2>
      <p className="mt-2 text-3xl font-bold">
        {formatCompact(memberCount)}{" "}
        <span className="text-base font-medium text-fg-muted">
          {memberCount === 1 ? "member" : "members"}
        </span>
      </p>

      {recentMembers.length === 0 ? (
        <p className="mt-4 text-sm text-fg-muted">
          No members yet. Share your channel to get your first supporter.
        </p>
      ) : (
        <ul className="mt-6 flex flex-col gap-4 border-t border-border pt-6">
          {recentMembers.map((m) => {
            const displayName = m.user.name ?? m.user.username;
            return (
              <li key={m.id} className="flex items-center gap-3">
                <span className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-full bg-hover">
                  {m.user.avatarUrl ? (
                    // eslint-disable-next-line @next/next/no-img-element
                    <img
                      src={m.user.avatarUrl}
                      alt=""
                      className="h-full w-full object-cover"
                    />
                  ) : (
                    <User className="h-5 w-5 text-fg-muted" />
                  )}
                </span>
                <div className="min-w-0 flex-1">
                  <p className="truncate text-sm font-medium">{displayName}</p>
                  <p className="truncate text-xs text-fg-muted">
                    @{m.user.username}
                    {m.tier ? ` • ${m.tier.name}` : ""}
                  </p>
                </div>
                <span className="shrink-0 text-xs text-fg-muted">
                  joined {formatTimeAgo(m.startedAt)}
                </span>
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

The studio monetization page

Now let's bring together the panels for enabling monetization, managing tiers, viewing earnings, opening the payment portal, and listing members on a single page. Create src/app/studio/monetization/page.tsx:

page.tsx
import { requireChannel } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { isSandbox } from "@/lib/env";
import { getEarnings } from "@/lib/earnings";
import { getChannelMemberCount, getRecentMembers } from "@/lib/membership";
import { MonetizationPanel } from "./monetization-panel";
import { MembershipsPanel } from "./membership-panel";
import { MembersPanel } from "./members-panel";
import { PayoutPortal } from "@/components/payouts/payout-portal";

export const metadata = { title: "Monetization - Wavora Studio" };

function money(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`;
}

export default async function MonetizationPage() {
  const { channel } = await requireChannel();
  const enrolled = Boolean(channel.whopCompanyId);

  const [tiers, earnings, memberCount, recentMembers] = enrolled
    ? await Promise.all([
        prisma.membershipTier.findMany({
          where: { channelId: channel.id },
          orderBy: { priceCents: "asc" },
          select: { id: true, name: true, description: true, priceCents: true },
        }),
        getEarnings(channel.id),
        getChannelMemberCount(channel.id),
        getRecentMembers(channel.id),
      ])
    : [[], { membershipNetCents: 0, tipNetCents: 0, lifetimeNetCents: 0 }, 0, []];

  return (
    <div>
      <h1 className="mb-2 text-2xl font-bold">Monetization</h1>
      <p className="mb-8 max-w-xl text-sm text-fg-muted">
        Get paid by your viewers through Whop - channel memberships and
        Cheers, withdrawn to your own connected account.
      </p>

      <MonetizationPanel
        enrolled={enrolled}
        whopCompanyId={channel.whopCompanyId}
        payoutEnabled={channel.payoutEnabled}
      />

      {enrolled && channel.whopCompanyId ? (
        <div className="mt-8 flex flex-col gap-8">
          <MembershipsPanel tiers={tiers} />

          <div className="max-w-lg rounded-2xl border border-border p-6">
            <h2 className="text-lg font-bold">Earnings & payouts</h2>
            <div className="mt-4 grid grid-cols-3 gap-3 text-center">
              <div>
                <p className="text-xl font-bold">
                  {money(earnings.lifetimeNetCents)}
                </p>
                <p className="text-xs text-fg-muted">Lifetime net</p>
              </div>
              <div>
                <p className="text-xl font-bold">
                  {money(earnings.membershipNetCents)}
                </p>
                <p className="text-xs text-fg-muted">Memberships</p>
              </div>
              <div>
                <p className="text-xl font-bold">
                  {money(earnings.tipNetCents)}
                </p>
                <p className="text-xs text-fg-muted">Cheers</p>
              </div>
            </div>
            <div className="mt-6 border-t border-border pt-6">
              <PayoutPortal
                companyId={channel.whopCompanyId}
                environment={isSandbox() ? "sandbox" : "production"}
              />
            </div>
          </div>

          <MembersPanel
            memberCount={memberCount}
            recentMembers={recentMembers}
          />
        </div>
      ) : null}
    </div>
  );
}

Add a Monetization link to the studio nav so creators can reach it. Open src/app/studio/layout.tsx and replace its contents:

layout.tsx
import type { ReactNode } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { User } from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { WavoraLogo } from "@/components/ui/wavora-logo";

export default async function StudioLayout({
  children,
}: {
  children: ReactNode;
}) {
  const user = await getCurrentUser();
  if (!user) redirect("/sign-in");

  return (
    <div className="min-h-dvh bg-canvas">
      <header className="fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-4 border-b border-border bg-canvas px-4">
        <div className="flex items-center gap-2">
          <Link href="/" aria-label="Wavora home">
            <WavoraLogo />
          </Link>
          <span className="text-sm font-medium text-fg-muted">Studio</span>
          <nav className="ml-3 hidden items-center gap-1 sm:flex">
            <Link
              href="/studio/videos"
              className="rounded-lg px-3 py-1.5 text-sm hover:bg-hover"
            >
              Content
            </Link>
            <Link
              href="/studio/monetization"
              className="rounded-lg px-3 py-1.5 text-sm hover:bg-hover"
            >
              Monetization
            </Link>
          </nav>
        </div>
        <div className="flex items-center gap-4">
          <Link href="/" className="text-sm text-fg-muted hover:text-fg">
            View site
          </Link>
          <span className="grid h-8 w-8 place-items-center overflow-hidden rounded-full bg-hover">
            {user.avatarUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={user.avatarUrl}
                alt=""
                className="h-full w-full object-cover"
              />
            ) : (
              <User className="h-5 w-5 text-fg-muted" />
            )}
          </span>
        </div>
      </header>

      <main className="mx-auto max-w-screen-xl px-6 pb-16 pt-20">{children}</main>
    </div>
  );
}

Notifications

A minimal in-app inbox notifies content creators when a new member joins or a tip is received, and notifies subscribers about new uploads.

The notification reads and writes. Create src/lib/notifications.ts:

notifications.ts
import "server-only";
import { prisma } from "./prisma";

export async function notifyNewUpload(
  channelId: string,
  channelName: string,
  video: { id: string; title: string },
): Promise<void> {
  const subs = await prisma.subscription.findMany({
    where: { channelId, notify: { not: "NONE" } },
    select: { subscriberId: true },
  });
  if (subs.length === 0) return;

  await prisma.notification.createMany({
    data: subs.map((s) => ({
      recipientId: s.subscriberId,
      type: "NEW_UPLOAD" as const,
      title: `${channelName} posted a new video`,
      body: video.title,
      data: { videoId: video.id, channelId },
    })),
  });
}

export async function getNotifications(userId: string) {
  return prisma.notification.findMany({
    where: { recipientId: userId },
    orderBy: { createdAt: "desc" },
    take: 30,
    select: {
      id: true,
      type: true,
      title: true,
      body: true,
      readAt: true,
      createdAt: true,
    },
  });
}

export async function getUnreadCount(userId: string): Promise<number> {
  return prisma.notification.count({
    where: { recipientId: userId, readAt: null },
  });
}

Create src/app/api/notifications/route.ts:

route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { getNotifications, getUnreadCount } from "@/lib/notifications";

export async function GET(): Promise<Response> {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ notifications: [], unreadCount: 0 });
  const [notifications, unreadCount] = await Promise.all([
    getNotifications(user.id),
    getUnreadCount(user.id),
  ]);
  return NextResponse.json({ notifications, unreadCount });
}

export async function POST(): Promise<Response> {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ ok: false }, { status: 401 });
  await prisma.notification.updateMany({
    where: { recipientId: user.id, readAt: null },
    data: { readAt: new Date() },
  });
  return NextResponse.json({ ok: true });
}

Now, let's make the bell we stubbed in Part 1 real. Open src/components/layout/notification-bell.tsx and replace its contents:

notification-bell.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { Bell } from "lucide-react";
import { formatTimeAgo } from "@/lib/format";
import { useEscape } from "@/hooks/use-escape";
import { cn } from "@/lib/utils";

type Notif = {
  id: string;
  type: string;
  title: string;
  body: string | null;
  readAt: string | null;
  createdAt: string;
};

export function NotificationBell() {
  const [open, setOpen] = useState(false);
  const [items, setItems] = useState<Notif[]>([]);
  const [unread, setUnread] = useState(0);
  const fetched = useRef(false);

  useEscape(open, () => setOpen(false));

  async function load() {
    try {
      const res = await fetch("/api/notifications");
      const data = await res.json();
      setItems(data.notifications ?? []);
      setUnread(data.unreadCount ?? 0);
    } catch {}
  }

  useEffect(() => {
    if (fetched.current) return;
    fetched.current = true;
    load();
  }, []);

  async function toggle() {
    const next = !open;
    setOpen(next);
    if (next && unread > 0) {
      setUnread(0);
      try {
        await fetch("/api/notifications", { method: "POST" });
      } catch {}
    }
  }

  return (
    <div className="relative">
      <button
        type="button"
        aria-label="Notifications"
        onClick={toggle}
        className="relative grid h-10 w-10 place-items-center rounded-full hover:bg-hover"
      >
        <Bell className="h-5 w-5" />
        {unread > 0 ? (
          <span className="absolute right-1 top-1 grid h-4 min-w-4 place-items-center rounded-full bg-brand px-1 text-[10px] font-bold text-white">
            {unread > 9 ? "9+" : unread}
          </span>
        ) : null}
      </button>
      {open ? (
        <>
          <div
            className="fixed inset-0 z-[55]"
            onClick={() => setOpen(false)}
          />
          <div className="absolute right-0 z-[60] mt-1 max-h-[70vh] w-80 overflow-auto rounded-xl border border-border bg-surface shadow-lg">
            <h3 className="border-b border-border px-4 py-3 font-medium">
              Notifications
            </h3>
            {items.length === 0 ? (
              <p className="px-4 py-10 text-center text-sm text-fg-muted">
                No notifications yet.
              </p>
            ) : (
              <ul>
                {items.map((n) => (
                  <li
                    key={n.id}
                    className={cn(
                      "border-b border-border px-4 py-3 text-sm last:border-0",
                      !n.readAt && "bg-accent/5",
                    )}
                  >
                    <p className="font-medium">{n.title}</p>
                    {n.body ? (
                      <p className="mt-0.5 text-fg-muted">{n.body}</p>
                    ) : null}
                    <p className="mt-1 text-xs text-fg-muted">
                      {formatTimeAgo(n.createdAt)}
                    </p>
                  </li>
                ))}
              </ul>
            )}
          </div>
        </>
      ) : null}
    </div>
  );
}

Then, for the upload action to notify subscribers, open src/app/studio/actions.ts and replace its contents:

actions.ts
"use server";

import { del } from "@vercel/blob";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { notifyNewUpload } from "@/lib/notifications";
import {
  createVideoSchema,
  updateVideoSchema,
  type CreateVideoInput,
} from "@/lib/validators";

export type CreateVideoResult = { id?: string; error?: string };
export type UpdateVideoResult = { ok?: boolean; error?: string };

async function ownedVideo(videoId: string) {
  const user = await getCurrentUser();
  if (!user) return null;
  const video = await prisma.video.findUnique({
    where: { id: videoId },
    select: {
      id: true,
      videoUrl: true,
      thumbnailUrl: true,
      publishedAt: true,
      channel: { select: { userId: true, membershipsEnabled: true } },
    },
  });
  if (!video || video.channel.userId !== user.id) return null;
  return video;
}

export async function createVideo(
  input: CreateVideoInput,
): Promise<CreateVideoResult> {
  const user = await getCurrentUser();
  if (!user) return { error: "Please sign in." };

  const channel = await prisma.channel.findUnique({
    where: { userId: user.id },
    select: { id: true, name: true, membershipsEnabled: true },
  });
  if (!channel) return { error: "Create a channel first." };

  const parsed = createVideoSchema.safeParse(input);
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Invalid video data." };
  }
  const d = parsed.data;

  const video = await prisma.video.create({
    data: {
      channelId: channel.id,
      title: d.title,
      description: d.description ? d.description : null,
      visibility: d.visibility,
      category: d.category,
      status: "READY",
      videoUrl: d.videoUrl,
      videoPathname: d.videoPathname,
      thumbnailUrl: d.thumbnailUrl ?? null,
      durationSeconds: Math.round(d.durationSeconds),
      membersOnly: d.membersOnly && channel.membershipsEnabled,
      isShort: d.isShort,
      publishedAt: d.visibility === "PRIVATE" ? null : new Date(),
    },
    select: { id: true },
  });

  if (d.visibility === "PUBLIC") {
    try {
      await notifyNewUpload(channel.id, channel.name, {
        id: video.id,
        title: d.title,
      });
    } catch {}
  }

  revalidatePath("/");
  revalidatePath("/waves");
  revalidatePath("/studio/videos");
  return { id: video.id };
}

export async function updateVideo(
  _prev: UpdateVideoResult,
  formData: FormData,
): Promise<UpdateVideoResult> {
  const parsed = updateVideoSchema.safeParse({
    id: formData.get("id"),
    title: formData.get("title"),
    description: formData.get("description"),
    visibility: formData.get("visibility"),
    category: formData.get("category"),
    membersOnly: formData.get("membersOnly") === "on",
    isShort: formData.get("isShort") === "on",
  });
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Invalid input." };
  }

  const owned = await ownedVideo(parsed.data.id);
  if (!owned) return { error: "Video not found." };

  await prisma.video.update({
    where: { id: parsed.data.id },
    data: {
      title: parsed.data.title,
      description: parsed.data.description ? parsed.data.description : null,
      visibility: parsed.data.visibility,
      category: parsed.data.category,
      membersOnly: parsed.data.membersOnly && owned.channel.membershipsEnabled,
      isShort: parsed.data.isShort,
      publishedAt:
        parsed.data.visibility === "PRIVATE"
          ? null
          : owned.publishedAt
            ? undefined
            : new Date(),
    },
  });

  revalidatePath("/");
  revalidatePath("/waves");
  revalidatePath("/studio/videos");
  revalidatePath("/watch");
  return { ok: true };
}

export async function deleteVideo(formData: FormData): Promise<void> {
  const id = String(formData.get("id") ?? "");
  const owned = await ownedVideo(id);
  if (!owned) return;

  const blobUrls = [owned.videoUrl, owned.thumbnailUrl].filter(
    (u): u is string => !!u && u.includes(".blob.vercel-storage.com"),
  );
  if (blobUrls.length > 0) {
    try {
      await del(blobUrls);
    } catch {}
  }

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

  revalidatePath("/");
  revalidatePath("/studio/videos");
  redirect("/studio/videos");
}

Wire the money into the app

We want to add the Cheers button next to the Like and Save buttons in the watch page. Open src/app/(main)/watch/page.tsx and replace its contents:

page.tsx
import { notFound } from "next/navigation";
import Link from "next/link";
import { User } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { getRelatedVideos } from "@/lib/videos";
import { getChannelSocial, getVideoReactions } from "@/lib/social";
import { getCommentsData } from "@/lib/comments";
import { isInWatchLater } from "@/lib/library";
import { getResumePosition } from "@/lib/history";
import { isSandbox } from "@/lib/env";
import {
  formatDuration,
  formatSubscribers,
  formatTimeAgo,
  formatViews,
} from "@/lib/format";
import type { FeedVideo } from "@/components/feed/video-card";
import { SubscribeButton } from "@/components/watch/subscribe-button";
import { LikeDislike } from "@/components/watch/like-dislike";
import { SaveButton } from "@/components/watch/save-button";
import { SuperThanks } from "@/components/watch/super-thanks";
import { WatchPlayer } from "@/components/watch/watch-player";
import { Comments } from "@/components/comments/comments";
import { ViewTracker } from "./view-tracker";

type SearchParams = Promise<{ v?: string | string[] }>;

export default async function WatchPage({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const { v: rawV } = await searchParams;
  const v = Array.isArray(rawV) ? rawV[0] : rawV;
  if (!v) notFound();

  const video = await prisma.video.findUnique({
    where: { id: v },
    include: {
      channel: {
        select: {
          handle: true,
          name: true,
          avatarUrl: true,
          userId: true,
          whopCompanyId: true,
          superThanksEnabled: true,
        },
      },
    },
  });
  if (!video || video.status !== "READY" || !video.videoUrl) notFound();

  const user = await getCurrentUser();

  if (video.visibility === "PRIVATE" && user?.id !== video.channel.userId) {
    notFound();
  }

  const [related, channelSocial, reactions, commentsData, savedToWatchLater, resumeAt] =
    await Promise.all([
      getRelatedVideos(video.id),
      getChannelSocial(video.channelId, user?.id),
      getVideoReactions(video.id, user?.id),
      getCommentsData(video.id, user?.id, "top"),
      user ? isInWatchLater(user.id, video.id) : Promise.resolve(false),
      user ? getResumePosition(user.id, video.id) : Promise.resolve(0),
    ]);

  return (
    <div className="mx-auto flex max-w-[1700px] flex-col gap-6 lg:flex-row">
      {}
      <div className="min-w-0 flex-1">
        <div className="overflow-hidden rounded-xl bg-black">
          <WatchPlayer
            key={video.id}
            videoId={video.id}
            src={video.videoUrl}
            poster={video.thumbnailUrl ?? undefined}
            resumeAt={resumeAt}
            isSignedIn={Boolean(user)}
          />
        </div>
        <ViewTracker videoId={video.id} />

        <h1 className="mt-3 text-xl font-bold leading-tight">{video.title}</h1>

        {}
        <div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
          <div className="flex items-center gap-3">
            <Link
              href={`/@${video.channel.handle}`}
              className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-full bg-hover"
            >
              {video.channel.avatarUrl ? (
                // eslint-disable-next-line @next/next/no-img-element
                <img
                  src={video.channel.avatarUrl}
                  alt=""
                  className="h-full w-full object-cover"
                />
              ) : (
                <User className="h-5 w-5 text-fg-muted" />
              )}
            </Link>
            <div className="min-w-0">
              <Link
                href={`/@${video.channel.handle}`}
                className="block truncate font-medium"
              >
                {video.channel.name}
              </Link>
              <p className="truncate text-xs text-fg-muted">
                {formatSubscribers(channelSocial.subscriberCount)}
              </p>
            </div>
            <SubscribeButton
              channelId={video.channelId}
              isOwner={user?.id === video.channel.userId}
              isSignedIn={Boolean(user)}
              initialSubscribed={channelSocial.isSubscribed}
              initialNotify={channelSocial.notifyLevel}
            />
          </div>

          <div className="flex items-center gap-2">
            <LikeDislike
              videoId={video.id}
              isSignedIn={Boolean(user)}
              initialReaction={reactions.myReaction}
              initialLikeCount={reactions.likeCount}
            />
            <SaveButton
              videoId={video.id}
              isSignedIn={Boolean(user)}
              initialSaved={savedToWatchLater}
            />
            {video.channel.whopCompanyId &&
            video.channel.superThanksEnabled &&
            user?.id !== video.channel.userId ? (
              <SuperThanks
                videoId={video.id}
                isSignedIn={Boolean(user)}
                environment={isSandbox() ? "sandbox" : "production"}
              />
            ) : null}
          </div>
        </div>

        {}
        <div className="mt-4 rounded-xl bg-hover p-3 text-sm">
          <p className="font-medium">
            {formatViews(video.viewCount)}
            {video.publishedAt
              ? ` • ${formatTimeAgo(video.publishedAt)}`
              : " • Unpublished"}
          </p>
          {video.description ? (
            <p className="mt-2 whitespace-pre-wrap text-fg">
              {video.description}
            </p>
          ) : null}
        </div>

        {commentsData ? (
          <Comments
            videoId={video.id}
            isSignedIn={Boolean(user)}
            isCreator={commentsData.isCreator}
            myAvatar={user?.avatarUrl ?? null}
            initial={{
              enabled: commentsData.enabled,
              count: commentsData.count,
              comments: commentsData.comments,
            }}
          />
        ) : null}
      </div>

      {}
      <aside className="w-full shrink-0 lg:w-[402px]">
        <div className="flex flex-col gap-2">
          {related.map((r) => (
            <RelatedRow key={r.id} video={r} />
          ))}
        </div>
      </aside>
    </div>
  );
}

function RelatedRow({ video }: { video: FeedVideo }) {
  return (
    <Link href={`/watch?v=${video.id}`} className="flex gap-2">
      <div className="relative aspect-video w-40 shrink-0 overflow-hidden rounded-lg bg-hover">
        {video.thumbnailUrl ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={video.thumbnailUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        ) : null}
        {video.durationSeconds > 0 ? (
          <span className="absolute bottom-1 right-1 rounded bg-black/80 px-1 text-xs text-white">
            {formatDuration(video.durationSeconds)}
          </span>
        ) : null}
      </div>
      <div className="min-w-0 flex-1">
        <h3 className="line-clamp-2 text-sm font-medium leading-5">
          {video.title}
        </h3>
        <p className="mt-1 truncate text-xs text-fg-muted">
          {video.channel.name}
        </p>
        <p className="truncate text-xs text-fg-muted">
          {formatViews(video.viewCount)}
          {video.publishedAt ? ` • ${formatTimeAgo(video.publishedAt)}` : ""}
        </p>
      </div>
    </Link>
  );
}

The channel page gets a Join button and a membership tab. Open src/app/(main)/[handle]/layout.tsx and replace its contents:

layout.tsx
import type { ReactNode } from "react";
import { User } from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { getChannelSocial } from "@/lib/social";
import { getChannelVideoCount, resolveChannel } from "@/lib/channels";
import { getChannelTiers, isActiveMember } from "@/lib/membership";
import { isSandbox } from "@/lib/env";
import { SubscribeButton } from "@/components/watch/subscribe-button";
import { JoinMembership } from "@/components/channel/join-membership";
import { ChannelTabs } from "@/components/channel/channel-tabs";
import { formatCompact, formatSubscribers } from "@/lib/format";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ handle: string }>;
}) {
  const { handle } = await params;
  const channel = await resolveChannel(handle);
  return { title: `${channel.name} - Wavora` };
}

export default async function ChannelLayout({
  children,
  params,
}: {
  children: ReactNode;
  params: Promise<{ handle: string }>;
}) {
  const { handle: raw } = await params;
  const channel = await resolveChannel(raw);
  const user = await getCurrentUser();
  const [videoCount, social, tiers, member] = await Promise.all([
    getChannelVideoCount(channel.id),
    getChannelSocial(channel.id, user?.id),
    channel.membershipsEnabled
      ? getChannelTiers(channel.id)
      : Promise.resolve([]),
    user && channel.membershipsEnabled
      ? isActiveMember(user.id, channel.id)
      : Promise.resolve(false),
  ]);

  return (
    <div className="mx-auto max-w-[1280px]">
      {channel.bannerUrl ? (
        <div className="mb-4 aspect-[6/1] w-full overflow-hidden rounded-xl bg-hover">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={channel.bannerUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        </div>
      ) : null}

      <div className="flex flex-col items-center gap-4 py-4 sm:flex-row sm:items-end">
        <div className="grid h-28 w-28 shrink-0 place-items-center overflow-hidden rounded-full bg-hover sm:h-40 sm:w-40">
          {channel.avatarUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={channel.avatarUrl}
              alt=""
              className="h-full w-full object-cover"
            />
          ) : (
            <User className="h-16 w-16 text-fg-muted" />
          )}
        </div>

        <div className="min-w-0 text-center sm:text-left">
          <h1 className="text-2xl font-bold sm:text-3xl">{channel.name}</h1>
          <p className="mt-1 text-sm text-fg-muted">
            <span className="font-medium text-fg">@{channel.handle}</span>
            {" • "}
            {formatSubscribers(social.subscriberCount)}
            {" • "}
            {formatCompact(videoCount)} video{videoCount === 1 ? "" : "s"}
          </p>
          {channel.description ? (
            <p className="mt-2 line-clamp-1 max-w-xl text-sm text-fg-muted">
              {channel.description}
            </p>
          ) : null}
          <div className="mt-4 flex justify-center gap-2 sm:justify-start">
            <SubscribeButton
              channelId={channel.id}
              isOwner={user?.id === channel.userId}
              isSignedIn={Boolean(user)}
              initialSubscribed={social.isSubscribed}
              initialNotify={social.notifyLevel}
            />
            {channel.membershipsEnabled && user?.id !== channel.userId ? (
              <JoinMembership
                tiers={tiers}
                isSignedIn={Boolean(user)}
                isMember={member}
                environment={isSandbox() ? "sandbox" : "production"}
              />
            ) : null}
          </div>
        </div>
      </div>

      <ChannelTabs
        handle={channel.handle}
        showShorts={false}
        showMembership={channel.membershipsEnabled}
      />

      {children}
    </div>
  );
}

And the comments query starts flagging channel members and highlighting Cheers. Open src/lib/comments.ts and replace its contents:

comments.ts
import "server-only";
import { prisma } from "./prisma";
import { activeMemberIds } from "./membership";

export type CommentDTO = {
  id: string;
  body: string;
  createdAt: string;
  author: {
    id: string;
    name: string | null;
    username: string;
    avatarUrl: string | null;
  };
  likeCount: number;
  myLiked: boolean;
  heartedByCreator: boolean;
  isPinned: boolean;
  replyCount: number;
  isOwn: boolean;
  authorIsMember: boolean;
  isSuperThanks: boolean;
  superThanksAmount: number | null;
};

const authorSelect = {
  id: true,
  name: true,
  username: true,
  avatarUrl: true,
} as const;

function commentInclude(currentUserId?: string) {
  return {
    author: { select: authorSelect },
    _count: { select: { reactions: true } },
    reactions: {
      where: { userId: currentUserId ?? "__no_user__" },
      select: { id: true },
    },
  };
}

type RawComment = {
  id: string;
  body: string;
  createdAt: Date;
  heartedByCreator: boolean;
  isPinned: boolean;
  isSuperThanks: boolean;
  superThanksAmount: number | null;
  author: {
    id: string;
    name: string | null;
    username: string;
    avatarUrl: string | null;
  };
  _count: { reactions: number };
  reactions: { id: string }[];
};

export function toCommentDTO(
  c: RawComment,
  currentUserId: string | undefined,
  replyCount: number,
  authorIsMember = false,
): CommentDTO {
  return {
    id: c.id,
    body: c.body,
    createdAt: c.createdAt.toISOString(),
    author: c.author,
    likeCount: c._count.reactions,
    myLiked: c.reactions.length > 0,
    heartedByCreator: c.heartedByCreator,
    isPinned: c.isPinned,
    replyCount,
    isOwn: Boolean(currentUserId) && c.author.id === currentUserId,
    authorIsMember,
    isSuperThanks: c.isSuperThanks,
    superThanksAmount: c.superThanksAmount,
  };
}

export async function getCommentsData(
  videoId: string,
  currentUserId: string | undefined,
  sort: "top" | "newest",
) {
  const video = await prisma.video.findUnique({
    where: { id: videoId },
    select: {
      commentsEnabled: true,
      channelId: true,
      channel: { select: { userId: true } },
    },
  });
  if (!video) return null;

  const isCreator =
    Boolean(currentUserId) && currentUserId === video.channel.userId;

  const [count, rows] = await Promise.all([
    prisma.comment.count({ where: { videoId, status: "PUBLISHED" } }),
    prisma.comment.findMany({
      where: { videoId, parentId: null, status: "PUBLISHED" },
      orderBy:
        sort === "top"
          ? [
              { isPinned: "desc" },
              { reactions: { _count: "desc" } },
              { createdAt: "desc" },
            ]
          : [{ isPinned: "desc" }, { createdAt: "desc" }],
      take: 50,
      include: commentInclude(currentUserId),
    }),
  ]);

  const ids = rows.map((r) => r.id);
  const replyGroups = ids.length
    ? await prisma.comment.groupBy({
        by: ["parentId"],
        where: { parentId: { in: ids }, status: "PUBLISHED" },
        _count: { _all: true },
      })
    : [];
  const replyCounts = new Map(
    replyGroups.map((g) => [g.parentId as string, g._count._all]),
  );

  const members = await activeMemberIds(
    video.channelId,
    rows.map((r) => r.author.id),
  );

  return {
    enabled: video.commentsEnabled,
    isCreator,
    count,
    comments: rows.map((r) =>
      toCommentDTO(
        r,
        currentUserId,
        replyCounts.get(r.id) ?? 0,
        members.has(r.author.id),
      ),
    ),
  };
}

export async function getReplies(
  parentId: string,
  currentUserId: string | undefined,
): Promise<CommentDTO[]> {
  const [parent, rows] = await Promise.all([
    prisma.comment.findUnique({
      where: { id: parentId },
      select: { video: { select: { channelId: true } } },
    }),
    prisma.comment.findMany({
      where: { parentId, status: "PUBLISHED" },
      orderBy: { createdAt: "asc" },
      take: 100,
      include: commentInclude(currentUserId),
    }),
  ]);
  const members = parent
    ? await activeMemberIds(
        parent.video.channelId,
        rows.map((r) => r.author.id),
      )
    : new Set<string>();
  return rows.map((r) =>
    toCommentDTO(r, currentUserId, 0, members.has(r.author.id)),
  );
}

Checkpoint

Before moving on, confirm:

  • Enabling monetization in the studio creates a connected account, and its id shows up.
  • Adding a membership tier creates a Whop product and plan on that account.
  • A channel with tiers shows a Join button and a membership tab, and joining opens Whop's checkout.
  • A Cheers button appears on videos from monetized channels.
  • The webhook handlers record a membership or tip exactly once, even on a repeat delivery.
  • The studio shows earnings and an embedded payout portal.
  • The notification bell shows new members, tips, and uploads.

Part 5: Playlists, Waves, and shipping

The foundations of our project are now done. In this part, we'll finalize the app with comprehensive playlists, member-exclusive videos, a Waves feed, a Discover section, and some security improvements, and send it to production.

Full playlists

In addition to "Watch Later" and "Favorites," users will be able to create their own playlists and add videos to them. The simple "Save" button will now be replaced by a "Save" menu.

The playlist reads. Create src/lib/playlists.ts:

playlists.ts
import "server-only";
import { prisma } from "./prisma";
import type { FeedVideo } from "@/components/feed/video-card";

export type PlaylistVisibility = "PUBLIC" | "UNLISTED" | "PRIVATE";

export type PlaylistSummary = {
  id: string;
  title: string;
  visibility: PlaylistVisibility;
  itemCount: number;
  thumbnailUrl: string | null;
};

export async function getUserPlaylists(
  userId: string,
): Promise<PlaylistSummary[]> {
  const rows = await prisma.playlist.findMany({
    where: { ownerId: userId },
    orderBy: { updatedAt: "desc" },
    take: 100,
    select: {
      id: true,
      title: true,
      visibility: true,
      _count: { select: { items: true } },
      items: {
        orderBy: { position: "asc" },
        take: 1,
        select: { video: { select: { thumbnailUrl: true } } },
      },
    },
  });
  return rows.map((p) => ({
    id: p.id,
    title: p.title,
    visibility: p.visibility,
    itemCount: p._count.items,
    thumbnailUrl: p.items[0]?.video.thumbnailUrl ?? null,
  }));
}

export type PickerPlaylist = {
  id: string;
  title: string;
  visibility: PlaylistVisibility;
  contains: boolean;
};

export async function getPlaylistsForPicker(
  userId: string,
  videoId: string,
): Promise<PickerPlaylist[]> {
  const rows = await prisma.playlist.findMany({
    where: { ownerId: userId },
    orderBy: { updatedAt: "desc" },
    take: 100,
    select: {
      id: true,
      title: true,
      visibility: true,
      items: { where: { videoId }, select: { id: true } },
    },
  });
  return rows.map((p) => ({
    id: p.id,
    title: p.title,
    visibility: p.visibility,
    contains: p.items.length > 0,
  }));
}

export type PlaylistDetail = {
  id: string;
  title: string;
  description: string | null;
  visibility: PlaylistVisibility;
  ownerId: string;
  ownerName: string;
  ownerHandle: string | null;
  videos: FeedVideo[];
  isOwner: boolean;
};

export async function getPlaylistDetail(
  playlistId: string,
  viewerId: string | null,
): Promise<PlaylistDetail | null> {
  const p = await prisma.playlist.findUnique({
    where: { id: playlistId },
    select: {
      id: true,
      title: true,
      description: true,
      visibility: true,
      ownerId: true,
      owner: {
        select: { name: true, username: true, channel: { select: { handle: true } } },
      },
      items: {
        orderBy: { position: "asc" },
        select: {
          video: {
            select: {
              id: true,
              title: true,
              thumbnailUrl: true,
              durationSeconds: true,
              viewCount: true,
              publishedAt: true,
              status: true,
              visibility: true,
              channel: {
                select: {
                  handle: true,
                  name: true,
                  avatarUrl: true,
                  userId: true,
                },
              },
            },
          },
        },
      },
    },
  });
  if (!p) return null;

  const isOwner = viewerId !== null && viewerId === p.ownerId;
  if (p.visibility === "PRIVATE" && !isOwner) return null;

  const videos: FeedVideo[] = p.items
    .map((i) => i.video)
    .filter(
      (v) =>
        v.status === "READY" &&
        (v.visibility !== "PRIVATE" || v.channel.userId === viewerId),
    )
    .map((v) => ({
      id: v.id,
      title: v.title,
      thumbnailUrl: v.thumbnailUrl,
      durationSeconds: v.durationSeconds,
      viewCount: v.viewCount,
      publishedAt: v.publishedAt,
      channel: {
        handle: v.channel.handle,
        name: v.channel.name,
        avatarUrl: v.channel.avatarUrl,
      },
    }));

  return {
    id: p.id,
    title: p.title,
    description: p.description,
    visibility: p.visibility,
    ownerId: p.ownerId,
    ownerName: p.owner.name ?? p.owner.username,
    ownerHandle: p.owner.channel?.handle ?? null,
    videos,
    isOwner,
  };
}

The writes. Create src/lib/playlist-actions.ts:

playlist-actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "./prisma";
import { getCurrentUser } from "./session";

const VISIBILITIES = ["PUBLIC", "UNLISTED", "PRIVATE"] as const;

function prismaCode(e: unknown): string | undefined {
  return e && typeof e === "object" && "code" in e
    ? (e as { code?: string }).code
    : undefined;
}

const createSchema = z.object({
  title: z.string().trim().min(1).max(150),
  visibility: z.enum(VISIBILITIES).default("PRIVATE"),
});

export async function createPlaylist(input: {
  title: string;
  visibility?: (typeof VISIBILITIES)[number];
}): Promise<{ id: string } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };

  const parsed = createSchema.safeParse(input);
  if (!parsed.success) return { error: "Enter a playlist name." };

  const playlist = await prisma.playlist.create({
    data: {
      ownerId: user.id,
      title: parsed.data.title,
      visibility: parsed.data.visibility,
    },
    select: { id: true },
  });
  revalidatePath("/feed/playlists");
  revalidatePath("/feed/you");
  return { id: playlist.id };
}

export async function togglePlaylistItem(
  playlistId: string,
  videoId: string,
): Promise<{ contains: boolean } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };

  const playlist = await prisma.playlist.findUnique({
    where: { id: playlistId },
    select: { ownerId: true },
  });
  if (!playlist || playlist.ownerId !== user.id) return { error: "not_found" };

  const existing = await prisma.playlistItem.findUnique({
    where: { playlistId_videoId: { playlistId, videoId } },
    select: { id: true },
  });

  if (existing) {
    try {
      await prisma.playlistItem.delete({ where: { id: existing.id } });
    } catch (e) {
      if (prismaCode(e) !== "P2025") throw e;
    }
  } else {
    const last = await prisma.playlistItem.findFirst({
      where: { playlistId },
      orderBy: { position: "desc" },
      select: { position: true },
    });
    try {
      await prisma.playlistItem.create({
        data: { playlistId, videoId, position: (last?.position ?? -1) + 1 },
      });
    } catch (e) {
      const code = prismaCode(e);
      if (code === "P2003") return { error: "not_found" };
      if (code !== "P2002") throw e;
    }
  }
  await prisma.playlist.update({
    where: { id: playlistId },
    data: { updatedAt: new Date() },
  });

  revalidatePath(`/playlist`);
  revalidatePath("/feed/playlists");
  revalidatePath("/feed/you");
  return { contains: !existing };
}

export async function createPlaylistWithVideo(
  title: string,
  visibility: (typeof VISIBILITIES)[number],
  videoId: string,
): Promise<{ id: string } | { error: string }> {
  const created = await createPlaylist({ title, visibility });
  if ("error" in created) return created;
  try {
    const res = await togglePlaylistItem(created.id, videoId);
    if ("error" in res) throw new Error(res.error);
  } catch (e) {
    await prisma.playlist.delete({ where: { id: created.id } }).catch(() => {});
    return { error: e instanceof Error ? e.message : "failed" };
  }
  return { id: created.id };
}

export async function removeFromPlaylist(
  playlistId: string,
  videoId: string,
): Promise<{ ok: true } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };

  const playlist = await prisma.playlist.findUnique({
    where: { id: playlistId },
    select: { ownerId: true },
  });
  if (!playlist || playlist.ownerId !== user.id) return { error: "not_found" };

  await prisma.playlistItem.deleteMany({ where: { playlistId, videoId } });
  await prisma.playlist.update({
    where: { id: playlistId },
    data: { updatedAt: new Date() },
  });
  revalidatePath(`/playlist`);
  return { ok: true };
}

export async function deletePlaylist(
  playlistId: string,
): Promise<{ ok: true } | { error: string }> {
  const user = await getCurrentUser();
  if (!user) return { error: "sign_in" };

  const playlist = await prisma.playlist.findUnique({
    where: { id: playlistId },
    select: { ownerId: true },
  });
  if (!playlist || playlist.ownerId !== user.id) return { error: "not_found" };

  await prisma.playlist.delete({ where: { id: playlistId } });
  revalidatePath("/feed/playlists");
  revalidatePath("/feed/you");
  return { ok: true };
}

The Save-to menu. Create src/components/watch/save-menu.tsx:

save-menu.tsx
"use client";

import { useState, useTransition } from "react";
import { Bookmark, BookmarkCheck, Plus, X } from "lucide-react";
import { toggleWatchLater } from "@/lib/library-actions";
import {
  createPlaylistWithVideo,
  togglePlaylistItem,
} from "@/lib/playlist-actions";
import { useEscape } from "@/hooks/use-escape";
import { cn } from "@/lib/utils";

type Option = {
  id: string;
  title: string;
  contains: boolean;
};

function goSignIn() {
  window.location.href = `/sign-in?next=${encodeURIComponent(
    window.location.pathname + window.location.search,
  )}`;
}

export function SaveMenu({
  videoId,
  isSignedIn,
  initialSaved,
  playlists,
}: {
  videoId: string;
  isSignedIn: boolean;
  initialSaved: boolean;
  playlists: Option[];
}) {
  const [open, setOpen] = useState(false);
  const [saved, setSaved] = useState(initialSaved);
  const [options, setOptions] = useState<Option[]>(playlists);
  const [creating, setCreating] = useState(false);
  const [newTitle, setNewTitle] = useState("");
  const [, startTransition] = useTransition();

  useEscape(open, () => setOpen(false));

  const anySaved = saved || options.some((o) => o.contains);

  function onButton() {
    if (!isSignedIn) return goSignIn();
    setOpen((v) => !v);
  }

  function onToggleWatchLater() {
    const next = !saved;
    setSaved(next);
    startTransition(async () => {
      const res = await toggleWatchLater(videoId);
      setSaved("error" in res ? !next : res.saved);
    });
  }

  function onTogglePlaylist(id: string) {
    setOptions((prev) =>
      prev.map((o) => (o.id === id ? { ...o, contains: !o.contains } : o)),
    );
    startTransition(async () => {
      const res = await togglePlaylistItem(id, videoId);
      if ("error" in res) {
        setOptions((prev) =>
          prev.map((o) => (o.id === id ? { ...o, contains: !o.contains } : o)),
        );
      }
    });
  }

  function onCreate() {
    const title = newTitle.trim();
    if (!title) return;
    startTransition(async () => {
      const res = await createPlaylistWithVideo(title, "PRIVATE", videoId);
      if ("id" in res) {
        setOptions((prev) => [
          { id: res.id, title, contains: true },
          ...prev,
        ]);
        setNewTitle("");
        setCreating(false);
      }
    });
  }

  return (
    <div className="relative">
      <button
        type="button"
        onClick={onButton}
        className={cn(
          "flex items-center gap-2 rounded-full bg-chip px-4 py-2 text-sm font-medium hover:bg-hover-strong",
          anySaved && "text-accent",
        )}
      >
        {anySaved ? (
          <BookmarkCheck className="h-5 w-5" />
        ) : (
          <Bookmark className="h-5 w-5" />
        )}
        {anySaved ? "Saved" : "Save"}
      </button>

      {open ? (
        <>
          <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
          <div className="absolute right-0 z-50 mt-2 w-64 rounded-xl border border-border bg-surface p-3 shadow-lg">
            <div className="mb-2 flex items-center justify-between">
              <span className="text-sm font-medium">Save to…</span>
              <button
                type="button"
                aria-label="Close"
                onClick={() => setOpen(false)}
                className="grid h-6 w-6 place-items-center rounded-full hover:bg-hover"
              >
                <X className="h-4 w-4" />
              </button>
            </div>

            <label className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1.5 hover:bg-hover">
              <input
                type="checkbox"
                checked={saved}
                onChange={onToggleWatchLater}
                className="h-4 w-4 accent-accent"
              />
              <span className="text-sm">Watch later</span>
            </label>

            {options.map((o) => (
              <label
                key={o.id}
                className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1.5 hover:bg-hover"
              >
                <input
                  type="checkbox"
                  checked={o.contains}
                  onChange={() => onTogglePlaylist(o.id)}
                  className="h-4 w-4 accent-accent"
                />
                <span className="truncate text-sm">{o.title}</span>
              </label>
            ))}

            <div className="mt-2 border-t border-border pt-2">
              {creating ? (
                <div className="flex flex-col gap-2">
                  <input
                    autoFocus
                    value={newTitle}
                    onChange={(e) => setNewTitle(e.target.value)}
                    onKeyDown={(e) => {
                      if (e.key === "Enter") onCreate();
                    }}
                    placeholder="Playlist name"
                    maxLength={150}
                    className="w-full rounded-lg border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none focus:border-accent"
                  />
                  <div className="flex justify-end gap-2">
                    <button
                      type="button"
                      onClick={() => {
                        setCreating(false);
                        setNewTitle("");
                      }}
                      className="rounded-full px-3 py-1 text-sm hover:bg-hover"
                    >
                      Cancel
                    </button>
                    <button
                      type="button"
                      disabled={!newTitle.trim()}
                      onClick={onCreate}
                      className="rounded-full bg-accent px-3 py-1 text-sm font-medium text-accent-fg disabled:opacity-60"
                    >
                      Create
                    </button>
                  </div>
                </div>
              ) : (
                <button
                  type="button"
                  onClick={() => setCreating(true)}
                  className="flex w-full items-center gap-2 rounded-lg px-1 py-1.5 text-sm hover:bg-hover"
                >
                  <Plus className="h-4 w-4" /> New playlist
                </button>
              )}
            </div>
          </div>
        </>
      ) : null}
    </div>
  );
}

A card for the playlists grid. Create src/components/feed/playlist-card.tsx:

playlist-card.tsx
import Link from "next/link";
import { ListVideo } from "lucide-react";
import type { PlaylistSummary } from "@/lib/playlists";

export function PlaylistCard({ playlist }: { playlist: PlaylistSummary }) {
  return (
    <Link
      href={`/playlist?list=${playlist.id}`}
      className="group flex flex-col gap-2"
    >
      <div className="relative aspect-video w-full overflow-hidden rounded-xl bg-hover">
        {playlist.thumbnailUrl ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={playlist.thumbnailUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        ) : (
          <div className="grid h-full place-items-center text-fg-muted">
            <ListVideo className="h-8 w-8" />
          </div>
        )}
        <div className="absolute inset-y-0 right-0 flex w-2/5 flex-col items-center justify-center gap-1 bg-black/70 text-white">
          <ListVideo className="h-5 w-5" />
          <span className="text-xs font-medium">{playlist.itemCount}</span>
        </div>
      </div>
      <h3 className="line-clamp-2 text-sm font-medium">{playlist.title}</h3>
      <span className="text-xs text-fg-muted">View full playlist</span>
    </Link>
  );
}

The playlist detail view, with play-all and remove. Create src/components/playlist/playlist-view.tsx:

playlist-view.tsx
"use client";

import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Trash2, X } from "lucide-react";
import { deletePlaylist, removeFromPlaylist } from "@/lib/playlist-actions";
import { formatDuration, formatViews } from "@/lib/format";
import type { FeedVideo } from "@/components/feed/video-card";

export function PlaylistView({
  playlistId,
  videos,
  isOwner,
}: {
  playlistId: string;
  videos: FeedVideo[];
  isOwner: boolean;
}) {
  const [items, setItems] = useState(videos);
  const [, startTransition] = useTransition();

  function remove(id: string) {
    setItems((prev) => prev.filter((v) => v.id !== id));
    startTransition(async () => {
      await removeFromPlaylist(playlistId, id);
    });
  }

  if (items.length === 0) {
    return (
      <p className="py-10 text-center text-sm text-fg-muted">
        No videos in this playlist yet.
      </p>
    );
  }

  return (
    <ol className="flex flex-col">
      {items.map((v, i) => (
        <li
          key={v.id}
          className="group flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-hover"
        >
          <span className="w-6 shrink-0 text-center text-sm text-fg-muted">
            {i + 1}
          </span>
          <Link
            href={`/watch?v=${v.id}`}
            className="relative aspect-video w-40 shrink-0 overflow-hidden rounded-lg bg-hover"
          >
            {v.thumbnailUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={v.thumbnailUrl}
                alt=""
                className="h-full w-full object-cover"
              />
            ) : null}
            {v.durationSeconds > 0 ? (
              <span className="absolute bottom-1 right-1 rounded bg-black/80 px-1 text-xs font-medium text-white">
                {formatDuration(v.durationSeconds)}
              </span>
            ) : null}
          </Link>
          <div className="min-w-0 flex-1">
            <Link href={`/watch?v=${v.id}`}>
              <h3 className="line-clamp-2 text-sm font-medium">{v.title}</h3>
            </Link>
            <Link
              href={`/@${v.channel.handle}`}
              className="mt-1 block truncate text-xs text-fg-muted hover:text-fg"
            >
              {v.channel.name}
            </Link>
            <p className="truncate text-xs text-fg-muted">
              {formatViews(v.viewCount)}
            </p>
          </div>
          {isOwner ? (
            <button
              type="button"
              onClick={() => remove(v.id)}
              aria-label="Remove from playlist"
              className="grid h-8 w-8 shrink-0 place-items-center rounded-full hover:bg-hover-strong [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100"
            >
              <X className="h-4 w-4" />
            </button>
          ) : null}
        </li>
      ))}
    </ol>
  );
}

export function DeletePlaylistButton({ id }: { id: string }) {
  const router = useRouter();
  const [confirming, setConfirming] = useState(false);
  const [pending, startTransition] = useTransition();

  function onDelete() {
    startTransition(async () => {
      const res = await deletePlaylist(id);
      if ("ok" in res) router.push("/feed/playlists");
    });
  }

  if (confirming) {
    return (
      <div className="mt-3 flex gap-2">
        <button
          type="button"
          onClick={onDelete}
          disabled={pending}
          className="rounded-full bg-brand px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
        >
          {pending ? "Deleting…" : "Delete"}
        </button>
        <button
          type="button"
          onClick={() => setConfirming(false)}
          className="rounded-full bg-chip px-4 py-2 text-sm font-medium hover:bg-hover-strong"
        >
          Cancel
        </button>
      </div>
    );
  }

  return (
    <button
      type="button"
      onClick={() => setConfirming(true)}
      className="mt-3 flex items-center gap-2 text-sm text-fg-muted hover:text-brand"
    >
      <Trash2 className="h-4 w-4" /> Delete playlist
    </button>
  );
}

Create src/app/(main)/feed/playlists/page.tsx:

page.tsx
import { requireUser } from "@/lib/auth";
import { getUserPlaylists } from "@/lib/playlists";
import { PlaylistCard } from "@/components/feed/playlist-card";

export const metadata = { title: "Playlists - Wavora" };

export default async function PlaylistsPage() {
  const user = await requireUser();
  const playlists = await getUserPlaylists(user.id);

  return (
    <div className="mx-auto max-w-[1600px]">
      <h1 className="mb-6 text-2xl font-bold">Playlists</h1>
      {playlists.length === 0 ? (
        <p className="py-16 text-center text-sm text-fg-muted">
          Playlists you create will show up here. Use Save on any video.
        </p>
      ) : (
        <div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
          {playlists.map((p) => (
            <PlaylistCard key={p.id} playlist={p} />
          ))}
        </div>
      )}
    </div>
  );
}

The playlist page now serves custom playlists too, not just the system lists. Open src/app/(main)/playlist/page.tsx and replace its contents:

page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { PlaySquare } from "lucide-react";
import { requireUser } from "@/lib/auth";
import { getCurrentUser } from "@/lib/session";
import { getLikedVideos, getWatchLater } from "@/lib/library";
import { getPlaylistDetail } from "@/lib/playlists";
import { VideoGrid } from "@/components/feed/video-grid";
import {
  DeletePlaylistButton,
  PlaylistView,
} from "@/components/playlist/playlist-view";

type SearchParams = Promise<{ list?: string | string[] }>;

const listId = (list: string | string[] | undefined) =>
  Array.isArray(list) ? list[0] : list;

const SYSTEM = {
  WL: {
    title: "Watch later",
    load: getWatchLater,
    empty: "Videos you save will show up here.",
  },
  LL: {
    title: "Liked videos",
    load: getLikedVideos,
    empty: "Videos you like will show up here.",
  },
} as const;

const VIS_LABEL: Record<string, string> = {
  PUBLIC: "Public",
  UNLISTED: "Unlisted",
  PRIVATE: "Private",
};

export async function generateMetadata({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const list = listId((await searchParams).list);
  if (list === "WL" || list === "LL") {
    return { title: `${SYSTEM[list].title} - Wavora` };
  }
  if (list) {
    const detail = await getPlaylistDetail(list, null);
    if (detail) return { title: `${detail.title} - Wavora` };
  }
  return { title: "Playlist - Wavora" };
}

export default async function PlaylistPage({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const list = listId((await searchParams).list);
  if (!list) notFound();

  if (list === "WL" || list === "LL") {
    const meta = SYSTEM[list];
    const user = await requireUser();
    const videos = await meta.load(user.id);
    return (
      <div className="mx-auto max-w-[2400px]">
        <h1 className="mb-6 text-2xl font-bold">{meta.title}</h1>
        {videos.length > 0 ? (
          <VideoGrid videos={videos} />
        ) : (
          <p className="py-16 text-center text-sm text-fg-muted">
            {meta.empty}
          </p>
        )}
      </div>
    );
  }

  const viewer = await getCurrentUser();
  const detail = await getPlaylistDetail(list, viewer?.id ?? null);
  if (!detail) notFound();

  const cover = detail.videos[0];

  return (
    <div className="mx-auto flex max-w-[1600px] flex-col gap-6 lg:flex-row">
      <aside className="lg:w-80 lg:shrink-0">
        <div className="rounded-2xl bg-surface p-5">
          <div className="aspect-video w-full overflow-hidden rounded-xl bg-hover">
            {cover?.thumbnailUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={cover.thumbnailUrl}
                alt=""
                className="h-full w-full object-cover"
              />
            ) : null}
          </div>
          <h1 className="mt-4 text-xl font-bold">{detail.title}</h1>
          <p className="mt-1 text-sm text-fg-muted">{detail.ownerName}</p>
          <p className="text-xs text-fg-muted">
            {detail.videos.length} video{detail.videos.length === 1 ? "" : "s"}
            {" • "}
            {VIS_LABEL[detail.visibility]}
          </p>
          {detail.description ? (
            <p className="mt-3 whitespace-pre-wrap text-sm text-fg-muted">
              {detail.description}
            </p>
          ) : null}
          {cover ? (
            <Link
              href={`/watch?v=${cover.id}`}
              className="mt-4 flex items-center justify-center gap-2 rounded-full bg-fg px-4 py-2.5 text-sm font-medium text-canvas hover:opacity-90"
            >
              <PlaySquare className="h-5 w-5" /> Play
            </Link>
          ) : null}
          {detail.isOwner ? <DeletePlaylistButton id={detail.id} /> : null}
        </div>
      </aside>

      <div className="min-w-0 flex-1">
        <PlaylistView
          playlistId={detail.id}
          videos={detail.videos}
          isOwner={detail.isOwner}
        />
      </div>
    </div>
  );
}

And the library hub gets a playlists shelf. Open src/app/(main)/feed/you/page.tsx and replace its contents:

page.tsx
import Link from "next/link";
import { requireUser } from "@/lib/auth";
import { getLikedVideos, getWatchLater } from "@/lib/library";
import { getUserPlaylists } from "@/lib/playlists";
import { getHistory } from "@/lib/history";
import { VideoCard, type FeedVideo } from "@/components/feed/video-card";
import { PlaylistCard } from "@/components/feed/playlist-card";

export const metadata = { title: "You - Wavora" };

function Shelf({
  title,
  href,
  videos,
  progress,
}: {
  title: string;
  href: string;
  videos: FeedVideo[];
  progress?: Map<string, number>;
}) {
  if (videos.length === 0) return null;
  return (
    <section className="mb-8">
      <div className="mb-3 flex items-center justify-between">
        <h2 className="text-lg font-bold">{title}</h2>
        <Link
          href={href}
          className="text-sm font-medium text-accent hover:underline"
        >
          View all
        </Link>
      </div>
      <div className="flex gap-4 overflow-x-auto pb-2 [scrollbar-width:none]">
        {videos.map((v) => (
          <div key={v.id} className="w-72 shrink-0 sm:w-80">
            <VideoCard video={v} progressSeconds={progress?.get(v.id)} />
          </div>
        ))}
      </div>
    </section>
  );
}

export default async function YouPage() {
  const user = await requireUser();
  const [history, watchLater, liked, playlists] = await Promise.all([
    getHistory(user.id),
    getWatchLater(user.id),
    getLikedVideos(user.id),
    getUserPlaylists(user.id),
  ]);

  const progress = new Map(history.map((h) => [h.video.id, h.progressSeconds]));
  const empty =
    history.length === 0 &&
    watchLater.length === 0 &&
    liked.length === 0 &&
    playlists.length === 0;

  return (
    <div className="mx-auto max-w-[1400px]">
      <h1 className="mb-6 text-2xl font-bold">You</h1>
      {empty ? (
        <p className="py-16 text-center text-sm text-fg-muted">
          Watch, like, and save videos to build your library.
        </p>
      ) : (
        <>
          <Shelf
            title="History"
            href="/feed/history"
            videos={history.slice(0, 10).map((h) => h.video)}
            progress={progress}
          />
          <Shelf
            title="Watch later"
            href="/playlist?list=WL"
            videos={watchLater.slice(0, 10)}
          />
          <Shelf
            title="Liked videos"
            href="/playlist?list=LL"
            videos={liked.slice(0, 10)}
          />
          {playlists.length > 0 ? (
            <section className="mb-8">
              <div className="mb-3 flex items-center justify-between">
                <h2 className="text-lg font-bold">Playlists</h2>
                <Link
                  href="/feed/playlists"
                  className="text-sm font-medium text-accent hover:underline"
                >
                  View all
                </Link>
              </div>
              <div className="flex gap-4 overflow-x-auto pb-2 [scrollbar-width:none]">
                {playlists.slice(0, 10).map((p) => (
                  <div key={p.id} className="w-72 shrink-0 sm:w-80">
                    <PlaylistCard playlist={p} />
                  </div>
                ))}
              </div>
            </section>
          ) : null}
        </>
      )}
    </div>
  );
}

Members-only videos

We'll allow content creators to mark videos as exclusive to members only. The upload and editing forms already supported this transition.

The watch page checks for an active membership before playing and shows a join prompt otherwise. Open src/app/(main)/watch/page.tsx and replace its contents:

page.tsx
import { notFound } from "next/navigation";
import Link from "next/link";
import { User } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { getRelatedVideos } from "@/lib/videos";
import { getChannelSocial, getVideoReactions } from "@/lib/social";
import { getCommentsData } from "@/lib/comments";
import { isInWatchLater } from "@/lib/library";
import { getPlaylistsForPicker } from "@/lib/playlists";
import { isActiveMember } from "@/lib/membership";
import { getResumePosition } from "@/lib/history";
import { isSandbox } from "@/lib/env";
import {
  formatDuration,
  formatSubscribers,
  formatTimeAgo,
  formatViews,
} from "@/lib/format";
import type { FeedVideo } from "@/components/feed/video-card";
import { SubscribeButton } from "@/components/watch/subscribe-button";
import { LikeDislike } from "@/components/watch/like-dislike";
import { SaveMenu } from "@/components/watch/save-menu";
import { SuperThanks } from "@/components/watch/super-thanks";
import { WatchPlayer } from "@/components/watch/watch-player";
import { Comments } from "@/components/comments/comments";
import { ViewTracker } from "./view-tracker";

type SearchParams = Promise<{ v?: string | string[] }>;

export default async function WatchPage({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const { v: rawV } = await searchParams;
  const v = Array.isArray(rawV) ? rawV[0] : rawV;
  if (!v) notFound();

  const video = await prisma.video.findUnique({
    where: { id: v },
    include: {
      channel: {
        select: {
          handle: true,
          name: true,
          avatarUrl: true,
          userId: true,
          whopCompanyId: true,
          superThanksEnabled: true,
        },
      },
    },
  });
  if (!video || video.status !== "READY" || !video.videoUrl) notFound();

  const user = await getCurrentUser();

  if (video.visibility === "PRIVATE" && user?.id !== video.channel.userId) {
    notFound();
  }

  const [
    related,
    channelSocial,
    reactions,
    commentsData,
    savedToWatchLater,
    resumeAt,
    pickerPlaylists,
  ] = await Promise.all([
    getRelatedVideos(video.id),
    getChannelSocial(video.channelId, user?.id),
    getVideoReactions(video.id, user?.id),
    getCommentsData(video.id, user?.id, "top"),
    user ? isInWatchLater(user.id, video.id) : Promise.resolve(false),
    user ? getResumePosition(user.id, video.id) : Promise.resolve(0),
    user
      ? getPlaylistsForPicker(user.id, video.id)
      : Promise.resolve([]),
  ]);

  const isOwner = user?.id === video.channel.userId;
  const canWatch =
    !video.membersOnly ||
    isOwner ||
    (user ? await isActiveMember(user.id, video.channelId) : false);

  return (
    <div className="mx-auto flex max-w-[1700px] flex-col gap-6 lg:flex-row">
      {}
      <div className="min-w-0 flex-1">
        {canWatch ? (
          <>
            <div className="overflow-hidden rounded-xl bg-black">
              <WatchPlayer
                key={video.id}
                videoId={video.id}
                src={video.videoUrl}
                poster={video.thumbnailUrl ?? undefined}
                resumeAt={resumeAt}
                isSignedIn={Boolean(user)}
              />
            </div>
            <ViewTracker videoId={video.id} />
          </>
        ) : (
          <div
            className="grid aspect-video w-full place-items-center rounded-xl text-center"
            style={
              video.thumbnailUrl
                ? {
                    backgroundImage: `linear-gradient(rgba(0,0,0,.7),rgba(0,0,0,.7)), url(${video.thumbnailUrl})`,
                    backgroundSize: "cover",
                    backgroundPosition: "center",
                  }
                : undefined
            }
          >
            <div className="max-w-sm p-6 text-white">
              <p className="text-lg font-semibold">Members-only video</p>
              <p className="mt-2 text-sm text-white/80">
                Join {video.channel.name}&apos;s channel membership to watch
                this video.
              </p>
              <Link
                href={`/@${video.channel.handle}`}
                className="mt-4 inline-block rounded-full bg-accent px-5 py-2 text-sm font-medium text-accent-fg hover:opacity-90"
              >
                See membership options
              </Link>
            </div>
          </div>
        )}

        <h1 className="mt-3 text-xl font-bold leading-tight">{video.title}</h1>

        {}
        <div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
          <div className="flex items-center gap-3">
            <Link
              href={`/@${video.channel.handle}`}
              className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-full bg-hover"
            >
              {video.channel.avatarUrl ? (
                // eslint-disable-next-line @next/next/no-img-element
                <img
                  src={video.channel.avatarUrl}
                  alt=""
                  className="h-full w-full object-cover"
                />
              ) : (
                <User className="h-5 w-5 text-fg-muted" />
              )}
            </Link>
            <div className="min-w-0">
              <Link
                href={`/@${video.channel.handle}`}
                className="block truncate font-medium"
              >
                {video.channel.name}
              </Link>
              <p className="truncate text-xs text-fg-muted">
                {formatSubscribers(channelSocial.subscriberCount)}
              </p>
            </div>
            <SubscribeButton
              channelId={video.channelId}
              isOwner={user?.id === video.channel.userId}
              isSignedIn={Boolean(user)}
              initialSubscribed={channelSocial.isSubscribed}
              initialNotify={channelSocial.notifyLevel}
            />
          </div>

          <div className="flex items-center gap-2">
            <LikeDislike
              videoId={video.id}
              isSignedIn={Boolean(user)}
              initialReaction={reactions.myReaction}
              initialLikeCount={reactions.likeCount}
            />
            <SaveMenu
              videoId={video.id}
              isSignedIn={Boolean(user)}
              initialSaved={savedToWatchLater}
              playlists={pickerPlaylists.map((p) => ({
                id: p.id,
                title: p.title,
                contains: p.contains,
              }))}
            />
            {video.channel.whopCompanyId &&
            video.channel.superThanksEnabled &&
            user?.id !== video.channel.userId ? (
              <SuperThanks
                videoId={video.id}
                isSignedIn={Boolean(user)}
                environment={isSandbox() ? "sandbox" : "production"}
              />
            ) : null}
          </div>
        </div>

        {}
        <div className="mt-4 rounded-xl bg-hover p-3 text-sm">
          <p className="font-medium">
            {formatViews(video.viewCount)}
            {video.publishedAt
              ? ` • ${formatTimeAgo(video.publishedAt)}`
              : " • Unpublished"}
          </p>
          {video.description ? (
            <p className="mt-2 whitespace-pre-wrap text-fg">
              {video.description}
            </p>
          ) : null}
        </div>

        {commentsData ? (
          <Comments
            videoId={video.id}
            isSignedIn={Boolean(user)}
            isCreator={commentsData.isCreator}
            myAvatar={user?.avatarUrl ?? null}
            initial={{
              enabled: commentsData.enabled,
              count: commentsData.count,
              comments: commentsData.comments,
            }}
          />
        ) : null}
      </div>

      {}
      <aside className="w-full shrink-0 lg:w-[402px]">
        <div className="flex flex-col gap-2">
          {related.map((r) => (
            <RelatedRow key={r.id} video={r} />
          ))}
        </div>
      </aside>
    </div>
  );
}

function RelatedRow({ video }: { video: FeedVideo }) {
  return (
    <Link href={`/watch?v=${video.id}`} className="flex gap-2">
      <div className="relative aspect-video w-40 shrink-0 overflow-hidden rounded-lg bg-hover">
        {video.thumbnailUrl ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={video.thumbnailUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        ) : null}
        {video.durationSeconds > 0 ? (
          <span className="absolute bottom-1 right-1 rounded bg-black/80 px-1 text-xs text-white">
            {formatDuration(video.durationSeconds)}
          </span>
        ) : null}
      </div>
      <div className="min-w-0 flex-1">
        <h3 className="line-clamp-2 text-sm font-medium leading-5">
          {video.title}
        </h3>
        <p className="mt-1 truncate text-xs text-fg-muted">
          {video.channel.name}
        </p>
        <p className="truncate text-xs text-fg-muted">
          {formatViews(video.viewCount)}
          {video.publishedAt ? ` • ${formatTimeAgo(video.publishedAt)}` : ""}
        </p>
      </div>
    </Link>
  );
}

Waves

Waves (Shorts) are short vertical videos in a scrollable, full-screen feed. Vertical videos are automatically recognized as Waves upon upload, so they do not appear in the regular grid. The feed automatically plays the Wave video on screen and includes its own like, subscribe, and share options.

Create src/lib/shorts.ts:

shorts.ts
import "server-only";
import { prisma } from "./prisma";
import type { FeedVideo } from "@/components/feed/video-card";

export type ShortItem = {
  id: string;
  title: string;
  videoUrl: string | null;
  thumbnailUrl: string | null;
  viewCount: number;
  likeCount: number;
  commentCount: number;
  channel: {
    id: string;
    handle: string;
    name: string;
    avatarUrl: string | null;
  };
  myReaction: "LIKE" | "DISLIKE" | null;
  isSubscribed: boolean;
};

const shortSelect = {
  id: true,
  title: true,
  videoUrl: true,
  thumbnailUrl: true,
  viewCount: true,
  channel: { select: { id: true, handle: true, name: true, avatarUrl: true } },
} as const;

export async function getShortsFeed(
  viewerId: string | null,
  startId?: string,
): Promise<ShortItem[]> {
  const rows = await prisma.video.findMany({
    where: { isShort: true, status: "READY", visibility: "PUBLIC" },
    orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
    take: 40,
    select: shortSelect,
  });

  if (startId && !rows.some((r) => r.id === startId)) {
    const extra = await prisma.video.findFirst({
      where: {
        id: startId,
        isShort: true,
        status: "READY",
        visibility: "PUBLIC",
      },
      select: shortSelect,
    });
    if (extra) rows.unshift(extra);
  }

  if (rows.length === 0) return [];

  const ids = rows.map((r) => r.id);
  const channelIds = [...new Set(rows.map((r) => r.channel.id))];

  const [likeGroups, commentGroups, myReactions, mySubs] = await Promise.all([
    prisma.reaction.groupBy({
      by: ["videoId"],
      where: { videoId: { in: ids }, type: "LIKE" },
      _count: true,
    }),
    prisma.comment.groupBy({
      by: ["videoId"],
      where: { videoId: { in: ids }, status: "PUBLISHED" },
      _count: true,
    }),
    viewerId
      ? prisma.reaction.findMany({
          where: { userId: viewerId, videoId: { in: ids } },
          select: { videoId: true, type: true },
        })
      : Promise.resolve([]),
    viewerId
      ? prisma.subscription.findMany({
          where: { subscriberId: viewerId, channelId: { in: channelIds } },
          select: { channelId: true },
        })
      : Promise.resolve([]),
  ]);

  const likeBy = new Map(likeGroups.map((g) => [g.videoId, g._count]));
  const commentBy = new Map(commentGroups.map((g) => [g.videoId, g._count]));
  const reactionBy = new Map(myReactions.map((r) => [r.videoId, r.type]));
  const subbed = new Set(mySubs.map((s) => s.channelId));

  const items: ShortItem[] = rows.map((r) => ({
    id: r.id,
    title: r.title,
    videoUrl: r.videoUrl,
    thumbnailUrl: r.thumbnailUrl,
    viewCount: r.viewCount,
    likeCount: likeBy.get(r.id) ?? 0,
    commentCount: commentBy.get(r.id) ?? 0,
    channel: r.channel,
    myReaction: reactionBy.get(r.id) ?? null,
    isSubscribed: subbed.has(r.channel.id),
  }));

  if (startId) {
    const i = items.findIndex((it) => it.id === startId);
    if (i > 0) items.unshift(items.splice(i, 1)[0]);
  }
  return items;
}

export async function getChannelShorts(channelId: string): Promise<FeedVideo[]> {
  return prisma.video.findMany({
    where: {
      channelId,
      isShort: true,
      status: "READY",
      visibility: "PUBLIC",
    },
    orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
    take: 60,
    select: {
      id: true,
      title: true,
      thumbnailUrl: true,
      durationSeconds: true,
      viewCount: true,
      publishedAt: true,
      channel: { select: { handle: true, name: true, avatarUrl: true } },
    },
  });
}

export async function getHomeShorts(take = 12): Promise<FeedVideo[]> {
  return prisma.video.findMany({
    where: { isShort: true, status: "READY", visibility: "PUBLIC" },
    orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
    take,
    select: {
      id: true,
      title: true,
      thumbnailUrl: true,
      durationSeconds: true,
      viewCount: true,
      publishedAt: true,
      channel: { select: { handle: true, name: true, avatarUrl: true } },
    },
  });
}

export async function channelHasShorts(channelId: string): Promise<boolean> {
  const row = await prisma.video.findFirst({
    where: {
      channelId,
      isShort: true,
      status: "READY",
      visibility: "PUBLIC",
    },
    select: { id: true },
  });
  return Boolean(row);
}

To build the vertical feed, create src/components/shorts/shorts-feed.tsx:

shorts-feed.tsx
"use client";

import { useEffect, useRef, useState, useTransition } from "react";
import Link from "next/link";
import {
  ThumbsUp,
  ThumbsDown,
  MessageCircle,
  Share2,
  Volume2,
  VolumeX,
  Play,
  User,
} from "lucide-react";
import { toggleReaction, toggleSubscribe } from "@/lib/social-actions";
import { formatCompact } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ShortItem } from "@/lib/shorts";

function goSignIn() {
  window.location.href = `/sign-in?next=${encodeURIComponent(
    window.location.pathname + window.location.search,
  )}`;
}

export function ShortsFeed({
  shorts,
  isSignedIn,
}: {
  shorts: ShortItem[];
  isSignedIn: boolean;
}) {
  const [muted, setMuted] = useState(true);

  if (shorts.length === 0) {
    return (
      <p className="py-24 text-center text-sm text-fg-muted">
        No Waves yet. Upload a vertical video to get started.
      </p>
    );
  }

  return (
    <div className="-mx-4 -my-4 h-[calc(100dvh-7.5rem)] snap-y snap-mandatory overflow-y-auto [scrollbar-width:none] sm:-mx-6 lg:mx-0 lg:h-[calc(100dvh-3.5rem)]">
      {shorts.map((s) => (
        <ShortSlide
          key={s.id}
          short={s}
          isSignedIn={isSignedIn}
          muted={muted}
          onToggleMute={() => setMuted((m) => !m)}
        />
      ))}
    </div>
  );
}

function RailButton({
  icon: Icon,
  label,
  active,
  onClick,
}: {
  icon: typeof ThumbsUp;
  label: string;
  active?: boolean;
  onClick: () => void;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      className="flex flex-col items-center gap-1"
    >
      <span
        className={cn(
          "grid h-11 w-11 place-items-center rounded-full bg-black/40 text-white backdrop-blur-sm transition hover:bg-black/60",
          active && "bg-accent text-accent-fg hover:bg-accent",
        )}
      >
        <Icon className="h-6 w-6" />
      </span>
      <span className="text-xs font-medium text-white [text-shadow:0_1px_2px_rgba(0,0,0,0.6)]">
        {label}
      </span>
    </button>
  );
}

function ShortSlide({
  short,
  isSignedIn,
  muted,
  onToggleMute,
}: {
  short: ShortItem;
  isSignedIn: boolean;
  muted: boolean;
  onToggleMute: () => void;
}) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const slideRef = useRef<HTMLDivElement>(null);
  const [playing, setPlaying] = useState(false);
  const [reaction, setReaction] = useState(short.myReaction);
  const [likeCount, setLikeCount] = useState(short.likeCount);
  const [subscribed, setSubscribed] = useState(short.isSubscribed);
  const [copied, setCopied] = useState(false);
  const [, startTransition] = useTransition();

  useEffect(() => {
    const el = videoRef.current;
    const slide = slideRef.current;
    if (!el || !slide) return;
    const obs = new IntersectionObserver(
      ([entry]) => {
        if (entry.intersectionRatio >= 0.6) {
          el.play()
            .then(() => setPlaying(true))
            .catch(() => {});
        } else {
          el.pause();
          el.currentTime = 0;
          setPlaying(false);
        }
      },
      { threshold: [0, 0.6, 1] },
    );
    obs.observe(slide);
    return () => obs.disconnect();
  }, []);

  useEffect(() => {
    if (videoRef.current) videoRef.current.muted = muted;
  }, [muted]);

  function togglePlay() {
    const el = videoRef.current;
    if (!el) return;
    if (el.paused) {
      el.play()
        .then(() => setPlaying(true))
        .catch(() => {});
    } else {
      el.pause();
      setPlaying(false);
    }
  }

  function react(type: "LIKE" | "DISLIKE") {
    if (!isSignedIn) return goSignIn();
    const prev = reaction;
    const next = prev === type ? null : type;
    setReaction(next);
    setLikeCount(
      (c) => c + (next === "LIKE" ? 1 : 0) - (prev === "LIKE" ? 1 : 0),
    );
    startTransition(async () => {
      const res = await toggleReaction(short.id, type);
      if ("error" in res) {
        setReaction(prev);
        setLikeCount(short.likeCount);
      } else {
        setReaction(res.reaction);
        setLikeCount(res.likeCount);
      }
    });
  }

  function subscribe() {
    if (!isSignedIn) return goSignIn();
    const next = !subscribed;
    setSubscribed(next);
    startTransition(async () => {
      const res = await toggleSubscribe(short.channel.id);
      if ("error" in res) setSubscribed(!next);
      else setSubscribed(res.subscribed);
    });
  }

  function share() {
    const url = `${window.location.origin}/watch?v=${short.id}`;
    navigator.clipboard?.writeText(url).then(
      () => {
        setCopied(true);
        window.setTimeout(() => setCopied(false), 1500);
      },
      () => {},
    );
  }

  return (
    <div
      ref={slideRef}
      className="flex h-full snap-start items-center justify-center"
    >
      {}
      <div className="relative aspect-[9/16] h-full max-w-full overflow-hidden bg-black lg:rounded-2xl">
        {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
        <video
          ref={videoRef}
          src={short.videoUrl ?? undefined}
          poster={short.thumbnailUrl ?? undefined}
          loop
          muted
          playsInline
          onClick={togglePlay}
          className="h-full w-full cursor-pointer object-contain"
        />

        {!playing ? (
          <button
            type="button"
            onClick={togglePlay}
            aria-label="Play"
            className="absolute inset-0 grid place-items-center"
          >
            <span className="grid h-16 w-16 place-items-center rounded-full bg-black/50 text-white">
              <Play className="h-8 w-8" />
            </span>
          </button>
        ) : null}

        <button
          type="button"
          onClick={onToggleMute}
          aria-label={muted ? "Unmute" : "Mute"}
          className="absolute right-3 top-3 grid h-9 w-9 place-items-center rounded-full bg-black/50 text-white hover:bg-black/70"
        >
          {muted ? (
            <VolumeX className="h-5 w-5" />
          ) : (
            <Volume2 className="h-5 w-5" />
          )}
        </button>

        {}
        <div className="absolute bottom-4 right-2 flex flex-col items-center gap-4">
          <RailButton
            icon={ThumbsUp}
            label={formatCompact(likeCount)}
            active={reaction === "LIKE"}
            onClick={() => react("LIKE")}
          />
          <RailButton
            icon={ThumbsDown}
            label="Dislike"
            active={reaction === "DISLIKE"}
            onClick={() => react("DISLIKE")}
          />
          <Link
            href={`/watch?v=${short.id}`}
            className="flex flex-col items-center gap-1"
          >
            <span className="grid h-11 w-11 place-items-center rounded-full bg-black/40 text-white backdrop-blur-sm hover:bg-black/60">
              <MessageCircle className="h-6 w-6" />
            </span>
            <span className="text-xs font-medium text-white [text-shadow:0_1px_2px_rgba(0,0,0,0.6)]">
              {formatCompact(short.commentCount)}
            </span>
          </Link>
          <RailButton
            icon={Share2}
            label={copied ? "Copied" : "Share"}
            onClick={share}
          />
        </div>

        {}
        <div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 pr-20 text-white">
          <div className="pointer-events-auto flex flex-wrap items-center gap-2">
            <Link
              href={`/@${short.channel.handle}`}
              className="grid h-8 w-8 shrink-0 place-items-center overflow-hidden rounded-full bg-white/20"
            >
              {short.channel.avatarUrl ? (
                // eslint-disable-next-line @next/next/no-img-element
                <img
                  src={short.channel.avatarUrl}
                  alt=""
                  className="h-full w-full object-cover"
                />
              ) : (
                <User className="h-4 w-4" />
              )}
            </Link>
            <Link
              href={`/@${short.channel.handle}`}
              className="text-sm font-medium hover:underline"
            >
              {short.channel.name}
            </Link>
            <button
              type="button"
              onClick={subscribe}
              className={cn(
                "ml-1 rounded-full px-3 py-1 text-xs font-semibold",
                subscribed
                  ? "bg-white/20 text-white"
                  : "bg-white text-black hover:bg-white/90",
              )}
            >
              {subscribed ? "Subscribed" : "Subscribe"}
            </button>
          </div>
          <p className="mt-2 line-clamp-2 text-sm">{short.title}</p>
        </div>
      </div>
    </div>
  );
}

And let's mount the Waves shelf in the home page. Create src/components/shorts/shorts-shelf.tsx:

shorts-shelf.tsx
import Link from "next/link";
import { Clapperboard } from "lucide-react";
import { formatViews } from "@/lib/format";
import type { FeedVideo } from "@/components/feed/video-card";

export function ShortsShelf({ shorts }: { shorts: FeedVideo[] }) {
  if (shorts.length === 0) return null;

  return (
    <section className="mb-8">
      <div className="mb-3 flex items-center gap-2">
        <Clapperboard className="h-6 w-6 text-brand" />
        <Link href="/waves" className="text-lg font-bold hover:underline">
          Waves
        </Link>
      </div>
      <div className="flex gap-3 overflow-x-auto pb-2 [scrollbar-width:none]">
        {shorts.map((s) => (
          <Link
            key={s.id}
            href={`/waves?v=${s.id}`}
            className="group w-40 shrink-0 sm:w-44"
          >
            <div className="relative aspect-[9/16] w-full overflow-hidden rounded-xl bg-hover">
              {s.thumbnailUrl ? (
                // eslint-disable-next-line @next/next/no-img-element
                <img
                  src={s.thumbnailUrl}
                  alt=""
                  className="h-full w-full object-cover"
                />
              ) : null}
            </div>
            <h3 className="mt-2 line-clamp-2 text-sm font-medium">{s.title}</h3>
            <p className="text-xs text-fg-muted">{formatViews(s.viewCount)}</p>
          </Link>
        ))}
      </div>
    </section>
  );
}

Create src/app/(main)/waves/page.tsx:

page.tsx
import { getCurrentUser } from "@/lib/session";
import { getShortsFeed } from "@/lib/shorts";
import { ShortsFeed } from "@/components/shorts/shorts-feed";

export const metadata = { title: "Waves - Wavora" };

export default async function ShortsPage({
  searchParams,
}: {
  searchParams: Promise<{ v?: string | string[] }>;
}) {
  const { v: rawV } = await searchParams;
  const v = Array.isArray(rawV) ? rawV[0] : rawV;
  const user = await getCurrentUser();
  const shorts = await getShortsFeed(
    user?.id ?? null,
    Array.isArray(v) ? v[0] : v,
  );

  return <ShortsFeed shorts={shorts} isSignedIn={Boolean(user)} />;
}

Create src/app/(main)/[handle]/waves/page.tsx:

page.tsx
import Link from "next/link";
import { resolveChannel } from "@/lib/channels";
import { getChannelShorts } from "@/lib/shorts";
import { formatViews } from "@/lib/format";

export default async function ChannelShortsPage({
  params,
}: {
  params: Promise<{ handle: string }>;
}) {
  const { handle } = await params;
  const channel = await resolveChannel(handle);
  const shorts = await getChannelShorts(channel.id);

  if (shorts.length === 0) {
    return (
      <p className="py-16 text-center text-sm text-fg-muted">
        This channel hasn&apos;t posted any Waves yet.
      </p>
    );
  }

  return (
    <div className="grid grid-cols-2 gap-x-3 gap-y-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
      {shorts.map((s) => (
        <Link
          key={s.id}
          href={`/waves?v=${s.id}`}
          className="group flex flex-col gap-2"
        >
          <div className="relative aspect-[9/16] w-full overflow-hidden rounded-xl bg-hover">
            {s.thumbnailUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={s.thumbnailUrl}
                alt=""
                className="h-full w-full object-cover"
              />
            ) : null}
          </div>
          <div>
            <h3 className="line-clamp-2 text-sm font-medium">{s.title}</h3>
            <p className="mt-0.5 text-xs text-fg-muted">
              {formatViews(s.viewCount)}
            </p>
          </div>
        </Link>
      ))}
    </div>
  );
}

The cards get a working menu with save to Watch Later and copy link buttons. Create src/components/feed/video-card-menu.tsx:

video-card-menu.tsx
"use client";

import { useState, useTransition } from "react";
import { Clock, MoreVertical, Share2 } from "lucide-react";
import { toggleWatchLater } from "@/lib/library-actions";
import { useEscape } from "@/hooks/use-escape";

export function VideoCardMenu({ videoId }: { videoId: string }) {
  const [open, setOpen] = useState(false);
  const [toast, setToast] = useState<string | null>(null);
  const [, startTransition] = useTransition();
  useEscape(open, () => setOpen(false));

  function flash(msg: string) {
    setToast(msg);
    window.setTimeout(() => setToast(null), 1600);
  }

  function saveWatchLater() {
    setOpen(false);
    startTransition(async () => {
      const res = await toggleWatchLater(videoId);
      if ("error" in res) {
        window.location.href = `/sign-in?next=${encodeURIComponent(
          window.location.pathname + window.location.search,
        )}`;
      } else {
        flash(res.saved ? "Saved to Watch later" : "Removed from Watch later");
      }
    });
  }

  function copyLink() {
    setOpen(false);
    navigator.clipboard
      ?.writeText(`${window.location.origin}/watch?v=${videoId}`)
      .then(() => flash("Link copied"), () => {});
  }

  return (
    <div className="relative">
      <button
        type="button"
        aria-label="More actions"
        onClick={() => setOpen((o) => !o)}
        className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-full hover:bg-hover [@media(hover:hover)]:hidden [@media(hover:hover)]:group-hover:grid"
      >
        <MoreVertical className="h-5 w-5" />
      </button>

      {open ? (
        <>
          <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
          <div className="absolute right-0 z-50 mt-1 w-52 rounded-xl border border-border bg-surface p-1 shadow-lg">
            <button
              type="button"
              onClick={saveWatchLater}
              className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-hover"
            >
              <Clock className="h-4 w-4" /> Save to Watch later
            </button>
            <button
              type="button"
              onClick={copyLink}
              className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-hover"
            >
              <Share2 className="h-4 w-4" /> Copy link
            </button>
          </div>
        </>
      ) : null}

      {toast ? (
        <div className="absolute right-0 top-9 z-50 whitespace-nowrap rounded-lg bg-fg px-3 py-1.5 text-xs font-medium text-canvas shadow-lg">
          {toast}
        </div>
      ) : null}
    </div>
  );
}

Open src/components/feed/video-card.tsx and replace its contents:

video-card.tsx
import Link from "next/link";
import { User } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDuration, formatTimeAgo, formatViews } from "@/lib/format";
import { VideoCardMenu } from "./video-card-menu";

export type FeedVideo = {
  id: string;
  title: string;
  thumbnailUrl: string | null;
  durationSeconds: number;
  viewCount: number;
  publishedAt: Date | string | null;
  channel: {
    handle: string;
    name: string;
    avatarUrl: string | null;
  };
};

export function VideoCard({
  video,
  className,
  progressSeconds,
}: {
  video: FeedVideo;
  className?: string;
  progressSeconds?: number;
}) {
  const { channel } = video;
  const watchHref = `/watch?v=${video.id}`;
  const channelHref = `/@${channel.handle}`;

  return (
    <div className={cn("group flex flex-col gap-3", className)}>
      {}
      <Link
        href={watchHref}
        className="relative block aspect-video w-full overflow-hidden rounded-xl bg-hover"
      >
        {video.thumbnailUrl ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={video.thumbnailUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        ) : null}
        {video.durationSeconds > 0 ? (
          <span className="absolute bottom-1.5 right-1.5 rounded bg-black/80 px-1 py-0.5 text-xs font-medium text-white">
            {formatDuration(video.durationSeconds)}
          </span>
        ) : null}
        {}
        {progressSeconds && progressSeconds > 0 && video.durationSeconds > 0 ? (
          <span className="absolute inset-x-0 bottom-0 h-1 bg-white/30">
            <span
              className="block h-full bg-brand"
              style={{
                width: `${Math.min(100, (progressSeconds / video.durationSeconds) * 100)}%`,
              }}
            />
          </span>
        ) : null}
      </Link>

      {}
      <div className="flex gap-3">
        <Link
          href={channelHref}
          aria-label={channel.name}
          className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-full bg-hover"
        >
          {channel.avatarUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={channel.avatarUrl}
              alt=""
              className="h-full w-full object-cover"
            />
          ) : (
            <User className="h-5 w-5 text-fg-muted" />
          )}
        </Link>

        <div className="min-w-0 flex-1">
          <Link href={watchHref}>
            <h3 className="line-clamp-2 text-sm font-medium leading-5 text-fg">
              {video.title}
            </h3>
          </Link>
          <Link
            href={channelHref}
            className="mt-1 block truncate text-sm text-fg-muted hover:text-fg"
          >
            {channel.name}
          </Link>
          <p className="truncate text-sm text-fg-muted">
            {formatViews(video.viewCount)}
            {video.publishedAt ? ` • ${formatTimeAgo(video.publishedAt)}` : ""}
          </p>
        </div>

        <VideoCardMenu videoId={video.id} />
      </div>
    </div>
  );
}

We also want the channels to have a Waves tab so users can easily access a channel's Waves. Open src/app/(main)/[handle]/layout.tsx and replace its contents:

layout.tsx
import type { ReactNode } from "react";
import { User } from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { getChannelSocial } from "@/lib/social";
import { getChannelVideoCount, resolveChannel } from "@/lib/channels";
import { channelHasShorts } from "@/lib/shorts";
import { getChannelTiers, isActiveMember } from "@/lib/membership";
import { isSandbox } from "@/lib/env";
import { SubscribeButton } from "@/components/watch/subscribe-button";
import { JoinMembership } from "@/components/channel/join-membership";
import { ChannelTabs } from "@/components/channel/channel-tabs";
import { formatCompact, formatSubscribers } from "@/lib/format";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ handle: string }>;
}) {
  const { handle } = await params;
  const channel = await resolveChannel(handle);
  return { title: `${channel.name} - Wavora` };
}

export default async function ChannelLayout({
  children,
  params,
}: {
  children: ReactNode;
  params: Promise<{ handle: string }>;
}) {
  const { handle: raw } = await params;
  const channel = await resolveChannel(raw);
  const user = await getCurrentUser();
  const [videoCount, social, tiers, member, hasShorts] = await Promise.all([
    getChannelVideoCount(channel.id),
    getChannelSocial(channel.id, user?.id),
    channel.membershipsEnabled
      ? getChannelTiers(channel.id)
      : Promise.resolve([]),
    user && channel.membershipsEnabled
      ? isActiveMember(user.id, channel.id)
      : Promise.resolve(false),
    channelHasShorts(channel.id),
  ]);

  return (
    <div className="mx-auto max-w-[1280px]">
      {channel.bannerUrl ? (
        <div className="mb-4 aspect-[6/1] w-full overflow-hidden rounded-xl bg-hover">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={channel.bannerUrl}
            alt=""
            className="h-full w-full object-cover"
          />
        </div>
      ) : null}

      <div className="flex flex-col items-center gap-4 py-4 sm:flex-row sm:items-end">
        <div className="grid h-28 w-28 shrink-0 place-items-center overflow-hidden rounded-full bg-hover sm:h-40 sm:w-40">
          {channel.avatarUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={channel.avatarUrl}
              alt=""
              className="h-full w-full object-cover"
            />
          ) : (
            <User className="h-16 w-16 text-fg-muted" />
          )}
        </div>

        <div className="min-w-0 text-center sm:text-left">
          <h1 className="text-2xl font-bold sm:text-3xl">{channel.name}</h1>
          <p className="mt-1 text-sm text-fg-muted">
            <span className="font-medium text-fg">@{channel.handle}</span>
            {" • "}
            {formatSubscribers(social.subscriberCount)}
            {" • "}
            {formatCompact(videoCount)} video{videoCount === 1 ? "" : "s"}
          </p>
          {channel.description ? (
            <p className="mt-2 line-clamp-1 max-w-xl text-sm text-fg-muted">
              {channel.description}
            </p>
          ) : null}
          <div className="mt-4 flex justify-center gap-2 sm:justify-start">
            <SubscribeButton
              channelId={channel.id}
              isOwner={user?.id === channel.userId}
              isSignedIn={Boolean(user)}
              initialSubscribed={social.isSubscribed}
              initialNotify={social.notifyLevel}
            />
            {channel.membershipsEnabled && user?.id !== channel.userId ? (
              <JoinMembership
                tiers={tiers}
                isSignedIn={Boolean(user)}
                isMember={member}
                environment={isSandbox() ? "sandbox" : "production"}
              />
            ) : null}
          </div>
        </div>
      </div>

      <ChannelTabs
        handle={channel.handle}
        showShorts={hasShorts}
        showMembership={channel.membershipsEnabled}
      />

      {children}
    </div>
  );
}

Explore and category chips

Now let's add category tabs to the top of the homepage. The Explore section showcases trending and category-specific pages. Create src/lib/explore.ts:

explore.ts
import "server-only";
import { prisma } from "./prisma";
import type { FeedVideo } from "@/components/feed/video-card";
import type { VideoCategory } from "@/generated/prisma/client";

const cardSelect = {
  id: true,
  title: true,
  thumbnailUrl: true,
  durationSeconds: true,
  viewCount: true,
  publishedAt: true,
  channel: { select: { handle: true, name: true, avatarUrl: true } },
} as const;

export async function getTrending(): Promise<FeedVideo[]> {
  return prisma.video.findMany({
    where: { status: "READY", visibility: "PUBLIC", isShort: false },
    orderBy: [{ viewCount: "desc" }, { publishedAt: "desc" }],
    take: 50,
    select: cardSelect,
  });
}

export async function getByCategory(
  category: VideoCategory,
): Promise<FeedVideo[]> {
  return prisma.video.findMany({
    where: { status: "READY", visibility: "PUBLIC", category, isShort: false },
    orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
    take: 50,
    select: cardSelect,
  });
}

Create src/app/(main)/explore/trending/page.tsx:

page.tsx
import { getTrending } from "@/lib/explore";
import { VideoGrid } from "@/components/feed/video-grid";

export const metadata = { title: "Trending - Wavora" };

export default async function TrendingPage() {
  const videos = await getTrending();

  return (
    <div className="mx-auto max-w-[2400px]">
      <h1 className="mb-6 text-2xl font-bold">Trending</h1>
      {videos.length > 0 ? (
        <VideoGrid videos={videos} />
      ) : (
        <div className="py-16 text-center text-sm text-fg-muted">
          Nothing trending yet. Check back once videos start racking up views.
        </div>
      )}
    </div>
  );
}

Create src/app/(main)/explore/[category]/page.tsx:

page.tsx
import { notFound } from "next/navigation";
import { getByCategory } from "@/lib/explore";
import { VideoGrid } from "@/components/feed/video-grid";
import type { VideoCategory } from "@/generated/prisma/client";

const CATEGORIES: Record<string, VideoCategory> = {
  music: "MUSIC",
  gaming: "GAMING",
  news: "NEWS",
  sports: "SPORTS",
  comedy: "COMEDY",
  education: "EDUCATION",
  entertainment: "ENTERTAINMENT",
  tech: "TECH",
  podcasts: "PODCASTS",
  cooking: "COOKING",
  other: "OTHER",
};

function titleCase(category: VideoCategory): string {
  return category.charAt(0) + category.slice(1).toLowerCase();
}

function categoryOf(slug: string): VideoCategory | undefined {
  const key = slug.toLowerCase();
  return Object.hasOwn(CATEGORIES, key) ? CATEGORIES[key] : undefined;
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ category: string }>;
}) {
  const { category } = await params;
  const enumValue = categoryOf(category);
  if (!enumValue) return { title: "Explore - Wavora" };
  return { title: `${titleCase(enumValue)} - Wavora` };
}

export default async function ExploreCategoryPage({
  params,
}: {
  params: Promise<{ category: string }>;
}) {
  const { category } = await params;
  const enumValue = categoryOf(category);
  if (!enumValue) notFound();

  const videos = await getByCategory(enumValue);

  return (
    <div className="mx-auto max-w-[2400px]">
      <h1 className="mb-6 text-2xl font-bold">{titleCase(enumValue)}</h1>
      {videos.length > 0 ? (
        <VideoGrid videos={videos} />
      ) : (
        <div className="py-16 text-center text-sm text-fg-muted">
          No {titleCase(enumValue)} videos yet. Check back soon.
        </div>
      )}
    </div>
  );
}

The home page gets the chips and a Waves shelf. Let's build its final form. Open src/app/(main)/page.tsx and replace its contents:

page.tsx
import Link from "next/link";
import { cn } from "@/lib/utils";
import { getCurrentUser } from "@/lib/session";
import { getFeedVideos } from "@/lib/videos";
import { getHomeShorts } from "@/lib/shorts";
import { getContinueWatching } from "@/lib/history";
import type { VideoCategory } from "@/generated/prisma/client";
import { VideoCard } from "@/components/feed/video-card";
import { ShortsShelf } from "@/components/shorts/shorts-shelf";

const CHIP_CATEGORY: Record<string, VideoCategory | undefined> = {
  All: undefined,
  Music: "MUSIC",
  Gaming: "GAMING",
  News: "NEWS",
  Sports: "SPORTS",
  Comedy: "COMEDY",
  Podcasts: "PODCASTS",
  Cooking: "COOKING",
  Tech: "TECH",
  Education: "EDUCATION",
  Entertainment: "ENTERTAINMENT",
};
const CHIPS = Object.keys(CHIP_CATEGORY);

export default async function HomePage({
  searchParams,
}: {
  searchParams: Promise<{ chip?: string | string[] }>;
}) {
  const { chip } = await searchParams;
  const activeChip =
    typeof chip === "string" && Object.hasOwn(CHIP_CATEGORY, chip)
      ? chip
      : "All";
  const category = CHIP_CATEGORY[activeChip];

  const user = await getCurrentUser();
  const [videos, continueWatching, shorts] = await Promise.all([
    getFeedVideos(24, category),
    !category && user ? getContinueWatching(user.id) : Promise.resolve([]),
    !category ? getHomeShorts() : Promise.resolve([]),
  ]);

  return (
    <div className="mx-auto max-w-[2400px]">
      {}
      <div className="sticky top-14 z-30 -mx-4 mb-4 flex gap-3 overflow-x-auto bg-canvas px-4 py-3 [scrollbar-width:none] sm:-mx-6 sm:px-6">
        {CHIPS.map((c) => (
          <Link
            key={c}
            href={c === "All" ? "/" : `/?chip=${c}`}
            scroll={false}
            className={cn(
              "whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium",
              c === activeChip
                ? "bg-chip-active text-chip-active-fg"
                : "bg-chip hover:bg-hover-strong",
            )}
          >
            {c}
          </Link>
        ))}
      </div>

      {continueWatching.length > 0 ? (
        <section className="mb-8">
          <h2 className="mb-3 text-lg font-bold">Continue watching</h2>
          <div className="flex gap-4 overflow-x-auto pb-2 [scrollbar-width:none]">
            {continueWatching.map((it) => (
              <div key={it.video.id} className="w-72 shrink-0 sm:w-80">
                <VideoCard
                  video={it.video}
                  progressSeconds={it.progressSeconds}
                />
              </div>
            ))}
          </div>
        </section>
      ) : null}

      {shorts.length > 0 ? <ShortsShelf shorts={shorts} /> : null}

      {videos.length === 0 ? (
        <div className="flex flex-col items-center gap-3 py-24 text-center">
          <p className="text-lg font-medium">
            {category ? "Nothing here yet" : "No videos yet"}
          </p>
          <p className="max-w-sm text-sm text-fg-muted">
            {category
              ? "No videos in this category yet. Try another chip."
              : "Be the first to upload. Create a channel and publish a video - it shows up right here."}
          </p>
          {category ? (
            <Link
              href="/"
              className="mt-2 rounded-full bg-chip px-5 py-2.5 font-medium hover:bg-hover-strong"
            >
              Back to all
            </Link>
          ) : (
            <Link
              href="/studio/upload"
              className="mt-2 rounded-full bg-accent px-5 py-2.5 font-medium text-accent-fg hover:opacity-90"
            >
              Upload a video
            </Link>
          )}
        </div>
      ) : (
        <div className="grid grid-cols-1 gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
          {videos.map((video) => (
            <VideoCard key={video.id} video={video} />
          ))}
        </div>
      )}
    </div>
  );
}

Hardening

Before moving to Production, we'll do two more things: add rate limits to user login routes and implement security headers along with a content security policy.

In a real production environment, you'll need to support this rate limiter with a shared store like Redis. Create src/lib/rate-limit.ts:

rate-limit.ts
import "server-only";

type Bucket = { count: number; resetAt: number };

const buckets = new Map<string, Bucket>();

function sweep(now: number): void {
  if (buckets.size < 5000) return;
  for (const [k, v] of buckets) if (v.resetAt <= now) buckets.delete(k);
}

export function rateLimit(
  key: string,
  limit: number,
  windowMs: number,
): { ok: boolean; retryAfterSeconds: number } {
  const now = Date.now();
  sweep(now);
  const b = buckets.get(key);
  if (!b || b.resetAt <= now) {
    buckets.set(key, { count: 1, resetAt: now + windowMs });
    return { ok: true, retryAfterSeconds: 0 };
  }
  if (b.count >= limit) {
    return { ok: false, retryAfterSeconds: Math.ceil((b.resetAt - now) / 1000) };
  }
  b.count += 1;
  return { ok: true, retryAfterSeconds: 0 };
}

export function clientIp(req: Request): string {
  const h = req.headers;
  return (
    h.get("x-forwarded-for")?.split(",")[0]?.trim() ||
    h.get("x-real-ip") ||
    "unknown"
  );
}

The handle check are rate limited as well. Open src/app/api/handle-check/route.ts and replace its contents:

route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { handleSchema } from "@/lib/validators";
import { rateLimit } from "@/lib/rate-limit";

export async function GET(request: Request): Promise<NextResponse> {
  const user = await getCurrentUser();
  if (!user) {
    return NextResponse.json({ available: false, reason: "auth" }, { status: 401 });
  }

  const rl = rateLimit(`handle-check:${user.id}`, 40, 10_000);
  if (!rl.ok) {
    return NextResponse.json(
      { available: false, reason: "rate_limited" },
      { status: 429, headers: { "Retry-After": String(rl.retryAfterSeconds) } },
    );
  }

  const raw = new URL(request.url).searchParams.get("handle") ?? "";
  const parsed = handleSchema.safeParse(raw);
  if (!parsed.success) {
    return NextResponse.json({
      available: false,
      reason: "invalid",
      message: parsed.error.issues[0]?.message ?? "Invalid handle.",
    });
  }

  const taken = await prisma.channel.findUnique({
    where: { handle: parsed.data },
    select: { id: true },
  });

  return NextResponse.json({
    available: !taken,
    reason: taken ? "taken" : "ok",
    handle: parsed.data,
  });
}

And now the login route. Open src/app/api/auth/login/route.ts and replace its contents:

route.ts
import { type NextRequest, NextResponse } from "next/server";
import { randomToken, pkceChallenge } from "@/lib/pkce";
import { buildAuthorizeUrl } from "@/lib/whop-oauth";
import { PKCE_COOKIE, sessionCookieOptions } from "@/lib/session-config";
import { rateLimit, clientIp } from "@/lib/rate-limit";

export function safeNext(raw: string | null | undefined): string | undefined {
  if (!raw || !raw.startsWith("/")) return undefined;
  try {
    const resolved = new URL(raw, "http://internal");
    if (resolved.origin !== "http://internal") return undefined;
    return resolved.pathname + resolved.search;
  } catch {
    return undefined;
  }
}

export async function GET(request: NextRequest) {
  const rl = rateLimit(`auth-login:${clientIp(request)}`, 20, 60_000);
  if (!rl.ok) {
    return new NextResponse("Too many requests - try again shortly.", {
      status: 429,
      headers: { "Retry-After": String(rl.retryAfterSeconds) },
    });
  }

  const verifier = randomToken(32);
  const state = randomToken(16);
  const nonce = randomToken(16);
  const next = safeNext(request.nextUrl.searchParams.get("next"));
  const codeChallenge = await pkceChallenge(verifier);

  const res = NextResponse.redirect(
    buildAuthorizeUrl({ state, nonce, codeChallenge }),
  );

  res.cookies.set(
    PKCE_COOKIE,
    JSON.stringify({ verifier, state, nonce, next }),
    { ...sessionCookieOptions, maxAge: 600 },
  );

  return res;
}

Let's add Content Security Policy (CSP) and other security headers to the configuration. The CSP allows Vercel Blob access to Whop's checkout and embed domains. Open next.config.ts and replace its contents:

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

const csp = [
  "default-src 'self'",
  "base-uri 'self'",
  "object-src 'none'",
  "frame-ancestors 'self'",
  "img-src 'self' data: blob: https:",
  "media-src 'self' blob: https:",
  "font-src 'self' data:",
  "style-src 'self' 'unsafe-inline'",
  "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.whop.com https://whop.com",
  "frame-src 'self' https://*.whop.com https://whop.com",
  "connect-src 'self' https: wss: ws:",
  "form-action 'self' https://*.whop.com https://whop.com",
].join("; ");

const securityHeaders = [
  { key: "Content-Security-Policy", value: csp },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "X-Frame-Options", value: "SAMEORIGIN" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
  { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
];

const nextConfig: NextConfig = {
  turbopack: {
    root: __dirname,
  },
  async headers() {
    return [{ source: "/:path*", headers: securityHeaders }];
  },
};

export default nextConfig;

Channel customization

Content creators can edit their channel name, username, bio, avatar, and banner image. Since avatars and banners are small, we'll route them through our server using Whop's file upload endpoint.

Let's create the update action. Create src/lib/channel-actions.ts:

channel-actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { prisma } from "./prisma";
import { getCurrentUser } from "./session";
import { updateChannelSchema, type UpdateChannelInput } from "./validators";

export type UpdateChannelResult =
  | { ok: true; handle: string }
  | { error: string };

export async function updateChannel(
  input: UpdateChannelInput,
): Promise<UpdateChannelResult> {
  const user = await getCurrentUser();
  if (!user) return { error: "Please sign in." };

  const channel = await prisma.channel.findUnique({
    where: { userId: user.id },
    select: { id: true, handle: true },
  });
  if (!channel) return { error: "Create a channel first." };

  const parsed = updateChannelSchema.safeParse(input);
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Invalid input." };
  }
  const d = parsed.data;

  if (d.handle !== channel.handle) {
    const taken = await prisma.channel.findUnique({
      where: { handle: d.handle },
      select: { id: true },
    });
    if (taken) return { error: "That handle is already taken." };
  }

  try {
    await prisma.channel.update({
      where: { id: channel.id },
      data: {
        name: d.name,
        description: d.description ? d.description : null,
        handle: d.handle,
        avatarUrl: d.avatarUrl ? d.avatarUrl : null,
        bannerUrl: d.bannerUrl ? d.bannerUrl : null,
      },
    });
  } catch (e) {
    if (
      e &&
      typeof e === "object" &&
      "code" in e &&
      (e as { code?: string }).code === "P2002"
    ) {
      return { error: "That handle is already taken." };
    }
    throw e;
  }

  revalidatePath(`/@${channel.handle}`);
  revalidatePath(`/@${d.handle}`);
  revalidatePath("/studio/customize");
  return { ok: true, handle: d.handle };
}

For the Whop file upload helper, create src/lib/whop-files.ts:

whop-files.ts
import "server-only";
import { whopCompany } from "./whop";

const POLL_INTERVAL_MS = 1000;
const POLL_TIMEOUT_MS = 60_000;

export async function uploadImageToWhop(file: File): Promise<string> {
  const created = await whopCompany.files.create({
    filename: file.name || "image",
    visibility: "public",
  });

  if (created.upload_status !== "ready") {
    if (!created.upload_url) {
      throw new Error("Whop files: missing upload URL in the create response.");
    }
    const headers: Record<string, string> = {};
    for (const [key, value] of Object.entries(created.upload_headers ?? {})) {
      if (value != null) headers[key] = String(value);
    }
    const res = await fetch(created.upload_url, {
      method: "PUT",
      headers,
      body: file,
    });
    if (!res.ok) {
      throw new Error(`Whop files: upload failed (${res.status} ${res.statusText}).`);
    }
  }

  const deadline = Date.now() + POLL_TIMEOUT_MS;
  for (;;) {
    const current = await whopCompany.files.retrieve(created.id);
    if (current.upload_status === "ready" && current.url) return current.url;
    if (current.upload_status === "failed") {
      throw new Error("Whop files: processing failed.");
    }
    if (Date.now() >= deadline) {
      throw new Error("Whop files: timed out waiting for the file to be ready.");
    }
    await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
  }
}

We want the upload route to be rate limited and image-only. Create src/app/api/whop/upload/route.ts:

route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { uploadImageToWhop } from "@/lib/whop-files";
import { rateLimit } from "@/lib/rate-limit";

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

function sniffImageType(b: Uint8Array): string | null {
  if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47)
    return "image/png";
  if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
  if (b.length >= 4 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38)
    return "image/gif";
  if (
    b.length >= 12 &&
    b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
    b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50
  )
    return "image/webp";
  return null;
}

export async function POST(request: Request): Promise<NextResponse> {
  try {
    const user = await getCurrentUser();
    if (!user) {
      return NextResponse.json({ error: "Sign in to upload." }, { status: 401 });
    }
    const channel = await prisma.channel.findUnique({
      where: { userId: user.id },
      select: { id: true },
    });
    if (!channel) {
      return NextResponse.json(
        { error: "Create a channel before uploading." },
        { status: 403 },
      );
    }

    const rl = rateLimit(`whop-upload:${user.id}`, 20, 60_000);
    if (!rl.ok) {
      return NextResponse.json(
        { error: "Too many uploads - try again shortly." },
        { status: 429, headers: { "Retry-After": String(rl.retryAfterSeconds) } },
      );
    }

    const form = await request.formData();
    const file = form.get("file");
    if (!(file instanceof File)) {
      return NextResponse.json({ error: "No file provided." }, { status: 400 });
    }
    if (!ALLOWED_TYPES.includes(file.type)) {
      return NextResponse.json(
        { error: "Upload a JPEG, PNG, WebP, or GIF image." },
        { status: 400 },
      );
    }
    if (file.size > MAX_BYTES) {
      return NextResponse.json(
        { error: "Image must be 4MB or smaller." },
        { status: 400 },
      );
    }

    const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
    if (!sniffImageType(head)) {
      return NextResponse.json(
        { error: "That file isn't a valid image." },
        { status: 400 },
      );
    }

    const url = await uploadImageToWhop(file);
    return NextResponse.json({ url });
  } catch (err) {
    console.error("Whop file upload failed", err);
    return NextResponse.json(
      { error: (err as Error).message || "Upload failed." },
      { status: 500 },
    );
  }
}

Create src/app/studio/customize/page.tsx:

page.tsx
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { CustomizeForm } from "./customize-form";

export const metadata = { title: "Customize channel - Wavora Studio" };

export default async function CustomizePage() {
  const user = await getCurrentUser();
  if (!user) redirect("/sign-in");

  const channel = await prisma.channel.findUnique({
    where: { userId: user.id },
    select: {
      name: true,
      handle: true,
      description: true,
      avatarUrl: true,
      bannerUrl: true,
    },
  });
  if (!channel) redirect("/create-channel");

  return (
    <div>
      <h1 className="mb-8 text-2xl font-bold">Customize channel</h1>
      <CustomizeForm
        channel={{
          name: channel.name,
          handle: channel.handle,
          description: channel.description ?? "",
          avatarUrl: channel.avatarUrl ?? "",
          bannerUrl: channel.bannerUrl ?? "",
        }}
      />
    </div>
  );
}

Create src/app/studio/customize/customize-form.tsx:

customize-form.tsx
"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ImagePlus, User } from "lucide-react";
import { updateChannel } from "@/lib/channel-actions";

type ChannelProfile = {
  name: string;
  handle: string;
  description: string;
  avatarUrl: string;
  bannerUrl: string;
};

export function CustomizeForm({ channel }: { channel: ChannelProfile }) {
  const router = useRouter();
  const [name, setName] = useState(channel.name);
  const [handle, setHandle] = useState(channel.handle);
  const [description, setDescription] = useState(channel.description);
  const [avatarUrl, setAvatarUrl] = useState(channel.avatarUrl);
  const [bannerUrl, setBannerUrl] = useState(channel.bannerUrl);
  const [uploading, setUploading] = useState<null | "avatar" | "banner">(null);
  const [error, setError] = useState<string | null>(null);
  const [saved, setSaved] = useState(false);
  const [pending, startTransition] = useTransition();

  async function pickImage(kind: "avatar" | "banner", file: File) {
    setUploading(kind);
    setError(null);
    setSaved(false);
    try {
      const form = new FormData();
      form.append("file", file);
      const res = await fetch("/api/whop/upload", { method: "POST", body: form });
      const data = (await res.json()) as { url?: string; error?: string };
      if (!res.ok || !data.url) {
        throw new Error(data.error || "Image upload failed.");
      }
      if (kind === "avatar") setAvatarUrl(data.url);
      else setBannerUrl(data.url);
    } catch (e) {
      setError((e as Error).message || "Image upload failed.");
    } finally {
      setUploading(null);
    }
  }

  function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setSaved(false);
    startTransition(async () => {
      const res = await updateChannel({
        name,
        handle,
        description,
        avatarUrl,
        bannerUrl,
      });
      if ("error" in res) {
        setError(res.error);
      } else {
        setSaved(true);
        router.refresh();
      }
    });
  }

  return (
    <form onSubmit={onSubmit} className="flex max-w-2xl flex-col gap-6">
      {}
      <div>
        <span className="mb-2 block text-xs text-fg-muted">Banner image</span>
        <div className="relative aspect-[6/1] w-full overflow-hidden rounded-xl bg-hover">
          {bannerUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={bannerUrl}
              alt=""
              className="h-full w-full object-cover"
            />
          ) : null}
          <label className="absolute inset-0 grid cursor-pointer place-items-center bg-black/40 text-sm font-medium text-white opacity-0 transition hover:opacity-100">
            <span className="flex items-center gap-2">
              <ImagePlus className="h-5 w-5" />
              {uploading === "banner" ? "Uploading…" : "Change banner"}
            </span>
            <input
              type="file"
              accept="image/*"
              className="hidden"
              onChange={(e) => {
                const f = e.target.files?.[0];
                if (f) pickImage("banner", f);
              }}
            />
          </label>
        </div>
      </div>

      {}
      <div className="flex items-center gap-4">
        <div className="grid h-24 w-24 shrink-0 place-items-center overflow-hidden rounded-full bg-hover">
          {avatarUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={avatarUrl}
              alt=""
              className="h-full w-full object-cover"
            />
          ) : (
            <User className="h-10 w-10 text-fg-muted" />
          )}
        </div>
        <label className="cursor-pointer rounded-full bg-chip px-4 py-2 text-sm font-medium hover:bg-hover-strong">
          {uploading === "avatar" ? "Uploading…" : "Change avatar"}
          <input
            type="file"
            accept="image/*"
            className="hidden"
            onChange={(e) => {
              const f = e.target.files?.[0];
              if (f) pickImage("avatar", f);
            }}
          />
        </label>
      </div>

      <label className="block">
        <span className="mb-1 block text-xs text-fg-muted">Name</span>
        <input
          value={name}
          onChange={(e) => setName(e.target.value)}
          required
          maxLength={50}
          className="w-full rounded-lg border border-border bg-transparent px-3 py-2.5 outline-none focus:border-accent"
        />
      </label>

      <label className="block">
        <span className="mb-1 block text-xs text-fg-muted">Handle</span>
        <div className="flex items-center rounded-lg border border-border focus-within:border-accent">
          <span className="pl-3 text-fg-muted">@</span>
          <input
            value={handle}
            onChange={(e) => setHandle(e.target.value)}
            required
            maxLength={30}
            className="w-full bg-transparent px-1 py-2.5 outline-none"
          />
        </div>
        <span className="mt-1 block text-xs text-fg-muted">
          Changing your handle changes your channel URL.
        </span>
      </label>

      <label className="block">
        <span className="mb-1 block text-xs text-fg-muted">Description</span>
        <textarea
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          rows={5}
          maxLength={1000}
          className="w-full resize-y rounded-lg border border-border bg-transparent px-3 py-2.5 outline-none focus:border-accent"
        />
      </label>

      {error ? <p className="text-sm text-red-500">{error}</p> : null}
      {saved ? <p className="text-sm text-green-500">Saved.</p> : null}

      <div>
        <button
          type="submit"
          disabled={pending || uploading !== null || !name.trim()}
          className="rounded-full bg-accent px-5 py-2.5 font-medium text-accent-fg hover:opacity-90 disabled:opacity-60"
        >
          {pending ? "Saving…" : "Save changes"}
        </button>
      </div>
    </form>
  );
}

Now, let's add the studio navigation's Customize tab. Open src/app/studio/layout.tsx and replace its contents:

layout.tsx
import type { ReactNode } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { User } from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { WavoraLogo } from "@/components/ui/wavora-logo";

export default async function StudioLayout({
  children,
}: {
  children: ReactNode;
}) {
  const user = await getCurrentUser();
  if (!user) redirect("/sign-in");

  return (
    <div className="min-h-dvh bg-canvas">
      <header className="fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-4 border-b border-border bg-canvas px-4">
        <div className="flex items-center gap-2">
          <Link href="/" aria-label="Wavora home">
            <WavoraLogo />
          </Link>
          <span className="text-sm font-medium text-fg-muted">Studio</span>
          <nav className="ml-3 hidden items-center gap-1 sm:flex">
            <Link
              href="/studio/videos"
              className="rounded-lg px-3 py-1.5 text-sm hover:bg-hover"
            >
              Content
            </Link>
            <Link
              href="/studio/monetization"
              className="rounded-lg px-3 py-1.5 text-sm hover:bg-hover"
            >
              Monetization
            </Link>
            <Link
              href="/studio/customize"
              className="rounded-lg px-3 py-1.5 text-sm hover:bg-hover"
            >
              Customize
            </Link>
          </nav>
        </div>
        <div className="flex items-center gap-4">
          <Link href="/" className="text-sm text-fg-muted hover:text-fg">
            View site
          </Link>
          <span className="grid h-8 w-8 place-items-center overflow-hidden rounded-full bg-hover">
            {user.avatarUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={user.avatarUrl}
                alt=""
                className="h-full w-full object-cover"
              />
            ) : (
              <User className="h-5 w-5 text-fg-muted" />
            )}
          </span>
        </div>
      </header>

      <main className="mx-auto max-w-screen-xl px-6 pb-16 pt-20">{children}</main>
    </div>
  );
}

Shipping the project

There are two things left. Registering the webhook so the money flow works end to end, and switching from the sandbox to production.

We're going to register a webhook to test payments first, then create a new Whop company at Whop.com (live, not sandbox), and get all secrets again.

Register the webhook

In your Whop company dashboard's Develope page, create a webhook pointing at <your-url>/api/webhooks/whop. If you want to skip to switching to production, you can do this at a live Whop company created at Whop.com.

Subscribe it to the events:

  • payment.succeeded
  • payment.failed
  • membership.activated
  • membership.deactivated
  • refund.created

To receive events from your content creators' accounts as well, make sure you've enabled connected account events. Copy the signing secret and paste it into the WHOP_WEBHOOK_SECRET variable in Vercel, then redeploy.

You can now test the entire workflow in the sandbox environment: enable monetization on a channel, add a tier, and join with Whop's test card 4242 4242 4242 4242. The subscription webhook grants access, and earnings and the payment portal become active.

Switch to production

Everything we did so far uses the sandbox environment of Whop. The SDK base URL and the checkout and payout components all read the WHOP_SANDBOX flag.

Now, at the live Whop.com, create a new company In your Whop company dashboard, get your production credentials (a production app with its own client ID and client secret, and a production Company API key), then in the Vercel dashboard:

  • Set WHOP_CLIENT_ID, WHOP_CLIENT_SECRET, WHOP_COMPANY_API_KEY, and WHOP_PLATFORM_COMPANY_ID to the production values.
  • Set WHOP_SANDBOX to false.
  • Remove WHOP_OAUTH_BASE_URL so OAuth uses the default https://api.whop.com.
  • Register <your-url>/api/auth/callback as a redirect URI on the production app.
  • Create a production company webhook at <your-url>/api/webhooks/whop with the same events, and set WHOP_WEBHOOK_SECRET to its signing secret.

Then redeploy the Vercel project:

Terminal
vercel deploy --prod

From here on, real cards charge real money. Each creator finishes KYC in the embedded payout portal the first time they withdraw.

Checkpoint

Before calling it done, confirm:

  • A user can create a playlist and save videos to it from the Save menu.
  • A members-only video plays for active members and shows a join prompt to everyone else.
  • Portrait videos appear in the Waves feed, which autoplays and scrolls vertically.
  • The home page shows category chips, and Explore browses trending and categories.
  • The rate-limited routes reject a flood of requests, and responses carry a content security policy.
  • A creator can change their channel's avatar and banner.
  • Deployed with the webhook registered, a real membership or tip flows end to end: checkout, webhook, access or tip, and a payout.

Wrapping up the project

In this tutorial, we've rebuilt the core of YouTube with a creator economy including memberships, tips, and regular YouTube features like channels, playlists, and short-form vertical videos (which we call Waves).

We built this with a single SDK and a handful of webhook handlers. Money flows from the viewer to the creator through the creator’s own account, our cut is deducted at the top, and we never touch it.

The payment flow is production-ready from the very first request. Everything else runs on the free tiers and you should scale these up as you go live:

  • Switch to a paid Postgres plan (pooled connections, zero-scaling disabled) and send view counts and playback progress to a counter store or analytics data warehouse instead of one row per event.
  • Add Mux or Cloudinary for transcoding, adaptive streaming, and protected playback. Only the upload stream and Video model change.
  • Support Redis so the in-memory rate limiter can count across instances.

Use Whop on your other projects

As we've covered, a lot of aspects in this project from payments to user authentication uses Whop - but that's not all it offers. From adding checkouts or paywalls to existing Next.js apps to creating paid Chrome extensions, Whop offers a full suite.

If you want to learn more about how you can use Whop for your projects, check out our developer documentation and our other tutorials.