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 Network, video hosting with Mux, user authentication with Whop OAuth, and more.

Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

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.

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 Payments Network
  • 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

Tech stack

  • Next.js 16 (App Router, Turbopack). Server Components, API routes, and Vercel deployment in one framework
  • React 19. Server Components for data fetching, Client Components for interactivity
  • Tailwind CSS v4. CSS-first configuration with @theme blocks, no config file
  • Whop OAuth 2.1 + PKCE. Sign-in and identity for both instructors and students
  • Whop for Platforms. Connected accounts for instructor onboarding, direct charges with application fees for payment splits
  • Neon. Serverless Postgres via the Vercel integration. Auto-populated connection strings
  • Prisma 7. ESM-only ORM with @prisma/adapter-pg for Neon compatibility. Client generated into src/generated/prisma
  • Mux. Direct browser uploads, adaptive streaming, signed playback, and processing webhooks
  • Zod 4. Runtime validation for env vars, API inputs, and form data
  • iron-session 8. Encrypted cookie sessions. No session store, no Redis
  • Vercel. Deployment with vercel.ts for type-safe configuration

Pages

  • / Landing page with featured courses, categories, and instructor CTA
  • /sign-in Whop OAuth entry point
  • /courses Browse catalog with search, category filter, and pagination
  • /courses/[slug] Course detail with curriculum, reviews, and enrollment
  • /courses/[slug]/learn/[lessonId] Video player with curriculum sidebar and progress tracking
  • /teach Instructor onboarding page
  • /teach/dashboard Instructor dashboard with courses, earnings, and management
  • /teach/courses/new Create a new course
  • /teach/courses/[courseId]/edit Course editor with inline section/lesson CRUD and video upload
  • /dashboard Student dashboard with enrolled courses and progress

Payment flow

  1. Instructor clicks "Become an Instructor" and creates a connected account through Whop's hosted KYC flow
  2. Instructor publishes a paid course. The app creates a Whop product and plan with a 20% application fee
  3. Student clicks "Enroll" and pays through Whop's hosted checkout
  4. Whop fires a payment.succeeded webhook. The app creates an Enrollment record
  5. Instructor manages payouts through Whop's dashboard

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:

  • The Whop Payments Network 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)
  • 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:

bash
npx create-next-app@latest courstar --ts --tailwind --eslint --app --src-dir --turbopack --import-alias "@/*"
We call our project Courstar in this tutorial, feel free to give your project a unique name.

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

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

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.

.env.local
NEXT_PUBLIC_APP_URL=https://your-app.vercel.app

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:

  1. Go to sandbox.whop.com, create a whop, and go to its Developer tab
  2. There, find the Apps section and click Create app and go to its OAuth tab
  3. There, copy the WHOP_CLIENT_ID and WHOP_CLIENT_SECRET keys and note them down
  4. Copy the company ID from the dashboard URL (starts with biz_) and note it down
  5. 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
  6. Go to the Permissions tab and enable the permissions below:
    1. oauth:token_exchange
    2. company:manage_checkout
    3. company:basic:read
    4. company:create_child
    5. member:basic:read
    6. member:email:read
    7. payment:basic:read
    8. plan:basic:read
    9. plan:basic:read
    10. checkout_configuration:create
    11. chat:message:create
    12. chat:read
  7. Go back to the Developer page and create an API key and note it down
Make sure NEXT_PUBLIC_APP_URL has no trailing slash. A trailing slash produces a double-slash in the redirect URI (https://example.com//api/auth/callback) which Whop rejects as invalid.

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:

bash
openssl rand -base64 32

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

VariableSourceDescription
DATABASE_URLNeon via VercelPooled connection (PgBouncer), used at runtime
DATABASE_URL_UNPOOLEDNeon via VercelDirect connection, used by Prisma CLI
WHOP_CLIENT_IDWhop app OAuth tabOAuth client identifier
WHOP_CLIENT_SECRETWhop app OAuth tabOAuth client secret
WHOP_API_KEYBusiness Settings > API KeysCompany API key for Whop for Platforms
WHOP_COMPANY_IDDashboard URLStarts with biz_, identifies your platform company
SESSION_SECRETGeneratedAt least 32 characters for iron-session encryption
NEXT_PUBLIC_APP_URLSet manuallyProduction URL on Vercel, http://localhost:3000 locally
WHOP_SANDBOXSet manuallytrue during development

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

bash
vercel link vercel env pull .env.local

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:

.env.local
WHOP_SANDBOX=true

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

.env.local
NEXT_PUBLIC_APP_URL=http://localhost:3000

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 will have a dark theme with teal accents. Let's create our color system using @theme by going into src/app and updating globals.css with the content:

globals.css
@import "tailwindcss";

@custom-variant dark (&: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-success: #34D399;
  --color-warning: #FBBF24;
  --color-error: #F87171;

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

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

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

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:

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

datasource db {
  provider = "postgresql"
}

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

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

prisma.config.ts
import { config } from "dotenv";
config({ path: ".env.local" });

import { defineConfig } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: process.env["DATABASE_URL_UNPOOLED"],
  },
});

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:

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

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 === "development" ? ["query"] : [],
  });

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

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

bash
npx prisma generate
npx prisma db push

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:

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

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("biz_"),
  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<typeof envSchema>;

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];
  },
});

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:

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

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

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

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

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:

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

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("base64")
        : undefined,
      ...(process.env.WHOP_SANDBOX === "true" && {
        baseURL: "https://sandbox-api.whop.com/api/v1",
      }),
    });
  }
  return _whop;
}

