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.
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
@themeblocks, 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-pgfor Neon compatibility. Client generated intosrc/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.tsfor type-safe configuration
Pages
/Landing page with featured courses, categories, and instructor CTA/sign-inWhop OAuth entry point/coursesBrowse 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/teachInstructor onboarding page/teach/dashboardInstructor dashboard with courses, earnings, and management/teach/courses/newCreate a new course/teach/courses/[courseId]/editCourse editor with inline section/lesson CRUD and video upload/dashboardStudent dashboard with enrolled courses and progress
Payment flow
- Instructor clicks "Become an Instructor" and creates a connected account through Whop's hosted KYC flow
- Instructor publishes a paid course. The app creates a Whop product and plan with a 20% application fee
- Student clicks "Enroll" and pays through Whop's hosted checkout
- Whop fires a
payment.succeededwebhook. The app creates an Enrollment record - 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:
npx create-next-app@latest courstar --ts --tailwind --eslint --app --src-dir --turbopack --import-alias "@/*"
Then, let's install the dependencies we'll use in this project upfront:
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.
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:
- Go to sandbox.whop.com, create a whop, and go to its Developer tab
- There, find the Apps section and click Create app and go to its OAuth tab
- There, copy the
WHOP_CLIENT_IDandWHOP_CLIENT_SECRETkeys and note them down - Copy the company ID from the dashboard URL (starts with
biz_) and note it down - Add the
http://localhost:3000/api/auth/callback(local development) andhttps://your-app.vercel.app/api/auth/callback(production) URLs (change the your-app with your production URL) ad redirect URIs - Go to the Permissions tab and enable the permissions below:
oauth:token_exchangecompany:manage_checkoutcompany:basic:readcompany:create_childmember:basic:readmember:email:readpayment:basic:readplan:basic:readplan:basic:readcheckout_configuration:createchat:message:createchat:read
- Go back to the Developer page and create an API key and note it down
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:
openssl rand -base64 32
Then, go to the Environment Variables page of your Vercel project settings and add these environment variables:
| Variable | Source | Description |
|---|---|---|
DATABASE_URL | Neon via Vercel | Pooled connection (PgBouncer), used at runtime |
DATABASE_URL_UNPOOLED | Neon via Vercel | Direct connection, used by Prisma CLI |
WHOP_CLIENT_ID | Whop app OAuth tab | OAuth client identifier |
WHOP_CLIENT_SECRET | Whop app OAuth tab | OAuth client secret |
WHOP_API_KEY | Business Settings > API Keys | Company API key for Whop for Platforms |
WHOP_COMPANY_ID | Dashboard URL | Starts with biz_, identifies your platform company |
SESSION_SECRET | Generated | At least 32 characters for iron-session encryption |
NEXT_PUBLIC_APP_URL | Set manually | Production URL on Vercel, http://localhost:3000 locally |
WHOP_SANDBOX | Set manually | true during development |
Now, let's link the local project to Vercel and pull the variables:
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:
WHOP_SANDBOX=true
Also override NEXT_PUBLIC_APP_URL for local development. Find the line that was pulled from Vercel and change it to:
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:
@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:
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:
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:
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:
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:
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:
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:
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:
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:
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?.();
}
@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:
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:
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:
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:
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:
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:
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:
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:
"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>
</>
);
}
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:
<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 "next/link";
export default function SignInPage() {
return (
<div className="min-h-full flex items-center justify-center px-8">
<div className="w-full max-w-sm p-10 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] text-center">
<h1 className="text-2xl font-bold mb-2">Courstar</h1>
<p className="text-sm text-[var(--color-text-secondary)] mb-10">
Learn from the best creators on the internet
</p>
<a
href="/api/auth/login"
className="block w-full py-3.5 px-4 rounded-lg bg-[var(--color-accent)] text-white font-semibold hover:bg-[var(--color-accent-hover)]"
>
Sign in with Whop
</a>
<Link
href="/"
className="block mt-5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
>
&larr; Back to home
</Link>
</div>
</div>
);
}</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:
<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: "nextjs" as const,
buildCommand: "prisma generate && next build",
regions: ["iad1"],
headers: [
{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
{
key: "Content-Security-Policy",
value:
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.whop.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://*.whop.com; img-src 'self' https://*.whop.com https://image.mux.com https://ui-avatars.com data:; media-src 'self' https://stream.mux.com https://*.mux.com blob:; font-src 'self' https://*.whop.com; connect-src 'self' https://*.mux.com https://*.production.mux.com https://*.whop.com wss://*.whop.com https://inferred.litix.io; frame-src 'self' https://*.whop.com; frame-ancestors 'none'; form-action 'self'; base-uri 'self'",
},
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
],
};
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:
<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 "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "**.whop.com" },
{ protocol: "https", hostname: "image.mux.com" },
],
},
};
export default nextConfig;</code></pre>
</div>
</div>
Checkpoint for Part 1
Start the dev server:
npm run dev
Walk through the full authentication flow:
- Visit
http://localhost:3000. We should see the landing page placeholder. - Navigate to
/sign-inand click "Sign in with Whop." We should land on Whop's OAuth authorization page onsandbox.whop.com. - Authorize the app. We should be redirected back to
/dashboard. - Check the Neon console. A User row should exist in the
Usertable with awhopUserIdmatching the sandbox account. - Open the browser's developer tools and check cookies. A
courstar_sessioncookie should be present, markedhttpOnlyandsameSite=lax. - Visit
/api/auth/logout. We should be redirected to the sign-in page and the session cookie should be cleared. - Try navigating directly to
/dashboardwithout 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:
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:
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:
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:
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:
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:
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:
"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'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:
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:
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:
- Create a free Mux account at mux.com
- In the Mux dashboard, go to Settings and API Access Tokens. There, create a new token and note the Token ID and Token Secret.
- Create a webhook endpoint by going into Settings > Webhooks > Create Webhook. Set the URL to
https://your-app.vercel.app/api/webhooks/mux(use your real production URL). Select two events:video.asset.readyandvideo.upload.asset_created. - Copy the webhook signing secret.
Add the env vars to Vercel under MUX_TOKEN_ID, MUX_TOKEN_SECRET, and MUX_WEBOOK_SECRET via the Environment Variables section of the project settings at Vercel, then pull them locally:
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:
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:
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:
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:
"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:
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:
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:
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:
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:
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:
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 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:
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:
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
- Navigate to
/teach/courses/new. The creation form appears with fields for title, description, price, category, and thumbnail URL. - Fill in the form (title: "Introduction to Web Development", price: $29, category: DEVELOPMENT) and submit. You are redirected to the course editor at
/teach/courses/[courseId]/edit. - Using the API (or a client component), add two sections to the course: "Getting Started" and "HTML Basics"
- Add lessons to each section: "Welcome" and "Setting Up" under the first section, "Your First Page" under the second
- Upload a video to the "Welcome" lesson. The progress bar fills as the file uploads to Mux, then the status changes to "Processing", and after Mux finishes transcoding it flips to "Ready" with a duration displayed.
- Toggle the "Welcome" lesson as a free preview using the PATCH endpoint with
isFree: true - Call the publish endpoint (POST to
/api/teach/courses/[courseId]/publish). The response returns{ success: true }. - Check the database: the course row has
status: "PUBLISHED", andwhopProductId,whopPlanId, andwhopCheckoutUrlare 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:
- Open the Whop sandbox dashboard at
sandbox.whop.com - Navigate to the Developer page (bottom of the left sidebar)
- In the Webhooks section, click Create Webhook
- Set the URL to our production domain followed by
/api/webhooks/whop, for examplehttps://courstar.vercel.app/api/webhooks/whop - Under Events, enable
payment.succeeded - Click Save
Then, copy the secret (starts with ws_) from the Secret column and add it to Vercel using the Environment Variables page of the project settings as WHOP_WEBHOOK_SECRET. Once done, go to src/lib and update add the new variable to the env.ts file:
WHOP_WEBHOOK_SECRET: z.string().min(1).optional(),
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:
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:
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:
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:
{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:
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:
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:
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:
vercel env pull .env.local
Now, we update the Mux client to include signing credentials. Open src/lib/mux.ts and replace its contents:
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:
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:
"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:
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)]">
← {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:
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:
"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:
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
- Enroll in a course and click "Start Learning" on the course detail page. The page redirects to the first lesson, and the video player loads with Mux's signed playback.
- The video plays through the Mux Player with teal-tinted controls.
- Click "Mark as Complete." The button swaps to a green "Completed" label, a checkmark appears next to the lesson in the sidebar, and the progress bar updates.
- Click "Next" below the video. The player navigates to the next lesson, crossing section boundaries if needed.
- Complete all lessons in a course. The progress bar shows 100%.
- Visit
/courses/[slug]/learn(no lesson ID). The page redirects to the first incomplete lesson, or the first lesson if all are complete. - Open a paid lesson URL while not enrolled. The page redirects to the course detail page.
- Open a free preview lesson without signing in. The video plays normally, with no "Mark as Complete" button visible.
In Part 6, we add reviews, build out the full dashboards for instructors and students, design the landing page, and ship to production.
Part 6: Reviews, dashboards, and production deploy
In this final part, we're going to implement a review system for courses, build fully functioning dashboards for users and creators, create a landing page, and deploy our project to production.
Review system
We built the review system API in Part 4, but the reivews aren't on course pages yet. Open src/app/courses/[slug]/page.tsx and add a review section below the curriculum. Each review renders as a card with the student's name and a star row:
<div 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:
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:
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'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:
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)]">
© {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_) forWHOP_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_SANDBOXenvironment 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.succeededevent type - Copy the webhook secret (starts with
ws_) and set it asWHOP_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.readyandvideo.upload.asset_createdevents - 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 IDWHOP_CLIENT_SECRET: production OAuth app secretWHOP_API_KEY: production API keyWHOP_COMPANY_ID: production company ID (starts withbiz_)WHOP_WEBHOOK_SECRET: production webhook signing secretDATABASE_URL: Neon pooled connection stringDATABASE_URL_UNPOOLED: Neon direct connection stringSESSION_SECRET: at least 32 characters, generated fresh for productionNEXT_PUBLIC_APP_URL: the production domain (e.g.,https://courstar.com)MUX_TOKEN_ID: production Mux tokenMUX_TOKEN_SECRET: production Mux secretMUX_WEBHOOK_SECRET: production Mux webhook signing secretMUX_SIGNING_KEY_ID: production signing key for playback tokensMUX_SIGNING_PRIVATE_KEY: production signing private key
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:
DATABASE_URL="your-production-unpooled-url" npx prisma migrate deploy
Checkpoint
Run through the full user journey on the production URL:
- Landing page loads with stats, popular courses, and both CTAs
- Browse and search courses at
/courses - View a course detail page with curriculum, reviews, and enrollment card
- Watch a free preview lesson without enrolling
- Purchase a paid course through Whop checkout and confirm enrollment
- Watch a paid lesson, mark it complete, and verify the progress bar updates
- Submit a review on a course we are enrolled in
- Check both dashboards: student (
/dashboard) and instructor (/teach/dashboard) - Test on mobile: sidebar collapses to hamburger menu, grids stack to single column
Instructor profiles
Go to src/app/instructors/[id]/ and create a file called page.tsx. This public page shows the instructor's avatar, bio, stats, and published courses. Instructor names on course pages are clickable links to this profile. Add /instructors to the middleware whitelist.
Delete course
The DeleteCourseButton in src/components/delete-course-button.tsx shows a trash icon with inline confirmation. The DELETE handler verifies ownership, cleans up Mux video assets, then cascade-deletes the course and all related records.
Unenroll
The UnenrollButton in src/components/unenroll-button.tsx works the same way: inline confirmation, then DELETE to /api/enrollments/[enrollmentId] which removes all progress records and the enrollment.
Future implementations
- Subscriptions: Swap the one-time plan for Whop's recurring plan type. Students pay monthly for access to an instructor's full catalog.
- Quizzes: Add a
Questionmodel 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/rendererto template it with the student's name and course title. - Discussion forums: Create a
Commentmodel 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
discountfield when creating the checkout. - Auto-advance: When a video ends, automatically navigate to the next lesson after a brief countdown. The
nextLessonvariable 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.