BTST

School Plugin

CMS-backed courses, lessons, enrollment, progress, quizzes, and certificates

Full-stackDependentReleased · Preview

Best for

Teams adding an online school to an existing React application.

Teach courses with structured lessons, quizzes, and saved student progress.

BTST supplies

  • A CMS-backed course catalog, lesson player, and instructor dashboard
  • Enrollment, progress, scored quizzes, and certificate verification
  • Student course portal and lifecycle hooks for payments and certificates

You supply

  • A database adapter, CMS registration, and application identity and authorization rules
  • Hosted lesson videos and resources
  • Payment verification for paid enrollment and optional certificate generation

You own and customize

Courses stay in CMS; enrollments, progress, and quiz attempts stay in your database. Your application supplies identity, payments, and certificate generation.

Compatibility and dependencies

Maintained: Next.js 15+ App Router, React Router v7, TanStack Start.

Requires: The CMS plugin with SCHOOL_COURSE_CONTENT_TYPE; A BTST database adapter.

External services: Optional adopter-owned payment, video, and certificate services.

From registration to result

A semantic workflow, not a setup shortcut

  1. 1Build a course

    Write course details and arrange sections with text, video, and quiz lessons.

  2. 2Enroll and learn

    Students enroll, resume lessons, and complete scored quizzes.

  3. 3Review progress

    Instructors track enrollments and completion; students verify certificates.

School adds a course catalog, student portal, lesson player, and instructor dashboard. Courses are CMS content items; School stores sections, lessons, enrollments, progress, reviews, and quiz attempts in its own models. Videos and resource files remain on your chosen hosting service.

Setup

The CLI adds CMS automatically when you select School:

npx @btst/codegen init --plugins school

For manual setup, register the course content type and both backend plugins:

lib/stack.ts
import { createBackendStack } from "@btst/stack/api"
import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api"
import {
  schoolBackendPlugin,
  SCHOOL_COURSE_CONTENT_TYPE,
} from "@btst/stack/plugins/school/api"

export const myStack = createBackendStack({
  basePath: "/api/data",
  adapter,
  auth: serverAuth,
  plugins: {
    cms: cmsBackendPlugin({ contentTypes: [SCHOOL_COURSE_CONTENT_TYPE] }),
    school: schoolBackendPlugin({
      quizPassMark: 70,
      certificatesEnabled: true,
      hooks: {
        onCourseComplete: async (enrollment, course) => {
          const certificateId = crypto.randomUUID()
          // Generate your certificate document or send a completion email here.
          return { certificateId }
        },
      },
    }),
  },
})

Use an adapter with serializable transactions for consistent progress and quiz attempt limits across application instances. In-process learning writes are serialized per adapter; that does not replace database isolation. Your application owns schema generation, migrations, identity, and authorization. Register schoolPermissions from @btst/stack/plugins/school/permissions in your shared authorization contract. Permit public catalog and certificate reads, restrict course management to the owning instructor or your administrators, and allow students to access only their own enrollments. School resolves request identity on the server and checks enrollment before returning protected lesson content.

A minimal policy for an application whose session has an id and role looks like this. Register both permission namespaces because the backend includes both School and CMS:

lib/authorization.ts
import { defineAuthorization } from "@btst/stack/authorization"
import { schoolPermissions } from "@btst/stack/plugins/school/permissions"
import { cmsPermissions } from "@btst/stack/plugins/cms/permissions"
import { z } from "zod"

export const authorization = defineAuthorization({
  identity: z.object({ id: z.string(), role: z.enum(["user", "admin"]) }),
  permissions: [schoolPermissions, cmsPermissions] as const,
  rules: ({ school, cms }) => [
    cms.contentType.read.when(({ identity }) => identity?.role === "admin"),
    cms.record.read.when(({ identity }) => identity?.role === "admin"),
    cms.record.create.when(({ identity }) => identity?.role === "admin"),
    cms.record.update.when(({ identity }) => identity?.role === "admin"),
    cms.record.delete.when(({ identity }) => identity?.role === "admin"),
    school.course.read.when(({ identity, facts }) =>
      facts.scope === "catalog" ||
      (facts.scope === "course" && facts.status === "published") ||
      (identity !== null && (facts.scope === "instructor" ||
        identity.role === "admin" || identity.id === facts.instructorId))),
    school.course.create.when(({ identity }) => identity !== null),
    school.course.manage.when(({ identity, facts }) =>
      identity !== null && (identity.role === "admin" || identity.id === facts.instructorId)),
    school.enrollment.read.when(({ identity, facts }) =>
      identity !== null && (facts.scope === "own"
        ? facts.userId === undefined || identity.id === facts.userId
        : identity.role === "admin" || identity.id === facts.instructorId)),
    school.enrollment.enroll.when(({ identity }) => identity !== null),
    school.lesson.read.when(({ identity, facts }) =>
      facts.status === "published" && (facts.isFreePreview || identity !== null)),
    school.lesson.complete.when(({ identity }) => identity !== null),
    school.quiz.submit.when(({ identity }) => identity !== null),
    school.certificate.read.allow(),
  ],
})

