---
title: How to build a Udemy clone with Next.js and Whop
slug: build-udemy-clone
excerpt: "You can build a Udemy clone with Next.js and Whop infrastructure in under a day. In this tutorial, we'll walk you through building that project with a multi-vendor marketplace with Whop payments, video hosting with Mux, user authentication with Whop OAuth, and more."
customExcerpt: "You can build a Udemy clone with Next.js and Whop infrastructure in under a day. In this tutorial, we'll walk you through building that project with a multi-vendor marketplace with Whop payments, video hosting with Mux, user authentication with Whop OAuth, and more."
featureImage: "https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/04/how-to-build-a-udemy-clone.webp"
status: published
publishedAt: "2026-04-02T11:27:00.000Z"
updatedAt: "2026-05-04T14:28:57.000Z"
createdAt: "2026-03-28T06:00:08.565Z"
tags:
  - { name: Tutorials, slug: tutorials }
  - { name: Developers, slug: developers }
authors:
  - { name: East, slug: east }
  - { name: Destinee Walston, slug: destinee }
---

# How to build a Udemy clone with Next.js and Whop

## Key takeaways

- Whop handles the hardest parts of a course marketplace—payments, payment splits, and user authentication—so developers can focus on building.
- Combining Next.js with Whop's connected accounts and OAuth enables a full multi-vendor marketplace with instructor onboarding and student enrollment.
- A production-ready Udemy clone requires surprisingly few services: Next.js, Whop, Neon (Postgres), Mux for video, and Vercel for deployment.

<div class="ai-prompt-widget">
  <div class="ai-prompt-widget__header">
    <span class="ai-prompt-widget__icon">✨</span>
    <span class="ai-prompt-widget__title">Build this with AI</span>
  </div>
  <p class="ai-prompt-widget__description">Open the tutorial prompt in your favorite AI coding tool:</p>
  <div class="ai-prompt-widget__buttons" id="ai-prompt-buttons"></div>
</div>

Building a multi-vendor online course marketplace with Next.js and Whop's infrastructure is easier than ever. While building video hosting, payments systems, and the platform itself, Whop handles some of the most critical and complex parts of this project.

In this tutorial, we're going to build a Udemy clone (which we'll call Courstar). A course marketplace where users sign up, become teachers, create video courses, set prices, and publish their courses to the discovery of our project.

There, students can browse all courses, learn about them in their course details pages, and enroll. You can preview the [demo of our project here](https://courstar-demo.vercel.app/).

## Project overview

Before we dive deep into coding, let's take a general look at our project:

- **Multi-vendor marketplace** where any user can become a teacher through Whop's connected account flow
- **Structured course builder** which lets teachers create modals, lessons, and upload videos to them
- **Video hosting with Mux** which allows us to easily integrate a video player and hosting solution
- **One time course purchases** where teachers set a one-time price and students pay for lifetime access through Whop
- **Progress tracking and course reviews** that improves the quality of life of our project for students
- **Student and teacher dashboards** where they can see their enrolled courses with progress and teacher analytics

### Why we use Whop

Whop helps us easily solve two of the biggest problems we're going to face building this project: the payments system, and user authentication:

- **Whop** helps us by providing a out-of-the-box solution for payments. It's a technology layer built on best-in-class payment rails, giving sellers access to intelligently routed transactions through Whop's partner network of leading payment processors.
- **Whop OAuth** helps us by integrating a user authentication system for both students and teachers, allowing us to focus on development instead of authentication security, credential storage, and other complex systems.

### What you need first

Before starting, make sure you have:

- Working familiarity with Next.js and React (App Router, Server Components)
- A Whop sandbox account (free, sign up at [sandbox.whop.com](https://sandbox.whop.com/))
- A Vercel account (free tier works)
- A Neon account (free, provisioned through the Vercel integration)
- A Mux account (free tier, no credit card required)

## Part 1: Scaffold, deploy, and authenticate

In this first part, we're going to scaffold a new Next.js project, deploy it to Vercel, connect a Neon database, and implement Whop OAuth so users can sign in.

By the end of this part, we'll have a production URL ready (which we need for the authentication redirect URI), and establishes the deployment flow for future parts.

### Create the project

To create the project, use the commands below:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx create-next-app@latest courstar --ts --tailwind --eslint --app --src-dir --turbopack --import-alias &quot;@/*&quot;</code></pre>
  </div>
</div>

> 

Then, let's install the dependencies we'll use in this project upfront:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install @whop/sdk @mux/mux-node @mux/mux-player-react @mux/mux-uploader-react @prisma/client @prisma/adapter-pg pg iron-session zod lucide-react next-themes clsx tailwind-merge
npm install -D prisma dotenv @types/pg</code></pre>
  </div>
</div>

### Deploying first

Now, let's push the scaffolded project to a new GitHub repository and connect it to Vercel. The default NExt.js build should work without any file changes.

Once deployed, copy the production URL, go to the settings of the Vercel project, and add the URL under `NEXT_PUBLIC_APP_URL` in the environment variables section.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">.env.local</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">NEXT_PUBLIC_APP_URL=https://your-app.vercel.app</code></pre>
  </div>
</div>

### Set up a Neon database

It's time to set up our database. While this sounds complex for many beginners, it's actually quite easy. Go to your project's Vercel page and open the Integrations tab.

There, add the Neon database to your project. This will automatically create the `DATABASE_URL` and `DATABASE_URL_UNPOOLED` environment variables to your project.

### Set up Whop OAuth

During development, we're going to use Whop's sandbox environment at sandbox.whop.com. It provides a real simulation of how the Whop infrastructure works without moving real money.

In this step, you should go to sandbox.whop.com and create an app for OAuth:

- Go to sandbox.whop.com, create a whop, and go to its Developer tab
- There, find the Apps section and click **Create app** and go to its OAuth tab
- There, copy the `WHOP_CLIENT_ID` and `WHOP_CLIENT_SECRET` keys and note them down
- Copy the company ID from the dashboard URL (starts with `biz_`) and note it down
- Add the  `http://localhost:3000/api/auth/callback` (local development) and `https://your-app.vercel.app/api/auth/callback` (production) URLs (change the your-app with your production URL) ad redirect URIs
- Go to the Permissions tab and enable the permissions below:
- - `oauth:token_exchange`
  - `company:manage_checkout`
  - `company:basic:read`
  - `company:create_child`
  - `member:basic:read`
  - `member:email:read`
  - `payment:basic:read`
  - `plan:basic:read`
  - `plan:basic:read`
  - `checkout_configuration:create`
  - `chat:message:create`
  - `chat:read`
- Go back to the Developer page and create an API key and note it down

> 

### Configure environment variables

Now that we have our keys, let's configure our environment variables in Vercel, but first, let's create a session encryption key by using the command below:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">openssl rand -base64 32</code></pre>
  </div>
</div>

Then, go to the Environment Variables page of your Vercel project settings and add these environment variables:

<table>
  <tbody><tr><th>Variable</th><th>Source</th><th>Description</th></tr>
  <tr><td><code>DATABASE_URL</code></td><td>Neon via Vercel</td><td>Pooled connection (PgBouncer), used at runtime</td></tr>
  <tr><td><code>DATABASE_URL_UNPOOLED</code></td><td>Neon via Vercel</td><td>Direct connection, used by Prisma CLI</td></tr>
  <tr><td><code>WHOP_CLIENT_ID</code></td><td>Whop app OAuth tab</td><td>OAuth client identifier</td></tr>
  <tr><td><code>WHOP_CLIENT_SECRET</code></td><td>Whop app OAuth tab</td><td>OAuth client secret</td></tr>
  <tr><td><code>WHOP_API_KEY</code></td><td>Business Settings &gt; API Keys</td><td>Company API key for Whop for Platforms</td></tr>
  <tr><td><code>WHOP_COMPANY_ID</code></td><td>Dashboard URL</td><td>Starts with <code>biz_</code>, identifies your platform company</td></tr>
  <tr><td><code>SESSION_SECRET</code></td><td>Generated</td><td>At least 32 characters for iron-session encryption</td></tr>
  <tr><td><code>NEXT_PUBLIC_APP_URL</code></td><td>Set manually</td><td>Production URL on Vercel, <code>http://localhost:3000</code> locally</td></tr>
  <tr><td><code>WHOP_SANDBOX</code></td><td>Set manually</td><td><code>true</code> during development</td></tr>
</tbody></table>

Now, let's link the local project to Vercel and pull the variables:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">vercel link vercel env pull .env.local</code></pre>
  </div>
</div>

After pulling, open `.env.local` and add the variables that are not stored in Vercel. Append this line to let the system know we're using the sandbox environment:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">.env.local</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">WHOP_SANDBOX=true</code></pre>
  </div>
</div>

Also override `NEXT_PUBLIC_APP_URL` for local development. Find the line that was pulled from Vercel and change it to:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">.env.local</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">NEXT_PUBLIC_APP_URL=http://localhost:3000</code></pre>
  </div>
</div>

The production `NEXT_PUBLIC_APP_URL` on Vercel stays as the `vercel.app` URL. Locally, we override it so OAuth redirects come back to the dev server.

### Global styles

Our project supports both dark and light themes with teal accents. The dark theme is the default.

Let's create our color system using `@theme` by going into `src/app` and updating `globals.css` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">globals.css</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-css">@import &quot;tailwindcss&quot;;

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

@theme {
  --color-background: #0A0A0F;
  --color-surface: #13131A;
  --color-surface-elevated: #1C1C26;
  --color-border: #2A2A3C;
  --color-text-primary: #F0F0F5;
  --color-text-secondary: #8A8A9A;
  --color-accent: #14B8A6;
  --color-accent-hover: #0D9488;
  --color-accent-active: #0F766E;
  --color-success: #34D399;
  --color-warning: #FBBF24;
  --color-error: #F87171;

  --font-sans: &quot;Inter&quot;, ui-sans-serif, system-ui, sans-serif;
  --font-mono: &quot;JetBrains Mono&quot;, ui-monospace, monospace;
}

body {
  background-color: var(--color-background);
  color: var(--color-text-primary);
}

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

/* Light mode overrides */
.light {
  --color-background: #FAFAFA;
  --color-surface: #FFFFFF;
  --color-surface-elevated: #F3F4F6;
  --color-border: #E5E7EB;
  --color-text-primary: #111827;
  --color-text-secondary: #6B7280;
  --color-accent: #14B8A6;
  --color-accent-hover: #0D9488;
  --color-accent-active: #0F766E;
  --color-success: #059669;
  --color-warning: #D97706;
  --color-error: #DC2626;
}</code></pre>
  </div>
</div>

### Prisma setup

For now, we need a single `User` model to store authenticated users. We're going to add more models to our Prisma in the next part. Let's go to the `prisma` folder and update the `schema.prisma` file with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">schema.prisma</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-prisma">generator client {
  provider = &quot;prisma-client&quot;
  output   = &quot;../src/generated/prisma&quot;
}

datasource db {
  provider = &quot;postgresql&quot;
}

model User {
  id         String   @id @default(cuid())
  whopUserId String   @unique
  email      String?
  name       String?
  avatarUrl  String?
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
}</code></pre>
  </div>
</div>

Then, create a file in project root called `prisma.config.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">prisma.config.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { config } from &quot;dotenv&quot;;
config({ path: &quot;.env.local&quot; });

import { defineConfig } from &quot;prisma/config&quot;;

export default defineConfig({
  schema: &quot;prisma/schema.prisma&quot;,
  migrations: {
    path: &quot;prisma/migrations&quot;,
  },
  datasource: {
    url: process.env[&quot;DATABASE_URL_UNPOOLED&quot;],
  },
});</code></pre>
  </div>
</div>

And lastly, create a shared Prisma client that the entire app imports. Without this, reload during development would open a new database connection until Neon refuses more. Go to `src/lib` and create a file called `prisma.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">prisma.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { PrismaClient } from &quot;@/generated/prisma/client&quot;;
import { PrismaPg } from &quot;@prisma/adapter-pg&quot;;

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

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

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    adapter,
    log: process.env.NODE_ENV === &quot;development&quot; ? [&quot;query&quot;] : [],
  });

if (process.env.NODE_ENV !== &quot;production&quot;) globalForPrisma.prisma = prisma;</code></pre>
  </div>
</div>

Then generate the client and push the schema to the database:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx prisma generate
npx prisma db push</code></pre>
  </div>
</div>

### Environment variable validation

Problems with environment variables can be silent and break the whole project. Instead, we want a proper validation system that lets us know when an environment variable is broken. Go to `src/lib` and create a file called `env.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">env.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { z } from &quot;zod&quot;;

const envSchema = z.object({
  WHOP_CLIENT_ID: z.string().min(1),
  WHOP_CLIENT_SECRET: z.string().min(1),
  WHOP_API_KEY: z.string().min(1),
  WHOP_COMPANY_ID: z.string().startsWith(&quot;biz_&quot;),
  DATABASE_URL: z.string().min(1),
  DATABASE_URL_UNPOOLED: z.string().min(1),
  SESSION_SECRET: z.string().min(32),
  NEXT_PUBLIC_APP_URL: z.string().min(1),
  WHOP_SANDBOX: z.string().optional(),
});

type EnvType = z.infer&lt;typeof envSchema&gt;;

let _env: EnvType | null = null;

export function getEnv(): EnvType {
  if (!_env) {
    _env = envSchema.parse(process.env);
  }
  return _env;
}

export const env = new Proxy({} as EnvType, {
  get(_target, prop: string) {
    return getEnv()[prop as keyof EnvType];
  },
});</code></pre>
  </div>
</div>

### Session configuration

`iron-session` encrypts the session into a cookie so we don't need any other session storage solution. Go to `src/lib` and create a file called `session.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">session.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { getIronSession, SessionOptions } from &quot;iron-session&quot;;
import { cookies } from &quot;next/headers&quot;;

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

const sessionOptions: SessionOptions = {
  password: process.env.SESSION_SECRET!,
  cookieName: &quot;courstar_session&quot;,
  cookieOptions: {
    secure: process.env.NODE_ENV === &quot;production&quot;,
    httpOnly: true,
    sameSite: &quot;lax&quot; as const,
    maxAge: 60 * 60 * 24 * 7, // 7 days
  },
};

export async function getSession() {
  const cookieStore = await cookies();
  return getIronSession&lt;SessionData&gt;(cookieStore, sessionOptions);
}</code></pre>
  </div>
</div>

### Whop SDK and OAuth configuration

Now, we need a file to set up the Whop SDK client, the OAuth endpoints, and a PKCE helper. Go to `src/lib` and create a file called `whop.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Whop from &quot;@whop/sdk&quot;;

let _whop: Whop | null = null;

export function getWhop(): Whop {
  if (!_whop) {
    _whop = new Whop({
      apiKey: process.env.WHOP_API_KEY!,
      webhookKey: process.env.WHOP_WEBHOOK_SECRET
        ? Buffer.from(process.env.WHOP_WEBHOOK_SECRET).toString(&quot;base64&quot;)
        : undefined,
      ...(process.env.WHOP_SANDBOX === &quot;true&quot; &amp;&amp; {
        baseURL: &quot;https://sandbox-api.whop.com/api/v1&quot;,
      }),
    });
  }
  return _whop;
}

const isSandbox = () =&gt; process.env.WHOP_SANDBOX === &quot;true&quot;;
const whopApiDomain = () =&gt;
  isSandbox() ? &quot;sandbox-api.whop.com&quot; : &quot;api.whop.com&quot;;

export const WHOP_OAUTH = {
  get authorizationUrl() {
    return `https://${whopApiDomain()}/oauth/authorize`;
  },
  get tokenUrl() {
    return `https://${whopApiDomain()}/oauth/token`;
  },
  get userInfoUrl() {
    return `https://${whopApiDomain()}/oauth/userinfo`;
  },
  get clientId() {
    return process.env.WHOP_CLIENT_ID!;
  },
  get clientSecret() {
    return process.env.WHOP_CLIENT_SECRET!;
  },
  scopes: [&quot;openid&quot;, &quot;profile&quot;, &quot;email&quot;],
  get redirectUri() {
    return `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback`;
  },
};

export async function generatePKCE() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  const verifier = base64UrlEncode(array);

  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest(&quot;SHA-256&quot;, data);
  const challenge = base64UrlEncode(new Uint8Array(digest));

  return { verifier, challenge };
}

function base64UrlEncode(buffer: Uint8Array): string {
  let binary = &quot;&quot;;
  for (const byte of buffer) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary)
    .replace(/\+/g, &quot;-&quot;)
    .replace(/\//g, &quot;_&quot;)
    .replace(/=+$/, &quot;&quot;);
}</code></pre>
  </div>
</div>

### Authentication helpers

Every page and API route on our project has to be able to identify the user interacting with it. `requireAuth()` handles that in one place: it redirects unauthenticated visitors on pages, or returns `null` in API routes when we pass `{ redirect: false }`.

To do this, go to `src/lib` and create a file called `auth.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">auth.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &quot;next/navigation&quot;;
import { getSession } from &quot;./session&quot;;
import { prisma } from &quot;./prisma&quot;;

export async function requireAuth(
  options?: { redirect?: boolean }
): Promise&lt;{
  id: string;
  whopUserId: string;
  email: string | null;
  name: string | null;
  avatarUrl: string | null;
} | null&gt; {
  const session = await getSession();

  if (!session.userId) {
    if (options?.redirect === false) return null;
    redirect(&quot;/sign-in&quot;);
  }

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

  if (!user) {
    session.destroy();
    if (options?.redirect === false) return null;
    redirect(&quot;/sign-in&quot;);
  }

  return user;
}

export async function isAuthenticated(): Promise&lt;boolean&gt; {
  const session = await getSession();
  return !!session.userId;
}</code></pre>
  </div>
</div>

### Rate limiting

Our authentication routes are public endpoints and can be reached by anyone. Because of this, we protect them with a simple rate limiter. It tracks request counts per IP in memory and returns a 429 when the limit is exceeded.

Go to `src/lib` and create a file called `rate-limit.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">rate-limit.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;

interface RateLimitConfig {
  interval: number;
  maxRequests: number;
}

const rateLimitMap = new Map&lt;string, { count: number; lastReset: number }&gt;();

export function rateLimit(
  key: string,
  config: RateLimitConfig = { interval: 60_000, maxRequests: 30 }
): NextResponse | null {
  const now = Date.now();
  const entry = rateLimitMap.get(key);

  if (!entry || now - entry.lastReset &gt; config.interval) {
    rateLimitMap.set(key, { count: 1, lastReset: now });
    return null;
  }

  if (entry.count &gt;= config.maxRequests) {
    return NextResponse.json(
      { error: &quot;Too many requests. Please try again later.&quot; },
      {
        status: 429,
        headers: {
          &quot;Retry-After&quot;: String(
            Math.ceil((config.interval - (now - entry.lastReset)) / 1000)
          ),
        },
      }
    );
  }

  entry.count++;
  return null;
}

if (typeof globalThis !== &quot;undefined&quot;) {
  const CLEANUP_INTERVAL = 5 * 60 * 1000;
  setInterval(() =&gt; {
    const now = Date.now();
    for (const [key, entry] of rateLimitMap.entries()) {
      if (now - entry.lastReset &gt; 10 * 60 * 1000) {
        rateLimitMap.delete(key);
      }
    }
  }, CLEANUP_INTERVAL).unref?.();
}</code></pre>
  </div>
