Building an Online Learning Platform with ButterCMS and Django

Posted by Jean-Baptiste Rocher on July 19, 2022


Building a learning platform is a great endeavor, but it might prove challenging, even for experienced developers. If you’re already familiar with Django, and unsure how to undertake such a project, this article is for you! 

In this tutorial, I’ll demonstrate how to combine the power of ButterCMS and Django to painlessly build a collaborative learning platform. What is ButterCMS? Butter is an API-based CMS, aka headless CMS, that hosts and maintains your entire CMS platform comprised of a CMS dashboard and content API. You can find more details explaining how it differs from a traditional CMS here.

Here’s what we’ll be building today: 

  • A collection of courses divided into chapters
  • A cover page for each course
  • A way for students to enroll in a course
  • A way for students to save their progress 

Let’s get to it! 

Wait, why can’t we manage the content directly with Django? 

One of the best features of Django is the admin site that is generated automatically based on the models. Knowing this, you could be tempted to use it to create and manage the course content without any external dependencies. However, this approach doesn’t scale very well. 

First, while the admin website is a great utility for performing CRUD operations, it is bound by the models as defined in the codebase. This means each time a member of your team will want to create a new field or a new type of data, they will have to wait for you to implement the corresponding migrations. ButterCMS solves this by providing a flexible content modeling system that allows your content team to work in complete autonomy. 

Second, to be efficient your team will probably need additional features that exceed django-admin capabilities. Here are a few examples: 

  • Rich WYSIWYG editing
  • Content versioning
  • Collaboration features

ButterCMS provides all of these features and more, which means it will scale with your needs. You can find the code for this tutorial in this Github repo

Modeling the courses in Butter

First things first, we need some content!  We’ll start by creating a few courses in ButterCMS. I want to divide each course into successive chapters so everything is properly organized and we can track a student's progress by checking which chapter they have completed. This means we need a one-to-many relationship between a course and its related chapters.

ButterCMS makes modeling these relationships easy through collections. A collection doesn’t directly map to a page or blog post as you might see in a traditional CMS. Rather, it’s a way to create a database of related facts and information without having to write a single line of code. We’ll create two collections: 

  • Courses: The course our students will be able to follow
  • Chapters: The chapters making up the courses

Creating the chapters collection

We need to create the Chapters collection first so that we can reference it later in the Courses collection. 

Chapters collection schema in ButterCMS

We’re using a single field to store the chapter’s content with a WYSIWYG editor. This way we can easily vary the type of content we insert in each chapter depending on the need for the course.

We’re also using a self-reference to the Chapter collection to store a link to the next chapter. This will greatly help when saving a student’s progress and gives a bit more control over the organization of the course

Creating the courses collection

Now that we have a Chapters collection to reference, let’s create the Courses collection. Here’s the schema I’m using: 

Courses collection schema in ButterCMS

We’ve got a few basic fields to describe our course:

  • Title: The title of the course
  • Cover: A cover image 
  • Description: A brief description of the content of the course

I’ve also added a one-to-many reference to the Chapters collection. This will allow us to access all the chapters related to a course from the Courses collection. 

Looking for a powerful Django CMS that scales with your app?

Creating a cover page for our courses

To advertise the courses that the students will be able to follow on our platform, we need a cover page for each course. Each cover will have the same structure—only the content will differ. This is a perfect use case for page types. Page types are a feature of ButterCMS that works as a template for creating pages. We can query Butter’s API to retrieve a specific page based on its slug. 

Here’s a little diagram to help you picture how Django and Butter’s page types fit together: 

Diagram For Displaying the Cover Pages

Architecture Diagram For Displaying the Cover Pages

It’s pretty straightforward, actually. When a user accesses a course’s cover page, we simply forward its slug to Butter’s Page Type API. Butter responds with the course content, and we display it with HTML using Django’s built-in rendering engine!

Creating the cover page page type

Here’s the schema I’m using for the course’s cover page: 

Cover page schema in ButterCMS

Every cover page will have the following fields: 

  • SEO title and description: For populating the meta tag 
  • Title: The title of the page. It might be different from the title of the course itself
  • Pre-requisites: A repeater field listing concept that needs to be understood before taking the course
  • Reviews: Another repeater field to display reviews made by former students.

The cover pages hold content related to promoting the course, not the course material itself. Instead, it references the Course collection. This way we can access the content of the course without duplicating the data. 

Rendering the cover page in Django

Now let’s render these cover pages in Django. Time to type some code!

We need a way to query the ButterCMS API. Luckily, Butter provides a python client for retrieving data from their API. You can initialize it like so: 

from butter_cms import ButterCMS
from django.conf import settings

from .exceptions import MissingButterCMSToken

if settings.BUTTER_CMS_TOKEN is None:
    raise MissingButterCMSToken("BUTTER_CMS_TOKEN must be set")

client = ButterCMS(settings.BUTTER_CMS_TOKEN)

Then we can create a small Mixin to retrieve any page type we need using this client:

class PageTypeMixin(SingleObjectMixin):
    def get_object(self):
        slug = self.kwargs.get("slug")
        return client.pages.get(self.page_type, slug, {"levels": 3})

This PageTypeMixin inherits from SingleObjectMixin so the object is injected into the context automatically. Using a mixin means we can reuse this logic in multiple class-based views. This will come in handy later. 

Here’s how to use it with a DetailView to display our cover pages:

class GetCourseCover(PageTypeMixin, DetailView):
    page_type = "course_cover"
    template_name = "courses/course_cover.html"

Now it’s just a matter of hooking up the view into the UrlConf and doing some rendering as you would in any Django App. For instance, you can render the prerequisites for the course like this:

<!-- Loop through the repeater prerequisite field -->
 <ul class="list-group"> 
  {% for prerequisite in %}
    <li class="list-group-item"> {{ }} </li>
  {% endfor %}

The fields defined in our schema are available in the context. 

Here’s an example of a fully rendered cover page using all the fields of the schema and styled with Bootstrap:

Rendered course cover page

You can find the complete code for this page on GitHub. 

Enrolling in a course

Displaying stuff is great, but the students will also need to enroll in specific courses. Here’s what we want to achieve: 

  • The students can enroll by clicking a “Start course” button at the bottom of the cover page
  • The students can retrieve all their courses on an index page

To accomplish this, we’ll store the courses started by the students inside a database. Then we’ll be able to filter our “Courses” collection to show students all the courses they’re currently enrolled in.

Here’s a little diagram summarizing how this will work:

Architecture Diagram for Enrolling in a Course

Architecture Diagram for Enrolling in a Course

Setting up the models

In Django, everything flows from the models. Let’s define some to store the courses a student wants to follow. 

class Student(models.Model):
    user = models.OneToOneField(User, on_delete=models.PROTECT)