Collection operations filter to the server-resolved identity after authorization. Lesson operations additionally enforce active enrollment on the server. Tighten course.create to an instructor role if only some users teach.

lib/authorization.server.ts
import { createServerAuth } from "@btst/stack/authorization/server"
import { authorization } from "./authorization"
import { getSession } from "./your-session-service"

export const serverAuth = createServerAuth({
  authorization,
  getIdentityFromHeaders: async ({ headers }) => {
    const session = await getSession(headers)
    return session ? { id: session.user.id, role: session.user.role } : null
  },
})

getSession must validate your application's session. Use the same shared contract in your client auth adapter, as described in the authorization guide. The CLI registers the plugin and content type; protected operations require this application-owned session integration. Without server auth, protected School operations fail closed; public published catalog and preview reads remain available.

lib/stack-client.tsx
import { createClientStack } from "@btst/stack/client"
import { schoolClientPlugin } from "@btst/stack/plugins/school/client"

export function getStackClient(queryClient, origin: string) {
  return createClientStack({
    api: { baseURL: origin, basePath: "/api/data" },
    site: { baseURL: origin, basePath: "/pages" },
    queryClient,
    plugins: { school: schoolClientPlugin() },
  })
}
globals.css
@import "@btst/stack/plugins/school/css";

Mount the resolved client stack with StackProvider, your framework router, and your client auth adapter. Pass the request identity as identityPartition to schoolClientPlugin when building a protected SSR stack so its cache keys match the browser identity. Shared API, site, and QueryClient configuration belongs on createClientStack.

Courses and curriculum

Create a course in the instructor dashboard, add sections and lessons, then publish it. Draft and archived courses are excluded from the public catalog. Courses support categories, tags, difficulty, estimated duration, featured placement, and prices in integer minor currency units, such as USD cents. formatSchoolPrice from @btst/stack/plugins/school/query-keys formats these amounts in en-US using the currency's fraction digits, including JPY and KWD.

The catalog filters by category, difficulty, maximum duration, and free or paid pricing. Detail pages show the instructor, curriculum, and student reviews.

A lesson is text, video, or quiz. Text supports Markdown. Video lessons accept an HTTP or HTTPS video URL; School does not host or transcode media. Each lesson can include downloadable resource links. Enable free preview to make a lesson readable without enrollment. Playback follows section order, then lesson order within each section. Unassigned lessons play last. Instructors can drag lessons or use keyboard-accessible movement controls to reorder lessons within their section. Change a lesson's section in the lesson editor to move it between chapters.

Enrollment and progress

Students enroll in a free course with one action. onBeforeEnroll can reject an enrollment by returning false; onAfterEnroll runs after it is saved. Student operations use the authenticated request identity, regardless of any user ID submitted by a client.

Paid enrollment requires a server-side payment adapter. Its createPayment method must verify or collect payment and return a nonempty paymentRef. Returning null, omitting the adapter, or rejecting the before hook blocks paid enrollment. Never treat a browser-provided payment reference as proof of payment.

schoolBackendPlugin({
  paymentAdapter: {
    async createPayment({ userId, course, idempotencyKey }) {
      const payment = await payments.collect({
        userId,
        idempotencyKey,
        amount: course.price,
        currency: course.currency,
      })
      return payment.paid ? { paymentRef: payment.id } : null
    },
  },
  hooks: {
    onBeforeEnroll: async (userId, course) => true,
    onAfterEnroll: async (enrollment, course) => {
      await sendEnrollmentEmail(enrollment.userId, course.title)
    },
  },
})

payments and sendEnrollmentEmail are application-owned services. The payment provider must honor the supplied stable idempotencyKey so enrollment retries do not charge the same student twice.