</div>

> 

### Utility helpers

Now, let's build a few small helpers we will use throughout the app. First, go to `src/lib` and create a file called `utils.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">utils.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { clsx, type ClassValue } from &quot;clsx&quot;;
import { twMerge } from &quot;tailwind-merge&quot;;

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

export function formatPrice(cents: number): string {
  if (cents === 0) return &quot;Free&quot;;
  return new Intl.NumberFormat(&quot;en-US&quot;, {
    style: &quot;currency&quot;,
    currency: &quot;USD&quot;,
  }).format(cents / 100);
}

export function formatDuration(seconds: number): string {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = seconds % 60;
  if (h &gt; 0) return `${h}h ${m}m`;
  if (m &gt; 0) return `${m}m ${s}s`;
  return `${s}s`;
}</code></pre>
  </div>
</div>

The slug generator turns "Intro to Python" into `intro-to-python-k8x2m1`. The random suffix guarantees uniqueness without a database check. Go to `src/lib` and create a file called `slugify.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">slugify.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export function slugify(text: string): string {
  return (
    text
      .toLowerCase()
      .trim()
      .replace(/[^\w\s-]/g, &quot;&quot;)
      .replace(/[\s_]+/g, &quot;-&quot;)
      .replace(/-+/g, &quot;-&quot;)
      .slice(0, 80) +
    &quot;-&quot; +
    Math.random().toString(36).slice(2, 8)
  );
}</code></pre>
  </div>
</div>

We want to keep our limits and configurations in one place so they're easier for us to adjust in the future. Go to `src/lib` and create a file called `constants.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">constants.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export const PLATFORM_FEE_PERCENT = Number(process.env.PLATFORM_FEE_PERCENT) || 20;

export const MAX_COURSE_TITLE = 100;
export const MAX_COURSE_DESCRIPTION = 5000;
export const MAX_SECTION_TITLE = 100;
export const MAX_LESSON_TITLE = 100;
export const MAX_REVIEW_COMMENT = 1000;
export const MAX_SECTIONS_PER_COURSE = 20;
export const MAX_LESSONS_PER_SECTION = 30;
export const COURSES_PER_PAGE = 12;</code></pre>
  </div>
</div>

### Authentication routes

There are a few authentication routes we need to build - like login, callback, and logout. Let's break them down.

#### Login route

The login route creates a PKCE pair and a state token, stores them in cookies, and sends the user to Whop's authentication page. To build it, go to `src/app/api/auth/login` and create a file called `route.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse, NextRequest } from &quot;next/server&quot;;
import { WHOP_OAUTH, generatePKCE } from &quot;@/lib/whop&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;
import { headers } from &quot;next/headers&quot;;

export async function GET(_request: NextRequest) {
  const headersList = await headers();
  const ip =
    headersList.get(&quot;x-forwarded-for&quot;)?.split(&quot;,&quot;)[0]?.trim() ?? &quot;unknown&quot;;

  const limited = rateLimit(`auth:login:${ip}`, {
    interval: 60_000,
    maxRequests: 10,
  });
  if (limited) return limited;

  const { verifier, challenge } = await generatePKCE();

  const nonceArray = new Uint8Array(16);
  crypto.getRandomValues(nonceArray);
  const nonce = Array.from(nonceArray, (b) =&gt;
    b.toString(16).padStart(2, &quot;0&quot;)
  ).join(&quot;&quot;);

  const stateArray = new Uint8Array(16);
  crypto.getRandomValues(stateArray);
  const state = Array.from(stateArray, (b) =&gt;
    b.toString(16).padStart(2, &quot;0&quot;)
  ).join(&quot;&quot;);

  const params = new URLSearchParams({
    client_id: WHOP_OAUTH.clientId,
    redirect_uri: WHOP_OAUTH.redirectUri,
    response_type: &quot;code&quot;,
    scope: WHOP_OAUTH.scopes.join(&quot; &quot;),
    code_challenge: challenge,
    code_challenge_method: &quot;S256&quot;,
    state,
    nonce,
  });

  const response = NextResponse.redirect(
    `${WHOP_OAUTH.authorizationUrl}?${params.toString()}`
  );

  response.cookies.set(&quot;oauth_pkce&quot;, JSON.stringify({ verifier, state }), {
    httpOnly: true,
    secure: WHOP_OAUTH.redirectUri.startsWith(&quot;https&quot;),
    sameSite: &quot;lax&quot;,
    path: &quot;/&quot;,
    maxAge: 600,
  });

  return response;
}</code></pre>
  </div>
</div>

#### Callback route

Whop redirects the user back to our callback route with the authorization code. The code validates the PKCE state and gets the user an access token, updates their profile, updates the User row in our database, saves the session, and redirects the user to the `/dashboard` page.

Go to `src/app/api/auth/callback` and create a file called `route.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { WHOP_OAUTH } from &quot;@/lib/whop&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;

export async function GET(request: NextRequest) {
  try {
    const code = request.nextUrl.searchParams.get(&quot;code&quot;);
    const state = request.nextUrl.searchParams.get(&quot;state&quot;);

    const pkceCookie = request.cookies.get(&quot;oauth_pkce&quot;)?.value;
    if (!pkceCookie) {
      return NextResponse.redirect(
        new URL(&quot;/sign-in?error=missing_pkce&quot;, request.url)
      );
    }

    let verifier: string;
    let savedState: string;
    try {
      const parsed = JSON.parse(pkceCookie);
      verifier = parsed.verifier;
      savedState = parsed.state;
    } catch {
      return NextResponse.redirect(
        new URL(&quot;/sign-in?error=invalid_pkce&quot;, request.url)
      );
    }

    if (!code) {
      return NextResponse.redirect(
        new URL(&quot;/sign-in?error=missing_code&quot;, request.url)
      );
    }
    if (!savedState || savedState !== state) {
      return NextResponse.redirect(
        new URL(&quot;/sign-in?error=invalid_state&quot;, request.url)
      );
    }

    const tokenResponse = await fetch(WHOP_OAUTH.tokenUrl, {
      method: &quot;POST&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify({
        grant_type: &quot;authorization_code&quot;,
        code,
        redirect_uri: WHOP_OAUTH.redirectUri,
        client_id: WHOP_OAUTH.clientId,
        client_secret: WHOP_OAUTH.clientSecret,
        code_verifier: verifier,
      }),
    });

    if (!tokenResponse.ok) {
      console.error(&quot;Token exchange failed:&quot;, tokenResponse.status);
      return NextResponse.redirect(
        new URL(&quot;/sign-in?error=token_exchange&quot;, request.url)
      );
    }

    const tokenData = await tokenResponse.json();
    const accessToken: string = tokenData.access_token;

    const userInfoResponse = await fetch(WHOP_OAUTH.userInfoUrl, {
      headers: { Authorization: `Bearer ${accessToken}` },
    });

    if (!userInfoResponse.ok) {
      return NextResponse.redirect(
        new URL(&quot;/sign-in?error=userinfo&quot;, request.url)
      );
    }

    const userInfo = await userInfoResponse.json();

    const avatarUrl =
      typeof userInfo.picture === &quot;string&quot; &amp;&amp;
      userInfo.picture.startsWith(&quot;https://&quot;)
        ? userInfo.picture
        : null;
    const name =
      typeof userInfo.name === &quot;string&quot; ? userInfo.name.slice(0, 100) : null;

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

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

    const response = NextResponse.redirect(
      new URL(&quot;/dashboard&quot;, request.url)
    );
    response.cookies.delete(&quot;oauth_pkce&quot;);
    return response;
  } catch (error) {
    console.error(&quot;OAuth callback error:&quot;, error);
    return NextResponse.redirect(
      new URL(&quot;/sign-in?error=unknown&quot;, request.url)
    );
  }
}</code></pre>
  </div>
</div>

#### Logout route

To create the logout route, go to `src/app/api/auth/logout` and the create a file called `route.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { getSession } from &quot;@/lib/session&quot;;

export async function GET() {
  const session = await getSession();
  session.destroy();
  return NextResponse.redirect(
    new URL(&quot;/sign-in&quot;, process.env.NEXT_PUBLIC_APP_URL!)
  );
}</code></pre>
  </div>
</div>

#### Middleware

We're going to use a middleware file to check the session cookie and let a whitelist of the public pats go through. We want every route to be protected by default. To build this middleware, go to `src` and create a file called `middleware.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">middleware.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import type { NextRequest } from &quot;next/server&quot;;

const publicPaths = [
  &quot;/&quot;,
  &quot;/sign-in&quot;,
  &quot;/courses&quot;,
  &quot;/api/auth&quot;,
  &quot;/api/webhooks&quot;,
];

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

  if (publicPaths.some((p) =&gt; pathname === p || pathname.startsWith(p + &quot;/&quot;))) {
    return NextResponse.next();
  }

  if (
    pathname.startsWith(&quot;/_next/&quot;) ||
    pathname.startsWith(&quot;/favicon&quot;) ||
    pathname.includes(&quot;.&quot;)
  ) {
    return NextResponse.next();
  }

  const session = request.cookies.get(&quot;courstar_session&quot;);
  if (!session) {
    if (pathname.startsWith(&quot;/api/&quot;)) {
      return NextResponse.json(
        { error: &quot;Unauthorized&quot; },
        { status: 401 }
      );
    }
    return NextResponse.redirect(new URL(&quot;/sign-in&quot;, request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: [&quot;/((?!_next/static|_next/image|favicon.ico).*)&quot;],
};</code></pre>
  </div>
</div>

### UI pages

The root layout we're going to build wraps every page in the sidebar. If the user is logged in, the sidebar shows the right links, but if not, it redirects them away since pages like `/` and `/courses` are public.
To build it, go to `src/app` and create a file called `layout.tsx` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">layout.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import type { Metadata } from &quot;next&quot;;
import { Inter } from &quot;next/font/google&quot;;
import { ThemeProvider } from &quot;next-themes&quot;;
import { Sidebar } from &quot;@/components/sidebar&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import &quot;./globals.css&quot;;

const inter = Inter({ subsets: [&quot;latin&quot;] });

export const metadata: Metadata = {
  title: &quot;Courstar&quot;,
  description: &quot;Learn from the best creators on the internet&quot;,
};

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await requireAuth({ redirect: false });
  const creatorProfile = user ? await getCreatorProfile(user.id) : null;

  return (
    &lt;html lang=&quot;en&quot; suppressHydrationWarning&gt;
      &lt;body className={`${inter.className} antialiased`}&gt;
        &lt;ThemeProvider attribute=&quot;class&quot; defaultTheme=&quot;system&quot; enableSystem&gt;
          &lt;div className=&quot;flex h-screen overflow-hidden&quot;&gt;
            &lt;Sidebar
              user={
                user
                  ? { id: user.id, name: user.name, avatarUrl: user.avatarUrl }
                  : null
              }
              isInstructor={!!creatorProfile?.kycComplete}
            /&gt;
            &lt;main className=&quot;flex-1 overflow-y-auto pt-14 lg:pt-0&quot;&gt;{children}&lt;/main&gt;
          &lt;/div&gt;
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
  </div>
</div>

### Sign-in page

The only UI the user sees before authenticating is a single button that sends them to `api/auth/login`, which starts the Whop OAuth flow. To create it, go to `src/app/sign-in` and create a file called `page.tsx` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&lt;div class=&quot;ucb-box&quot;&gt;
  &lt;div class=&quot;ucb-header&quot;&gt;
    &lt;span class=&quot;ucb-title&quot;&gt;page.tsx&lt;/span&gt;
    &lt;button class=&quot;ucb-copy&quot; onclick=&quot;
      const code = this.closest(&#039;.ucb-box&#039;).querySelector(&#039;code&#039;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &#039;Copied!&#039;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    &quot;&gt;Copy&lt;/button&gt;
  &lt;/div&gt;
  &lt;div class=&quot;ucb-content&quot;&gt;
    &lt;pre class=&quot;ucb-pre&quot;&gt;&lt;code class=&quot;language-typescript&quot;&gt;import Link from &amp;quot;next/link&amp;quot;;

export default function SignInPage() {
  return (
    &amp;lt;div className=&amp;quot;min-h-full flex items-center justify-center px-8&amp;quot;&amp;gt;
      &amp;lt;div className=&amp;quot;w-full max-w-sm p-10 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] text-center&amp;quot;&amp;gt;
        &amp;lt;h1 className=&amp;quot;text-2xl font-bold mb-2&amp;quot;&amp;gt;Courstar&amp;lt;/h1&amp;gt;
        &amp;lt;p className=&amp;quot;text-sm text-[var(--color-text-secondary)] mb-10&amp;quot;&amp;gt;
          Learn from the best creators on the internet
        &amp;lt;/p&amp;gt;
        &amp;lt;a
          href=&amp;quot;/api/auth/login&amp;quot;
          className=&amp;quot;block w-full py-3.5 px-4 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)]&amp;quot;
        &amp;gt;
          Sign in with Whop
        &amp;lt;/a&amp;gt;
        &amp;lt;Link
          href=&amp;quot;/&amp;quot;
          className=&amp;quot;block mt-5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]&amp;quot;
        &amp;gt;
          &amp;amp;larr; Back to home
        &amp;lt;/Link&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;
&lt;/div&gt;</code></pre>
  </div>
</div>

The landing page (`src/app/page.tsx`) is a placeholder for now. A heading, a short description, and a link to `/courses`. We build the real version in Part 6.

### Vercel configuration

The build command runs `prisma generate` before `next build` because the client lives in `src/generated/prisma`, not `node_modules`.

Create a file called `vercel.ts` at the project root with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">vercel.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&lt;div class=&quot;ucb-box&quot;&gt;
  &lt;div class=&quot;ucb-header&quot;&gt;
    &lt;span class=&quot;ucb-title&quot;&gt;vercel.ts&lt;/span&gt;
    &lt;button class=&quot;ucb-copy&quot; onclick=&quot;
      const code = this.closest(&#039;.ucb-box&#039;).querySelector(&#039;code&#039;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &#039;Copied!&#039;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    &quot;&gt;Copy&lt;/button&gt;
  &lt;/div&gt;
  &lt;div class=&quot;ucb-content&quot;&gt;
    &lt;pre class=&quot;ucb-pre&quot;&gt;&lt;code class=&quot;language-typescript&quot;&gt;const config = {
  framework: &amp;quot;nextjs&amp;quot; as const,
  buildCommand: &amp;quot;prisma generate &amp;amp;&amp;amp; next build&amp;quot;,
  regions: [&amp;quot;iad1&amp;quot;],
  headers: [
    {
      source: &amp;quot;/(.*)&amp;quot;,
      headers: [
        { key: &amp;quot;X-Content-Type-Options&amp;quot;, value: &amp;quot;nosniff&amp;quot; },
        { key: &amp;quot;X-Frame-Options&amp;quot;, value: &amp;quot;DENY&amp;quot; },
        { key: &amp;quot;Referrer-Policy&amp;quot;, value: &amp;quot;strict-origin-when-cross-origin&amp;quot; },
        { key: &amp;quot;Strict-Transport-Security&amp;quot;, value: &amp;quot;max-age=31536000; includeSubDomains&amp;quot; },
        {
          key: &amp;quot;Content-Security-Policy&amp;quot;,
          value:
            &amp;quot;default-src &amp;#039;self&amp;#039;; script-src &amp;#039;self&amp;#039; &amp;#039;unsafe-inline&amp;#039; &amp;#039;unsafe-eval&amp;#039; https://*.whop.com https://www.gstatic.com; style-src &amp;#039;self&amp;#039; &amp;#039;unsafe-inline&amp;#039; https://*.whop.com; img-src &amp;#039;self&amp;#039; https://*.whop.com https://image.mux.com https://ui-avatars.com data:; media-src &amp;#039;self&amp;#039; https://stream.mux.com https://*.mux.com blob:; font-src &amp;#039;self&amp;#039; https://*.whop.com; connect-src &amp;#039;self&amp;#039; https://*.mux.com https://*.production.mux.com https://*.whop.com wss://*.whop.com https://inferred.litix.io; frame-src &amp;#039;self&amp;#039; https://*.whop.com; frame-ancestors &amp;#039;none&amp;#039;; form-action &amp;#039;self&amp;#039;; base-uri &amp;#039;self&amp;#039;&amp;quot;,
        },
        { key: &amp;quot;Permissions-Policy&amp;quot;, value: &amp;quot;camera=(), microphone=(), geolocation=()&amp;quot; },
      ],
    },
  ],
};

export default config;&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;
&lt;/div&gt;</code></pre>
  </div>
</div>

Now, let's allow external images from Whop (avatars) and Mux (video thumbnails). Update the contents of `next.config.ts` with:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">next.config.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&lt;div class=&quot;ucb-box&quot;&gt;
  &lt;div class=&quot;ucb-header&quot;&gt;
    &lt;span class=&quot;ucb-title&quot;&gt;next-config.ts&lt;/span&gt;
    &lt;button class=&quot;ucb-copy&quot; onclick=&quot;
      const code = this.closest(&#039;.ucb-box&#039;).querySelector(&#039;code&#039;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &#039;Copied!&#039;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    &quot;&gt;Copy&lt;/button&gt;
  &lt;/div&gt;
  &lt;div class=&quot;ucb-content&quot;&gt;
    &lt;pre class=&quot;ucb-pre&quot;&gt;&lt;code class=&quot;language-typescript&quot;&gt;import type { NextConfig } from &amp;quot;next&amp;quot;;

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      { protocol: &amp;quot;https&amp;quot;, hostname: &amp;quot;**.whop.com&amp;quot; },
      { protocol: &amp;quot;https&amp;quot;, hostname: &amp;quot;image.mux.com&amp;quot; },
    ],
  },
};

export default nextConfig;&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;
&lt;/div&gt;</code></pre>
  </div>
</div>

### Checkpoint for Part 1

Start the dev server:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm run dev</code></pre>
  </div>
</div>

Walk through the full authentication flow:

- Visit `http://localhost:3000`. We should see the landing page placeholder.
- Navigate to `/sign-in` and click "Sign in with Whop." We should land on Whop's OAuth authorization page on `sandbox.whop.com`.
- Authorize the app. We should be redirected back to `/dashboard`.
- Check the Neon console. A User row should exist in the `User` table with a `whopUserId` matching the sandbox account.
- Open the browser's developer tools and check cookies. A `courstar_session` cookie should be present, marked `httpOnly` and `sameSite=lax`.
- Visit `/api/auth/logout`. We should be redirected to the sign-in page and the session cookie should be cleared.
- Try navigating directly to `/dashboard` without signing in. The middleware should redirect to `/sign-in`.

If any step fails, check the terminal output. The most common issues are a mismatched redirect URI in the Whop app settings (it must exactly match `http://localhost:3000/api/auth/callback`) and a missing `SESSION_SECRET` in `.env.local`.

Once the flow works locally, push to GitHub. Vercel auto-deploys on push. Verify the same flow on the production URL, this time using the production redirect URI registered in the Whop app.

In Part 2, we expand the Prisma schema to all nine models and build the instructor onboarding flow with Whop connected accounts.

## Part 2: Data models and instructor onboarding

In this part, we're going to update your data models and build the instructor onboarding flow.

### The full schema

Let's define all nine models now so we don't need additional migrations. Go to `prisma` and update the `schema.prisma` file with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">schema.prisma</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-prisma">generator client {
  provider = &quot;prisma-client&quot;
  output   = &quot;../src/generated/prisma&quot;
}

datasource db {
  provider = &quot;postgresql&quot;
}

enum Category {
  DEVELOPMENT
  BUSINESS
  DESIGN
  MARKETING
  PHOTOGRAPHY
  MUSIC
  HEALTH
  LIFESTYLE
  DATA_SCIENCE
  ARTIFICIAL_INTELLIGENCE
  CYBERSECURITY
  CLOUD_COMPUTING
  MOBILE_DEVELOPMENT
  GAME_DEVELOPMENT
  FINANCE
  ENTREPRENEURSHIP
  PROJECT_MANAGEMENT
  PERSONAL_DEVELOPMENT
  WRITING
  VIDEO_PRODUCTION
  ANIMATION
  ARCHITECTURE
  ENGINEERING
  SCIENCE
  MATHEMATICS
  LANGUAGE
  COOKING
  FITNESS
  PARENTING
  TEACHING
}

enum CourseStatus {
  DRAFT
  PUBLISHED
}

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

  creatorProfile CreatorProfile?
  enrollments    Enrollment[]
  progress       Progress[]
  reviews        Review[]
}

