Loading
Build a complete course platform with authentication, content management, progress tracking, certificates, and payments as the ultimate full-stack capstone project.
This is the capstone project. Everything you have learned across earlier tutorials converges here: authentication, database design, server-side rendering, client-side interactivity, payment processing, PDF generation, and deployment. You will build a complete course platform where instructors create courses, students enroll and track progress, completion triggers certificate generation, and optional payments unlock premium content.
The stack is Next.js 15 with the App Router, PostgreSQL via Prisma, NextAuth.js for authentication, Stripe for payments, and Vercel for deployment. Every piece is production-grade. By the end, you will have a deployable platform and the confidence to build any full-stack application.
This tutorial focuses on architecture and integration. Each step introduces a major subsystem with working code. The goal is not just to copy code, but to understand how production systems are wired together.
Configure the environment in .env.local:
Start PostgreSQL:
Update prisma/schema.prisma with the complete data model:
Run the migration:
Create src/lib/auth.ts:
Create the API route at src/app/api/auth/[...nextauth]/route.ts:
Create src/lib/db.ts:
Create src/app/courses/page.tsx:
Create src/app/courses/[slug]/page.tsx:
Create src/app/courses/[slug]/EnrollButton.tsx:
Create src/app/api/enroll/route.ts:
Create src/app/api/webhooks/stripe/route.ts:
Create src/app/courses/[slug]/lessons/[lessonSlug]/page.tsx:
Create src/app/courses/[slug]/lessons/[lessonSlug]/MarkCompleteButton.tsx:
Create src/app/api/progress/route.ts:
Create src/app/courses/[slug]/certificate/page.tsx:
Create src/app/verify/[code]/page.tsx:
Create src/app/instructor/page.tsx:
Create a Dockerfile for containerized deployment:
For Vercel deployment, push to GitHub and connect the repository:
Set environment variables in the Vercel dashboard:
DATABASE_URL — your production PostgreSQL connection string (Supabase, Neon, or Railway)NEXTAUTH_SECRET — a cryptographically random stringNEXTAUTH_URL — your production URLSTRIPE_SECRET_KEY — your Stripe live secret keySTRIPE_WEBHOOK_SECRET — configure via Stripe dashboard pointing to your /api/webhooks/stripe endpointRun the production migration:
You now have a complete course platform with authentication supporting credential-based login with NextAuth.js, a course catalog with free and paid courses, Stripe payment integration with webhook-driven enrollment, lesson-by-lesson progress tracking with completion percentage, automatic certificate generation when all lessons are complete, a public certificate verification page, an instructor dashboard with enrollment and completion analytics, and a Docker-ready deployment pipeline. This is a production-grade foundation. Extend it with OAuth providers like GitHub and Google, rich text or MDX lesson content, discussion forums per lesson, email notifications for course updates, and analytics dashboards for instructors.
DATABASE_URL="postgresql://postgres:password@localhost:5432/courses?schema=public"
NEXTAUTH_SECRET="generate-a-random-secret-here"
NEXTAUTH_URL="http://localhost:3000"
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."npx create-next-app@latest course-platform --typescript --tailwind --app --eslint
cd course-platform
npm install prisma @prisma/client next-auth @auth/prisma-adapter zod stripe
npm install -D @types/node
npx prisma initdocker run --name courses-db -e POSTGRES_PASSWORD=password -e POSTGRES_DB=courses -p 5432:5432 -d postgres:16npx prisma migrate dev --name initimport NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "./db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
providers: [
CredentialsProvider({
name: "Email",
credentials: {
email: { label: "Email", type: "email" },
name: { label: "Name", type: "text" },
},
async authorize(credentials) {
if (!credentials?.email) return null;
const email = credentials.email as string;
const name = (credentials.name as string) || email.split("@")[0];
let user = await prisma.user.findUnique({ where: { email } });
if (!user) {
user = await prisma.user.create({
data: { email, name },
});
}
return { id: user.id, email: user.email, name: user.name };
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user && token.id) {
session.user.id = token.id as string;
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { role: true },
});
(session.user as Record<string, unknown>)["role"] = dbUser?.role ?? "student";
}
return session;
},
},
});import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}import Link from "next/link";
import { prisma } from "@/lib/db";
export default async function CoursesPage(): Promise<React.ReactElement> {
const courses = await prisma.course.findMany({
where: { published: true },
include: {
lessons: { select: { id: true } },
enrollments: { select: { id: true } },
},
orderBy: { createdAt: "desc" },
});
return (
<div className="max-w-6xl mx-auto px-6 py-12">
<h1 className="text-4xl font-bold mb-2">Courses</h1>
<p className="text-gray-500 mb-10">Learn at your own pace with structured, hands-on courses.</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{courses.map((course) => (
<Link
key={course.id}
href={`/courses/${course.slug}`}
className="group rounded-2xl border border-gray-200 overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="h-40 bg-gradient-to-br from-indigo-500 to-purple-600" />
<div className="p-6">
<h2 className="text-xl font-bold group-hover:text-indigo-600 transition-colors">
{course.title}
</h2>
<p className="text-gray-500 text-sm mt-2 line-clamp-2">{course.description}</p>
<div className="flex justify-between items-center mt-4 text-sm text-gray-400">
<span>{course.lessons.length} lessons</span>
<span>{course.price === 0 ? "Free" : `$${(course.price / 100).toFixed(2)}`}</span>
</div>
</div>
</Link>
))}
</div>
{courses.length === 0 && (
<p className="text-center text-gray-400 py-20">No courses available yet.</p>
)}
</div>
);
}import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
import { notFound } from "next/navigation";
import { EnrollButton } from "./EnrollButton";
interface CoursePageProps {
params: Promise<{ slug: string }>;
}
export default async function CoursePage({ params }: CoursePageProps): Promise<React.ReactElement> {
const { slug } = await params;
const session = await auth();
const course = await prisma.course.findUnique({
where: { slug, published: true },
include: {
lessons: { orderBy: { order: "asc" }, select: { id: true, title: true, slug: true, order: true } },
},
});
if (!course) notFound();
let enrollment = null;
let completedLessons: string[] = [];
if (session?.user?.id) {
enrollment = await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId: course.id } },
include: { progress: { where: { completed: true }, select: { lessonId: true } } },
});
completedLessons = enrollment?.progress.map((p) => p.lessonId) ?? [];
}
const progressPercent = course.lessons.length > 0
? Math.round((completedLessons.length / course.lessons.length) * 100)
: 0;
return (
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="mb-10">
<h1 className="text-4xl font-bold mb-4">{course.title}</h1>
<p className="text-gray-600 text-lg leading-relaxed">{course.description}</p>
{enrollment ? (
<div className="mt-6">
<div className="flex items-center gap-3 mb-2">
<span className="text-sm font-medium text-gray-500">Progress</span>
<span className="text-sm font-bold text-indigo-600">{progressPercent}%</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
) : (
<div className="mt-6">
<EnrollButton courseId={course.id} price={course.price} />
</div>
)}
</div>
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-6 border-b border-gray-100">
<h2 className="text-lg font-semibold">Lessons ({course.lessons.length})</h2>
</div>
{course.lessons.map((lesson, index) => {
const isCompleted = completedLessons.includes(lesson.id);
const isLocked = !enrollment;
return (
<div
key={lesson.id}
className={`flex items-center gap-4 p-4 px-6 border-b border-gray-50 ${
isLocked ? "opacity-50" : "hover:bg-gray-50"
}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${
isCompleted ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}>
{isCompleted ? "✓" : index + 1}
</div>
{enrollment ? (
<a href={`/courses/${slug}/lessons/${lesson.slug}`} className="flex-1 font-medium hover:text-indigo-600">
{lesson.title}
</a>
) : (
<span className="flex-1 font-medium">{lesson.title}</span>
)}
</div>
);
})}
</div>
</div>
);
}"use client";
import React, { useState } from "react";
interface EnrollButtonProps {
courseId: string;
price: number;
}
export function EnrollButton({ courseId, price }: EnrollButtonProps): React.ReactElement {
const [isLoading, setIsLoading] = useState<boolean>(false);
async function handleEnroll(): Promise<void> {
setIsLoading(true);
try {
const response = await fetch("/api/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseId }),
});
const data = (await response.json()) as { url?: string; enrolled?: boolean; error?: string };
if (data.url) {
window.location.href = data.url;
} else if (data.enrolled) {
window.location.reload();
} else {
console.error("Enrollment failed:", data.error);
}
} catch (error) {
console.error("Enrollment error:", error);
} finally {
setIsLoading(false);
}
}
return (
<button
onClick={() => void handleEnroll()}
disabled={isLoading}
className="bg-indigo-500 text-white px-8 py-3 rounded-xl font-semibold text-lg hover:bg-indigo-600 disabled:opacity-50 transition-colors"
>
{isLoading ? "Processing..." : price === 0 ? "Enroll Free" : `Enroll — $${(price / 100).toFixed(2)}`}
</button>
);
}import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-12-18.acacia" });
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = (await request.json()) as { courseId: string };
const course = await prisma.course.findUniqueOrThrow({
where: { id: body.courseId },
});
// Free course: enroll directly
if (course.price === 0) {
await prisma.enrollment.create({
data: {
userId: session.user.id,
courseId: course.id,
},
});
return NextResponse.json({ enrolled: true });
}
// Paid course: create Stripe checkout session
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: "usd",
product_data: { name: course.title },
unit_amount: course.price,
},
quantity: 1,
},
],
metadata: {
userId: session.user.id,
courseId: course.id,
},
success_url: `${process.env.NEXTAUTH_URL}/courses/${course.slug}?enrolled=true`,
cancel_url: `${process.env.NEXTAUTH_URL}/courses/${course.slug}`,
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to enroll";
return NextResponse.json({ error: message }, { status: 500 });
}
}import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-12-18.acacia" });
export async function POST(request: Request): Promise<NextResponse> {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
try {
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
const courseId = session.metadata?.courseId;
if (userId && courseId) {
await prisma.enrollment.upsert({
where: { userId_courseId: { userId, courseId } },
create: {
userId,
courseId,
stripePaymentId: session.payment_intent as string,
},
update: {
stripePaymentId: session.payment_intent as string,
},
});
}
}
return NextResponse.json({ received: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Webhook error";
console.error("Stripe webhook error:", message);
return NextResponse.json({ error: message }, { status: 400 });
}
}import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
import { notFound, redirect } from "next/navigation";
import { MarkCompleteButton } from "./MarkCompleteButton";
interface LessonPageProps {
params: Promise<{ slug: string; lessonSlug: string }>;
}
export default async function LessonPage({ params }: LessonPageProps): Promise<React.ReactElement> {
const { slug, lessonSlug } = await params;
const session = await auth();
if (!session?.user?.id) redirect("/api/auth/signin");
const course = await prisma.course.findUnique({
where: { slug },
include: { lessons: { orderBy: { order: "asc" } } },
});
if (!course) notFound();
const enrollment = await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId: course.id } },
});
if (!enrollment) redirect(`/courses/${slug}`);
const lesson = course.lessons.find((l) => l.slug === lessonSlug);
if (!lesson) notFound();
const progress = await prisma.progress.findUnique({
where: { enrollmentId_lessonId: { enrollmentId: enrollment.id, lessonId: lesson.id } },
});
const currentIndex = course.lessons.findIndex((l) => l.id === lesson.id);
const prevLesson = currentIndex > 0 ? course.lessons[currentIndex - 1] : null;
const nextLesson = currentIndex < course.lessons.length - 1 ? course.lessons[currentIndex + 1] : null;
return (
<div className="max-w-3xl mx-auto px-6 py-12">
<p className="text-sm text-gray-400 mb-2">
Lesson {currentIndex + 1} of {course.lessons.length}
</p>
<h1 className="text-3xl font-bold mb-8">{lesson.title}</h1>
<div
className="prose prose-lg max-w-none mb-12"
dangerouslySetInnerHTML={{ __html: lesson.content }}
/>
<div className="flex items-center justify-between pt-8 border-t border-gray-200">
{prevLesson ? (
<a href={`/courses/${slug}/lessons/${prevLesson.slug}`} className="text-indigo-600 font-medium">
← {prevLesson.title}
</a>
) : <div />}
<MarkCompleteButton
enrollmentId={enrollment.id}
lessonId={lesson.id}
courseSlug={slug}
isCompleted={progress?.completed ?? false}
isLastLesson={!nextLesson}
/>
{nextLesson ? (
<a href={`/courses/${slug}/lessons/${nextLesson.slug}`} className="text-indigo-600 font-medium">
{nextLesson.title} →
</a>
) : <div />}
</div>
</div>
);
}"use client";
import React, { useState } from "react";
interface MarkCompleteButtonProps {
enrollmentId: string;
lessonId: string;
courseSlug: string;
isCompleted: boolean;
isLastLesson: boolean;
}
export function MarkCompleteButton({
enrollmentId,
lessonId,
courseSlug,
isCompleted,
isLastLesson,
}: MarkCompleteButtonProps): React.ReactElement {
const [completed, setCompleted] = useState<boolean>(isCompleted);
const [isLoading, setIsLoading] = useState<boolean>(false);
async function handleComplete(): Promise<void> {
setIsLoading(true);
try {
const response = await fetch("/api/progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enrollmentId, lessonId }),
});
const data = (await response.json()) as { completed: boolean; courseCompleted?: boolean };
setCompleted(data.completed);
if (data.courseCompleted) {
window.location.href = `/courses/${courseSlug}/certificate`;
}
} catch (error) {
console.error("Failed to mark complete:", error);
} finally {
setIsLoading(false);
}
}
if (completed) {
return <span className="text-green-600 font-semibold">Completed</span>;
}
return (
<button
onClick={() => void handleComplete()}
disabled={isLoading}
className="bg-green-500 text-white px-6 py-2 rounded-lg font-semibold hover:bg-green-600 disabled:opacity-50"
>
{isLoading ? "Saving..." : isLastLesson ? "Complete Course" : "Mark Complete"}
</button>
);
}import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = (await request.json()) as { enrollmentId: string; lessonId: string };
const enrollment = await prisma.enrollment.findUniqueOrThrow({
where: { id: body.enrollmentId },
include: { course: { include: { lessons: true } } },
});
if (enrollment.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await prisma.progress.upsert({
where: {
enrollmentId_lessonId: {
enrollmentId: body.enrollmentId,
lessonId: body.lessonId,
},
},
create: {
enrollmentId: body.enrollmentId,
lessonId: body.lessonId,
completed: true,
completedAt: new Date(),
},
update: {
completed: true,
completedAt: new Date(),
},
});
// Check if all lessons are complete
const allProgress = await prisma.progress.findMany({
where: { enrollmentId: body.enrollmentId, completed: true },
});
const courseCompleted = allProgress.length >= enrollment.course.lessons.length;
if (courseCompleted && !enrollment.completedAt) {
await prisma.enrollment.update({
where: { id: body.enrollmentId },
data: { completedAt: new Date() },
});
// Generate certificate
const user = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
});
await prisma.certificate.create({
data: {
userId: session.user.id,
courseTitle: enrollment.course.title,
userName: user.name ?? user.email,
},
});
}
return NextResponse.json({ completed: true, courseCompleted });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update progress";
return NextResponse.json({ error: message }, { status: 500 });
}
}import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
import { notFound, redirect } from "next/navigation";
interface CertificatePageProps {
params: Promise<{ slug: string }>;
}
export default async function CertificatePage({ params }: CertificatePageProps): Promise<React.ReactElement> {
const { slug } = await params;
const session = await auth();
if (!session?.user?.id) redirect("/api/auth/signin");
const course = await prisma.course.findUnique({ where: { slug } });
if (!course) notFound();
const certificate = await prisma.certificate.findFirst({
where: { userId: session.user.id, courseTitle: course.title },
orderBy: { issuedAt: "desc" },
});
if (!certificate) notFound();
const issuedDate = certificate.issuedAt.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-8">
<div className="w-full max-w-2xl bg-white border-4 border-double border-indigo-200 rounded-2xl p-16 text-center shadow-xl print:shadow-none print:border-indigo-400">
<div className="text-indigo-500 text-sm font-semibold uppercase tracking-widest mb-6">
Certificate of Completion
</div>
<h1 className="text-4xl font-bold text-gray-900 mb-4">{certificate.courseTitle}</h1>
<p className="text-gray-500 text-lg mb-8">This certifies that</p>
<p className="text-3xl font-bold text-indigo-600 mb-8 italic">
{certificate.userName}
</p>
<p className="text-gray-500 mb-12">
has successfully completed all lessons on {issuedDate}
</p>
<div className="border-t border-gray-200 pt-6 flex justify-between items-center text-sm text-gray-400">
<span>ID: {certificate.verifyCode}</span>
<span>Verify: /verify/{certificate.verifyCode}</span>
</div>
<button
onClick="window.print()"
className="mt-8 bg-indigo-500 text-white px-6 py-2 rounded-lg font-semibold print:hidden"
>
Print Certificate
</button>
</div>
</div>
);
}import { prisma } from "@/lib/db";
import { notFound } from "next/navigation";
interface VerifyPageProps {
params: Promise<{ code: string }>;
}
export default async function VerifyPage({ params }: VerifyPageProps): Promise<React.ReactElement> {
const { code } = await params;
const certificate = await prisma.certificate.findUnique({
where: { verifyCode: code },
});
if (!certificate) notFound();
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-8">
<div className="max-w-md bg-white rounded-2xl border border-gray-200 p-8 text-center shadow-sm">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<span className="text-green-600 text-2xl font-bold">✓</span>
</div>
<h1 className="text-2xl font-bold mb-4">Certificate Verified</h1>
<p className="text-gray-600 mb-2">
<strong>{certificate.userName}</strong> completed
</p>
<p className="text-indigo-600 font-semibold text-lg mb-4">{certificate.courseTitle}</p>
<p className="text-sm text-gray-400">
Issued {certificate.issuedAt.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</div>
</div>
);
}import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
export default async function InstructorPage(): Promise<React.ReactElement> {
const session = await auth();
if (!session?.user?.id) redirect("/api/auth/signin");
const user = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
});
if (user.role !== "instructor" && user.role !== "admin") {
redirect("/");
}
const courses = await prisma.course.findMany({
where: { authorId: session.user.id },
include: {
lessons: { select: { id: true } },
enrollments: { select: { id: true, completedAt: true } },
},
orderBy: { updatedAt: "desc" },
});
const totalEnrollments = courses.reduce((sum, c) => sum + c.enrollments.length, 0);
const totalCompletions = courses.reduce(
(sum, c) => sum + c.enrollments.filter((e) => e.completedAt !== null).length,
0
);
return (
<div className="max-w-6xl mx-auto px-6 py-12">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Instructor Dashboard</h1>
<Link
href="/instructor/courses/new"
className="bg-indigo-500 text-white px-5 py-2.5 rounded-xl font-semibold hover:bg-indigo-600"
>
New Course
</Link>
</div>
<div className="grid grid-cols-3 gap-6 mb-10">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<p className="text-sm text-gray-500">Courses</p>
<p className="text-3xl font-bold mt-1">{courses.length}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<p className="text-sm text-gray-500">Enrollments</p>
<p className="text-3xl font-bold mt-1">{totalEnrollments}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<p className="text-sm text-gray-500">Completions</p>
<p className="text-3xl font-bold mt-1">{totalCompletions}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
<div className="p-6 border-b border-gray-100">
<h2 className="text-lg font-semibold">Your Courses</h2>
</div>
{courses.map((course) => (
<div key={course.id} className="flex items-center justify-between p-4 px-6 border-b border-gray-50">
<div>
<p className="font-medium">{course.title}</p>
<p className="text-sm text-gray-400">
{course.lessons.length} lessons · {course.enrollments.length} students
</p>
</div>
<div className="flex items-center gap-3">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
course.published ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}>
{course.published ? "Published" : "Draft"}
</span>
<Link href={`/instructor/courses/${course.id}`} className="text-indigo-600 text-sm font-medium">
Edit
</Link>
</div>
</div>
))}
</div>
</div>
);
}FROM node:20-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
EXPOSE 3000
CMD ["node", "server.js"]git init
git add .
git commit -m "feat: initial course platform"
npm install -g vercel
vercelnpx prisma migrate deploygenerator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
role String @default("student") // "student" | "instructor" | "admin"
accounts Account[]
sessions Session[]
enrollments Enrollment[]
certificates Certificate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Course {
id String @id @default(cuid())
title String
slug String @unique
description String
thumbnail String?
price Int @default(0) // cents, 0 = free
published Boolean @default(false)
authorId String
lessons Lesson[]
enrollments Enrollment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([slug])
@@index([published])
}
model Lesson {
id String @id @default(cuid())
title String
slug String
content String @db.Text
order Int
courseId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
progress Progress[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([courseId, slug])
@@index([courseId, order])
}
model Enrollment {
id String @id @default(cuid())
userId String
courseId String
user User @relation(fields: [userId], references: [id])
course Course @relation(fields: [courseId], references: [id])
progress Progress[]
completedAt DateTime?
stripePaymentId String?
createdAt DateTime @default(now())
@@unique([userId, courseId])
}
model Progress {
id String @id @default(cuid())
enrollmentId String
lessonId String
enrollment Enrollment @relation(fields: [enrollmentId], references: [id])
lesson Lesson @relation(fields: [lessonId], references: [id])
completed Boolean @default(false)
completedAt DateTime?
@@unique([enrollmentId, lessonId])
}
model Certificate {
id String @id @default(cuid())
userId String
courseTitle String
userName String
issuedAt DateTime @default(now())
verifyCode String @unique @default(cuid())
user User @relation(fields: [userId], references: [id])
@@index([verifyCode])
}