The player saves the current lesson and video position, and students mark text or video lessons complete. Passing a quiz completes its lesson. My courses shows each enrollment's progress, the next lesson, and completed certificates. Completing every lesson changes the enrollment from enrolled to completed. Students can cancel an active enrollment from My courses. A cancelled enrollment does not grant protected lesson access; completed enrollments cannot be cancelled. Cancelled enrollments cannot be restarted in v1.

Completed enrollments keep their completion date, 100% progress, and earned certificate when the instructor changes the curriculum. Students can read new lessons without losing completion. For enrollments still in progress, the next learning update recalculates progress against the current lessons.

Quizzes

Quiz lessons contain multiple-choice questions. Correct answers are zero-based option indexes and remain server-side in student lesson responses:

const quiz = {
  passMark: 80,
  maxAttempts: 3,
  questions: [
    {
      id: "semantic-button",
      question: "Which element should perform an action?",
      options: ["A button", "A styled div", "A paragraph"],
      correctAnswer: 0,
    },
  ],
}

passMark overrides the backend's quizPassMark default of 70. maxAttempts limits retakes; omit it for unlimited attempts. The server scores answers and stores the submitted answers, percentage, pass result, and attempt time. The player displays attempt history. Clients cannot supply a score or mark an unpassed quiz complete through the progress API. onQuizSubmit receives the persisted scored attempt and lesson.

Certificates

With certificates enabled, onCourseComplete returns a unique certificate ID that is stored on the enrollment. The hook can generate a PDF or notify the student through application-owned services. The built-in page verifies the certificate ID against the completed enrollment and course; it does not render a certificate PDF. Without a completion hook, completion is still recorded but no certificate is generated. Make your completion hook idempotent using the enrollment ID, since it can be retried after a hook failure. If the hook fails, lesson completion and quiz attempts remain saved. My courses shows Retry certificate for a completed enrollment awaiting a configured certificate provider. This calls POST /school/courses/:id/certificate to retry issuance for the signed-in student without consuming a quiz attempt. The same operation is available as forRequest(request).operations.school.retryCertificate({ id }). It works for completed archived courses and rejects incomplete enrollments. No retry is offered when certificates are disabled or no completion hook is configured.

certificatesEnabled: false disables generation and public verification.

Routes

Paths below are relative to your configured site base path:

Route keyPathPurpose
catalog/learnPublished courses and filters
course/learn/:slugCourse detail and enrollment
lesson/learn/:slug/lessons/:lessonIdText, video, or quiz lesson
quiz/learn/:slug/lessons/:lessonId/quizQuiz and attempt history
certificate/learn/certificates/:certificateIdPublic verification
myCourses/learn/meOwn enrollments and progress
instructorDashboard/learn/instructorCourse management and analytics
courseEditor/learn/instructor/courses/:idCourse and curriculum editor
lessonEditor/learn/instructor/courses/:id/lessons/:lessonIdLesson editor

The instructor dashboard shows students, enrollment activity, and completion rates. Page components can be replaced through pageComponents on schoolClientPlugin; data hooks remain available from @btst/stack/plugins/school/client/hooks.

Static generation

Use the narrow raw.school.prefetchForRoute helper at build time. Request operations belong on myStack.forRequest(request).operations.school; trusted jobs use myStack.trusted.school.

Route keyStatic generationCache content
catalogSupportedPublished courses, optional catalog filters
courseSupportedPublic course detail and curriculum summaries
All other routesDynamicEnrollment, progress, assessment, and management
app/(static)/pages/learn/page.tsx
import { HydrationBoundary, dehydrate } from "@tanstack/react-query"
import { CourseListPageComponent } from "@btst/stack/plugins/school/client/components"
import { myStack } from "@/lib/stack"
import { getOrCreateQueryClient } from "@/lib/query-client"

export const revalidate = 60

export default async function CoursesPage() {
  const queryClient = getOrCreateQueryClient()
  await myStack.raw.school.prefetchForRoute("catalog", queryClient)
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <CourseListPageComponent />
    </HydrationBoundary>
  )
}

For a course detail page, prefetch with await myStack.raw.school.prefetchForRoute("course", queryClient, { slug }). Keep static routes under a header-free layout; request identity must not enter public static caches. The shared query-key definitions and serializers keep build-time cache entries compatible with HTTP responses.

Configuration reference

Prop

Type

Prop

Type

Prop

Type

School v1 does not include live classes, discussion forums, assignment grading, video hosting, subscriptions, offline playback, or SCORM/xAPI support.