model CreatorProfile {
  id            String  @id @default(cuid())
  userId        String  @unique
  user          User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  headline      String?
  bio           String?
  whopCompanyId String  @unique
  kycComplete   Boolean @default(false)
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  courses Course[]
}

model Course {
  id              String       @id @default(cuid())
  title           String
  slug            String       @unique
  description     String
  price           Int
  thumbnailUrl    String?
  category        Category
  status          CourseStatus @default(DRAFT)
  creatorId       String
  creator         CreatorProfile @relation(fields: [creatorId], references: [id], onDelete: Cascade)
  whopProductId   String?
  whopPlanId      String?
  whopCheckoutUrl String?
  createdAt       DateTime     @default(now())
  updatedAt       DateTime     @updatedAt

  sections    Section[]
  enrollments Enrollment[]
  reviews     Review[]
}

model Section {
  id       String @id @default(cuid())
  title    String
  order    Int
  courseId  String
  course   Course @relation(fields: [courseId], references: [id], onDelete: Cascade)

  lessons Lesson[]

  @@unique([courseId, order])
}

model Lesson {
  id            String  @id @default(cuid())
  title         String
  order         Int
  isFree        Boolean @default(false)
  sectionId     String
  section       Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
  muxAssetId    String? @unique
  muxPlaybackId String?
  muxUploadId   String?
  duration      Int?
  videoReady    Boolean @default(false)

  progress Progress[]

  @@unique([sectionId, order])
}

model Enrollment {
  id            String   @id @default(cuid())
  userId        String
  user          User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  courseId       String
  course        Course   @relation(fields: [courseId], references: [id], onDelete: Cascade)
  whopPaymentId String?
  createdAt     DateTime @default(now())

  @@unique([userId, courseId])
}

model Progress {
  id          String    @id @default(cuid())
  userId      String
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  lessonId    String
  lesson      Lesson    @relation(fields: [lessonId], references: [id], onDelete: Cascade)
  completed   Boolean   @default(false)
  completedAt DateTime?

  @@unique([userId, lessonId])
}

model Review {
  id       String   @id @default(cuid())
  userId   String
  user     User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  courseId  String
  course   Course   @relation(fields: [courseId], references: [id], onDelete: Cascade)
  rating   Int
  comment  String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@unique([userId, courseId])
}

model WebhookEvent {
  id          String   @id
  source      String
  processedAt DateTime @default(now())
}</code></pre>
  </div>
</div>

Then, push the updated schema to add the new tables to the database and regenerate the client using the commands:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx prisma db push
npx prisma generate</code></pre>
  </div>
</div>

### Creator profile helper

Now that the `CreatorProfile` model exists in our database, we need a helper to check if a user is a teacher or not. Append the content below to the `auth.ts` file in `src/lib`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">auth.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export async function getCreatorProfile(userId: string) {
  return prisma.creatorProfile.findUnique({
    where: { userId },
  });
}</code></pre>
  </div>
</div>

Open `src/app/layout.tsx` and update the root layout to use the real instructor check instead of the hardcoded `false` from Part 1:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">layout.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;

// Inside the RootLayout function, after requireAuth:
const creatorProfile = user ? await getCreatorProfile(user.id) : null;

// Update the Sidebar prop:
isInstructor={!!creatorProfile?.kycComplete}</code></pre>
  </div>
</div>

### The onboarding API route

In the onboarding flow, users clicks the "Become an Instructor" button, our API creates a connected Whop account, user goes to the Whop-hosted KYC, and return to our dashboard once the KYC is completed.

From that point on, every course sale flows through the instructor's company with our 20% fee deducted automatically. Go to `src/app/api/teach/onboard` and create a file called `route.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;
import { headers } from &quot;next/headers&quot;;

const isSandbox = process.env.WHOP_SANDBOX === &quot;true&quot;;

export async function POST() {
  const headersList = await headers();
  const ip =
    headersList.get(&quot;x-forwarded-for&quot;)?.split(&quot;,&quot;)[0]?.trim() ?? &quot;unknown&quot;;
  const limited = rateLimit(`teach:onboard:${ip}`, {
    interval: 60_000,
    maxRequests: 5,
  });
  if (limited) return limited;

  const user = await requireAuth({ redirect: false });
  if (!user) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const existing = await getCreatorProfile(user.id);

  if (existing) {
    if (!existing.kycComplete) {
      if (isSandbox) {
        await prisma.creatorProfile.update({
          where: { id: existing.id },
          data: { kycComplete: true },
        });
        return NextResponse.json({ sandbox: true });
      }
      const whop = getWhop();
      const accountLink = await whop.accountLinks.create({
        company_id: existing.whopCompanyId,
        use_case: &quot;account_onboarding&quot;,
        return_url: `${process.env.NEXT_PUBLIC_APP_URL}/teach/dashboard`,
        refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/teach?refresh=true`,
      });
      return NextResponse.json({ url: accountLink.url });
    }
    return NextResponse.json({ url: &quot;/teach/dashboard&quot; });
  }

  const whop = getWhop();

  const company = await whop.companies.create({
    email: user.email || undefined,
    title: `${user.name || &quot;Instructor&quot;}&#039;s Teaching Account`,
    parent_company_id: process.env.WHOP_COMPANY_ID!,
  });

  if (isSandbox) {
    await prisma.creatorProfile.create({
      data: {
        userId: user.id,
        whopCompanyId: company.id,
        kycComplete: true,
      },
    });
    return NextResponse.json({ sandbox: true });
  }

  await prisma.creatorProfile.create({
    data: {
      userId: user.id,
      whopCompanyId: company.id,
      kycComplete: false,
    },
  });

  const accountLink = await whop.accountLinks.create({
    company_id: company.id,
    use_case: &quot;account_onboarding&quot;,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/teach/dashboard`,
    refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/teach?refresh=true`,
  });

  return NextResponse.json({ url: accountLink.url });
}</code></pre>
  </div>
</div>

### The teach page

This page pitches the instructor program to new users. If someone is already onboarded, it redirects straight to the dashboard. Go to `src/app/teach` and create a file called `page.tsx` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { DollarSign, CreditCard, Wallet } from &quot;lucide-react&quot;;
import { OnboardButton } from &quot;@/components/onboard-button&quot;;

export default async function TeachPage() {
  const user = await requireAuth();
  if (!user) redirect(&quot;/sign-in&quot;);

  const profile = await getCreatorProfile(user.id);
  if (profile?.kycComplete) redirect(&quot;/teach/dashboard&quot;);

  return (
    &lt;main className=&quot;max-w-3xl mx-auto px-8 py-24 text-center&quot;&gt;
      &lt;h1 className=&quot;text-4xl md:text-5xl font-extrabold mb-4&quot;&gt;
        Share your expertise with the world
      &lt;/h1&gt;
      &lt;p className=&quot;text-lg text-[var(--color-text-secondary)] mb-12 max-w-xl mx-auto&quot;&gt;
        Create video courses, set your own price, and earn money from every student enrollment. We handle payments and payouts.
      &lt;/p&gt;

      &lt;div className=&quot;grid grid-cols-1 md:grid-cols-3 gap-8 mb-12&quot;&gt;
        {[
          { icon: DollarSign, title: &quot;Set Your Price&quot;, desc: &quot;You decide what your course is worth&quot; },
          { icon: CreditCard, title: &quot;We Handle Payments&quot;, desc: &quot;Whop processes all transactions automatically&quot; },
          { icon: Wallet, title: &quot;Get Paid&quot;, desc: &quot;Withdraw earnings to your bank account anytime&quot; },
        ].map((item) =&gt; (
          &lt;div key={item.title} className=&quot;p-7 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)]&quot;&gt;
            &lt;item.icon className=&quot;w-8 h-8 text-[var(--color-accent)] mb-3 mx-auto&quot; /&gt;
            &lt;h3 className=&quot;font-semibold mb-1&quot;&gt;{item.title}&lt;/h3&gt;
            &lt;p className=&quot;text-sm text-[var(--color-text-secondary)]&quot;&gt;{item.desc}&lt;/p&gt;
          &lt;/div&gt;
        ))}
      &lt;/div&gt;

      &lt;p className=&quot;text-sm text-[var(--color-text-secondary)] mb-6&quot;&gt;
        Platform takes a 20% commission — you keep 80% of every sale
      &lt;/p&gt;

      &lt;OnboardButton hasProfile={!!profile} /&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

The button is a client component because it makes a fetch call and handles the redirect. In sandbox mode, it skips KYC and shows a success message instead. Go to `src/components` and create a file called `onboard-button.tsx` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">onboard-button.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&quot;use client&quot;;

import { useState } from &quot;react&quot;;

export function OnboardButton({ hasProfile }: { hasProfile: boolean }) {
  const [loading, setLoading] = useState(false);
  const [sandboxMessage, setSandboxMessage] = useState(false);

  async function handleClick() {
    setLoading(true);
    try {
      const res = await fetch(&quot;/api/teach/onboard&quot;, { method: &quot;POST&quot; });
      const data = await res.json();
      if (data.sandbox) {
        setSandboxMessage(true);
        setTimeout(() =&gt; {
          window.location.href = &quot;/teach/dashboard&quot;;
        }, 2000);
        return;
      }
      if (data.url) {
        window.location.href = data.url;
      }
    } catch {
      setLoading(false);
    }
  }

  if (sandboxMessage) {
    return (
      &lt;div className=&quot;rounded-lg bg-[var(--color-success)]/10 border border-[var(--color-success)]/20 p-5 text-center&quot;&gt;
        &lt;p className=&quot;text-[var(--color-success)] font-medium mb-1&quot;&gt;You&amp;apos;re all set!&lt;/p&gt;
        &lt;p className=&quot;text-sm text-[var(--color-text-secondary)]&quot;&gt;
          Since this demo uses the Whop sandbox, KYC is not required. Redirecting to your dashboard...
        &lt;/p&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;button
      onClick={handleClick}
      disabled={loading}
      className=&quot;px-8 py-4 rounded-lg bg-[var(--color-accent)] text-white text-lg font-semibold hover:bg-[var(--color-accent-hover)] disabled:opacity-50&quot;
    &gt;
      {loading
        ? &quot;Setting up...&quot;
        : hasProfile
          ? &quot;Complete Verification&quot;
          : &quot;Become an Instructor&quot;}
    &lt;/button&gt;
  );
}</code></pre>
  </div>
</div>

### Dashboard placeholders

Now, let's build two simple pages as landing spots. We'll replace them with full dashboards in Part 6.

### Instructor dashboard

To build the instructor dashboard, go to `src/app/teach/dashboard` and create a file called `page.tsx` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &quot;next/navigation&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { OnboardButton } from &quot;@/components/onboard-button&quot;;

export default async function TeachDashboardPage() {
  const user = await requireAuth();
  if (!user) return null;

  const profile = await getCreatorProfile(user.id);

  if (!profile) {
    redirect(&quot;/teach&quot;);
  }

  return (
    &lt;div className=&quot;mx-auto max-w-4xl px-8 py-10&quot;&gt;
      {!profile.kycComplete &amp;&amp; (
        &lt;div className=&quot;mb-8 rounded-lg border border-warning/30 bg-warning/10 p-4&quot;&gt;
          &lt;p className=&quot;mb-3 text-sm font-medium text-warning&quot;&gt;
            Complete your identity verification to start creating courses.
          &lt;/p&gt;
          &lt;OnboardButton hasProfile={true} /&gt;
        &lt;/div&gt;
      )}

      &lt;h1 className=&quot;mb-2 text-2xl font-bold tracking-tight text-text-primary&quot;&gt;
        Instructor Dashboard
      &lt;/h1&gt;
      &lt;p className=&quot;mb-8 text-text-secondary&quot;&gt;
        Welcome back, {user.name || &quot;Instructor&quot;}.
      &lt;/p&gt;

      &lt;div className=&quot;rounded-lg border border-border bg-surface p-12 text-center&quot;&gt;
        &lt;p className=&quot;text-text-secondary&quot;&gt;
          Your courses will appear here.
        &lt;/p&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### Student dashboard

To build the student dashboard, go to `src/app/dashboard` and create a file called `page.tsx` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { requireAuth } from &quot;@/lib/auth&quot;;

export default async function DashboardPage() {
  const user = await requireAuth();
  if (!user) return null;

  return (
    &lt;div className=&quot;mx-auto max-w-4xl px-8 py-10&quot;&gt;
      &lt;h1 className=&quot;mb-2 text-2xl font-bold tracking-tight text-text-primary&quot;&gt;
        My Learning
      &lt;/h1&gt;
      &lt;p className=&quot;mb-8 text-text-secondary&quot;&gt;
        Welcome back, {user.name || &quot;Student&quot;}.
      &lt;/p&gt;

      &lt;div className=&quot;rounded-lg border border-border bg-surface p-12 text-center&quot;&gt;
        &lt;p className=&quot;text-text-secondary&quot;&gt;
          Your enrolled courses will appear here.
        &lt;/p&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

## Part 3: Course builder and video hosting

In this part, we're going to build the instructor workflow: create a course, add sections and lessons, upload videos, and publish it with a Whop checkout link.

### Mux setup

We're going to use Mux for video uploads in this project and we need its keys first:

- Create a free Mux account at mux.com
- In the Mux dashboard, go to Settings and API Access Tokens. There, create a new token and note the Token ID and Token Secret.
- Create a webhook endpoint by going into Settings > Webhooks > Create Webhook. Set the URL to `https://your-app.vercel.app/api/webhooks/mux` (use your real production URL). Select two events: `video.asset.ready` and `video.upload.asset_created`.
- Copy the webhook signing secret.

Add the env vars to Vercel under `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET`, and `MUX_WEBOOK_SECRET` via the Environment Variables section of the project settings at Vercel, then pull them locally:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">vercel env pull .env.local</code></pre>
  </div>
</div>

Update the Zod schema in `src/lib/env.ts` to validate the new variables. Add these three fields to the `envSchema` object:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">env.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">MUX_TOKEN_ID: z.string().min(1).optional(),
MUX_TOKEN_SECRET: z.string().min(1).optional(),
MUX_WEBHOOK_SECRET: z.string().min(1).optional(),</code></pre>
  </div>
</div>

### Mux client

Now, we need a singleton pattern. Go to `src/lib` and create a file called `mux.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">mux.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Mux from &quot;@mux/mux-node&quot;;

let _mux: Mux | null = null;

export function getMux(): Mux {
  if (!_mux) {
    _mux = new Mux({
      tokenId: process.env.MUX_TOKEN_ID!,
      tokenSecret: process.env.MUX_TOKEN_SECRET!,
    });
  }
  return _mux;
}</code></pre>
  </div>
</div>

### Course creation

Instructors need a way to create and courses, and we're going to build a route for it. It takes a title, description, price, and category, then creates the course in `DRAFT` status. It stays a draft until the instructor adds content and publishes.
To build it, go to `src/app/api/teach/courses` and create a file called `route.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;
import { slugify } from &quot;@/lib/slugify&quot;;
import { z } from &quot;zod&quot;;
import { MAX_COURSE_TITLE, MAX_COURSE_DESCRIPTION } from &quot;@/lib/constants&quot;;
import { headers } from &quot;next/headers&quot;;

const categoryValues = [
  &quot;DEVELOPMENT&quot;, &quot;BUSINESS&quot;, &quot;DESIGN&quot;, &quot;MARKETING&quot;,
  &quot;PHOTOGRAPHY&quot;, &quot;MUSIC&quot;, &quot;HEALTH&quot;, &quot;LIFESTYLE&quot;,
  &quot;DATA_SCIENCE&quot;, &quot;ARTIFICIAL_INTELLIGENCE&quot;, &quot;CYBERSECURITY&quot;, &quot;CLOUD_COMPUTING&quot;,
  &quot;MOBILE_DEVELOPMENT&quot;, &quot;GAME_DEVELOPMENT&quot;, &quot;FINANCE&quot;, &quot;ENTREPRENEURSHIP&quot;,
  &quot;PROJECT_MANAGEMENT&quot;, &quot;PERSONAL_DEVELOPMENT&quot;, &quot;WRITING&quot;, &quot;VIDEO_PRODUCTION&quot;,
  &quot;ANIMATION&quot;, &quot;ARCHITECTURE&quot;, &quot;ENGINEERING&quot;, &quot;SCIENCE&quot;,
  &quot;MATHEMATICS&quot;, &quot;LANGUAGE&quot;, &quot;COOKING&quot;, &quot;FITNESS&quot;,
  &quot;PARENTING&quot;, &quot;TEACHING&quot;,
] as const;

const createCourseSchema = z.object({
  title: z.string().min(3).max(MAX_COURSE_TITLE),
  description: z.string().min(10).max(MAX_COURSE_DESCRIPTION),
  price: z.number().int().min(0),
  category: z.enum(categoryValues),
  thumbnailUrl: z.string().url().optional().or(z.literal(&quot;&quot;)),
});

export async function POST(request: Request) {
  const headersList = await headers();
  const ip = headersList.get(&quot;x-forwarded-for&quot;)?.split(&quot;,&quot;)[0]?.trim() ?? &quot;unknown&quot;;
  const limited = rateLimit(`teach:courses:${ip}`, { interval: 60_000, maxRequests: 10 });
  if (limited) return limited;

  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const profile = await getCreatorProfile(user.id);
  if (!profile || !profile.kycComplete) {
    return NextResponse.json({ error: &quot;Complete instructor onboarding first&quot; }, { status: 403 });
  }

  const body = await request.json();
  const parsed = createCourseSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
  }

  const { title, description, price, category, thumbnailUrl } = parsed.data;

  const course = await prisma.course.create({
    data: {
      title,
      slug: slugify(title),
      description,
      price,
      category,
      thumbnailUrl: thumbnailUrl || null,
      creatorId: profile.id,
      status: &quot;DRAFT&quot;,
    },
  });

  return NextResponse.json({ course }, { status: 201 });
}</code></pre>
  </div>
</div>

### Create course page

Now, let's build the form where instructors actually enter the course title, description, price, and category. Go to `src/components` and create a file called `create-course.form.tsx` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">create-course.form.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;

const CATEGORIES = [
  &quot;DEVELOPMENT&quot;, &quot;BUSINESS&quot;, &quot;DESIGN&quot;, &quot;MARKETING&quot;,
  &quot;PHOTOGRAPHY&quot;, &quot;MUSIC&quot;, &quot;HEALTH&quot;, &quot;LIFESTYLE&quot;,
  &quot;DATA_SCIENCE&quot;, &quot;ARTIFICIAL_INTELLIGENCE&quot;, &quot;CYBERSECURITY&quot;, &quot;CLOUD_COMPUTING&quot;,
  &quot;MOBILE_DEVELOPMENT&quot;, &quot;GAME_DEVELOPMENT&quot;, &quot;FINANCE&quot;, &quot;ENTREPRENEURSHIP&quot;,
  &quot;PROJECT_MANAGEMENT&quot;, &quot;PERSONAL_DEVELOPMENT&quot;, &quot;WRITING&quot;, &quot;VIDEO_PRODUCTION&quot;,
  &quot;ANIMATION&quot;, &quot;ARCHITECTURE&quot;, &quot;ENGINEERING&quot;, &quot;SCIENCE&quot;,
  &quot;MATHEMATICS&quot;, &quot;LANGUAGE&quot;, &quot;COOKING&quot;, &quot;FITNESS&quot;,
  &quot;PARENTING&quot;, &quot;TEACHING&quot;,
];

export function CreateCourseForm() {
  const router = useRouter();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(&quot;&quot;);

  async function handleSubmit(e: React.FormEvent&lt;HTMLFormElement&gt;) {
    e.preventDefault();
    setLoading(true);
    setError(&quot;&quot;);

    const form = new FormData(e.currentTarget);
    const body = {
      title: form.get(&quot;title&quot;) as string,
      description: form.get(&quot;description&quot;) as string,
      price: Math.round(Number(form.get(&quot;price&quot;)) * 100),
      category: form.get(&quot;category&quot;) as string,
      thumbnailUrl: (form.get(&quot;thumbnailUrl&quot;) as string) || &quot;&quot;,
    };

    try {
      const res = await fetch(&quot;/api/teach/courses&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify(body),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(typeof data.error === &quot;string&quot; ? data.error : &quot;Validation failed&quot;);
        return;
      }
      router.push(`/teach/courses/${data.course.id}/edit`);
    } catch {
      setError(&quot;Something went wrong&quot;);
    } finally {
      setLoading(false);
    }
  }

  return (
    &lt;form onSubmit={handleSubmit} className=&quot;space-y-6&quot;&gt;
      {error &amp;&amp; (
        &lt;div className=&quot;p-3 rounded-lg bg-[var(--color-error)]/10 text-[var(--color-error)] text-sm&quot;&gt;
          {error}
        &lt;/div&gt;
      )}
      &lt;div&gt;
        &lt;label className=&quot;block text-sm text-[var(--color-text-secondary)] mb-1&quot;&gt;Title&lt;/label&gt;
        &lt;input
          name=&quot;title&quot;
          required
          maxLength={100}
          className=&quot;w-full px-4 py-3.5 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-accent)]&quot;
          placeholder=&quot;e.g. Introduction to Python&quot;
        /&gt;
      &lt;/div&gt;
      &lt;div&gt;
        &lt;label className=&quot;block text-sm text-[var(--color-text-secondary)] mb-1&quot;&gt;Description&lt;/label&gt;
        &lt;textarea
          name=&quot;description&quot;
          required
          rows={4}
          maxLength={5000}
          className=&quot;w-full px-4 py-3.5 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-accent)] resize-none&quot;
          placeholder=&quot;What will students learn?&quot;
        /&gt;
      &lt;/div&gt;
      &lt;div className=&quot;grid grid-cols-2 gap-4&quot;&gt;
        &lt;div&gt;
          &lt;label className=&quot;block text-sm text-[var(--color-text-secondary)] mb-1&quot;&gt;Price (USD)&lt;/label&gt;
          &lt;input
            name=&quot;price&quot;
            type=&quot;number&quot;
            step=&quot;0.01&quot;
            min=&quot;0&quot;
            required
            className=&quot;w-full px-4 py-3.5 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-accent)]&quot;
            placeholder=&quot;0.00&quot;
          /&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;label className=&quot;block text-sm text-[var(--color-text-secondary)] mb-1&quot;&gt;Category&lt;/label&gt;
          &lt;select
  name=&quot;category&quot;
  required
  className=&quot;w-full px-4 py-3 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-accent)]&quot;