const isSandbox = () => process.env.WHOP_SANDBOX === "true";
const whopApiDomain = () =>
  isSandbox() ? "sandbox-api.whop.com" : "api.whop.com";

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: ["openid", "profile", "email"],
  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("SHA-256", data);
  const challenge = base64UrlEncode(new Uint8Array(digest));

  return { verifier, challenge };
}

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

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:

auth.ts
import { redirect } from "next/navigation";
import { getSession } from "./session";
import { prisma } from "./prisma";

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

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

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

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

  return user;
}

export async function isAuthenticated(): Promise<boolean> {
  const session = await getSession();
  return !!session.userId;
}

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:

rate-limit.ts
import { NextResponse } from "next/server";

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

const rateLimitMap = new Map<string, { count: number; lastReset: number }>();

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 > config.interval) {
    rateLimitMap.set(key, { count: 1, lastReset: now });
    return null;
  }

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

  entry.count++;
  return null;
}

if (typeof globalThis !== "undefined") {
  const CLEANUP_INTERVAL = 5 * 60 * 1000;
  setInterval(() => {
    const now = Date.now();
    for (const [key, entry] of rateLimitMap.entries()) {
      if (now - entry.lastReset > 10 * 60 * 1000) {
        rateLimitMap.delete(key);
      }
    }
  }, CLEANUP_INTERVAL).unref?.();
}
This rate limiter is in-memory, so it resets on server restart and does not share state across instances. For production traffic, consider @upstash/ratelimit. For tutorial scope, this is enough.

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:

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

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

export function formatPrice(cents: number): string {
  if (cents === 0) return "Free";
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
  }).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 > 0) return `${h}h ${m}m`;
  if (m > 0) return `${m}m ${s}s`;
  return `${s}s`;
}

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:

slugify.ts
export function slugify(text: string): string {
  return (
    text
      .toLowerCase()
      .trim()
      .replace(/[^\w\s-]/g, "")
      .replace(/[\s_]+/g, "-")
      .replace(/-+/g, "-")
      .slice(0, 80) +
    "-" +
    Math.random().toString(36).slice(2, 8)
  );
}

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:

constants.ts
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;

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:

route.ts
import { NextResponse, NextRequest } from "next/server";
import { WHOP_OAUTH, generatePKCE } from "@/lib/whop";
import { rateLimit } from "@/lib/rate-limit";
import { headers } from "next/headers";

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

  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) =>
    b.toString(16).padStart(2, "0")
  ).join("");

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

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

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

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

  return response;
}

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:

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

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

    const pkceCookie = request.cookies.get("oauth_pkce")?.value;
    if (!pkceCookie) {
      return NextResponse.redirect(
        new URL("/sign-in?error=missing_pkce", 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("/sign-in?error=invalid_pkce", request.url)
      );
    }

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

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

    if (!tokenResponse.ok) {
      console.error("Token exchange failed:", tokenResponse.status);
      return NextResponse.redirect(
        new URL("/sign-in?error=token_exchange", 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("/sign-in?error=userinfo", request.url)
      );
    }

    const userInfo = await userInfoResponse.json();

    const avatarUrl =
      typeof userInfo.picture === "string" &&
      userInfo.picture.startsWith("https://")
        ? userInfo.picture
        : null;
    const name =
      typeof userInfo.name === "string" ? 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("/dashboard", request.url)
    );
    response.cookies.delete("oauth_pkce");
    return response;
  } catch (error) {
    console.error("OAuth callback error:", error);
    return NextResponse.redirect(
      new URL("/sign-in?error=unknown", request.url)
    );
  }
}

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:

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

export async function GET() {
  const session = await getSession();
  session.destroy();
  return NextResponse.redirect(
    new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL!)
  );
}

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:

middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

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

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

  if (publicPaths.some((p) => pathname === p || pathname.startsWith(p + "/"))) {
    return NextResponse.next();
  }

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

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

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

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:

layout.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import {
  Home, Search, BookOpen, LayoutDashboard,
  PlusCircle, GraduationCap, LogIn, LogOut, Menu, X,
} from "lucide-react";

interface NavItem {
  label: string;
  href: string;
  icon: React.ComponentType<{ className?: string }>;
  match?: (pathname: string) => boolean;
}

interface NavSection {
  title: string;
  items: NavItem[];
}

