Overview
Add an online school plugin that covers the full learning lifecycle: course catalog, lesson player, student progress tracking, and instructor dashboard. Think "minimal Coursera" — focused on the core teaching and learning experience without the complexity of a full LMS platform.
The plugin ships:
- A course catalog backed by the CMS plugin (courses are CMS content items, similar to the Job Board / E-commerce pattern)
- A lesson player with support for video, text, and quiz lesson types
- Enrollment & progress tracking so students can resume where they left off
- An instructor dashboard for managing courses, lessons, and student activity
- A student portal showing enrolled courses and completion status
Core Features
Course Catalog
Curriculum
Enrollment
Progress Tracking
Quizzes
Certificates
Instructor Dashboard
Student Portal
Schema
```ts
import { createDbPlugin } from "@btst/stack/plugins/api"
export const schoolSchema = createDbPlugin("school", {
course: {
modelName: "course",
fields: {
title: { type: "string", required: true },
slug: { type: "string", required: true },
description: { type: "string", required: false },
coverImage: { type: "string", required: false }, // URL
instructorId: { type: "string", required: true }, // user ID
category: { type: "string", required: false },
tags: { type: "string", required: false }, // JSON array
difficulty: { type: "string", defaultValue: "beginner" }, // "beginner" | "intermediate" | "advanced"
durationMins: { type: "number", required: false },
price: { type: "number", defaultValue: 0 }, // 0 = free, cents otherwise
currency: { type: "string", defaultValue: "USD" },
status: { type: "string", defaultValue: "draft" },// "draft" | "published" | "archived"
maxStudents: { type: "number", required: false },
createdAt: { type: "date", defaultValue: () => new Date() },
updatedAt: { type: "date", defaultValue: () => new Date() },
},
},
section: {
modelName: "section",
fields: {
courseId: { type: "string", required: true },
title: { type: "string", required: true },
order: { type: "number", required: true },
},
},
lesson: {
modelName: "lesson",
fields: {
courseId: { type: "string", required: true },
sectionId: { type: "string", required: false },
title: { type: "string", required: true },
type: { type: "string", required: true }, // "video" | "text" | "quiz"
content: { type: "string", required: false }, // text/MDX body or JSON quiz data
videoUrl: { type: "string", required: false },
durationMins: { type: "number", required: false },
order: { type: "number", required: true },
isFreePreview: { type: "boolean", defaultValue: false },
resources: { type: "string", required: false }, // JSON: [{ label, url }]
},
},
enrollment: {
modelName: "enrollment",
fields: {
courseId: { type: "string", required: true },
userId: { type: "string", required: true },
status: { type: "string", defaultValue: "enrolled" }, // "enrolled" | "completed" | "cancelled"
progressPct: { type: "number", defaultValue: 0 },
lastLessonId: { type: "string", required: false },
certificateId: { type: "string", required: false },
paymentRef: { type: "string", required: false },
enrolledAt: { type: "date", defaultValue: () => new Date() },
completedAt: { type: "date", required: false },
},
},
lessonProgress: {
modelName: "lessonProgress",
fields: {
enrollmentId: { type: "string", required: true },
lessonId: { type: "string", required: true },
completed: { type: "boolean", defaultValue: false },
completedAt: { type: "date", required: false },
positionSecs: { type: "number", defaultValue: 0 }, // video resume position
},
},
quizAttempt: {
modelName: "quizAttempt",
fields: {
lessonId: { type: "string", required: true },
userId: { type: "string", required: true },
answers: { type: "string", required: true }, // JSON: [{ questionId, answer }]
score: { type: "number", required: true }, // 0-100
passed: { type: "boolean", required: true },
attemptedAt: { type: "date", defaultValue: () => new Date() },
},
},
})
```
Plugin Structure
```
src/plugins/school/
├── db.ts
├── types.ts
├── schemas.ts
├── query-keys.ts
├── client.css
├── style.css
├── api/
│ ├── plugin.ts # defineBackendPlugin — catalog, enrollment, progress, quiz endpoints
│ ├── getters.ts # listCourses, getCourse, listLessons, getLesson, getEnrollment, getProgress
│ ├── mutations.ts # enroll, completeLesson, submitQuiz, updateCourse, updateLesson
│ ├── query-key-defs.ts
│ ├── serializers.ts
│ └── index.ts
└── client/
├── plugin.tsx # defineClientPlugin — catalog, player, student portal, instructor dashboard
├── overrides.ts # SchoolPluginOverrides
├── index.ts
├── hooks/
│ ├── use-courses.tsx # useCourses, useCourse
│ ├── use-enrollment.tsx # useEnrollment, useEnroll
│ ├── use-progress.tsx # useLessonProgress, useCourseProgress
│ ├── use-quiz.tsx # useSubmitQuiz, useQuizAttempts
│ └── index.tsx
└── components/
└── pages/
├── course-list-page.tsx / .internal.tsx
├── course-detail-page.tsx / .internal.tsx
├── lesson-player-page.tsx / .internal.tsx
├── quiz-page.tsx / .internal.tsx
├── certificate-page.tsx / .internal.tsx
├── my-courses-page.tsx / .internal.tsx
├── instructor-dashboard-page.tsx / .internal.tsx
├── course-editor-page.tsx / .internal.tsx
└── lesson-editor-page.tsx / .internal.tsx
```
Routes
| Route |
Path |
Description |
catalog |
/learn |
Course catalog with filters |
course |
/learn/:slug |
Course detail + enroll CTA |
lesson |
/learn/:slug/lessons/:lessonId |
Lesson player (video / text / quiz) |
quiz |
/learn/:slug/lessons/:lessonId/quiz |
Quiz attempt page |
certificate |
/learn/certificates/:certificateId |
Public certificate verification |
myCourses |
/learn/me |
Student portal — enrolled courses |
instructorDashboard |
/learn/instructor |
Instructor course list |
courseEditor |
/learn/instructor/courses/:id |
Course + curriculum editor |
lessonEditor |
/learn/instructor/courses/:id/lessons/:lessonId |
Lesson editor |
Lifecycle Hooks
```ts
export interface SchoolPluginHooks {
/** Called after a student successfully enrolls (free or paid) /
onAfterEnroll?: (enrollment: Enrollment, course: Course) => Promise
/* Called when all lessons in a course are completed /
onCourseComplete?: (enrollment: Enrollment, course: Course) => Promise<{ certificateId: string }>
/* Called when a quiz attempt is submitted /
onQuizSubmit?: (attempt: QuizAttempt, lesson: Lesson) => Promise
/* Called before a paid enrollment — return false to abort */
onBeforeEnroll?: (userId: string, course: Course) => Promise
}
```
SSG Support
```ts
// prefetchForRoute route keys
export type SchoolRouteKey = "catalog" | "course"
// lesson / quiz / myCourses are user-specific — skipped
await myStack.api.school.prefetchForRoute("catalog", queryClient)
await myStack.api.school.prefetchForRoute("course", queryClient, { slug: "intro-to-typescript" })
```
| Route key |
SSG? |
Notes |
catalog |
✅ |
Published courses list |
course |
✅ |
Per-course static detail page |
lesson / quiz / myCourses / instructor routes |
❌ |
Dynamic, user-specific |
Consumer Setup
```ts
// lib/stack.ts
import { schoolBackendPlugin } from "@btst/stack/plugins/school/api"
school: schoolBackendPlugin({
hooks: {
onCourseComplete: async (enrollment, course) => {
const certId = crypto.randomUUID()
// generate PDF, email student, etc.
return { certificateId: certId }
},
},
})
```
```ts
// lib/stack-client.tsx
import { schoolClientPlugin } from "@btst/stack/plugins/school/client"
school: schoolClientPlugin({
apiBaseURL: "",
apiBasePath: "/api/data",
siteBaseURL: "https://example.com",
siteBasePath: "/pages",
queryClient,
})
```
Non-Goals (v1)
- Live / cohort-based classes (scheduled sessions)
- Discussion forums / community features
- Assignment submissions and instructor grading
- Built-in video hosting (accept any video URL)
- Multi-instructor / co-author workflows
- Subscription / membership pricing models
- Mobile app or offline downloads
- SCORM / xAPI compliance
Plugin Configuration Options
| Option |
Type |
Description |
hooks |
SchoolPluginHooks |
Enrollment, completion, quiz, and payment lifecycle hooks |
quizPassMark |
number |
Global default pass percentage (default: 70) |
certificatesEnabled |
boolean |
Toggle certificate generation (default: true) |
Documentation
Add docs/content/docs/plugins/school.mdx covering:
- Overview — course catalog + lesson player + progress tracking; v1 scope
- Setup —
schoolBackendPlugin with hooks, schoolClientPlugin
- Enrollment flow — free vs paid,
onAfterEnroll hook
- Quiz system — question schema, pass mark, attempt limits
- Certificates —
onCourseComplete hook, certificate verification page
- SSG —
prefetchForRoute route key table + Next.js page.tsx examples
- Schema reference —
AutoTypeTable for all config + hooks
- Routes — table of route keys, paths, descriptions
Related Issues
Overview
Add an online school plugin that covers the full learning lifecycle: course catalog, lesson player, student progress tracking, and instructor dashboard. Think "minimal Coursera" — focused on the core teaching and learning experience without the complexity of a full LMS platform.
The plugin ships:
Core Features
Course Catalog
beginner,intermediate,advanced)Curriculum
video,text,quizEnrollment
enrolled|completed|cancelledProgress Tracking
Quizzes
Certificates
onCourseComplete)Instructor Dashboard
Student Portal
Schema
```ts
import { createDbPlugin } from "@btst/stack/plugins/api"
export const schoolSchema = createDbPlugin("school", {
course: {
modelName: "course",
fields: {
title: { type: "string", required: true },
slug: { type: "string", required: true },
description: { type: "string", required: false },
coverImage: { type: "string", required: false }, // URL
instructorId: { type: "string", required: true }, // user ID
category: { type: "string", required: false },
tags: { type: "string", required: false }, // JSON array
difficulty: { type: "string", defaultValue: "beginner" }, // "beginner" | "intermediate" | "advanced"
durationMins: { type: "number", required: false },
price: { type: "number", defaultValue: 0 }, // 0 = free, cents otherwise
currency: { type: "string", defaultValue: "USD" },
status: { type: "string", defaultValue: "draft" },// "draft" | "published" | "archived"
maxStudents: { type: "number", required: false },
createdAt: { type: "date", defaultValue: () => new Date() },
updatedAt: { type: "date", defaultValue: () => new Date() },
},
},
section: {
modelName: "section",
fields: {
courseId: { type: "string", required: true },
title: { type: "string", required: true },
order: { type: "number", required: true },
},
},
lesson: {
modelName: "lesson",
fields: {
courseId: { type: "string", required: true },
sectionId: { type: "string", required: false },
title: { type: "string", required: true },
type: { type: "string", required: true }, // "video" | "text" | "quiz"
content: { type: "string", required: false }, // text/MDX body or JSON quiz data
videoUrl: { type: "string", required: false },
durationMins: { type: "number", required: false },
order: { type: "number", required: true },
isFreePreview: { type: "boolean", defaultValue: false },
resources: { type: "string", required: false }, // JSON: [{ label, url }]
},
},
enrollment: {
modelName: "enrollment",
fields: {
courseId: { type: "string", required: true },
userId: { type: "string", required: true },
status: { type: "string", defaultValue: "enrolled" }, // "enrolled" | "completed" | "cancelled"
progressPct: { type: "number", defaultValue: 0 },
lastLessonId: { type: "string", required: false },
certificateId: { type: "string", required: false },
paymentRef: { type: "string", required: false },
enrolledAt: { type: "date", defaultValue: () => new Date() },
completedAt: { type: "date", required: false },
},
},
lessonProgress: {
modelName: "lessonProgress",
fields: {
enrollmentId: { type: "string", required: true },
lessonId: { type: "string", required: true },
completed: { type: "boolean", defaultValue: false },
completedAt: { type: "date", required: false },
positionSecs: { type: "number", defaultValue: 0 }, // video resume position
},
},
quizAttempt: {
modelName: "quizAttempt",
fields: {
lessonId: { type: "string", required: true },
userId: { type: "string", required: true },
answers: { type: "string", required: true }, // JSON: [{ questionId, answer }]
score: { type: "number", required: true }, // 0-100
passed: { type: "boolean", required: true },
attemptedAt: { type: "date", defaultValue: () => new Date() },
},
},
})
```
Plugin Structure
```
src/plugins/school/
├── db.ts
├── types.ts
├── schemas.ts
├── query-keys.ts
├── client.css
├── style.css
├── api/
│ ├── plugin.ts # defineBackendPlugin — catalog, enrollment, progress, quiz endpoints
│ ├── getters.ts # listCourses, getCourse, listLessons, getLesson, getEnrollment, getProgress
│ ├── mutations.ts # enroll, completeLesson, submitQuiz, updateCourse, updateLesson
│ ├── query-key-defs.ts
│ ├── serializers.ts
│ └── index.ts
└── client/
├── plugin.tsx # defineClientPlugin — catalog, player, student portal, instructor dashboard
├── overrides.ts # SchoolPluginOverrides
├── index.ts
├── hooks/
│ ├── use-courses.tsx # useCourses, useCourse
│ ├── use-enrollment.tsx # useEnrollment, useEnroll
│ ├── use-progress.tsx # useLessonProgress, useCourseProgress
│ ├── use-quiz.tsx # useSubmitQuiz, useQuizAttempts
│ └── index.tsx
└── components/
└── pages/
├── course-list-page.tsx / .internal.tsx
├── course-detail-page.tsx / .internal.tsx
├── lesson-player-page.tsx / .internal.tsx
├── quiz-page.tsx / .internal.tsx
├── certificate-page.tsx / .internal.tsx
├── my-courses-page.tsx / .internal.tsx
├── instructor-dashboard-page.tsx / .internal.tsx
├── course-editor-page.tsx / .internal.tsx
└── lesson-editor-page.tsx / .internal.tsx
```
Routes
catalog/learncourse/learn/:sluglesson/learn/:slug/lessons/:lessonIdquiz/learn/:slug/lessons/:lessonId/quizcertificate/learn/certificates/:certificateIdmyCourses/learn/meinstructorDashboard/learn/instructorcourseEditor/learn/instructor/courses/:idlessonEditor/learn/instructor/courses/:id/lessons/:lessonIdLifecycle Hooks
```ts
export interface SchoolPluginHooks {
/** Called after a student successfully enrolls (free or paid) /
onAfterEnroll?: (enrollment: Enrollment, course: Course) => Promise
/* Called when all lessons in a course are completed /
onCourseComplete?: (enrollment: Enrollment, course: Course) => Promise<{ certificateId: string }>
/* Called when a quiz attempt is submitted /
onQuizSubmit?: (attempt: QuizAttempt, lesson: Lesson) => Promise
/* Called before a paid enrollment — return false to abort */
onBeforeEnroll?: (userId: string, course: Course) => Promise
}
```
SSG Support
```ts
// prefetchForRoute route keys
export type SchoolRouteKey = "catalog" | "course"
// lesson / quiz / myCourses are user-specific — skipped
await myStack.api.school.prefetchForRoute("catalog", queryClient)
await myStack.api.school.prefetchForRoute("course", queryClient, { slug: "intro-to-typescript" })
```
catalogcourselesson/quiz/myCourses/ instructor routesConsumer Setup
```ts
// lib/stack.ts
import { schoolBackendPlugin } from "@btst/stack/plugins/school/api"
school: schoolBackendPlugin({
hooks: {
onCourseComplete: async (enrollment, course) => {
const certId = crypto.randomUUID()
// generate PDF, email student, etc.
return { certificateId: certId }
},
},
})
```
```ts
// lib/stack-client.tsx
import { schoolClientPlugin } from "@btst/stack/plugins/school/client"
school: schoolClientPlugin({
apiBaseURL: "",
apiBasePath: "/api/data",
siteBaseURL: "https://example.com",
siteBasePath: "/pages",
queryClient,
})
```
Non-Goals (v1)
Plugin Configuration Options
hooksSchoolPluginHooksquizPassMarknumber70)certificatesEnabledbooleantrue)Documentation
Add
docs/content/docs/plugins/school.mdxcovering:schoolBackendPluginwith hooks,schoolClientPluginonAfterEnrollhookonCourseCompletehook, certificate verification pageprefetchForRouteroute key table + Next.jspage.tsxexamplesAutoTypeTablefor all config + hooksRelated Issues