&gt;
  &lt;option value=&quot;&quot;&gt;Select...&lt;/option&gt;
  {CATEGORIES.map((c) =&gt; (
    &lt;option key={c} value={c}&gt;{c.split(&quot;_&quot;).map(w =&gt; w.charAt(0) + w.slice(1).toLowerCase()).join(&quot; &quot;)}&lt;/option&gt;
  ))}
&lt;/select&gt;
        &lt;/div&gt;
      &lt;/div&gt;
      &lt;div&gt;
        &lt;label className=&quot;block text-sm text-[var(--color-text-secondary)] mb-1&quot;&gt;Thumbnail URL (optional)&lt;/label&gt;
        &lt;input
          name=&quot;thumbnailUrl&quot;
          type=&quot;url&quot;
          className=&quot;w-full px-4 py-3.5 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-accent)]&quot;
          placeholder=&quot;https://...&quot;
        /&gt;
      &lt;/div&gt;
      &lt;button
        type=&quot;submit&quot;
        disabled={loading}
        className=&quot;w-full py-3.5 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)] transition-colors disabled:opacity-50&quot;
      &gt;
        {loading ? &quot;Creating...&quot; : &quot;Create Course&quot;}
      &lt;/button&gt;
    &lt;/form&gt;
  );
}</code></pre>
  </div>
</div>

Then, go to `src/app/teach/courses/new` and create a file called `page.tsx` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &quot;next/navigation&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { CreateCourseForm } from &quot;@/components/create-course-form&quot;;

export default async function NewCoursePage() {
  const user = await requireAuth();
  if (!user) redirect(&quot;/sign-in&quot;);

  const profile = await getCreatorProfile(user.id);
  if (!profile?.kycComplete) redirect(&quot;/teach&quot;);

  return (
    &lt;main className=&quot;max-w-2xl mx-auto px-8 py-10&quot;&gt;
      &lt;h1 className=&quot;text-3xl font-bold tracking-tight mb-10&quot;&gt;Create New Course&lt;/h1&gt;
      &lt;CreateCourseForm /&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

### Course editor

After creating a course, we need to let instructors edit the curriculum. To build it, go to `src/app/api/teach/courses/[courseId]` and create a file called `route.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { z } from &quot;zod&quot;;
import { MAX_COURSE_TITLE, MAX_COURSE_DESCRIPTION } from &quot;@/lib/constants&quot;;

const updateCourseSchema = z
  .object({
    title: z.string().min(3).max(MAX_COURSE_TITLE),
    description: z.string().min(10).max(MAX_COURSE_DESCRIPTION),
    price: z.number().int().min(0),
    category: z.enum([
  &quot;DEVELOPMENT&quot;, &quot;BUSINESS&quot;, &quot;DESIGN&quot;, &quot;MARKETING&quot;,
  &quot;PHOTOGRAPHY&quot;, &quot;MUSIC&quot;, &quot;HEALTH&quot;, &quot;LIFESTYLE&quot;,
  &quot;DATA_SCIENCE&quot;, &quot;ARTIFICIAL_INTELLIGENCE&quot;, &quot;CYBERSECURITY&quot;, &quot;CLOUD_COMPUTING&quot;,
  &quot;MOBILE_DEVELOPMENT&quot;, &quot;GAME_DEVELOPMENT&quot;, &quot;FINANCE&quot;, &quot;ENTREPRENEURSHIP&quot;,
  &quot;PROJECT_MANAGEMENT&quot;, &quot;PERSONAL_DEVELOPMENT&quot;, &quot;WRITING&quot;, &quot;VIDEO_PRODUCTION&quot;,
  &quot;ANIMATION&quot;, &quot;ARCHITECTURE&quot;, &quot;ENGINEERING&quot;, &quot;SCIENCE&quot;,
  &quot;MATHEMATICS&quot;, &quot;LANGUAGE&quot;, &quot;COOKING&quot;, &quot;FITNESS&quot;,
  &quot;PARENTING&quot;, &quot;TEACHING&quot;,
    ] as const).optional(),
    thumbnailUrl: z.string().url().optional().or(z.literal(&quot;&quot;)),
  })
  .partial();

export async function PATCH(
  request: Request,
  { params }: { params: Promise&lt;{ courseId: string }&gt; }
) {
  const { courseId } = await params;

  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const profile = await getCreatorProfile(user.id);
  if (!profile) return NextResponse.json({ error: &quot;Not an instructor&quot; }, { status: 403 });

  const course = await prisma.course.findUnique({ where: { id: courseId } });
  if (!course || course.creatorId !== profile.id) {
    return NextResponse.json({ error: &quot;Course not found&quot; }, { status: 404 });
  }

  const body = await request.json();
  const parsed = updateCourseSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
  }

  const updated = await prisma.course.update({
    where: { id: courseId },
    data: {
      ...parsed.data,
      thumbnailUrl: parsed.data.thumbnailUrl === &quot;&quot; ? null : parsed.data.thumbnailUrl,
    },
  });

  return NextResponse.json({ course: updated });
}</code></pre>
  </div>
</div>

Then, go to `src/app/teach/courses/[courseId]/edit` and create a file called `page.tsx` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect, notFound } from &quot;next/navigation&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { CourseEditor } from &quot;@/components/course-editor&quot;;

export default async function EditCoursePage({
  params,
}: {
  params: Promise&lt;{ courseId: string }&gt;;
}) {
  const { courseId } = await params;
  const user = await requireAuth();
  if (!user) redirect(&quot;/sign-in&quot;);

  const profile = await getCreatorProfile(user.id);
  if (!profile) redirect(&quot;/teach&quot;);

  const course = await prisma.course.findUnique({
    where: { id: courseId },
    include: {
      sections: {
        orderBy: { order: &quot;asc&quot; },
        include: {
          lessons: { orderBy: { order: &quot;asc&quot; } },
        },
      },
    },
  });

  if (!course || course.creatorId !== profile.id) notFound();

  return (
    &lt;main className=&quot;max-w-4xl mx-auto px-8 py-10&quot;&gt;
      &lt;div className=&quot;flex items-center justify-between mb-10&quot;&gt;
        &lt;h1 className=&quot;text-3xl font-bold tracking-tight&quot;&gt;Edit: {course.title}&lt;/h1&gt;
        {course.status === &quot;PUBLISHED&quot; ? (
          &lt;span className=&quot;px-3.5 py-1.5 rounded-lg text-sm font-medium bg-[var(--color-success)]/15 text-[var(--color-success)]&quot;&gt;Published&lt;/span&gt;
        ) : (
          &lt;span className=&quot;px-3.5 py-1.5 rounded-lg text-sm font-medium bg-[var(--color-warning)]/15 text-[var(--color-warning)]&quot;&gt;Draft&lt;/span&gt;
        )}
      &lt;/div&gt;

      &lt;div className=&quot;space-y-8&quot;&gt;
        &lt;div className=&quot;p-7 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)]&quot;&gt;
          &lt;h2 className=&quot;font-semibold mb-5&quot;&gt;Course Info&lt;/h2&gt;
          &lt;div className=&quot;space-y-3 text-sm&quot;&gt;
            &lt;div&gt;&lt;span className=&quot;text-[var(--color-text-secondary)]&quot;&gt;Title:&lt;/span&gt; {course.title}&lt;/div&gt;
            &lt;div&gt;&lt;span className=&quot;text-[var(--color-text-secondary)]&quot;&gt;Category:&lt;/span&gt; {course.category}&lt;/div&gt;
            &lt;div&gt;&lt;span className=&quot;text-[var(--color-text-secondary)]&quot;&gt;Price:&lt;/span&gt; ${(course.price / 100).toFixed(2)}&lt;/div&gt;
            &lt;div&gt;&lt;span className=&quot;text-[var(--color-text-secondary)]&quot;&gt;Description:&lt;/span&gt; &lt;span className=&quot;line-clamp-2&quot;&gt;{course.description}&lt;/span&gt;&lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;CourseEditor
          courseId={course.id}
          sections={course.sections.map((s) =&gt; ({
            id: s.id, title: s.title, order: s.order,
            lessons: s.lessons.map((l) =&gt; ({
              id: l.id, title: l.title, order: l.order,
              isFree: l.isFree, videoReady: l.videoReady,
              muxUploadId: l.muxUploadId,
            })),
          }))}
          status={course.status}
        /&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

### Section and lesson CRUD

The curriculum is built from sections (groups of related lessons) and lessons within them.

We need CRUD routes for both so the course editor can add, rename, reorder, and delete them. Go to `src/app/api/teach/sections` and create a file called `route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { z } from &quot;zod&quot;;
import { MAX_SECTION_TITLE, MAX_SECTIONS_PER_COURSE } from &quot;@/lib/constants&quot;;

async function verifyCourseOwnership(userId: string, courseId: string) {
  const profile = await getCreatorProfile(userId);
  if (!profile) return null;
  const course = await prisma.course.findUnique({ where: { id: courseId } });
  if (!course || course.creatorId !== profile.id) return null;
  return course;
}

const createSchema = z.object({
  title: z.string().min(1).max(MAX_SECTION_TITLE),
  courseId: z.string().min(1),
});

const updateSchema = z.object({
  id: z.string().min(1),
  title: z.string().min(1).max(MAX_SECTION_TITLE).optional(),
  order: z.number().int().min(0).optional(),
});

const deleteSchema = z.object({ id: z.string().min(1) });

export async function POST(request: Request) {
  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const body = await request.json();
  const parsed = createSchema.safeParse(body);
  if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });

  const course = await verifyCourseOwnership(user.id, parsed.data.courseId);
  if (!course) return NextResponse.json({ error: &quot;Course not found&quot; }, { status: 404 });

  const count = await prisma.section.count({ where: { courseId: course.id } });
  if (count &gt;= MAX_SECTIONS_PER_COURSE) {
    return NextResponse.json(
      { error: `Maximum ${MAX_SECTIONS_PER_COURSE} sections per course` },
      { status: 400 }
    );
  }

  const section = await prisma.section.create({
    data: { title: parsed.data.title, courseId: course.id, order: count },
  });

  return NextResponse.json({ section }, { status: 201 });
}

export async function PATCH(request: Request) {
  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const body = await request.json();
  const parsed = updateSchema.safeParse(body);
  if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });

  const section = await prisma.section.findUnique({
    where: { id: parsed.data.id },
  });
  if (!section) return NextResponse.json({ error: &quot;Section not found&quot; }, { status: 404 });

  const course = await verifyCourseOwnership(user.id, section.courseId);
  if (!course) return NextResponse.json({ error: &quot;Not authorized&quot; }, { status: 403 });

  const updated = await prisma.section.update({
    where: { id: parsed.data.id },
    data: {
      ...(parsed.data.title !== undefined &amp;&amp; { title: parsed.data.title }),
      ...(parsed.data.order !== undefined &amp;&amp; { order: parsed.data.order }),
    },
  });

  return NextResponse.json({ section: updated });
}

export async function DELETE(request: Request) {
  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const body = await request.json();
  const parsed = deleteSchema.safeParse(body);
  if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });

  const section = await prisma.section.findUnique({
    where: { id: parsed.data.id },
  });
  if (!section) return NextResponse.json({ error: &quot;Section not found&quot; }, { status: 404 });

  const course = await verifyCourseOwnership(user.id, section.courseId);
  if (!course) return NextResponse.json({ error: &quot;Not authorized&quot; }, { status: 403 });

  await prisma.section.delete({ where: { id: parsed.data.id } });
  return NextResponse.json({ success: true });
}</code></pre>
  </div>
</div>

Lessons follow the same pattern, with one addition: deleting a lesson also cleans up its video on Mux. Go to `src/app/api/teach/lessons` and create a file called `route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getMux } from &quot;@/lib/mux&quot;;
import { z } from &quot;zod&quot;;
import { MAX_LESSON_TITLE, MAX_LESSONS_PER_SECTION } from &quot;@/lib/constants&quot;;

async function verifyLessonOwnership(userId: string, sectionId: string) {
  const section = await prisma.section.findUnique({
    where: { id: sectionId },
    include: { course: true },
  });
  if (!section) return null;
  const profile = await getCreatorProfile(userId);
  if (!profile || section.course.creatorId !== profile.id) return null;
  return section;
}

const createSchema = z.object({
  title: z.string().min(1).max(MAX_LESSON_TITLE),
  sectionId: z.string().min(1),
  isFree: z.boolean().optional(),
});

const updateSchema = z.object({
  id: z.string().min(1),
  title: z.string().min(1).max(MAX_LESSON_TITLE).optional(),
  order: z.number().int().min(0).optional(),
  isFree: z.boolean().optional(),
});

const deleteSchema = z.object({ id: z.string().min(1) });

export async function POST(request: Request) {
  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const body = await request.json();
  const parsed = createSchema.safeParse(body);
  if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });

  const section = await verifyLessonOwnership(user.id, parsed.data.sectionId);
  if (!section) return NextResponse.json({ error: &quot;Section not found&quot; }, { status: 404 });

  const count = await prisma.lesson.count({ where: { sectionId: section.id } });
  if (count &gt;= MAX_LESSONS_PER_SECTION) {
    return NextResponse.json(
      { error: `Maximum ${MAX_LESSONS_PER_SECTION} lessons per section` },
      { status: 400 }
    );
  }

  const lesson = await prisma.lesson.create({
    data: {
      title: parsed.data.title,
      sectionId: section.id,
      order: count,
      isFree: parsed.data.isFree ?? false,
    },
  });

  return NextResponse.json({ lesson }, { status: 201 });
}

export async function PATCH(request: Request) {
  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const body = await request.json();
  const parsed = updateSchema.safeParse(body);
  if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });

  const lesson = await prisma.lesson.findUnique({
    where: { id: parsed.data.id },
    include: { section: { include: { course: true } } },
  });
  if (!lesson) return NextResponse.json({ error: &quot;Lesson not found&quot; }, { status: 404 });

  const profile = await getCreatorProfile(user.id);
  if (!profile || lesson.section.course.creatorId !== profile.id) {
    return NextResponse.json({ error: &quot;Not authorized&quot; }, { status: 403 });
  }

  const updated = await prisma.lesson.update({
    where: { id: parsed.data.id },
    data: {
      ...(parsed.data.title !== undefined &amp;&amp; { title: parsed.data.title }),
      ...(parsed.data.order !== undefined &amp;&amp; { order: parsed.data.order }),
      ...(parsed.data.isFree !== undefined &amp;&amp; { isFree: parsed.data.isFree }),
    },
  });

  return NextResponse.json({ lesson: updated });
}

export async function DELETE(request: Request) {
  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const body = await request.json();
  const parsed = deleteSchema.safeParse(body);
  if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });

  const lesson = await prisma.lesson.findUnique({
    where: { id: parsed.data.id },
    include: { section: { include: { course: true } } },
  });
  if (!lesson) return NextResponse.json({ error: &quot;Lesson not found&quot; }, { status: 404 });

  const profile = await getCreatorProfile(user.id);
  if (!profile || lesson.section.course.creatorId !== profile.id) {
    return NextResponse.json({ error: &quot;Not authorized&quot; }, { status: 403 });
  }

  if (lesson.muxAssetId) {
    try {
      const mux = getMux();
      await mux.video.assets.delete(lesson.muxAssetId);
    } catch {
    }
  }

  await prisma.lesson.delete({ where: { id: parsed.data.id } });
  return NextResponse.json({ success: true });
}</code></pre>
  </div>
</div>

### Video upload flow

In this project, all lessons require a video. Rather than uploading through our server, the browser should directly upload to Mux. The route we're going to build creates a direct upload URL and returns it. If the lesson already has a video, it should also delete the old asset first.

Go to `src/app/api/teach/upload/` and create a file called `route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getMux } from &quot;@/lib/mux&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;
import { z } from &quot;zod&quot;;
import { headers } from &quot;next/headers&quot;;