export function Sidebar({
  user,
  isInstructor,
}: {
  user: { id: string; name: string | null; avatarUrl: string | null } | null;
  isInstructor: boolean;
}) {
  const pathname = usePathname();
  const [mobileOpen, setMobileOpen] = useState(false);

  const sections: NavSection[] = [
    {
      title: "Discover",
      items: [
        { label: "Home", href: "/", icon: Home, match: (p) => p === "/" },
        {
          label: "Browse Courses", href: "/courses", icon: Search,
          match: (p) => p === "/courses" || (p.startsWith("/courses/") && !p.includes("/learn/")),
        },
      ],
    },
  ];

  if (user) {
    sections.push({
      title: "Learning",
      items: [
        { label: "My Courses", href: "/dashboard", icon: BookOpen, match: (p) => p === "/dashboard" },
      ],
    });
  }

  if (isInstructor) {
    sections.push({
      title: "Teaching",
      items: [
        { label: "Dashboard", href: "/teach/dashboard", icon: LayoutDashboard, match: (p) => p === "/teach/dashboard" },
        { label: "Create Course", href: "/teach/courses/new", icon: PlusCircle, match: (p) => p === "/teach/courses/new" },
      ],
    });
  } else if (user) {
    sections.push({
      title: "Teaching",
      items: [
        { label: "Become Instructor", href: "/teach", icon: GraduationCap, match: (p) => p.startsWith("/teach") },
      ],
    });
  }

  // Shared nav content rendered in both mobile overlay and desktop sidebar
  const navContent = (/* nav JSX — see companion code for full implementation */);

  return (
    <>
      {/* Mobile top bar */}
      <div className="lg:hidden fixed top-0 left-0 right-0 z-40 h-14 flex items-center px-4 gap-3 bg-[var(--color-surface)] border-b border-[var(--color-border)]">
        <button onClick={() => setMobileOpen(true)} className="p-2 -ml-2 rounded-lg text-[var(--color-text-primary)] hover:bg-[var(--color-surface-elevated)]">
          <Menu className="w-5 h-5" />
        </button>
        <Link href="/" className="text-lg font-bold text-[var(--color-text-primary)]">Courstar</Link>
      </div>

      {/* Mobile overlay */}
      {mobileOpen && (
        <div className="lg:hidden fixed inset-0 z-50 bg-black/50" onClick={() => setMobileOpen(false)}>
          <aside className="w-72 h-full flex flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)]" onClick={(e) => e.stopPropagation()}>
            <div className="px-5 py-5 flex items-center justify-between">
              <Link href="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold text-[var(--color-text-primary)]">Courstar</Link>
              <button onClick={() => setMobileOpen(false)} className="p-1 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
                <X className="w-5 h-5" />
              </button>
            </div>
            {navContent}
          </aside>
        </div>
      )}

      {/* Desktop sidebar */}
      <aside className="hidden lg:flex w-64 flex-shrink-0 h-screen sticky top-0 flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)]">
        <div className="px-5 py-5">
          <Link href="/" className="text-xl font-bold text-[var(--color-text-primary)]">Courstar</Link>
        </div>
        {navContent}
      </aside>
    </>
  );
}
The full navContent implementation (section rendering, active states, user avatar, sign-out link) is in the GitHub repository.

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:

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

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

Optional polish

The GitHub repo also includes an error boundary (src/app/error.tsx) and a 404 page (src/app/not-found.tsx). These are nice to have but not required to continue.

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:

vercel.ts
<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">const config = {
  framework: &quot;nextjs&quot; as const,
  buildCommand: &quot;prisma generate &amp;&amp; next build&quot;,
  regions: [&quot;iad1&quot;],
  headers: [
    {
      source: &quot;/(.*)&quot;,
      headers: [
        { key: &quot;X-Content-Type-Options&quot;, value: &quot;nosniff&quot; },
        { key: &quot;X-Frame-Options&quot;, value: &quot;DENY&quot; },
        { key: &quot;Referrer-Policy&quot;, value: &quot;strict-origin-when-cross-origin&quot; },
        { key: &quot;Strict-Transport-Security&quot;, value: &quot;max-age=31536000; includeSubDomains&quot; },
        {
          key: &quot;Content-Security-Policy&quot;,
          value:
            &quot;default-src &#039;self&#039;; script-src &#039;self&#039; &#039;unsafe-inline&#039; &#039;unsafe-eval&#039; https://*.whop.com https://www.gstatic.com; style-src &#039;self&#039; &#039;unsafe-inline&#039; https://*.whop.com; img-src &#039;self&#039; https://*.whop.com https://image.mux.com https://ui-avatars.com data:; media-src &#039;self&#039; https://stream.mux.com https://*.mux.com blob:; font-src &#039;self&#039; https://*.whop.com; connect-src &#039;self&#039; https://*.mux.com https://*.production.mux.com https://*.whop.com wss://*.whop.com https://inferred.litix.io; frame-src &#039;self&#039; https://*.whop.com; frame-ancestors &#039;none&#039;; form-action &#039;self&#039;; base-uri &#039;self&#039;&quot;,
        },
        { key: &quot;Permissions-Policy&quot;, value: &quot;camera=(), microphone=(), geolocation=()&quot; },
      ],
    },
  ],
};

export default config;</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:

next.config.ts
<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">import type { NextConfig } from &quot;next&quot;;

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

export default nextConfig;</code></pre>
  </div>
</div>

Checkpoint for Part 1

Start the dev server:

bash
npm run dev

Walk through the full authentication flow:

  1. Visit http://localhost:3000. We should see the landing page placeholder.
  2. Navigate to /sign-in and click "Sign in with Whop." We should land on Whop's OAuth authorization page on sandbox.whop.com.
  3. Authorize the app. We should be redirected back to /dashboard.
  4. Check the Neon console. A User row should exist in the User table with a whopUserId matching the sandbox account.
  5. Open the browser's developer tools and check cookies. A courstar_session cookie should be present, marked httpOnly and sameSite=lax.
  6. Visit /api/auth/logout. We should be redirected to the sign-in page and the session cookie should be cleared.
  7. 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:

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

datasource db {
  provider = "postgresql"
}

enum Category {
  DEVELOPMENT
  BUSINESS
  DESIGN
  MARKETING
  PHOTOGRAPHY
  MUSIC
  HEALTH
  LIFESTYLE
}

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())
}

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

bash
npx prisma db push
npx prisma generate

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:

auth.ts
export async function getCreatorProfile(userId: string) {
  return prisma.creatorProfile.findUnique({
    where: { userId },
  });
}

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

layout.tsx
import { requireAuth, getCreatorProfile } from "@/lib/auth";

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

// Update the Sidebar prop:
isInstructor={!!creatorProfile?.kycComplete}

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:

route.ts
import { NextResponse } from "next/server";
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { getWhop } from "@/lib/whop";
import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { headers } from "next/headers";

const isSandbox = process.env.WHOP_SANDBOX === "true";