class Course(models.Model):
    student = models.ForeignKey(
        Student, on_delete=models.PROTECT, related_name="courses"
    butter_id = models.IntegerField()
    current_chapter = models.IntegerField()

    class Meta:
        constraints = [
                fields=["student", "butter_id"],

I like to extend Django's user model to have a better separation of concerns. We can store data specific to a student on the Student model, without interfering with Django’s built-in User model.

In the Course model, I’ve added a few interesting fields: 

  • student: A foreign key to the student model. We have a one-to-many between a student and the courses they’re studying. 
  • butter_id: The corresponding course ID in ButterCMS
  • current_chapter: The chapter the student is currently studying. This will be used to track the progress

You can use whichever database engine you like the most to store your models. I usually use Postgres.

Now that we have some models we can populate them using a Django Form!

Creating the Start Course form

Let’s create a Start Course button on the course cover page. When a student clicks the button, the course will be linked to them in the database. To accomplish this, we’ll use Django’s ModelForm and a slightly modified form view.

The form looks like this:

class StartCourseForm(forms.ModelForm):
    class Meta:
        model = Course
        fields = ["student", "butter_id", "current_chapter"]

It’s a simple ModelForm that uses all the fields from our Course model.

The view is a bit more interesting: 

class StartCourse(LoginRequiredMixin, PageTypeMixin, FormView):
    page_type = "course_cover"
    template_name = "courses/course_cover.html"
    form_class = StartCourseForm

    # The courses chosen by the student will be displayed here
    success_url = "/learn/"

    def post(self, request, *args, **kwargs):
        self.object = self.get_object()
        return super().post(request, *args, **kwargs)

    def form_valid(self, form):
        return super().form_valid(form)

You’ll note that I’m using Django’s LoginRequiredMixin. Indeed, the student will need a user account so we have something to link the course with. You can use Django’s built-in authentication views to create a login and a registration form. LoginRequiredMixin will take care of redirecting the user to the login form while preserving the initial URL. This way, the user will automatically be redirected to the page they wanted to access upon successful authentication.

You might also be wondering why we’re using our PageTypeMixin combined with a FormView rather than the higher-level CreateView. This is because I want the URL to be the same when GETting the cover page or when POSTing the StartCourse form. This is accomplished by combining our GetCourseCover from before with our new StartCourse view like so:

class CourseCover(View):
    def get(self, request, *args, **kwargs):
        view = GetCourseCover.as_view()
        return view(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        view = StartCourse.as_view()
        return view(request, *args, **kwargs)

This means the StartCourse view must render the same template as GetCourseCover to be able to redisplay the form in case of errors. (If the student has already started the course, for example.) To achieve this, we’re simply reusing our PageTypeMixin to make the course cover data available in the context. As the CreateView generics write to self.object, we’re using FormView instead to avoid buggy interactions. 

This also means that GetCourseCover must make the form available to the template. We can do so by adding some extra context to the GetCourseCover view: 

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["form"] = StartCourseForm()
        return context

Render the form in your template with a Start Course button  and the following hidden fields:

  • student_id: We could use request.user instead but it’s easier to have all the fields coming from the POST data
  • current_chapter: We initialize the current chapter with the first chapter of the course
  • butter_id: The butter id for the course.

All that’s left to do is adding a view to list a student’s courses:

class MyCourses(LoginRequiredMixin, TemplateView):
    template_name = "courses/my-courses.html"

    def get_context_data(self):
        context = super().get_context_data()
        courses = client.content_fields.get(["courses"])["data"]["courses"]
        user_courses_id = [
            for user_course in

        user_courses = [
            course for course in courses if course["meta"]["id"] in user_courses_id
        context["user_courses"] = user_courses
        return context

Let’s have a look at the end result! 

Login end result demonstration

Accessing a course’s chapters

Now that our students can enroll in any course they want, let’s give them access to the content! We’ll create a page for each course, which will display the current chapter, and a button to mark it as complete. 

Here’s a container diagram detailing how this will happen:

Architecture of displaying current chapter

Upon accessing the course page in Django, we’ll find the chapter that matches the current chapter for the logged-in student. Clicking the Complete Chapter button will POST the form to update the current chapter for the course and display it to the user. We’ll use the same URL for displaying the chapter and posting the form. We’ll achieve this by combining the two as we did for the cover page. 

Displaying the current chapter

Let’s start by creating a mixin for retrieving the current chapter from the ButterCMS API:

class SingleCourseMixin(SingleObjectMixin):
    def get_object(self):
        return self.get_queryset().get(butter_id=self.kwargs.get("butter_id"))

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        courses = client.content_fields.get(["courses"], {"levels": 3})["data"][
        selected_course = next(
                for course in courses
                if course["meta"]["id"] == self.object.butter_id
        context["course"] = selected_course

        context["current_chapter"] = next(
                for chapter in selected_course["chapters"]
                if chapter["meta"]["id"] == self.object.current_chapter

        return context

This mixin fetches the course matching the butter_id of the user it is accessing and extracts the current chapter from the chapters list referenced by the course collection.

Then we simply use the mixin with a DetailView to render the template to the user: 

class CourseDetail(LoginRequiredMixin, SingleCourseMixin, DetailView):
    model = Course
    template_name = "courses/chapter.html"

    def get_queryset(self):

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["form"] = CompleteChapterForm()

        return context

The LoginRequiredMixin ensures the request.user exists. Therefore, we can use the related object manager to get all the courses the user is enrolled in. The mixin does the rest of the work, and the current chapter is displayed to the user. 

Here’s what it looks like :

Rendered chapter page

The “content_chapter” form the WYSIWYG editor contains HTML, which is rendered using the safe Django filter. This means most of the rendering is provided by Butter. Pretty handy!

Looking for a powerful Django CMS that scales with your app?

Completing a chapter

To mark a chapter as completed, we’ll use the exact same strategy as the Start Course form. We’ll combine a form view with the CourseDetail view we’ve just created to have a common URL between the POST and GET requests.

Here’s the form view:

class CompleteChapter(LoginRequiredMixin, SingleCourseMixin, UpdateView):
    model = Course
    fields = ["current_chapter"]
    template_name = "courses/chapter.html"

    def get_success_url(self):
        return self.request.path

    def form_valid(self, form):
        return super().form_valid(form)

And the combined view : 

class Course(View):
    def get(self, request, *args, **kwargs):
        view = CourseDetail.as_view()
        return view(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        view = CompleteChapter.as_view()
        return view(request, *args, **kwargs)

We’re using UpdateView which updates the current_chapter attribute of the course the user is accessing. The new value for current_chapter is embedded in the HTML template using the context data from SingleCourseMixin. Remember the next_chapter reference we added to our Chapters collection schema? This is where it shines :

<form method="post">
  {{ form.errors }}
  {% csrf_token %}

  <input  type="hidden" name="{{ form.current_chapter.html_name }}" value="{{ }}">
  <input class="btn btn-primary" type="submit" value="Complete Chapter">

That’s it! Let’s admire the end result: 

End result of an online learning platform

Closing thoughts

In this tutorial, we combined the flexibility of ButterCMS with the robust models of Django to build a learning platform. Thanks to Butter’s collections and page types,  managing the course's material is a breeze. Implementing authentication, rendering the courses, and tracking progress is a perfect use case for Django.  

The platform we’ve just built could easily be extended with a ton of cool features. Here are a few examples: 

  • The ability to navigate the history of completed chapters
  • Quizzes that could be managed in Butter and taken at the end of a chapter
  • Receiving updates from ButterCMS with webhooks to notify students if a course is updated while they’re in the process of completing it

That’s all for today, I hope this article helped you understand how ButterCMS and Django can help you meet the needs of your team!

Make sure you receive the freshest Butter product updates and Django tutorials.
Jean-Baptiste Rocher

Lead Software Engineer in an Eco-Responsible Start-Up, Jean-Baptiste loves to learn and share knowledge with others. Tech Blogger and App builder, you can follow his work at

ButterCMS is the #1 rated Headless CMS

Related articles

Don’t miss a single post

Get our latest articles, stay updated!