const uploadSchema = z.object({ lessonId: z.string().min(1) });

export async function POST(request: Request) {
  const headersList = await headers();
  const ip = headersList.get(&quot;x-forwarded-for&quot;)?.split(&quot;,&quot;)[0]?.trim() ?? &quot;unknown&quot;;
  const limited = rateLimit(`teach:upload:${ip}`, { interval: 60_000, maxRequests: 10 });
  if (limited) return limited;

  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const body = await request.json();
  const parsed = uploadSchema.safeParse(body);
  if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });

  const lesson = await prisma.lesson.findUnique({
    where: { id: parsed.data.lessonId },
    include: { section: { include: { course: true } } },
  });
  if (!lesson) return NextResponse.json({ error: &quot;Lesson not found&quot; }, { status: 404 });

  const profile = await getCreatorProfile(user.id);
  if (!profile || lesson.section.course.creatorId !== profile.id) {
    return NextResponse.json({ error: &quot;Not authorized&quot; }, { status: 403 });
  }

  const mux = getMux();

  if (lesson.muxAssetId) {
    try {
      await mux.video.assets.delete(lesson.muxAssetId);
    } catch {
    }
    await prisma.lesson.update({
      where: { id: lesson.id },
      data: {
        muxAssetId: null,
        muxPlaybackId: null,
        muxUploadId: null,
        duration: null,
        videoReady: false,
      },
    });
  }

  const upload = await mux.video.uploads.create({
    cors_origin: process.env.NEXT_PUBLIC_APP_URL!,
    new_asset_settings: {
      passthrough: lesson.id,
      playback_policy: [&quot;signed&quot;],
      video_quality: &quot;basic&quot;,
    },
  });

  await prisma.lesson.update({
    where: { id: lesson.id },
    data: { muxUploadId: upload.id },
  });

  return NextResponse.json({ url: upload.url, uploadId: upload.id });
}</code></pre>
  </div>
</div>

> 

### Mux webhooks

After an instructor uploads a video, we need to know when Mux finishes processing it so we can mark the lesson as ready. Mux tells us via webhooks, specifically the `video.upload.asset_created` (links the asset ID to the lesson early) and `video.asset.ready` (transcoding complete, gives us the playback ID and duration).

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

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getMux } from &quot;@/lib/mux&quot;;

export async function POST(request: NextRequest) {
  const body = await request.text();

  const signature = request.headers.get(&quot;mux-signature&quot;);
  if (!signature) {
    return NextResponse.json({ error: &quot;Missing signature&quot; }, { status: 401 });
  }

  type MuxEvent = {
    type: string;
    id: string;
    data: Record&lt;string, unknown&gt;;
  };

  let event: MuxEvent;
  try {
    const mux = getMux();
    event = mux.webhooks.unwrap(
      body,
      { &quot;mux-signature&quot;: signature },
      process.env.MUX_WEBHOOK_SECRET!
    ) as unknown as MuxEvent;
  } catch {
    return NextResponse.json({ error: &quot;Invalid signature&quot; }, { status: 401 });
  }

  const existing = await prisma.webhookEvent.findUnique({
    where: { id: event.id },
  });
  if (existing) {
    return NextResponse.json({ received: true });
  }

  await prisma.webhookEvent.create({
    data: { id: event.id, source: &quot;mux&quot; },
  });

  if (event.type === &quot;video.asset.ready&quot;) {
    const asset = event.data as {
      id: string;
      passthrough?: string;
      duration?: number;
      playback_ids?: Array&lt;{ id: string; policy: string }&gt;;
    };

    if (asset.passthrough) {
      await prisma.lesson.update({
        where: { id: asset.passthrough },
        data: {
          muxAssetId: asset.id,
          muxPlaybackId: asset.playback_ids?.[0]?.id ?? null,
          duration: asset.duration ? Math.round(asset.duration) : null,
          videoReady: true,
        },
      });
    }
  }

  if (event.type === &quot;video.upload.asset_created&quot;) {
    const upload = event.data as { asset_id?: string; id?: string };
    if (upload.asset_id &amp;&amp; upload.id) {
      await prisma.lesson.updateMany({
        where: { muxUploadId: upload.id },
        data: { muxAssetId: upload.asset_id },
      });
    }
  }

  return NextResponse.json({ received: true });
}</code></pre>
  </div>
</div>

### Publishing a course

After filling out the form details of courses, they appear as drafts. When the instructor publishes the course, it becomes visible to the students (for free courses) or creates a Whop product with a checkout link (for paid courses).

The `application_fee_amount` on the checkout configuration is our 20% platform cut. Free courses skip Whop and just flip the status.

Go to `src/app/api/teach/courses/[courseId]/publish/` and create a file called `route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;
import { PLATFORM_FEE_PERCENT } from &quot;@/lib/constants&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;
import { headers } from &quot;next/headers&quot;;

export async function POST(
  _request: Request,
  { params }: { params: Promise&lt;{ courseId: string }&gt; }
) {
  const { courseId } = await params;

  const headersList = await headers();
  const ip = headersList.get(&quot;x-forwarded-for&quot;)?.split(&quot;,&quot;)[0]?.trim() ?? &quot;unknown&quot;;
  const limited = rateLimit(`teach:publish:${ip}`, { interval: 60_000, maxRequests: 5 });
  if (limited) return limited;

  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const profile = await getCreatorProfile(user.id);
  if (!profile) return NextResponse.json({ error: &quot;Not an instructor&quot; }, { status: 403 });

  const course = await prisma.course.findUnique({
    where: { id: courseId },
    include: {
      sections: {
        include: {
          lessons: { where: { videoReady: true } },
        },
      },
    },
  });

  if (!course || course.creatorId !== profile.id) {
    return NextResponse.json({ error: &quot;Course not found&quot; }, { status: 404 });
  }

  if (course.status === &quot;PUBLISHED&quot;) {
    return NextResponse.json({ error: &quot;Already published&quot; }, { status: 400 });
  }

  const sectionsWithLessons = course.sections.filter(
    (s) =&gt; s.lessons.length &gt; 0
  );
  if (sectionsWithLessons.length === 0) {
    return NextResponse.json(
      { error: &quot;Course must have at least one section with a ready video lesson&quot; },
      { status: 400 }
    );
  }

  if (course.price &gt; 0) {
    const whop = getWhop();

    const product = await whop.products.create({
      company_id: profile.whopCompanyId,
      title: course.title.slice(0, 40),
      description: course.description.slice(0, 500),
    });

    const priceInDollars = course.price / 100;
    const plan = await whop.plans.create({
      company_id: profile.whopCompanyId,
      product_id: product.id,
      initial_price: priceInDollars,
      plan_type: &quot;one_time&quot;,
    });

    const applicationFee = Math.round(priceInDollars * (PLATFORM_FEE_PERCENT / 100) * 100) / 100;

    const checkout = await whop.checkoutConfigurations.create({
      plan: {
        company_id: profile.whopCompanyId,
        currency: &quot;usd&quot;,
        initial_price: priceInDollars,
        plan_type: &quot;one_time&quot;,
        application_fee_amount: applicationFee,
      },
      metadata: {
        courstar_course_id: course.id,
      },
      redirect_url: `${process.env.NEXT_PUBLIC_APP_URL}/courses/${course.slug}/learn`,
    });

    await prisma.course.update({
      where: { id: courseId },
      data: {
        status: &quot;PUBLISHED&quot;,
        whopProductId: product.id,
        whopPlanId: plan.id,
        whopCheckoutUrl: checkout.purchase_url,
      },
    });
  } else {
    await prisma.course.update({
      where: { id: courseId },
      data: { status: &quot;PUBLISHED&quot; },
    });
  }

  return NextResponse.json({ success: true });
}</code></pre>
  </div>
</div>

### Checkpoint

- Navigate to `/teach/courses/new`. The creation form appears with fields for title, description, price, category, and thumbnail URL.
- Fill in the form (title: "Introduction to Web Development", price: $29, category: DEVELOPMENT) and submit. You are redirected to the course editor at `/teach/courses/[courseId]/edit`.
- Using the API (or a client component), add two sections to the course: "Getting Started" and "HTML Basics"
- Add lessons to each section: "Welcome" and "Setting Up" under the first section, "Your First Page" under the second
- Upload a video to the "Welcome" lesson. The progress bar fills as the file uploads to Mux, then the status changes to "Processing", and after Mux finishes transcoding it flips to "Ready" with a duration displayed.
- Toggle the "Welcome" lesson as a free preview using the PATCH endpoint with `isFree: true`
- Call the publish endpoint (POST to `/api/teach/courses/[courseId]/publish`). The response returns `{ success: true }`.
- Check the database: the course row has `status: "PUBLISHED"`, and `whopProductId`, `whopPlanId`, and `whopCheckoutUrl` are all populated

In Part 4, we build the student-facing storefront and wire up payments so students can browse, purchase, and enroll in courses.

## Part 4: Storefront and payments

In this part, we're going to build the student-facing side of our project, including the course catalog, a course details page with enrollment, and the payment form via Whop.

### Whop webhook setup

When a student completes a purchase, we need to be aware of it. To do this, we need an endpoint first:

- Open the Whop sandbox dashboard at `sandbox.whop.com`
- Navigate to the Developer page (bottom of the left sidebar)
- In the Webhooks section, click **Create Webhook**
- Set the URL to our production domain followed by `/api/webhooks/whop`, for example `https://courstar.vercel.app/api/webhooks/whop`
- Under Events, enable `payment.succeeded`
- Click **Save**

Then, copy the secret (starts with `ws_`) from the Secret column and add it to Vercel using the Environment Variables page of the project settings as `WHOP_WEBHOOK_SECRET`. Once done, go to `src/lib` and update add the new variable to the `env.ts` file:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">env.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">WHOP_WEBHOOK_SECRET: z.string().min(1).optional(),</code></pre>
  </div>
</div>

> 

### Course catalog

Now we build a page that helps students discover courses with a search bar, category filter, and paginated courses list. Go to `src/app/courses/` and create a file called `page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { COURSES_PER_PAGE } from &quot;@/lib/constants&quot;;
import { formatPrice } from &quot;@/lib/utils&quot;;
import { Star, Users } from &quot;lucide-react&quot;;
import type { Category } from &quot;@/generated/prisma/client&quot;;

const CATEGORIES = [
  &quot;DEVELOPMENT&quot;, &quot;BUSINESS&quot;, &quot;DESIGN&quot;, &quot;MARKETING&quot;,
  &quot;PHOTOGRAPHY&quot;, &quot;MUSIC&quot;, &quot;HEALTH&quot;, &quot;LIFESTYLE&quot;,
  &quot;DATA_SCIENCE&quot;, &quot;ARTIFICIAL_INTELLIGENCE&quot;, &quot;CYBERSECURITY&quot;, &quot;CLOUD_COMPUTING&quot;,
  &quot;MOBILE_DEVELOPMENT&quot;, &quot;GAME_DEVELOPMENT&quot;, &quot;FINANCE&quot;, &quot;ENTREPRENEURSHIP&quot;,
  &quot;PROJECT_MANAGEMENT&quot;, &quot;PERSONAL_DEVELOPMENT&quot;, &quot;WRITING&quot;, &quot;VIDEO_PRODUCTION&quot;,
  &quot;ANIMATION&quot;, &quot;ARCHITECTURE&quot;, &quot;ENGINEERING&quot;, &quot;SCIENCE&quot;,
  &quot;MATHEMATICS&quot;, &quot;LANGUAGE&quot;, &quot;COOKING&quot;, &quot;FITNESS&quot;,
  &quot;PARENTING&quot;, &quot;TEACHING&quot;,
] as const;

export default async function CoursesPage({
  searchParams,
}: {
  searchParams: Promise&lt;{ q?: string; category?: string; page?: string }&gt;;
}) {
  const { q, category, page: pageStr } = await searchParams;
  const page = Math.max(1, Number(pageStr) || 1);

  const where = {
    status: &quot;PUBLISHED&quot; as const,
    ...(category &amp;&amp; CATEGORIES.includes(category as Category) &amp;&amp; { category: category as Category }),
    ...(q &amp;&amp; { title: { contains: q, mode: &quot;insensitive&quot; as const } }),
  };

  const [courses, total] = await Promise.all([
    prisma.course.findMany({
      where,
      include: {
        creator: { include: { user: true } },
        _count: { select: { enrollments: true } },
        reviews: { select: { rating: true } },
        sections: { include: { _count: { select: { lessons: true } } } },
      },
      orderBy: { createdAt: &quot;desc&quot; },
      skip: (page - 1) * COURSES_PER_PAGE,
      take: COURSES_PER_PAGE,
    }),
    prisma.course.count({ where }),
  ]);

  const totalPages = Math.ceil(total / COURSES_PER_PAGE);

  return (
    &lt;div className=&quot;max-w-6xl mx-auto px-8 py-10&quot;&gt;
      &lt;h1 className=&quot;text-3xl font-bold tracking-tight mb-10&quot;&gt;Browse Courses&lt;/h1&gt;

      &lt;form className=&quot;mb-10 flex flex-col sm:flex-row gap-4&quot;&gt;
        &lt;input
          type=&quot;text&quot;
          name=&quot;q&quot;
          defaultValue={q}
          placeholder=&quot;Search courses...&quot;
          className=&quot;flex-1 px-5 py-3.5 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-secondary)]&quot;
        /&gt;
        &lt;select
          name=&quot;category&quot;
          defaultValue={category}
          className=&quot;px-5 py-3.5 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-primary)]&quot;
        &gt;
          &lt;option value=&quot;&quot;&gt;All Categories&lt;/option&gt;
          {CATEGORIES.map((c) =&gt; (
            &lt;option key={c} value={c}&gt;
              {c.split(&quot;_&quot;).map(w =&gt; w.charAt(0) + w.slice(1).toLowerCase()).join(&quot; &quot;)}
            &lt;/option&gt;
          ))}
        &lt;/select&gt;
        &lt;button type=&quot;submit&quot; className=&quot;px-8 py-3.5 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)]&quot;&gt;
          Search
        &lt;/button&gt;
      &lt;/form&gt;

      {courses.length === 0 ? (
        &lt;p className=&quot;text-[var(--color-text-secondary)] text-center py-20&quot;&gt;No courses found.&lt;/p&gt;
      ) : (
        &lt;div className=&quot;grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8&quot;&gt;
          {courses.map((course) =&gt; {
            const avgRating =
              course.reviews.length &gt; 0
                ? course.reviews.reduce((sum, r) =&gt; sum + r.rating, 0) / course.reviews.length
                : 0;
            const lessonCount = course.sections.reduce(
              (sum, s) =&gt; sum + s._count.lessons,
              0
            );

            return (
              &lt;Link
                key={course.id}
                href={`/courses/${course.slug}`}
                className=&quot;group rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] overflow-hidden hover:shadow-lg hover:shadow-black/20 hover:border-[var(--color-surface-elevated)]&quot;
              &gt;
                &lt;div className=&quot;relative aspect-video bg-[var(--color-surface-elevated)]&quot;&gt;
                  {course.thumbnailUrl &amp;&amp; (
                    &lt;img
                      src={course.thumbnailUrl}
                      alt={course.title}
                      className=&quot;w-full h-full object-cover&quot;
                    /&gt;
                  )}
                  &lt;span className=&quot;absolute top-3 right-3 px-3 py-1.5 rounded-lg text-xs font-semibold bg-black/70 text-white backdrop-blur-sm&quot;&gt;
                    {formatPrice(course.price)}
                  &lt;/span&gt;
                &lt;/div&gt;
                &lt;div className=&quot;p-5&quot;&gt;
                  &lt;h3 className=&quot;font-semibold text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-[var(--color-accent)]&quot;&gt;
                    {course.title}
                  &lt;/h3&gt;
                  &lt;p className=&quot;text-sm text-[var(--color-text-secondary)] mb-3&quot;&gt;
                    {course.creator.user.name || &quot;Instructor&quot;}
                  &lt;/p&gt;
                  &lt;div className=&quot;flex items-center gap-3 text-xs text-[var(--color-text-secondary)]&quot;&gt;
                    {avgRating &gt; 0 &amp;&amp; (
                      &lt;span className=&quot;flex items-center gap-1&quot;&gt;
                        &lt;Star className=&quot;w-3.5 h-3.5 fill-[var(--color-warning)] text-[var(--color-warning)]&quot; /&gt;
                        {avgRating.toFixed(1)} ({course.reviews.length})
                      &lt;/span&gt;
                    )}
                    &lt;span className=&quot;flex items-center gap-1&quot;&gt;
                      &lt;Users className=&quot;w-3.5 h-3.5&quot; /&gt;
                      {course._count.enrollments}
                    &lt;/span&gt;
                    &lt;span&gt;{lessonCount} lessons&lt;/span&gt;
                  &lt;/div&gt;
                &lt;/div&gt;
              &lt;/Link&gt;
            );
          })}
        &lt;/div&gt;
      )}

      {totalPages &gt; 1 &amp;&amp; (
        &lt;div className=&quot;flex justify-center gap-2 mt-12&quot;&gt;
          {Array.from({ length: totalPages }, (_, i) =&gt; i + 1).map((p) =&gt; (
            &lt;Link
              key={p}
              href={`/courses?${new URLSearchParams({
                ...(q &amp;&amp; { q }),
                ...(category &amp;&amp; { category }),
                page: String(p),
              })}`}
              className={`px-4 py-2 rounded-lg text-sm font-medium ${
                p === page
                  ? &quot;bg-[var(--color-accent)] text-white&quot;
                  : &quot;bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]&quot;
              }`}
            &gt;
              {p}
            &lt;/Link&gt;
          ))}
        &lt;/div&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### Course details page

Now let's build the course details page where students can see a detailed information view of specific courses. Go to `src/app/courses/[slug]/` and create a file called `page.tsx` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { notFound } from &quot;next/navigation&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { formatPrice, formatDuration } from &quot;@/lib/utils&quot;;
import { Star, Clock, Play, Lock, Users, BookOpen } from &quot;lucide-react&quot;;
import type { Metadata } from &quot;next&quot;;

export async function generateMetadata({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;;
}): Promise&lt;Metadata&gt; {
  const { slug } = await params;
  const course = await prisma.course.findUnique({ where: { slug } });
  if (!course) return { title: &quot;Course Not Found&quot; };
  return {
    title: `${course.title} | Courstar`,
    description: course.description.slice(0, 160),
    openGraph: {
      title: course.title,
      description: course.description.slice(0, 160),
      images: course.thumbnailUrl ? [course.thumbnailUrl] : [],
    },
  };
}</code></pre>
  </div>
</div>

In the same file, the page component fetches the course and checks enrollment status:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export default async function CourseDetailPage({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;;
}) {
  const { slug } = await params;
  const course = await prisma.course.findUnique({
    where: { slug },
    include: {
      creator: { include: { user: true } },
      sections: {
        orderBy: { order: &quot;asc&quot; },
        include: { lessons: { orderBy: { order: &quot;asc&quot; } } },
      },
      reviews: { include: { user: true }, orderBy: { createdAt: &quot;desc&quot; }, take: 10 },
      _count: { select: { enrollments: true } },
    },
  });
  if (!course || course.status !== &quot;PUBLISHED&quot;) notFound();

  const user = await requireAuth({ redirect: false });
  let isEnrolled = false;
  if (user) {
    const enrollment = await prisma.enrollment.findUnique({
      where: { userId_courseId: { userId: user.id, courseId: course.id } },
    });
    isEnrolled = !!enrollment;
  }

  const avgRating = course.reviews.length &gt; 0
    ? course.reviews.reduce((sum, r) =&gt; sum + r.rating, 0) / course.reviews.length : 0;
  const totalLessons = course.sections.reduce((sum, s) =&gt; sum + s.lessons.length, 0);
  const totalDuration = course.sections.reduce(
    (sum, s) =&gt; sum + s.lessons.reduce((ls, l) =&gt; ls + (l.duration || 0), 0), 0);
  // ... render (described below)
}</code></pre>
  </div>
</div>

Still in the same file, the enrollment card adapts to three states:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">{isEnrolled ? (
  &lt;Link href={`/courses/${course.slug}/learn`}
    className=&quot;block w-full text-center py-3 rounded-lg bg-[var(--color-success)] text-white font-semibold hover:opacity-90 transition-opacity&quot;&gt;
    Start Learning
  &lt;/Link&gt;
) : user ? (
  course.price &gt; 0 &amp;&amp; course.whopCheckoutUrl ? (
    &lt;a href={course.whopCheckoutUrl}
      className=&quot;block w-full text-center py-3.5 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)] transition-colors&quot;&gt;
      Enroll Now
    &lt;/a&gt;
  ) : (
    &lt;form action={`/api/courses/${course.id}/enroll`} method=&quot;POST&quot;&gt;
      &lt;button type=&quot;submit&quot; className=&quot;w-full py-3.5 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)] transition-colors&quot;&gt;
        Enroll for Free
      &lt;/button&gt;
    &lt;/form&gt;
  )
) : (
  &lt;Link href=&quot;/sign-in&quot;
    className=&quot;block w-full text-center py-3.5 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)] transition-colors&quot;&gt;
    Sign in to Enroll
  &lt;/Link&gt;
)}</code></pre>
  </div>
</div>

### Free enrollment route

Free courses skip the checkout entirely so we need to verify the course is free and the user is not already enrolled to it. Go to `src/app/api/courses/[courseId]/enroll/` and create a file called `route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;
import { headers } from &quot;next/headers&quot;;