export async function POST() {
  const headersList = await headers();
  const ip =
    headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
  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: "Unauthorized" }, { 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: "account_onboarding",
        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: "/teach/dashboard" });
  }

  const whop = getWhop();

  const company = await whop.companies.create({
    email: user.email || undefined,
    title: `${user.name || "Instructor"}'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: "account_onboarding",
    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 });
}

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:

page.tsx
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { redirect } from "next/navigation";
import { DollarSign, CreditCard, Wallet } from "lucide-react";
import { OnboardButton } from "@/components/onboard-button";

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

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

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

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

      <p className="text-sm text-[var(--color-text-secondary)] mb-6">
        Platform takes a 20% commission — you keep 80% of every sale
      </p>

      <OnboardButton hasProfile={!!profile} />
    </main>
  );
}

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:

onboard-button.tsx
"use client";

import { useState } from "react";

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("/api/teach/onboard", { method: "POST" });
      const data = await res.json();
      if (data.sandbox) {
        setSandboxMessage(true);
        setTimeout(() => {
          window.location.href = "/teach/dashboard";
        }, 2000);
        return;
      }
      if (data.url) {
        window.location.href = data.url;
      }
    } catch {
      setLoading(false);
    }
  }

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

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className="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"
    >
      {loading
        ? "Setting up..."
        : hasProfile
          ? "Complete Verification"
          : "Become an Instructor"}
    </button>
  );
}

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:

page.tsx
import { redirect } from "next/navigation";
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { OnboardButton } from "@/components/onboard-button";

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

  const profile = await getCreatorProfile(user.id);

  if (!profile) {
    redirect("/teach");
  }

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

      <h1 className="mb-2 text-2xl font-bold tracking-tight text-text-primary">
        Instructor Dashboard
      </h1>
      <p className="mb-8 text-text-secondary">
        Welcome back, {user.name || "Instructor"}.
      </p>

      <div className="rounded-lg border border-border bg-surface p-12 text-center">
        <p className="text-text-secondary">
          Your courses will appear here.
        </p>
      </div>
    </div>
  );
}

Student dashboard

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

page.tsx
import { requireAuth } from "@/lib/auth";

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

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

      <div className="rounded-lg border border-border bg-surface p-12 text-center">
        <p className="text-text-secondary">
          Your enrolled courses will appear here.
        </p>
      </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:

  1. Create a free Mux account at mux.com
  2. In the Mux dashboard, go to Settings and API Access Tokens. There, create a new token and note the Token ID and Token Secret.
  3. 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.
  4. 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:

bash
vercel env pull .env.local

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

env.ts
MUX_TOKEN_ID: z.string().min(1).optional(),
MUX_TOKEN_SECRET: z.string().min(1).optional(),
MUX_WEBHOOK_SECRET: z.string().min(1).optional(),

Mux client

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

mux.ts
import Mux from "@mux/mux-node";

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

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:

route.ts
import { NextResponse } from "next/server";
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { slugify } from "@/lib/slugify";
import { z } from "zod";
import { MAX_COURSE_TITLE, MAX_COURSE_DESCRIPTION } from "@/lib/constants";
import { headers } from "next/headers";

const categoryValues = [
  "DEVELOPMENT", "BUSINESS", "DESIGN", "MARKETING",
  "PHOTOGRAPHY", "MUSIC", "HEALTH", "LIFESTYLE",
] 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("")),
});

export async function POST(request: Request) {
  const headersList = await headers();
  const ip = headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
  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: "Unauthorized" }, { status: 401 });

  const profile = await getCreatorProfile(user.id);
  if (!profile || !profile.kycComplete) {
    return NextResponse.json({ error: "Complete instructor onboarding first" }, { 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: "DRAFT",
    },
  });

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

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:

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

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

const CATEGORIES = [
  "DEVELOPMENT", "BUSINESS", "DESIGN", "MARKETING",
  "PHOTOGRAPHY", "MUSIC", "HEALTH", "LIFESTYLE",
];

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

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setLoading(true);
    setError("");

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

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

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      {error && (
        <div className="p-3 rounded-lg bg-[var(--color-error)]/10 text-[var(--color-error)] text-sm">
          {error}
        </div>
      )}
      <div>
        <label className="block text-sm text-[var(--color-text-secondary)] mb-1">Title</label>
        <input
          name="title"
          required
          maxLength={100}
          className="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)]"
          placeholder="e.g. Introduction to Python"
        />
      </div>
      <div>
        <label className="block text-sm text-[var(--color-text-secondary)] mb-1">Description</label>
        <textarea
          name="description"
          required
          rows={4}
          maxLength={5000}
          className="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"
          placeholder="What will students learn?"
        />
      </div>
      <div className="grid grid-cols-2 gap-4">
        <div>
          <label className="block text-sm text-[var(--color-text-secondary)] mb-1">Price (USD)</label>
          <input
            name="price"
            type="number"
            step="0.01"
            min="0"
            required
            className="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)]"
            placeholder="0.00"
          />
        </div>
        <div>
          <label className="block text-sm text-[var(--color-text-secondary)] mb-1">Category</label>
          <select
            name="category"
            required
            className="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)]"
          >
            <option value="">Select...</option>
            {CATEGORIES.map((c) => (
              <option key={c} value={c}>{c.charAt(0) + c.slice(1).toLowerCase()}</option>
            ))}
          </select>
        </div>
      </div>
      <div>
        <label className="block text-sm text-[var(--color-text-secondary)] mb-1">Thumbnail URL (optional)</label>
        <input
          name="thumbnailUrl"
          type="url"
          className="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)]"
          placeholder="https://..."
        />
      </div>
      <button
        type="submit"
        disabled={loading}
        className="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"
      >
        {loading ? "Creating..." : "Create Course"}
      </button>
    </form>
  );
}

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

page.tsx
import { redirect } from "next/navigation";
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { CreateCourseForm } from "@/components/create-course-form";

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

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

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

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:

route.ts
import { NextResponse } from "next/server";
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
import { MAX_COURSE_TITLE, MAX_COURSE_DESCRIPTION } from "@/lib/constants";

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([
      "DEVELOPMENT", "BUSINESS", "DESIGN", "MARKETING",
      "PHOTOGRAPHY", "MUSIC", "HEALTH", "LIFESTYLE",
    ]),
    thumbnailUrl: z.string().url().optional().or(z.literal("")),
  })
  .partial();

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

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

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

  const course = await prisma.course.findUnique({ where: { id: courseId } });
  if (!course || course.creatorId !== profile.id) {
    return NextResponse.json({ error: "Course not found" }, { 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 === "" ? null : parsed.data.thumbnailUrl,
    },
  });

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

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

page.tsx
import { redirect, notFound } from "next/navigation";
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { CourseEditor } from "@/components/course-editor";

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

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

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

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

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

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

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

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:

route.ts
import { NextResponse } from "next/server";
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
import { MAX_SECTION_TITLE, MAX_SECTIONS_PER_COURSE } from "@/lib/constants";

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: "Unauthorized" }, { 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: "Course not found" }, { status: 404 });

  const count = await prisma.section.count({ where: { courseId: course.id } });
  if (count >= 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: "Unauthorized" }, { 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: "Section not found" }, { status: 404 });

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

  const updated = await prisma.section.update({
    where: { id: parsed.data.id },
    data: {
      ...(parsed.data.title !== undefined && { title: parsed.data.title }),
      ...(parsed.data.order !== undefined && { 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: "Unauthorized" }, { 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: "Section not found" }, { status: 404 });

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

  await prisma.section.delete({ where: { id: parsed.data.id } });
  return NextResponse.json({ success: true });
}

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:

route.ts
import { NextResponse } from "next/server";
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getMux } from "@/lib/mux";
import { z } from "zod";
import { MAX_LESSON_TITLE, MAX_LESSONS_PER_SECTION } from "@/lib/constants";

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: "Unauthorized" }, { 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: "Section not found" }, { status: 404 });

  const count = await prisma.lesson.count({ where: { sectionId: section.id } });
  if (count >= 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: "Unauthorized" }, { 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: "Lesson not found" }, { status: 404 });

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

  const updated = await prisma.lesson.update({
    where: { id: parsed.data.id },
    data: {
      ...(parsed.data.title !== undefined && { title: parsed.data.title }),
      ...(parsed.data.order !== undefined && { order: parsed.data.order }),
      ...(parsed.data.isFree !== undefined && { 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: "Unauthorized" }, { 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: "Lesson not found" }, { status: 404 });

  const profile = await getCreatorProfile(user.id);
  if (!profile || lesson.section.course.creatorId !== profile.id) {
    return NextResponse.json({ error: "Not authorized" }, { 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 });
}

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:

route.ts
import { NextResponse } from "next/server";
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getMux } from "@/lib/mux";
import { rateLimit } from "@/lib/rate-limit";
import { z } from "zod";
import { headers } from "next/headers";

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

export async function POST(request: Request) {
  const headersList = await headers();
  const ip = headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
  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: "Unauthorized" }, { 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: "Lesson not found" }, { status: 404 });

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

  const mux = getMux();

  if (lesson.muxAssetId) {
    try {
      await mux.video.assets.delete(lesson.muxAssetId);
    } catch {
      // continue
    }
    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: ["signed"],
      video_quality: "basic",
    },
  });

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

  return NextResponse.json({ url: upload.url, uploadId: upload.id });
}
Mux's free tier includes 10 on-demand videos and 100,000 delivery minutes per month, more than enough for development.

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:

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

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

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

  type MuxEvent = {
    type: string;
    id: string;
    data: Record<string, unknown>;
  };

  let event: MuxEvent;
  try {
    const mux = getMux();
    event = mux.webhooks.unwrap(
      body,
      { "mux-signature": signature },
      process.env.MUX_WEBHOOK_SECRET!
    ) as unknown as MuxEvent;
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { 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: "mux" },
  });

  if (event.type === "video.asset.ready") {
    const asset = event.data as {
      id: string;
      passthrough?: string;
      duration?: number;
      playback_ids?: Array<{ id: string; policy: string }>;
    };

    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 === "video.upload.asset_created") {
    const upload = event.data as { asset_id?: string; id?: string };
    if (upload.asset_id && upload.id) {
      await prisma.lesson.updateMany({
        where: { muxUploadId: upload.id },
        data: { muxAssetId: upload.asset_id },
      });
    }
  }

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

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:

route.ts
import { NextResponse } from "next/server";
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getWhop } from "@/lib/whop";
import { PLATFORM_FEE_PERCENT } from "@/lib/constants";
import { rateLimit } from "@/lib/rate-limit";
import { headers } from "next/headers";

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

  const headersList = await headers();
  const ip = headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
  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: "Unauthorized" }, { status: 401 });

  const profile = await getCreatorProfile(user.id);
  if (!profile) return NextResponse.json({ error: "Not an instructor" }, { 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: "Course not found" }, { status: 404 });
  }

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

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

  if (course.price > 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: "one_time",
    });

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

    const checkout = await whop.checkoutConfigurations.create({
      plan: {
        company_id: profile.whopCompanyId,
        currency: "usd",
        initial_price: priceInDollars,
        plan_type: "one_time",
        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: "PUBLISHED",
        whopProductId: product.id,
        whopPlanId: plan.id,
        whopCheckoutUrl: checkout.purchase_url,
      },
    });
  } else {
    await prisma.course.update({
      where: { id: courseId },
      data: { status: "PUBLISHED" },
    });
  }

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

Checkpoint

  1. Navigate to /teach/courses/new. The creation form appears with fields for title, description, price, category, and thumbnail URL.
  2. 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.
  3. Using the API (or a client component), add two sections to the course: "Getting Started" and "HTML Basics"
  4. Add lessons to each section: "Welcome" and "Setting Up" under the first section, "Your First Page" under the second
  5. 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.
  6. Toggle the "Welcome" lesson as a free preview using the PATCH endpoint with isFree: true
  7. Call the publish endpoint (POST to /api/teach/courses/[courseId]/publish). The response returns { success: true }.
  8. 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 from via Whop Payments Network.

Whop webhook setup

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

  1. Open the Whop sandbox dashboard at sandbox.whop.com
  2. Navigate to the Developer page (bottom of the left sidebar)
  3. In the Webhooks section, click Create Webhook
  4. Set the URL to our production domain followed by /api/webhooks/whop, for example https://courstar.vercel.app/api/webhooks/whop
  5. Under Events, enable payment.succeeded
  6. 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:

env.ts
WHOP_WEBHOOK_SECRET: z.string().min(1).optional(),
Webhooks require a publicly reachable URL. Since we deployed to Vercel in Part 1, the production URL already works. For local testing, use a tool like ngrok to tunnel requests to localhost.

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:

page.tsx
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { COURSES_PER_PAGE } from "@/lib/constants";
import { formatPrice } from "@/lib/utils";
import { Star, Users } from "lucide-react";
import type { Category } from "@/generated/prisma/client";

const CATEGORIES = [
  "DEVELOPMENT", "BUSINESS", "DESIGN", "MARKETING",
  "PHOTOGRAPHY", "MUSIC", "HEALTH", "LIFESTYLE",
] as const;

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

  const where = {
    status: "PUBLISHED" as const,
    ...(category && CATEGORIES.includes(category as Category) && {
      category: category as Category,
    }),
    ...(q && { title: { contains: q, mode: "insensitive" 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: "desc" },
      skip: (page - 1) * COURSES_PER_PAGE,
      take: COURSES_PER_PAGE,
    }),
    prisma.course.count({ where }),
  ]);

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

  return (
    <div className="max-w-6xl mx-auto px-8 py-10">
        <h1 className="text-3xl font-bold tracking-tight mb-10">Browse Courses</h1>
        <form className="mb-6 flex flex-col sm:flex-row gap-4">
          <input type="text" name="q" defaultValue={q} placeholder="Search courses..."
            className="flex-1 px-4 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)] focus:outline-none focus:border-[var(--color-accent)]" />
          <select name="category" defaultValue={category}
            className="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)]">
            <option value="">All Categories</option>
            {CATEGORIES.map((c) => (
              <option key={c} value={c}>{c.charAt(0) + c.slice(1).toLowerCase()}</option>
            ))}
          </select>
          <button type="submit" className="px-8 py-3.5 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)] transition-colors">
            Search
          </button>
        </form>
        {courses.length === 0 ? (
          <p className="text-[var(--color-text-secondary)] text-center py-16">No courses found.</p>
        ) : (
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
            {courses.map((course) => {
              const avgRating = course.reviews.length > 0
                ? course.reviews.reduce((sum, r) => sum + r.rating, 0) / course.reviews.length : 0;
              const lessonCount = course.sections.reduce((sum, s) => sum + s._count.lessons, 0);
              return (
                <Link key={course.id} href={`/courses/${course.slug}`}
                  className="group rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] overflow-hidden hover:border-[var(--color-accent)] hover:-translate-y-0.5 transition-all">
                  <div className="relative aspect-video bg-[var(--color-surface-elevated)]">
                    {course.thumbnailUrl && (
                      <img src={course.thumbnailUrl} alt={course.title} className="w-full h-full object-cover" />
                    )}
                    <span className="absolute top-3 right-3 px-2 py-1 rounded-md text-xs font-semibold bg-black/70 text-white">
                      {formatPrice(course.price)}
                    </span>
                  </div>
                  <div className="p-5">
                    <h3 className="font-semibold text-lg line-clamp-2 mb-1 group-hover:text-[var(--color-accent)] transition-colors">
                      {course.title}
                    </h3>
                    <p className="text-sm text-[var(--color-text-secondary)] mb-2">
                      {course.creator.user.name || "Instructor"}
                    </p>
                    <div className="flex items-center gap-3 text-xs text-[var(--color-text-secondary)]">
                      {avgRating > 0 && (
                        <span className="flex items-center gap-1">
                          <Star className="w-3.5 h-3.5 fill-[var(--color-warning)] text-[var(--color-warning)]" />
                          {avgRating.toFixed(1)} ({course.reviews.length})
                        </span>
                      )}
                      <span className="flex items-center gap-1">
                        <Users className="w-3.5 h-3.5" />{course._count.enrollments}
                      </span>
                      <span>{lessonCount} lessons</span>
                    </div>
                  </div>
                </Link>
              );
            })}
          </div>
        )}
        {totalPages > 1 && (
          <div className="flex justify-center gap-2 mt-10">
            {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
              <Link key={p}
                href={`/courses?${new URLSearchParams({
                  ...(q && { q }), ...(category && { category }), page: String(p),
                })}`}
                className={`px-3 py-1 rounded-md text-sm ${p === page
                  ? "bg-[var(--color-accent)] text-white"
                  : "bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"}`}>
                {p}
              </Link>
            ))}
          </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:

page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { formatPrice, formatDuration } from "@/lib/utils";
import { Star, Clock, Play, Lock, Users, BookOpen } from "lucide-react";
import type { Metadata } from "next";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const course = await prisma.course.findUnique({ where: { slug } });
  if (!course) return { title: "Course Not Found" };
  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] : [],
    },
  };
}

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

page.tsx
export default async function CourseDetailPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const course = await prisma.course.findUnique({
    where: { slug },
    include: {
      creator: { include: { user: true } },
      sections: {
        orderBy: { order: "asc" },
        include: { lessons: { orderBy: { order: "asc" } } },
      },
      reviews: { include: { user: true }, orderBy: { createdAt: "desc" }, take: 10 },
      _count: { select: { enrollments: true } },
    },
  });
  if (!course || course.status !== "PUBLISHED") 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 > 0
    ? course.reviews.reduce((sum, r) => sum + r.rating, 0) / course.reviews.length : 0;
  const totalLessons = course.sections.reduce((sum, s) => sum + s.lessons.length, 0);
  const totalDuration = course.sections.reduce(
    (sum, s) => sum + s.lessons.reduce((ls, l) => ls + (l.duration || 0), 0), 0);
  // ... render (described below)
}

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

page.tsx
{isEnrolled ? (
  <Link href={`/courses/${course.slug}/learn`}
    className="block w-full text-center py-3 rounded-lg bg-[var(--color-success)] text-white font-semibold hover:opacity-90 transition-opacity">
    Start Learning
  </Link>
) : user ? (
  course.price > 0 && course.whopCheckoutUrl ? (
    <a href={course.whopCheckoutUrl}
      className="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">
      Enroll Now
    </a>
  ) : (
    <form action={`/api/courses/${course.id}/enroll`} method="POST">
      <button type="submit" className="w-full py-3.5 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)] transition-colors">
        Enroll for Free
      </button>
    </form>
  )
) : (
  <Link href="/sign-in"
    className="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">
    Sign in to Enroll
  </Link>
)}

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:

route.ts
import { NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { headers } from "next/headers";

export async function POST(
  _request: Request,
  { params }: { params: Promise<{ courseId: string }> }
) {
  const { courseId } = await params;
  const headersList = await headers();
  const ip = headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
  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: "Unauthorized" }, { status: 401 });

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

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

  await prisma.enrollment.create({ data: { userId: user.id, courseId } });
  return NextResponse.json({ success: true });
}

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:

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

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<string, unknown> };

  let webhookData: WhopEvent;
  try {
    webhookData = whop.webhooks.unwrap(bodyText, {
      headers: headerObj,
    }) as unknown as WhopEvent;
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { 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: "whop" } });

  if (webhookData.type === "payment.succeeded") {
    const payment = webhookData.data as {
      id: string;
      plan?: { id: string };
      user?: { id: string; email?: string };
      metadata?: Record<string, string>;
    };

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

    if (!course && 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 && 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 });
}

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:

route.ts
import { NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
import { MAX_REVIEW_COMMENT } from "@/lib/constants";

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

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

  const enrollment = await prisma.enrollment.findUnique({
    where: { userId_courseId: { userId: user.id, courseId } },
  });
  if (!enrollment)
    return NextResponse.json({ error: "Must be enrolled to review" }, { 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 });
}

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:

bash
vercel env pull .env.local

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

mux.ts
import Mux from "@mux/mux-node";

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<string> {
  const mux = getMux();
  return mux.jwt.signPlaybackId(playbackId, { expiration: "4h" });
}

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:

route.ts
import { NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { signPlaybackId } from "@/lib/mux";
import { rateLimit } from "@/lib/rate-limit";
import { headers } from "next/headers";

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

  const headersList = await headers();
  const ip = headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
  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: "Not found" }, { 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: "Unauthorized" }, { 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: "Not enrolled" }, { status: 403 });
  }

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

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:

video-player.tsx
"use client";

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

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

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

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

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

  return (
    <MuxPlayer
      playbackId={playbackId}
      tokens={{ playback: token }}
      accentColor="#14B8A6"
      className="w-full aspect-video"
      onEnded={handleEnded}
    />
  );
}

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:

page.tsx
import Link from "next/link";
import { redirect, notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { formatDuration } from "@/lib/utils";
import { CheckCircle, Circle, ChevronLeft, ChevronRight } from "lucide-react";
import { VideoPlayer } from "@/components/video-player";
import { MarkCompleteButton } from "@/components/mark-complete-button";

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

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

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

  const user = await requireAuth({ redirect: false });
  let isEnrolled = false;
  let completedLessonIds = new Set<string>();

  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) => p.lessonId));
    }
  }

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

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

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

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

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

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

        {course.sections.map((section) => (
          <div key={section.id}>
            <div className="px-4 py-2.5 text-xs font-semibold text-[var(--color-text-secondary)] uppercase tracking-wide bg-[var(--color-surface-elevated)]">
              {section.title}
            </div>
            {section.lessons.map((lesson) => (
              <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
                    ? "border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-text-primary)]"
                    : "border-transparent hover:bg-[var(--color-surface-elevated)]"
                }`}
              >
                {completedLessonIds.has(lesson.id) ? (
                  <CheckCircle className="w-4 h-4 text-[var(--color-success)] flex-shrink-0" />
                ) : (
                  <Circle className="w-4 h-4 text-[var(--color-text-secondary)] flex-shrink-0" />
                )}
                <span className="flex-1 truncate">{lesson.title}</span>
                {lesson.duration && (
                  <span className="text-xs text-[var(--color-text-secondary)]">{formatDuration(lesson.duration)}</span>
                )}
              </Link>
            ))}
          </div>
        ))}
      </aside>
    </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:

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

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

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

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

  if (!lesson)
    return NextResponse.json({ error: "Lesson not found" }, { 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: "Not enrolled" }, { 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 });
}

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:

