headless lms

Building a frontend

Read a signed-in member's courses, downloads and community, and show their files.

The student app in apps/student is one frontend over the member API. Any other frontend uses the same routes, under /api/member.

Sessions and the organization

  • A member signs in with email and password through Better Auth (/api/auth/*). The session is a cookie.
  • The organization comes from the session, not from a header or subdomain.
  • Every /api/member route answers 401 without a session.

Access

The API decides what a member can open. A member can open a course, a download or a community when they hold an active grant to it, or to a bundle that contains it. Grants to bundles are resolved on the server; a frontend never sees the difference.

Member routes return only what the member can open and answer 404 for everything else, never 403, so a response never confirms that something exists. List with GET /api/member/courses and GET /api/member/downloads, and treat 404 as "not available to this member".

Files

Every file has a stable URL on the API. Each request to it checks access, then answers with a 302 redirect to a signed storage URL. Responses carry these URLs in their file fields, so a frontend puts them straight into src or href and never asks for a file by asset id.

FileFieldWho may fetch it
Course, download or community thumbnailthumbnailUrlAny org user, when the item is published; staff always
File in a lessonassetUrls[assetId] on each activityThe member can open the course, the course and lesson are published, and the lesson lists the file; staff always
File in a downloadurl on each file of GET /api/member/downloads/{downloadId}The member can open the download, it is published, and it lists the file; staff always. Saved under its display name
Post covercoverUrlWhoever can read the post
Message attachmenturl on each of attachmentsThe chat's participants while the message stands; moderators while it is flagged
Profile photoimageUrl on authors, senders, directory entries and notificationsOrg users of the photo's organization
Your own uploadurl on a finalized upload, and imageUrl on your own profileOnly the person who uploaded it

Anything else is a 404. The Files section of the API reference lists every route.

<img src={course.thumbnailUrl} alt="" />
<video src={activity.assetUrls[videoAssetId]} controls />
<a href={file.url} download>
  {file.displayName ?? file.filename}
</a>

An <img> or <video> request can't carry a token; it sends cookies. Serve the frontend and the API from the same site, as subdomains of one domain such as learn.example.com and api.example.com, and set AUTH_COOKIE_DOMAIN to the parent domain (.example.com). Locally everything runs on localhost, which already shares the cookie. Don't set crossorigin="anonymous" on media elements, since it stops the cookie being sent to another subdomain.

The stable URL never expires; only the storage link it redirects to does. Requesting the stable URL again returns a fresh one.

FileStorage link lifetimeSetting
File in a download5 minutesDELIVERY_URL_EXPIRY
Every other file24 hoursSTORAGE_DOWNLOAD_EXPIRY

A download link is used once to start the download. Video and audio keep fetching parts of the file while they play, so their links outlive a viewing.

Lesson content

A lesson's content is in its activity's settings.content, as { type, version, config }. config is in the editor's own format: render it with the editor's Renderer, passing the activity's assetUrls. See Custom editor.

On this page