export async function POST(
  _request: Request,
  { params }: { params: Promise&lt;{ courseId: string }&gt; }
) {
  const { courseId } = await params;
  const headersList = await headers();
  const ip = headersList.get(&quot;x-forwarded-for&quot;)?.split(&quot;,&quot;)[0]?.trim() ?? &quot;unknown&quot;;
  const limited = rateLimit(`enroll:${ip}`, { interval: 60_000, maxRequests: 10 });
  if (limited) return limited;

  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const course = await prisma.course.findUnique({ where: { id: courseId } });
  if (!course || course.status !== &quot;PUBLISHED&quot;)
    return NextResponse.json({ error: &quot;Course not found&quot; }, { status: 404 });
  if (course.price &gt; 0)
    return NextResponse.json({ error: &quot;This course requires payment&quot; }, { status: 400 });

  const existing = await prisma.enrollment.findUnique({
    where: { userId_courseId: { userId: user.id, courseId } },
  });
  if (existing) return NextResponse.json({ error: &quot;Already enrolled&quot; }, { status: 400 });

  await prisma.enrollment.create({ data: { userId: user.id, courseId } });
  return NextResponse.json({ success: true });
}</code></pre>
  </div>
</div>

### Whop payments webhook

When a student completes checkout, Whop fires a `payment.succeeded` event. We verify the signature, check idempotency, look up the course and user, and create the enrollment.

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

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;

export async function POST(request: NextRequest) {
  const bodyText = await request.text();
  const headerObj = Object.fromEntries(request.headers);
  const whop = getWhop();

  type WhopEvent = { type: string; id: string; data: Record&lt;string, unknown&gt; };

  let webhookData: WhopEvent;
  try {
    webhookData = whop.webhooks.unwrap(bodyText, {
      headers: headerObj,
    }) as unknown as WhopEvent;
  } catch {
    return NextResponse.json({ error: &quot;Invalid signature&quot; }, { status: 401 });
  }

  const existing = await prisma.webhookEvent.findUnique({ where: { id: webhookData.id } });
  if (existing) return NextResponse.json({ received: true });

  await prisma.webhookEvent.create({ data: { id: webhookData.id, source: &quot;whop&quot; } });

  if (webhookData.type === &quot;payment.succeeded&quot;) {
    const payment = webhookData.data as {
      id: string;
      plan?: { id: string };
      user?: { id: string; email?: string };
      metadata?: Record&lt;string, string&gt;;
    };

    let course = payment.plan?.id
      ? await prisma.course.findFirst({ where: { whopPlanId: payment.plan.id } })
      : null;

    if (!course &amp;&amp; payment.metadata?.courstar_course_id) {
      course = await prisma.course.findUnique({
        where: { id: payment.metadata.courstar_course_id },
      });
    }

    if (course) {
      const whopUserId = payment.user?.id;
      let user = whopUserId
        ? await prisma.user.findFirst({ where: { whopUserId } })
        : null;

      if (!user &amp;&amp; payment.user?.email) {
        user = await prisma.user.findFirst({ where: { email: payment.user.email } });
      }

      if (user) {
        await prisma.enrollment.upsert({
          where: { userId_courseId: { userId: user.id, courseId: course.id } },
          update: { whopPaymentId: payment.id },
          create: { userId: user.id, courseId: course.id, whopPaymentId: payment.id },
        });
      }
    }
  }

  return NextResponse.json({ received: true });
}</code></pre>
  </div>
</div>

### Reviews

Enrolled students in the project can leave reviews for courses (1-5 stars). To build this, go to `src/app/api/courses/[courseId]/review/` and create a file called `route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { z } from &quot;zod&quot;;
import { MAX_REVIEW_COMMENT } from &quot;@/lib/constants&quot;;

const reviewSchema = z.object({
  rating: z.number().int().min(1).max(5),
  comment: z.string().max(MAX_REVIEW_COMMENT).optional().or(z.literal(&quot;&quot;)),
});

export async function POST(
  request: Request,
  { params }: { params: Promise&lt;{ courseId: string }&gt; }
) {
  const { courseId } = await params;
  const user = await requireAuth({ redirect: false });
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const enrollment = await prisma.enrollment.findUnique({
    where: { userId_courseId: { userId: user.id, courseId } },
  });
  if (!enrollment)
    return NextResponse.json({ error: &quot;Must be enrolled to review&quot; }, { status: 403 });

  const body = await request.json();
  const parsed = reviewSchema.safeParse(body);
  if (!parsed.success)
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });

  const review = await prisma.review.upsert({
    where: { userId_courseId: { userId: user.id, courseId } },
    update: { rating: parsed.data.rating, comment: parsed.data.comment || null },
    create: { userId: user.id, courseId, rating: parsed.data.rating, comment: parsed.data.comment || null },
  });

  return NextResponse.json({ review });
}</code></pre>
  </div>
</div>

## Part 5: Course player and progress tracking

In this time, we're going to build the learning experience for students, including a video player, curriculum, and per-lesson progress tracking.

### Signed playback setup

Right now, anyone with a Mux playback ID can watch a video without paying. Signed playback tokens fixes this issue for us. Go to the Mux dashboard > Settings > Signing Keys and create a new key. Mux gives us a key ID and a base64-encoded private key.

Then, add them both to Vercel under `MUX_SIGNING_KEY_ID` and `MUX_SIGNING_PRIVATE_KEY`. Then pull the updated environment variables locally:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">vercel env pull .env.local</code></pre>
  </div>
</div>

Now, we update the Mux client to include signing credentials. Open `src/lib/mux.ts` and replace its contents:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">mux.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Mux from &quot;@mux/mux-node&quot;;

let _mux: Mux | null = null;

export function getMux(): Mux {
  if (!_mux) {
    _mux = new Mux({
      tokenId: process.env.MUX_TOKEN_ID!,
      tokenSecret: process.env.MUX_TOKEN_SECRET!,
      jwtSigningKey: process.env.MUX_SIGNING_KEY_ID,
      jwtPrivateKey: process.env.MUX_SIGNING_PRIVATE_KEY,
    });
  }
  return _mux;
}

export async function signPlaybackId(playbackId: string): Promise&lt;string&gt; {
  const mux = getMux();
  return mux.jwt.signPlaybackId(playbackId, { expiration: &quot;4h&quot; });
}</code></pre>
  </div>
</div>

### Playback token route

The video player needs a signed token before it can start playing the video for the user. Free lessons get their tokens instantly but paid lessons require authentication and enrollment. Go to `src/app/api/playback/[playbackId]/` and create a file called `route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { signPlaybackId } from &quot;@/lib/mux&quot;;
import { rateLimit } from &quot;@/lib/rate-limit&quot;;
import { headers } from &quot;next/headers&quot;;

export async function GET(
  _request: Request,
  { params }: { params: Promise&lt;{ playbackId: string }&gt; }
) {
  const { playbackId } = await params;

  const headersList = await headers();
  const ip = headersList.get(&quot;x-forwarded-for&quot;)?.split(&quot;,&quot;)[0]?.trim() ?? &quot;unknown&quot;;
  const limited = rateLimit(`playback:${ip}`, { interval: 60_000, maxRequests: 30 });
  if (limited) return limited;

  const lesson = await prisma.lesson.findFirst({
    where: { muxPlaybackId: playbackId },
    include: { section: { include: { course: true } } },
  });

  if (!lesson) {
    return NextResponse.json({ error: &quot;Not found&quot; }, { status: 404 });
  }

  if (lesson.isFree) {
    const token = await signPlaybackId(playbackId);
    return NextResponse.json({ token });
  }

  const user = await requireAuth({ redirect: false });
  if (!user)
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const enrollment = await prisma.enrollment.findUnique({
    where: {
      userId_courseId: {
        userId: user.id,
        courseId: lesson.section.course.id,
      },
    },
  });

  if (!enrollment) {
    return NextResponse.json({ error: &quot;Not enrolled&quot; }, { status: 403 });
  }

  const token = await signPlaybackId(playbackId);
  return NextResponse.json({ token });
}</code></pre>
  </div>
</div>

### The video player component

The video player component retrieves a single token from our video player API, displays a loading spinner whilst the video is loading, and then renders the Mux player. When the video has finished, it automatically marks the lesson as completed.
Go to `src/components/` and create a file called `video-player.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">video-player.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&quot;use client&quot;;

import { useEffect, useState, useCallback } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import MuxPlayer from &quot;@mux/mux-player-react&quot;;

export function VideoPlayer({
  playbackId,
  lessonId,
  isEnrolled,
}: {
  playbackId: string;
  lessonId?: string;
  isEnrolled?: boolean;
}) {
  const router = useRouter();
  const [token, setToken] = useState&lt;string | null&gt;(null);

  useEffect(() =&gt; {
    fetch(`/api/playback/${playbackId}`)
      .then((res) =&gt; res.json())
      .then((data) =&gt; {
        if (data.token) setToken(data.token);
      })
      .catch(console.error);
  }, [playbackId]);

  const handleEnded = useCallback(async () =&gt; {
    if (!lessonId || !isEnrolled) return;
    try {
      await fetch(`/api/lessons/${lessonId}/progress`, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ completed: true }),
      });
      router.refresh();
    } catch {
    }
  }, [lessonId, isEnrolled, router]);

  if (!token) {
    return (
      &lt;div className=&quot;w-full aspect-video bg-black flex items-center justify-center&quot;&gt;
        &lt;div className=&quot;w-8 h-8 border-2 border-[var(--color-accent)] border-t-transparent rounded-full animate-spin&quot; /&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;MuxPlayer
      playbackId={playbackId}
      tokens={{ playback: token }}
      accentColor=&quot;#14B8A6&quot;
      className=&quot;w-full aspect-video&quot;
      onEnded={handleEnded}
    /&gt;
  );
}</code></pre>
  </div>
</div>

### The course player page

Now let's build the course player with a video player on the left, curriculum sidebar on the right, progress bar at the top, and previous/next navigation below the video. Go to `src/app/courses/[slug]/learn/[lessonId]/` and create a file called `page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { redirect, notFound } from &quot;next/navigation&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { formatDuration } from &quot;@/lib/utils&quot;;
import { CheckCircle, Circle, ChevronLeft, ChevronRight } from &quot;lucide-react&quot;;
import { VideoPlayer } from &quot;@/components/video-player&quot;;
import { MarkCompleteButton } from &quot;@/components/mark-complete-button&quot;;