mark-complete-button.tsx
"use client";

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

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: "POST",
      });
      if (res.ok) {
        setCompleted(true);
        router.refresh();
      }
    } catch {
      // ignore
    } finally {
      setLoading(false);
    }
  }

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

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className="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"
    >
      <Circle className="w-4 h-4" />
      {loading ? "Saving..." : "Mark as Complete"}
    </button>
  );
}

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:

page.tsx
import { redirect, notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";

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

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

  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) => p.lessonId)
  );

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

  if (!target) redirect(`/courses/${slug}`);
  redirect(`/courses/${slug}/learn/${target.id}`);
}

Checkpoint

  1. 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.
  2. The video plays through the Mux Player with teal-tinted controls.
  3. 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.
  4. Click "Next" below the video. The player navigates to the next lesson, crossing section boundaries if needed.
  5. Complete all lessons in a course. The progress bar shows 100%.
  6. Visit /courses/[slug]/learn (no lesson ID). The page redirects to the first incomplete lesson, or the first lesson if all are complete.
  7. Open a paid lesson URL while not enrolled. The page redirects to the course detail page.
  8. 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:

page.tsx
<div className="flex">
  {Array.from({ length: 5 }).map((_, i) => (
    <Star
      key={i}
      className={`w-3.5 h-3.5 ${
        i < review.rating
          ? "fill-[var(--color-warning)] text-[var(--color-warning)]"
          : "text-[var(--color-border)]"
      }`}
    />
  ))}