export default async function LessonPage({
  params,
}: {
  params: Promise&lt;{ slug: string; lessonId: string }&gt;;
}) {
  const { slug, lessonId } = await params;

  const course = await prisma.course.findUnique({
    where: { slug },
    include: {
      sections: {
        orderBy: { order: &quot;asc&quot; },
        include: { lessons: { orderBy: { order: &quot;asc&quot; } } },
      },
    },
  });
  if (!course) notFound();

  const currentLesson = course.sections
    .flatMap((s) =&gt; s.lessons)
    .find((l) =&gt; l.id === lessonId);
  if (!currentLesson) notFound();

  const user = await requireAuth({ redirect: false });
  let isEnrolled = false;
  let completedLessonIds = new Set&lt;string&gt;();

  if (user) {
    const enrollment = await prisma.enrollment.findUnique({
      where: { userId_courseId: { userId: user.id, courseId: course.id } },
    });
    isEnrolled = !!enrollment;

    if (isEnrolled) {
      const progress = await prisma.progress.findMany({
        where: { userId: user.id, completed: true, lesson: { section: { courseId: course.id } } },
        select: { lessonId: true },
      });
      completedLessonIds = new Set(progress.map((p) =&gt; p.lessonId));
    }
  }

  if (!currentLesson.isFree &amp;&amp; !isEnrolled) {
    redirect(`/courses/${slug}`);
  }

  const allLessons = course.sections.flatMap((s) =&gt; s.lessons);
  const currentIndex = allLessons.findIndex((l) =&gt; l.id === lessonId);
  const prevLesson = currentIndex &gt; 0 ? allLessons[currentIndex - 1] : null;
  const nextLesson = currentIndex &lt; allLessons.length - 1 ? allLessons[currentIndex + 1] : null;

  const totalLessons = allLessons.length;
  const completedCount = allLessons.filter((l) =&gt; completedLessonIds.has(l.id)).length;
  const progressPercent = totalLessons &gt; 0 ? Math.round((completedCount / totalLessons) * 100) : 0;

  return (
    &lt;div className=&quot;h-full flex flex-col lg:flex-row&quot;&gt;
      &lt;div className=&quot;flex-1 flex flex-col min-w-0&quot;&gt;
        &lt;div className=&quot;bg-black aspect-video w-full&quot;&gt;
          {currentLesson.muxPlaybackId &amp;&amp; currentLesson.videoReady ? (
            &lt;VideoPlayer
              playbackId={currentLesson.muxPlaybackId}
              lessonId={currentLesson.id}
              isEnrolled={isEnrolled}
            /&gt;
          ) : (
            &lt;div className=&quot;w-full h-full flex items-center justify-center text-[var(--color-text-secondary)]&quot;&gt;
              Video not available
            &lt;/div&gt;
          )}
        &lt;/div&gt;

        &lt;div className=&quot;p-6&quot;&gt;
          &lt;h1 className=&quot;text-xl font-semibold mb-4&quot;&gt;{currentLesson.title}&lt;/h1&gt;
          &lt;div className=&quot;flex items-center gap-3&quot;&gt;
            {prevLesson ? (
              &lt;Link
                href={`/courses/${slug}/learn/${prevLesson.id}`}
                className=&quot;flex items-center gap-1 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors&quot;
              &gt;
                &lt;ChevronLeft className=&quot;w-4 h-4&quot; /&gt; Previous
              &lt;/Link&gt;
            ) : (
              &lt;span /&gt;
            )}
            {isEnrolled &amp;&amp; (
              &lt;MarkCompleteButton
                lessonId={currentLesson.id}
                isCompleted={completedLessonIds.has(currentLesson.id)}
              /&gt;
            )}
            {nextLesson ? (
              &lt;Link
                href={`/courses/${slug}/learn/${nextLesson.id}`}
                className=&quot;flex items-center gap-1 text-sm text-[var(--color-accent)] hover:text-[var(--color-accent-hover)] transition-colors ml-auto&quot;
              &gt;
                Next &lt;ChevronRight className=&quot;w-4 h-4&quot; /&gt;
              &lt;/Link&gt;
            ) : (
              &lt;span className=&quot;ml-auto text-sm text-[var(--color-success)]&quot;&gt;Last lesson&lt;/span&gt;
            )}
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;aside className=&quot;w-full lg:w-80 border-l border-[var(--color-border)] bg-[var(--color-surface)] overflow-y-auto&quot;&gt;
        &lt;div className=&quot;p-5 border-b border-[var(--color-border)]&quot;&gt;
          &lt;Link href={`/courses/${slug}`} className=&quot;text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]&quot;&gt;
            &amp;larr; {course.title}
          &lt;/Link&gt;
          {isEnrolled &amp;&amp; (
            &lt;div className=&quot;mt-3&quot;&gt;
              &lt;div className=&quot;flex justify-between text-xs text-[var(--color-text-secondary)] mb-1&quot;&gt;
                &lt;span&gt;Progress&lt;/span&gt;
                &lt;span&gt;{progressPercent}%&lt;/span&gt;
              &lt;/div&gt;
              &lt;div className=&quot;h-1.5 bg-[var(--color-border)] rounded-full overflow-hidden&quot;&gt;
                &lt;div
                  className=&quot;h-full bg-[var(--color-success)] rounded-full transition-all&quot;
                  style={{ width: `${progressPercent}%` }}
                /&gt;
              &lt;/div&gt;
            &lt;/div&gt;
          )}
        &lt;/div&gt;

        {course.sections.map((section) =&gt; (
          &lt;div key={section.id}&gt;
            &lt;div className=&quot;px-4 py-2.5 text-xs font-semibold text-[var(--color-text-secondary)] uppercase tracking-wide bg-[var(--color-surface-elevated)]&quot;&gt;
              {section.title}
            &lt;/div&gt;
            {section.lessons.map((lesson) =&gt; (
              &lt;Link
                key={lesson.id}
                href={`/courses/${slug}/learn/${lesson.id}`}
                className={`flex items-center gap-2 px-4 py-2.5 text-sm border-l-2 transition-colors ${
                  lesson.id === lessonId
                    ? &quot;border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-text-primary)]&quot;
                    : &quot;border-transparent hover:bg-[var(--color-surface-elevated)]&quot;
                }`}
              &gt;
                {completedLessonIds.has(lesson.id) ? (
                  &lt;CheckCircle className=&quot;w-4 h-4 text-[var(--color-success)] flex-shrink-0&quot; /&gt;
                ) : (
                  &lt;Circle className=&quot;w-4 h-4 text-[var(--color-text-secondary)] flex-shrink-0&quot; /&gt;
                )}
                &lt;span className=&quot;flex-1 truncate&quot;&gt;{lesson.title}&lt;/span&gt;
                {lesson.duration &amp;&amp; (
                  &lt;span className=&quot;text-xs text-[var(--color-text-secondary)]&quot;&gt;{formatDuration(lesson.duration)}&lt;/span&gt;
                )}
              &lt;/Link&gt;
            ))}
          &lt;/div&gt;
        ))}
      &lt;/aside&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### Progress tracking

We progress the course completion per-lesson so students can easily see where they left off. Go to `src/app/api/lessons/[lessonId]/progress/` and create a file called `route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;

export async function POST(
  _request: Request,
  { params }: { params: Promise&lt;{ lessonId: string }&gt; }
) {
  const { lessonId } = await params;

  const user = await requireAuth({ redirect: false });
  if (!user)
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const lesson = await prisma.lesson.findUnique({
    where: { id: lessonId },
    include: { section: { include: { course: true } } },
  });

  if (!lesson)
    return NextResponse.json({ error: &quot;Lesson not found&quot; }, { status: 404 });

  const enrollment = await prisma.enrollment.findUnique({
    where: {
      userId_courseId: {
        userId: user.id,
        courseId: lesson.section.course.id,
      },
    },
  });

  if (!enrollment) {
    return NextResponse.json({ error: &quot;Not enrolled&quot; }, { status: 403 });
  }

  const progress = await prisma.progress.upsert({
    where: { userId_lessonId: { userId: user.id, lessonId } },
    update: { completed: true, completedAt: new Date() },
    create: {
      userId: user.id,
      lessonId,
      completed: true,
      completedAt: new Date(),
    },
  });

  return NextResponse.json({ progress });
}</code></pre>
  </div>
</div>

### The mark complete button

We should also create a button that allows students to manually mark lessons as completed. Go to `src/components/` and create a file called `mark-complete-button.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">mark-complete-button.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { CheckCircle, Circle } from &quot;lucide-react&quot;;

export function MarkCompleteButton({
  lessonId,
  isCompleted,
}: {
  lessonId: string;
  isCompleted: boolean;
}) {
  const [completed, setCompleted] = useState(isCompleted);
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  async function handleClick() {
    if (completed || loading) return;
    setLoading(true);
    try {
      const res = await fetch(`/api/lessons/${lessonId}/progress`, {
        method: &quot;POST&quot;,
      });
      if (res.ok) {
        setCompleted(true);
        router.refresh();
      }
    } catch {
    } finally {
      setLoading(false);
    }
  }

  if (completed) {
    return (
      &lt;span className=&quot;flex items-center gap-1.5 text-sm text-[var(--color-success)]&quot;&gt;
        &lt;CheckCircle className=&quot;w-4 h-4&quot; /&gt; Completed
      &lt;/span&gt;
    );
  }

  return (
    &lt;button
      onClick={handleClick}
      disabled={loading}
      className=&quot;flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-[var(--color-border)] hover:border-[var(--color-accent)] transition-colors disabled:opacity-50&quot;
    &gt;
      &lt;Circle className=&quot;w-4 h-4&quot; /&gt;
      {loading ? &quot;Saving...&quot; : &quot;Mark as Complete&quot;}
    &lt;/button&gt;
  );
}</code></pre>
  </div>
</div>

### The learn redirect pages

When students click the "Start Learning" button, they will be taken to the `/courses/[slug]/learn` path without a lesson ID. This page identifies the user’s first uncompleted lesson and redirects them to it. Go to `src/app/courses/[slug]/learn/` and create a file called `page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect, notFound } from &quot;next/navigation&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;

export default async function LearnRedirectPage({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;;
}) {
  const { slug } = await params;
  const user = await requireAuth();
  if (!user) redirect(&quot;/sign-in&quot;);

  const course = await prisma.course.findUnique({
    where: { slug },
    include: {
      sections: {
        orderBy: { order: &quot;asc&quot; },
        include: { lessons: { orderBy: { order: &quot;asc&quot; } } },
      },
    },
  });

  if (!course) notFound();

  const enrollment = await prisma.enrollment.findUnique({
    where: { userId_courseId: { userId: user.id, courseId: course.id } },
  });
  if (!enrollment) redirect(`/courses/${slug}`);

  const completedLessonIds = new Set(
    (
      await prisma.progress.findMany({
        where: { userId: user.id, completed: true },
        select: { lessonId: true },
      })
    ).map((p) =&gt; p.lessonId)
  );

  const allLessons = course.sections.flatMap((s) =&gt; s.lessons);
  const firstIncomplete = allLessons.find((l) =&gt; !completedLessonIds.has(l.id));
  const target = firstIncomplete || allLessons[0];

  if (!target) redirect(`/courses/${slug}`);
  redirect(`/courses/${slug}/learn/${target.id}`);
}</code></pre>
  </div>
</div>

### Checkpoint

- Enroll in a course and click "Start Learning" on the course detail page. The page redirects to the first lesson, and the video player loads with Mux's signed playback.
- The video plays through the Mux Player with teal-tinted controls.
- Click "Mark as Complete." The button swaps to a green "Completed" label, a checkmark appears next to the lesson in the sidebar, and the progress bar updates.
- Click "Next" below the video. The player navigates to the next lesson, crossing section boundaries if needed.
- Complete all lessons in a course. The progress bar shows 100%.
- Visit `/courses/[slug]/learn` (no lesson ID). The page redirects to the first incomplete lesson, or the first lesson if all are complete.
- Open a paid lesson URL while not enrolled. The page redirects to the course detail page.
- Open a free preview lesson without signing in. The video plays normally, with no "Mark as Complete" button visible.

In Part 6, we add reviews, build out the full dashboards for instructors and students, design the landing page, and ship to production.

## Part 6: Reviews, dashboards, and production deploy

In this final part, we're going to implement a review system for courses, build fully functioning dashboards for users and creators, create a landing page, and deploy our project to production.

### Review system

We built the review system API in Part 4, but the reivews aren't on course pages yet. Open `src/app/courses/[slug]/page.tsx` and add a review section below the curriculum. Each review renders as a card with the student's name and a star row:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&lt;div className=&quot;flex&quot;&gt;
  {Array.from({ length: 5 }).map((_, i) =&gt; (
    &lt;Star
      key={i}
      className={`w-3.5 h-3.5 ${
        i &lt; review.rating
          ? &quot;fill-[var(--color-warning)] text-[var(--color-warning)]&quot;
          : &quot;text-[var(--color-border)]&quot;
      }`}
    /&gt;
  ))}
&lt;/div&gt;</code></pre>
  </div>
</div>

### Instructor dashboard

The instructor dashboard is one of the most important parts of our project. It shows the instructor's courses, total earnings, and student count. Open `src/app/teach/dashboard/page.tsx` and replace the placeholder with the full implementation:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { requireAuth, getCreatorProfile } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { formatPrice } from &quot;@/lib/utils&quot;;
import { PLATFORM_FEE_PERCENT } from &quot;@/lib/constants&quot;;
import { Plus, BookOpen, Users, DollarSign } from &quot;lucide-react&quot;;
import { DeleteCourseButton } from &quot;@/components/delete-course-button&quot;;

export default async function TeachDashboardPage() {
  const user = await requireAuth();
  if (!user) redirect(&quot;/sign-in&quot;);

  const profile = await getCreatorProfile(user.id);
  if (!profile) redirect(&quot;/teach&quot;);
  if (!profile.kycComplete) redirect(&quot;/teach&quot;);

  const courses = await prisma.course.findMany({
    where: { creatorId: profile.id },
    include: {
      _count: { select: { enrollments: true } },
    },
    orderBy: { createdAt: &quot;desc&quot; },
  });

  const totalStudents = courses.reduce((sum, c) =&gt; sum + c._count.enrollments, 0);
  const totalEarnings = courses.reduce(
    (sum, c) =&gt; sum + c.price * c._count.enrollments * ((100 - PLATFORM_FEE_PERCENT) / 100),
    0
  );

  return (
    &lt;div className=&quot;max-w-6xl mx-auto px-8 py-10&quot;&gt;
      &lt;div className=&quot;flex items-center justify-between mb-8&quot;&gt;
        &lt;h1 className=&quot;text-3xl font-bold tracking-tight&quot;&gt;Instructor Dashboard&lt;/h1&gt;
          &lt;Link
            href=&quot;/teach/courses/new&quot;
            className=&quot;flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)] transition-colors&quot;
          &gt;
            &lt;Plus className=&quot;w-4 h-4&quot; /&gt; New Course
          &lt;/Link&gt;
        &lt;/div&gt;

        &lt;div className=&quot;grid grid-cols-1 md:grid-cols-3 gap-8 mb-12&quot;&gt;
          {[
            { icon: DollarSign, label: &quot;Total Earnings&quot;, value: formatPrice(Math.round(totalEarnings)) },
            { icon: Users, label: &quot;Total Students&quot;, value: String(totalStudents) },
            { icon: BookOpen, label: &quot;Courses&quot;, value: String(courses.length) },
          ].map((stat) =&gt; (
            &lt;div key={stat.label} className=&quot;p-7 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)]&quot;&gt;
              &lt;stat.icon className=&quot;w-5 h-5 text-[var(--color-accent)] mb-2&quot; /&gt;
              &lt;p className=&quot;text-sm text-[var(--color-text-secondary)]&quot;&gt;{stat.label}&lt;/p&gt;
              &lt;p className=&quot;text-2xl font-bold&quot;&gt;{stat.value}&lt;/p&gt;
            &lt;/div&gt;
          ))}
        &lt;/div&gt;

        &lt;h2 className=&quot;text-xl font-semibold mb-4&quot;&gt;Your Courses&lt;/h2&gt;
        {courses.length === 0 ? (
          &lt;p className=&quot;text-[var(--color-text-secondary)]&quot;&gt;No courses yet. Create your first one!&lt;/p&gt;
        ) : (
          &lt;div className=&quot;space-y-3&quot;&gt;
            {courses.map((course) =&gt; (
              &lt;div key={course.id} className=&quot;flex items-center justify-between p-4 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)]&quot;&gt;
                &lt;div&gt;
                  &lt;h3 className=&quot;font-semibold&quot;&gt;{course.title}&lt;/h3&gt;
                  &lt;div className=&quot;flex items-center gap-3 mt-1 text-sm text-[var(--color-text-secondary)]&quot;&gt;
                    &lt;span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
                      course.status === &quot;PUBLISHED&quot;
                        ? &quot;bg-[var(--color-success)]/15 text-[var(--color-success)]&quot;
                        : &quot;bg-[var(--color-warning)]/15 text-[var(--color-warning)]&quot;
                    }`}&gt;
                      {course.status}
                    &lt;/span&gt;
                    &lt;span&gt;{course._count.enrollments} students&lt;/span&gt;
                    &lt;span&gt;{formatPrice(course.price)}&lt;/span&gt;
                  &lt;/div&gt;
                &lt;/div&gt;
                &lt;div className=&quot;flex items-center gap-2&quot;&gt;
                  &lt;Link
                    href={`/teach/courses/${course.id}/edit`}
                    className=&quot;text-sm px-5 py-2.5 rounded-lg border border-[var(--color-border)] hover:bg-[var(--color-surface-elevated)] font-medium&quot;
                  &gt;
                    Edit
                  &lt;/Link&gt;
                  &lt;DeleteCourseButton courseId={course.id} courseTitle={course.title} /&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            ))}
          &lt;/div&gt;
        )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

The earnings calculation estimates net revenue after the platform's 20% cut. For payout management (bank accounts, tax documents), instructors use Whop's hosted portal via `accountLinks.create` with the `payouts_portal` use case, so we don't build any compliance UI.

### Student dashboard

Now, let's build the student dashboard that shows enrolled courses with progress bars. Open `src/app/dashboard/page.tsx` and replace the placeholder:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { BookOpen } from &quot;lucide-react&quot;;

export default async function StudentDashboardPage() {
  const user = await requireAuth();
  if (!user) redirect(&quot;/sign-in&quot;);

  const enrollments = await prisma.enrollment.findMany({
    where: { userId: user.id },
    include: {
      course: {
        include: {
          creator: { include: { user: true } },
          sections: { include: { lessons: true } },
        },
      },
    },
    orderBy: { createdAt: &quot;desc&quot; },
  });

  const completedLessonIds = new Set(
    (
      await prisma.progress.findMany({
        where: { userId: user.id, completed: true },
        select: { lessonId: true },
      })
    ).map((p) =&gt; p.lessonId)
  );

  const enriched = enrollments.map((e) =&gt; {
    const totalLessons = e.course.sections.reduce(
      (sum, s) =&gt; sum + s.lessons.length, 0
    );
    const completedCount = e.course.sections.reduce(
      (sum, s) =&gt; sum + s.lessons.filter((l) =&gt; completedLessonIds.has(l.id)).length, 0
    );
    const percent = totalLessons &gt; 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
    return { ...e, totalLessons, completedCount, percent };
  });

  return (
    &lt;div className=&quot;max-w-6xl mx-auto px-8 py-10&quot;&gt;
      &lt;h1 className=&quot;text-3xl font-bold tracking-tight mb-10&quot;&gt;My Learning&lt;/h1&gt;

        {enriched.length === 0 ? (
          &lt;div className=&quot;text-center py-16&quot;&gt;
            &lt;BookOpen className=&quot;w-12 h-12 text-[var(--color-text-secondary)] mx-auto mb-4&quot; /&gt;
            &lt;p className=&quot;text-[var(--color-text-secondary)] mb-4&quot;&gt;You haven&amp;apos;t enrolled in any courses yet.&lt;/p&gt;
            &lt;Link href=&quot;/courses&quot; className=&quot;px-8 py-3.5 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)] transition-colors&quot;&gt;
              Browse Courses
            &lt;/Link&gt;
          &lt;/div&gt;
        ) : (
          &lt;div className=&quot;grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6&quot;&gt;
            {enriched.map((e) =&gt; (
              &lt;Link
                key={e.id}
                href={`/courses/${e.course.slug}/learn`}
                className=&quot;group rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] overflow-hidden hover:shadow-lg hover:shadow-black/20 hover:border-[var(--color-surface-elevated)]&quot;
              &gt;
                &lt;div className=&quot;relative aspect-video bg-[var(--color-surface-elevated)]&quot;&gt;
                  {e.course.thumbnailUrl &amp;&amp; (
                    &lt;img src={e.course.thumbnailUrl} alt={e.course.title} className=&quot;w-full h-full object-cover&quot; /&gt;
                  )}
                  &lt;div className=&quot;absolute bottom-0 left-0 right-0 h-1 bg-[var(--color-border)]&quot;&gt;
                    &lt;div className=&quot;h-full bg-[var(--color-success)] transition-all&quot; style={{ width: `${e.percent}%` }} /&gt;
                  &lt;/div&gt;
                &lt;/div&gt;
                &lt;div className=&quot;p-5&quot;&gt;
                  &lt;h3 className=&quot;font-semibold line-clamp-2 group-hover:text-[var(--color-accent)] transition-colors&quot;&gt;{e.course.title}&lt;/h3&gt;
                  &lt;p className=&quot;text-sm text-[var(--color-text-secondary)] mt-1&quot;&gt;{e.course.creator.user.name}&lt;/p&gt;
                  &lt;p className=&quot;text-xs text-[var(--color-text-secondary)] mt-2&quot;&gt;
                    {e.percent === 100 ? (
                      &lt;span className=&quot;text-[var(--color-success)]&quot;&gt;Completed&lt;/span&gt;
                    ) : (
                      `${e.percent}% complete`
                    )}
                  &lt;/p&gt;
                &lt;/div&gt;
              &lt;/Link&gt;
            ))}
          &lt;/div&gt;
        )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### Landing page

The landing page at `/` pulls real statistics from the database like course and student counts, and displays poplar courses, categories, and an instructor CTA.

Next.js creates a placeholder landing page, so let's go to `src/app` and update the `page.tsx` contents with:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { formatPrice } from &quot;@/lib/utils&quot;;
import {
  BookOpen, DollarSign, Users, GraduationCap, Star, ArrowRight,
  Code, Briefcase, Palette, Megaphone, Camera, Music, Heart, Sparkles,
  Database, Brain, Shield, Cloud, Smartphone, Gamepad2, TrendingUp,
  Rocket, ClipboardList, Target, PenTool, Video, Clapperboard,
  Building2, Wrench, FlaskConical, Calculator, Languages, CookingPot,
  Dumbbell, Baby, School,
} from &quot;lucide-react&quot;;

const CATEGORY_META: Record&lt;string, { icon: typeof Code; label: string }&gt; = {
  DEVELOPMENT: { icon: Code, label: &quot;Development&quot; },
  BUSINESS: { icon: Briefcase, label: &quot;Business&quot; },
  DESIGN: { icon: Palette, label: &quot;Design&quot; },
  MARKETING: { icon: Megaphone, label: &quot;Marketing&quot; },
  PHOTOGRAPHY: { icon: Camera, label: &quot;Photography&quot; },
  MUSIC: { icon: Music, label: &quot;Music&quot; },
  HEALTH: { icon: Heart, label: &quot;Health&quot; },
  LIFESTYLE: { icon: Sparkles, label: &quot;Lifestyle&quot; },
  DATA_SCIENCE: { icon: Database, label: &quot;Data Science&quot; },
  ARTIFICIAL_INTELLIGENCE: { icon: Brain, label: &quot;Artificial Intelligence&quot; },
  CYBERSECURITY: { icon: Shield, label: &quot;Cybersecurity&quot; },
  CLOUD_COMPUTING: { icon: Cloud, label: &quot;Cloud Computing&quot; },
  MOBILE_DEVELOPMENT: { icon: Smartphone, label: &quot;Mobile Development&quot; },
  GAME_DEVELOPMENT: { icon: Gamepad2, label: &quot;Game Development&quot; },
  FINANCE: { icon: TrendingUp, label: &quot;Finance&quot; },
  ENTREPRENEURSHIP: { icon: Rocket, label: &quot;Entrepreneurship&quot; },
  PROJECT_MANAGEMENT: { icon: ClipboardList, label: &quot;Project Management&quot; },
  PERSONAL_DEVELOPMENT: { icon: Target, label: &quot;Personal Development&quot; },
  WRITING: { icon: PenTool, label: &quot;Writing&quot; },
  VIDEO_PRODUCTION: { icon: Video, label: &quot;Video Production&quot; },
  ANIMATION: { icon: Clapperboard, label: &quot;Animation&quot; },
  ARCHITECTURE: { icon: Building2, label: &quot;Architecture&quot; },
  ENGINEERING: { icon: Wrench, label: &quot;Engineering&quot; },
  SCIENCE: { icon: FlaskConical, label: &quot;Science&quot; },
  MATHEMATICS: { icon: Calculator, label: &quot;Mathematics&quot; },
  LANGUAGE: { icon: Languages, label: &quot;Language&quot; },
  COOKING: { icon: CookingPot, label: &quot;Cooking&quot; },
  FITNESS: { icon: Dumbbell, label: &quot;Fitness&quot; },
  PARENTING: { icon: Baby, label: &quot;Parenting&quot; },
  TEACHING: { icon: School, label: &quot;Teaching&quot; },
};

export default async function HomePage() {
  const [popularCourses, courseCount, studentCount, instructorCount] = await Promise.all([
    prisma.course.findMany({
      where: { status: &quot;PUBLISHED&quot; },
      include: {
        creator: { include: { user: true } },
        _count: { select: { enrollments: true } },
        reviews: { select: { rating: true } },
        sections: { include: { _count: { select: { lessons: true } } } },
      },
      orderBy: { enrollments: { _count: &quot;desc&quot; } },
      take: 6,
    }),
    prisma.course.count({ where: { status: &quot;PUBLISHED&quot; } }),
    prisma.user.count(),
    prisma.creatorProfile.count({ where: { kycComplete: true } }),
  ]);

  // Get categories with course counts
  const rawCounts = await prisma.course.groupBy({
    by: [&quot;category&quot;],
    where: { status: &quot;PUBLISHED&quot; },
    _count: true,
  });
  const countMap = Object.fromEntries(rawCounts.map(({ category, _count }) =&gt; [category, _count]));
  const allCategories = Object.keys(CATEGORY_META).map((cat) =&gt; ({
    category: cat,
    _count: countMap[cat] || 0,
  }));

  return (
    &lt;div className=&quot;min-h-full bg-[var(--color-background)]&quot;&gt;
      &lt;main&gt;
        {/* Hero */}
        &lt;section className=&quot;max-w-6xl mx-auto px-8 py-24 md:py-32 text-center&quot;&gt;
          &lt;h1 className=&quot;text-5xl md:text-7xl font-extrabold tracking-tight leading-[1.08] mb-8&quot;&gt;
            Learn from the best
            &lt;br /&gt;
            &lt;span className=&quot;text-[var(--color-accent)]&quot;&gt;creators on the internet&lt;/span&gt;
          &lt;/h1&gt;
          &lt;p className=&quot;text-lg md:text-xl text-[var(--color-text-secondary)] max-w-2xl mx-auto mb-12 leading-relaxed&quot;&gt;
            A marketplace where expert instructors share video courses and students pay to learn. The platform handles everything.
          &lt;/p&gt;
          &lt;div className=&quot;flex items-center justify-center gap-5 mb-16&quot;&gt;
            &lt;Link
              href=&quot;/courses&quot;
              className=&quot;px-8 py-3.5 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)]&quot;
            &gt;
              Browse Courses
            &lt;/Link&gt;
            &lt;Link
              href=&quot;/teach&quot;
              className=&quot;px-8 py-3.5 rounded-lg border border-[var(--color-border)] text-[var(--color-text-primary)] font-semibold hover:bg-[var(--color-surface)]&quot;
            &gt;
              Start Teaching
            &lt;/Link&gt;
          &lt;/div&gt;

          {/* Social proof */}
          &lt;div className=&quot;flex items-center justify-center gap-8 md:gap-12 text-sm text-[var(--color-text-secondary)]&quot;&gt;
            &lt;div&gt;
              &lt;p className=&quot;text-2xl font-bold text-[var(--color-text-primary)]&quot;&gt;{courseCount}+&lt;/p&gt;
              &lt;p&gt;Courses&lt;/p&gt;
            &lt;/div&gt;
            &lt;div className=&quot;w-px h-8 bg-[var(--color-border)]&quot; /&gt;
            &lt;div&gt;
              &lt;p className=&quot;text-2xl font-bold text-[var(--color-text-primary)]&quot;&gt;{studentCount}+&lt;/p&gt;
              &lt;p&gt;Students&lt;/p&gt;
            &lt;/div&gt;
            &lt;div className=&quot;w-px h-8 bg-[var(--color-border)]&quot; /&gt;
            &lt;div&gt;
              &lt;p className=&quot;text-2xl font-bold text-[var(--color-text-primary)]&quot;&gt;{instructorCount}+&lt;/p&gt;
              &lt;p&gt;Instructors&lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/section&gt;

        {/* Popular courses */}
        {popularCourses.length &gt; 0 &amp;&amp; (
          &lt;section className=&quot;max-w-6xl mx-auto px-8 py-16&quot;&gt;
            &lt;div className=&quot;flex items-center justify-between mb-8&quot;&gt;
              &lt;h2 className=&quot;text-2xl font-bold tracking-tight&quot;&gt;Popular Courses&lt;/h2&gt;
              &lt;Link
                href=&quot;/courses&quot;
                className=&quot;flex items-center gap-1.5 text-sm text-[var(--color-accent)] hover:text-[var(--color-accent-hover)]&quot;
              &gt;
                View all &lt;ArrowRight className=&quot;w-4 h-4&quot; /&gt;
              &lt;/Link&gt;
            &lt;/div&gt;
            &lt;div className=&quot;grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8&quot;&gt;
              {popularCourses.map((course) =&gt; {
                const avgRating = course.reviews.length &gt; 0
                  ? course.reviews.reduce((s, r) =&gt; s + r.rating, 0) / course.reviews.length
                  : 0;
                const lessonCount = course.sections.reduce((s, sec) =&gt; s + sec._count.lessons, 0);

                return (
                  &lt;Link
                    key={course.id}
                    href={`/courses/${course.slug}`}
                    className=&quot;group rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] overflow-hidden hover:shadow-lg hover:shadow-black/20 hover:border-[var(--color-surface-elevated)]&quot;
                  &gt;
                    &lt;div className=&quot;relative aspect-video bg-[var(--color-surface-elevated)]&quot;&gt;
                      {course.thumbnailUrl &amp;&amp; (
                        &lt;img src={course.thumbnailUrl} alt={course.title} className=&quot;w-full h-full object-cover&quot; /&gt;
                      )}
                      &lt;span className=&quot;absolute top-3 right-3 px-3 py-1.5 rounded-lg text-xs font-semibold bg-black/70 text-white backdrop-blur-sm&quot;&gt;
                        {formatPrice(course.price)}
                      &lt;/span&gt;
                    &lt;/div&gt;
                    &lt;div className=&quot;p-5&quot;&gt;
                      &lt;h3 className=&quot;font-semibold text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-[var(--color-accent)]&quot;&gt;
                        {course.title}
                      &lt;/h3&gt;
                      &lt;p className=&quot;text-sm text-[var(--color-text-secondary)] mb-3&quot;&gt;
                        {course.creator.user.name || &quot;Instructor&quot;}
                      &lt;/p&gt;
                      &lt;div className=&quot;flex items-center gap-3 text-xs text-[var(--color-text-secondary)]&quot;&gt;
                        {avgRating &gt; 0 &amp;&amp; (
                          &lt;span className=&quot;flex items-center gap-1&quot;&gt;
                            &lt;Star className=&quot;w-3.5 h-3.5 fill-[var(--color-warning)] text-[var(--color-warning)]&quot; /&gt;
                            {avgRating.toFixed(1)}
                          &lt;/span&gt;
                        )}
                        &lt;span className=&quot;flex items-center gap-1&quot;&gt;
                          &lt;Users className=&quot;w-3.5 h-3.5&quot; /&gt;
                          {course._count.enrollments}
                        &lt;/span&gt;
                        &lt;span&gt;{lessonCount} lessons&lt;/span&gt;
                      &lt;/div&gt;
                    &lt;/div&gt;
                  &lt;/Link&gt;
                );
              })}
            &lt;/div&gt;
          &lt;/section&gt;
        )}

        {/* Categories */}
        {allCategories.length &gt; 0 &amp;&amp; (
          &lt;section className=&quot;max-w-6xl mx-auto px-8 py-16&quot;&gt;
            &lt;h2 className=&quot;text-2xl font-bold tracking-tight mb-8&quot;&gt;Browse by Category&lt;/h2&gt;
            &lt;div className=&quot;grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4&quot;&gt;
              {allCategories.map(({ category, _count }) =&gt; {
                const meta = CATEGORY_META[category] || { icon: BookOpen, label: category };
                const Icon = meta.icon;
                return (
                  &lt;Link
                    key={category}
                    href={`/courses?category=${category}`}
                    className=&quot;group p-5 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] hover:bg-[var(--color-surface-elevated)] hover:border-[var(--color-accent)]/30&quot;
                  &gt;
                    &lt;div className=&quot;w-10 h-10 rounded-lg bg-[var(--color-accent)]/10 flex items-center justify-center mb-3 group-hover:bg-[var(--color-accent)]/20&quot;&gt;
                      &lt;Icon className=&quot;w-5 h-5 text-[var(--color-accent)]&quot; /&gt;
                    &lt;/div&gt;
                    &lt;p className=&quot;font-medium text-sm&quot;&gt;{meta.label}&lt;/p&gt;
                    &lt;p className=&quot;text-xs text-[var(--color-text-secondary)] mt-1&quot;&gt;{_count} courses&lt;/p&gt;
                  &lt;/Link&gt;
                );
              })}
            &lt;/div&gt;
          &lt;/section&gt;
        )}

        {/* Features */}
        &lt;section className=&quot;max-w-6xl mx-auto px-8 py-16 border-t border-[var(--color-border)]&quot;&gt;
          &lt;div className=&quot;grid grid-cols-2 lg:grid-cols-4 gap-10&quot;&gt;
            {[
              { icon: BookOpen, title: &quot;Expert Courses&quot;, desc: &quot;Structured video lessons from industry professionals&quot; },
              { icon: DollarSign, title: &quot;Fair Revenue&quot;, desc: &quot;Instructors keep 80% of every sale&quot; },
              { icon: Users, title: &quot;Growing Community&quot;, desc: &quot;Join thousands of students and instructors&quot; },
              { icon: GraduationCap, title: &quot;Track Progress&quot;, desc: &quot;Pick up where you left off, every time&quot; },
            ].map((item) =&gt; (
              &lt;div key={item.title} className=&quot;text-center&quot;&gt;
                &lt;item.icon className=&quot;w-6 h-6 text-[var(--color-accent)] mx-auto mb-3&quot; /&gt;
                &lt;h3 className=&quot;font-semibold text-sm mb-1&quot;&gt;{item.title}&lt;/h3&gt;
                &lt;p className=&quot;text-xs text-[var(--color-text-secondary)] leading-relaxed&quot;&gt;{item.desc}&lt;/p&gt;
              &lt;/div&gt;
            ))}
          &lt;/div&gt;
        &lt;/section&gt;

        {/* Instructor CTA */}
        &lt;section className=&quot;max-w-6xl mx-auto px-8 py-16&quot;&gt;
          &lt;div className=&quot;rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] p-10 md:p-16 text-center&quot;&gt;
            &lt;h2 className=&quot;text-3xl md:text-4xl font-bold tracking-tight mb-4&quot;&gt;
              Share your expertise with the world
            &lt;/h2&gt;
            &lt;p className=&quot;text-[var(--color-text-secondary)] max-w-xl mx-auto mb-8 leading-relaxed&quot;&gt;
              Create video courses, set your own price, and earn money from every student enrollment. We handle payments, hosting, and payouts — you focus on teaching.
            &lt;/p&gt;
            &lt;div className=&quot;flex items-center justify-center gap-6 text-sm text-[var(--color-text-secondary)] mb-8&quot;&gt;
              &lt;span&gt;80% revenue share&lt;/span&gt;
              &lt;span className=&quot;w-1 h-1 rounded-full bg-[var(--color-border)]&quot; /&gt;
              &lt;span&gt;No upfront costs&lt;/span&gt;
            &lt;/div&gt;
            &lt;Link
              href=&quot;/teach&quot;
              className=&quot;inline-flex items-center gap-2 px-8 py-3.5 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)]&quot;
            &gt;
              Become an Instructor &lt;ArrowRight className=&quot;w-4 h-4&quot; /&gt;
            &lt;/Link&gt;
          &lt;/div&gt;
        &lt;/section&gt;
      &lt;/main&gt;

      &lt;footer className=&quot;border-t border-[var(--color-border)] mt-8&quot;&gt;
        &lt;div className=&quot;max-w-6xl mx-auto px-8 py-10 text-center text-sm text-[var(--color-text-secondary)]&quot;&gt;
          &amp;copy; {new Date().getFullYear()} Courstar. All rights reserved.
        &lt;/div&gt;
      &lt;/footer&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### Production deploy checklist

#### 1. Switch Whop to production

- Create a new company on `whop.com` (or use an existing one)
- Copy the company ID (starts with `biz_`) for `WHOP_COMPANY_ID`
- Go to the Developer page and create a new API key with the required permissions: create child companies, create products, create plans, create checkout configurations
- Create a new OAuth app and copy its client ID
- Set up the client secret for the OAuth app
- **Remove** the `WHOP_SANDBOX` environment variable entirely (or set it to any value other than `"true"`)

#### 2. Register production webhook URLs

**Whop webhooks:**

- Go to the Developer page on your Whop dashboard
- Create a webhook with endpoint URL: `https://your-domain.com/api/webhooks/whop`
- Enable "Connected account events"
- Select the `payment.succeeded` event type
- Copy the webhook secret (starts with `ws_`) and set it as `WHOP_WEBHOOK_SECRET`

**Mux webhooks:**

- Go to Settings > Webhooks in the Mux dashboard
- Add a new webhook with URL: `https://your-domain.com/api/webhooks/mux`
- Select `video.asset.ready` and `video.upload.asset_created` events
- Copy the signing secret and set it as `MUX_WEBHOOK_SECRET`

#### 3. Update OAuth redirect URIs

On the Whop Developer page, go to your OAuth app's settings and add the production redirect URI: `https://your-domain.com/api/auth/callback`. This must match `NEXT_PUBLIC_APP_URL` exactly.

#### 4. Verify environment variables

Confirm every variable is set in Vercel for the production environment:

- `WHOP_CLIENT_ID`: production OAuth app client ID
- `WHOP_CLIENT_SECRET`: production OAuth app secret
- `WHOP_API_KEY`: production API key
- `WHOP_COMPANY_ID`: production company ID (starts with `biz_`)
- `WHOP_WEBHOOK_SECRET`: production webhook signing secret
- `DATABASE_URL`: Neon pooled connection string
- `DATABASE_URL_UNPOOLED`: Neon direct connection string
- `SESSION_SECRET`: at least 32 characters, generated fresh for production
- `NEXT_PUBLIC_APP_URL`: the production domain (e.g., `https://courstar.com`)
- `MUX_TOKEN_ID`: production Mux token
- `MUX_TOKEN_SECRET`: production Mux secret
- `MUX_WEBHOOK_SECRET`: production Mux webhook signing secret
- `MUX_SIGNING_KEY_ID`: production signing key for playback tokens
- `MUX_SIGNING_PRIVATE_KEY`: production signing private key

> ❗ 

#### 5. Update NEXT_PUBLIC_APP_URL

Set it to the production domain with `https://` and no trailing slash. If this points to localhost, every redirect breaks.

#### 6. Run database migrations

If this is the first production deploy, the tables need to exist. Run the migration against the production database using the unpooled (direct) connection string:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">bash</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">DATABASE_URL=&quot;your-production-unpooled-url&quot; npx prisma migrate deploy</code></pre>
  </div>
</div>

### Checkpoint

Run through the full user journey on the production URL:

- Landing page loads with stats, popular courses, and both CTAs
- Browse and search courses at `/courses`
- View a course detail page with curriculum, reviews, and enrollment card
- Watch a free preview lesson without enrolling
- Purchase a paid course through Whop checkout and confirm enrollment
- Watch a paid lesson, mark it complete, and verify the progress bar updates
- Submit a review on a course we are enrolled in
- Check both dashboards: student (`/dashboard`) and instructor (`/teach/dashboard`)
- Test on mobile: sidebar collapses to hamburger menu, grids stack to single column

### Instructor profiles

Go to `src/app/instructors/[id]/` and create a file called `page.tsx`. This public page shows the instructor's avatar, bio, stats, and published courses. Instructor names on course pages are clickable links to this profile. Add `/instructors` to the middleware whitelist.

### Delete course

The `DeleteCourseButton` in `src/components/delete-course-button.tsx` shows a trash icon with inline confirmation. The DELETE handler verifies ownership, cleans up Mux video assets, then cascade-deletes the course and all related records.

### Unenroll

The `UnenrollButton` in `src/components/unenroll-button.tsx` works the same way: inline confirmation, then DELETE to `/api/enrollments/[enrollmentId]` which removes all progress records and the enrollment.

### Future implementations

- **Subscriptions**: Swap the one-time plan for Whop's recurring plan type. Students pay monthly for access to an instructor's full catalog.
- **Quizzes**: Add a `Question` model linked to lessons. Grade on submit, show results before the next lesson unlocks.
- **Certificates**: Generate a PDF when a course hits 100% completion. Use a library like `@react-pdf/renderer` to template it with the student's name and course title.
- **Discussion forums**: Create a `Comment` model on lessons. Threaded replies let students ask questions at the exact point in the curriculum where they got stuck.
- **Coupons and discounts**: Whop checkout configurations support promo codes. Pass a `discount` field when creating the checkout.
- **Auto-advance**: When a video ends, automatically navigate to the next lesson after a brief countdown. The `nextLesson` variable is already computed in the player page.

## Build your platform with Whop

In this tutorial, we walked through building a fully functioning Udemy clone with Whop handling the payments via [Whop](https://whop.com/network/), Mux handling video, and Neon handling data.

Similar architectures can be used for all kinds of platforms you can build, like a [Substack clone](https://whop.com/blog/build-substack-clone/), [StockX clone](https://whop.com/blog/build-stockx-clone/), or an [AI writing tool](https://whop.com/blog/build-an-ai-writing-tool/).

If you want to learn more about how you can build with Whop, check out our developer documentation and start your own business today.

**[Go to the Whop developer documentation](https://docs.whop.com/)**