</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:

page.tsx
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireAuth, getCreatorProfile } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { formatPrice } from "@/lib/utils";
import { PLATFORM_FEE_PERCENT } from "@/lib/constants";
import { Plus, BookOpen, Users, DollarSign } from "lucide-react";
import { DeleteCourseButton } from "@/components/delete-course-button";

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

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

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

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

  return (
    <div className="max-w-6xl mx-auto px-8 py-10">
      <div className="flex items-center justify-between mb-8">
        <h1 className="text-3xl font-bold tracking-tight">Instructor Dashboard</h1>
          <Link
            href="/teach/courses/new"
            className="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"
          >
            <Plus className="w-4 h-4" /> New Course
          </Link>
        </div>

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

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

page.tsx
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { BookOpen } from "lucide-react";

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

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

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

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

  return (
    <div className="max-w-6xl mx-auto px-8 py-10">
      <h1 className="text-3xl font-bold tracking-tight mb-10">My Learning</h1>

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

page.tsx
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { formatPrice } from "@/lib/utils";
import {
  BookOpen, DollarSign, Users, GraduationCap, Star, ArrowRight,
  Code, Briefcase, Palette, Megaphone, Camera, Music, Heart, Sparkles,
} from "lucide-react";

const CATEGORY_META: Record<string, { icon: typeof Code; label: string }> = {
  DEVELOPMENT: { icon: Code, label: "Development" },
  BUSINESS: { icon: Briefcase, label: "Business" },
  DESIGN: { icon: Palette, label: "Design" },
  MARKETING: { icon: Megaphone, label: "Marketing" },
  PHOTOGRAPHY: { icon: Camera, label: "Photography" },
  MUSIC: { icon: Music, label: "Music" },
  HEALTH: { icon: Heart, label: "Health" },
  LIFESTYLE: { icon: Sparkles, label: "Lifestyle" },
};

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

  // Get categories with course counts
  const categoryCounts = await prisma.course.groupBy({
    by: ["category"],
    where: { status: "PUBLISHED" },
    _count: true,
  });

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

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

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

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

        {/* Categories */}
        {categoryCounts.length > 0 && (
          <section className="max-w-6xl mx-auto px-8 py-16">
            <h2 className="text-2xl font-bold tracking-tight mb-8">Browse by Category</h2>
            <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
              {categoryCounts.map(({ category, _count }) => {
                const meta = CATEGORY_META[category] || { icon: BookOpen, label: category };
                const Icon = meta.icon;
                return (
                  <Link
                    key={category}
                    href={`/courses?category=${category}`}
                    className="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"
                  >
                    <div className="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">
                      <Icon className="w-5 h-5 text-[var(--color-accent)]" />
                    </div>
                    <p className="font-medium text-sm">{meta.label}</p>
                    <p className="text-xs text-[var(--color-text-secondary)] mt-1">{_count} courses</p>
                  </Link>
                );
              })}
            </div>
          </section>
        )}

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

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

      <footer className="border-t border-[var(--color-border)] mt-8">
        <div className="max-w-6xl mx-auto px-8 py-10 text-center text-sm text-[var(--color-text-secondary)]">
          &copy; {new Date().getFullYear()} Courstar. All rights reserved.
        </div>
      </footer>
    </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
Do not set WHOP_SANDBOX in production.

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:

bash
DATABASE_URL="your-production-unpooled-url" npx prisma migrate deploy

Checkpoint

Run through the full user journey on the production URL:

  1. Landing page loads with stats, popular courses, and both CTAs
  2. Browse and search courses at /courses
  3. View a course detail page with curriculum, reviews, and enrollment card
  4. Watch a free preview lesson without enrolling
  5. Purchase a paid course through Whop checkout and confirm enrollment
  6. Watch a paid lesson, mark it complete, and verify the progress bar updates
  7. Submit a review on a course we are enrolled in
  8. Check both dashboards: student (/dashboard) and instructor (/teach/dashboard)
  9. 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 the Whop Payments 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, StockX clone, or a Patreon clone.

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