Create a quiz app with HTMX and Django in 8 mins โ˜‘๏ธ

Published: March 5, 2024

We'll build a simple quiz application using Django and HTMX in 8 minutes. HTMX is great for creating dynamic web applications without writing JavaScript.

This includes:

  • building a multi-stage form using HTMX and Django.
  • adding data into your Django database from yaml using the Django loaddata management command.
  • generating data for your quiz app to learn whatever topic you want using an LLM

Here's how our final product will look:

Edit: Thanks to Alex Goulielmos (from Youtube) for carefully reading this guide and correcting an error. Now fixed ๐Ÿ‘

For a demo of the app, run the Replit here: Demo

I've made an optional video guide (featuring me ๐Ÿ‡๐Ÿฟ) here that follows the steps in this guide:

Let's get started ๐ŸŽ

Setup our Django app

  • Install packages and create our Django app
pip install --upgrade django pyyaml

django-admin startproject core .
python manage.py startapp sim
  • Add our app sim to the INSTALLED_APPS in settings.py:
# settings.py
INSTALLED_APPS = [
  'sim',
  ...
]

Add templates

  1. Create a folder templates in the sim app
  • Create a file start.html into the templates folder, containing:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Start your quiz</title>
    <script
      src="https://unpkg.com/htmx.org@1.9.10"
      integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
      crossorigin="anonymous"
    ></script>
    <style>
      body {
        font-family: 'Arial', sans-serif;
        background-color: #f0f0f0;
        color: #333;
        line-height: 1.6;
        padding: 20px;
      }

      #topic-container {
        background-color: #fff;
        padding: 20px;
        border-radius: 8px;
        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
        margin: auto;
        width: 50%;
      }

      #topic-list {
        justify-content: center;
        display: flex;
        flex-direction: column;
        align-items: center;
        padding: 20px;
      }

      #question-form {
        padding: 20px;
      }

      .option {
        border-radius: 10px;
      }

      .option input[type='radio'] {
        display: none;
        /* Hide the radio button */
      }

      .option label {
        display: block;
        padding: 10px 20px;
        background-color: #eeeeee;
        border-radius: 5px;
        margin: 5px 0;
        cursor: pointer;
        transition: background-color 0.3s;
      }

      .option label:hover {
        background-color: #c9c9c9;
      }

      .option input[type='radio']:checked + label {
        background-color: #818181;
        color: #fff;
      }

      #heading-text {
        text-align: center;
      }

      .btn {
        background-color: #007bff;
        color: #fff;
        border: none;
        padding: 10px 20px;
        border-radius: 5px;
        cursor: pointer;
        transition: background-color 0.3s ease-out;
        display: block;
        margin: 20px auto;
      }

      .btn:hover {
        background-color: #0056b3;
      }
    </style>
  </head>

  <body>
    <form id="topic-container" hx-post="{% url 'get-questions' %}/start">
      {% csrf_token %}
      <h2 id="heading-text">What would you like to learn about?</h2>
      <div id="topic-list">
        <p>Please pick a topic from the below topics</p>

        <ol style="list-style-type: none;">
          {% for topic in topics %}
          <li class="option">
            <input
              type="radio"
              id="topic-{{ forloop.counter0 }}"
              value="{{ topic.id }}"
              name="quiz_id"
              required
            />
            <label for="topic-{{ forloop.counter0 }}"
              >{{ topic.name }} ({{ topic.questions_count }} questions)</label
            >
          </li>
          {% endfor %} {% if not topics %}
          <li>
            No topics available. Have you added topics into your database?
          </li>
          {% endif %}
        </ol>

        <button class="btn" type="submit">Start your quiz</button>
      </div>
    </form>
  </body>
</html>
  1. Create a folder called partials in the templates folder
  • Create a file answer.html in the partials folder, containing:
<form hx-post="{% url 'get-questions' %}">
  {% csrf_token %}
  <input type="hidden" name="quiz_id" value="{{ answer.question.quiz_id }}" />

  <p>The question:</p>
  <p>{{ answer.question.text }}</p>

  <div>
    Your answer:
    <p>{{ submitted_answer.text }}</p>
  </div>

  <div>
    {% if submitted_answer.is_correct %}
    <div>
      <p>Correct โœ…</p>
    </div>
    {% else %}
    <div>
      <p>Incorrect โŒ</p>
      <p>The correct answer is:</p>
      <p>{{ answer.text }}</p>
    </div>
    {% endif %}
  </div>

  <button class="btn">Next question</button>
</form>
  1. Create a file finish.html in the partials folder, containing:
<div>
  <p>Quiz complete. You scored {{ percent_score }}%</p>
  <p>({{ score }} correct / {{ questions_count }} questions)</p>

  <a class="btn" href="{% url 'start' %}"> Start another quiz </a>
</div>
  1. Create a file question.html in the partials folder, containing:
<div>
  <form id="question-form" hx-post="{% url 'get-answer' %}">
    {% csrf_token %}

    <h2 id="heading-text">{{ question.text }}</h2>

    <ol style="list-style-type: none;">
      {% for answer in answers %}
      <li class="option">
        <input
          type="radio"
          id="answer-{{ forloop.counter0 }}"
          value="{{ answer.id }}"
          name="answer_id"
          required
        />
        <label for="answer-{{ forloop.counter0 }}">{{ answer.text }}</label>
      </li>
      {% endfor %}
    </ol>

    <button class="btn" type="submit">Submit your answer</button>
  </form>
</div>

<script>
  window.onbeforeunload = function () {
    return 'Are you sure you want to leave? You will lose your progress.'
  }
</script>

Add views

  • Copy the below into sim/views.py:
from django.shortcuts import render
from django.http import HttpResponse, HttpRequest
from django.db.models import Count
from .models import Quiz, Question, Answer
from django.core.paginator import Paginator
from typing import Optional

def start_quiz_view(request) -> HttpResponse:
  topics = Quiz.objects.all().annotate(questions_count=Count('question'))
  return render(
    request, 'start.html', context={'topics': topics}
  )

def get_questions(request, is_start=False) -> HttpResponse:
  if is_start:
    request = _reset_quiz(request)
    question = _get_first_question(request)
  else:
    question = _get_subsequent_question(request)
    if question is None:
      return get_finish(request)

  answers = Answer.objects.filter(question=question)
  request.session['question_id'] = question.id  # Update session state with current question id.

  return render(request, 'partials/question.html', context={
    'question': question, 'answers': answers
  })

def _get_first_question(request) -> Question:
  quiz_id = request.POST['quiz_id']
  return Question.objects.filter(quiz_id=quiz_id).order_by('id').first()

def _get_subsequent_question(request) -> Optional[Question]:
  quiz_id = request.POST['quiz_id']
  previous_question_id = request.session['question_id']

  try:
    return Question.objects.filter(
      quiz_id=quiz_id, id__gt=previous_question_id
    ).order_by('id').first()
  except Question.DoesNotExist:  # I.e., there are no more questions.
    return None

def get_answer(request) -> HttpResponse:
  submitted_answer_id = request.POST['answer_id']
  submitted_answer = Answer.objects.get(id=submitted_answer_id)

  if submitted_answer.is_correct:
    correct_answer = submitted_answer
    request.session['score'] = request.session.get('score', 0) + 1
  else:
    correct_answer = Answer.objects.get(
      question_id=submitted_answer.question_id, is_correct=True
    )

  return render(
    request, 'partials/answer.html', context={
      'submitted_answer': submitted_answer,
      'answer': correct_answer,
    }
  )

def get_finish(request) -> HttpResponse:
  quiz = Question.objects.get(id=request.session['question_id']).quiz
  questions_count = Question.objects.filter(quiz=quiz).count()
  score = request.session.get('score', 0)
  percent = int(score / questions_count * 100)
  request = _reset_quiz(request)

  return render(request, 'partials/finish.html', context={
    'questions_count': questions_count, 'score': score, 'percent_score': percent
  })

def _reset_quiz(request) -> HttpRequest:
  """
  We reset the quiz state to allow the user to start another quiz.
  """
  if 'question_id' in request.session:
    del request.session['question_id']
  if 'score' in request.session:
    del request.session['score']
  return request

Urls

  • Update core.urls with the below:
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
  path('admin/', admin.site.urls),
  path('', include('sim.urls')),
]
  • Create a file 'urls.py' in sim, containing:
from django.urls import path
from . import views

urlpatterns = [
  path('', views.start_quiz_view, name='start'),
  path('get-questions/start', views.get_questions, {'is_start': True}, name='get-questions'),
  path('get-questions', views.get_questions, {'is_start': False}, name='get-questions'),
  path('get-answer', views.get_answer, name='get-answer'),
  path('get-finish', views.get_finish, name='get-finish'),
]

Add your questions and answers database structure

Add models.py

  • Copy the below into sim/models.py:
from django.db import models

class Quiz(models.Model):
  name = models.CharField(max_length=300)

class Question(models.Model):
  quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE)
  text = models.CharField(max_length=300)

class Answer(models.Model):
  question = models.ForeignKey(Question, on_delete=models.CASCADE)
  text = models.CharField(max_length=300)
  is_correct = models.BooleanField(default=False)

  • Run the below to create the database table:
python manage.py makemigrations
python manage.py migrate

Load quiz data into your database

  • Load data manually by:

    • creating a Django superuser and adding data through the Django admin, or
    • adding data through the Django shell, or
    • adding data directly into your database using SQL
  • Load a batch of data into your database using the Django loaddata management command.

It is much faster to load data into your database as an entire batch, rather than adding rows individually.

Even if you want to specific questions and answers, I recommend editing the yaml file and then loading everything into your database.

Generally useful technique:

Loading data into your database is a useful general technique.

It's likely that you'll need to load data into a Django database at some point (I've done it many times for different products).

We'll use yaml because I find the syntax easy to ready and to write. You can use JSON or XML if you prefer.

Doing the fast way by using loaddata with yaml

  • Create a file quiz_data.yaml in the root folder. Here's some sample quiz data I wrote to get you started:
Click to see the sample quiz data (Copy button is at the bottom)
- model: sim.quiz
  pk: 1
  fields:
    name: Fundamental laws
- model: sim.question
  pk: 1
  fields:
    quiz: 1
    text: What does Newton's First Law of Motion state?
- model: sim.answer
  pk: 1
  fields:
    question: 1
    text: Every object in motion will change its velocity unless acted upon by an external force
    is_correct: false
- model: sim.answer
  pk: 2
  fields:
    question: 1
    text: For every action, there is an equal and opposite reaction
    is_correct: false
- model: sim.answer
  pk: 3
  fields:
    question: 1
    text: The force acting on an object is equal to the mass of that object times its acceleration
    is_correct: false
- model: sim.answer
  pk: 4
  fields:
    question: 1
    text: An object at rest stays at rest, and an object in motion stays in motion with the same speed and in the same direction unless acted upon by an unbalanced force
    is_correct: true
- model: sim.question
  pk: 6
  fields:
    quiz: 1
    text: An object moves in a circular path at constant speed. According to Newton's laws, which of the following is true about the force acting on the object?
- model: sim.answer
  pk: 21
  fields:
    question: 6
    text: The force acts towards the center of the circular path, keeping the object in motion.
    is_correct: true
- model: sim.answer
  pk: 22
  fields:
    question: 6
    text: The force acts in the direction of the object's motion, accelerating it.
    is_correct: false
- model: sim.answer
  pk: 23
  fields:
    question: 6
    text: No net force acts on the object since its speed is constant.
    is_correct: false
- model: sim.answer
  pk: 24
  fields:
    question: 6
    text: The force acts away from the center, balancing the object's tendency to move outward.
    is_correct: false
- model: sim.question
  pk: 7
  fields:
    quiz: 1
    text: When the temperature of an ideal gas is held constant, and its volume is halved, what happens to its pressure?
- model: sim.answer
  pk: 25
  fields:
    question: 7
    text: It doubles.
    is_correct: true
- model: sim.answer
  pk: 26
  fields:
    question: 7
    text: It halves.
    is_correct: false
- model: sim.answer
  pk: 27
  fields:
    question: 7
    text: It remains unchanged.
    is_correct: false
- model: sim.answer
  pk: 28
  fields:
    question: 7
    text: It quadruples.
    is_correct: false
- model: sim.question
  pk: 8
  fields:
    quiz: 1
    text: In a closed system where two objects collide and stick together, what happens to the total momentum of the system?
- model: sim.answer
  pk: 29
  fields:
    question: 8
    text: It increases.
    is_correct: false
- model: sim.answer
  pk: 30
  fields:
    question: 8
    text: It decreases.
    is_correct: false
- model: sim.answer
  pk: 31
  fields:
    question: 8
    text: It remains unchanged.
    is_correct: true
- model: sim.answer
  pk: 32
  fields:
    question: 8
    text: It becomes zero.
    is_correct: false
- model: sim.question
  pk: 9
  fields:
    quiz: 1
    text: According to the Second Law of Thermodynamics, in which direction does heat naturally flow?
- model: sim.answer
  pk: 33
  fields:
    question: 9
    text: From an object of lower temperature to one of higher temperature.
    is_correct: false
- model: sim.answer
  pk: 34
  fields:
    question: 9
    text: From an object of higher temperature to one of lower temperature.
    is_correct: true
- model: sim.answer
  pk: 35
  fields:
    question: 9
    text: Equally between two objects regardless of their initial temperatures.
    is_correct: false
- model: sim.answer
  pk: 36
  fields:
    question: 9
    text: Heat does not flow; it remains constant in an isolated system.
    is_correct: false
- model: sim.question
  pk: 10
  fields:
    quiz: 1
    text: According to the principle of wave-particle duality, how can the behavior of electrons be correctly described?
- model: sim.answer
  pk: 37
  fields:
    question: 10
    text: Electrons exhibit only particle-like properties.
    is_correct: false
- model: sim.answer
  pk: 38
  fields:
    question: 10
    text: Electrons exhibit only wave-like properties.
    is_correct: false
- model: sim.answer
  pk: 39
  fields:
    question: 10
    text: Electrons can exhibit both wave-like and particle-like properties, depending on the experiment.
    is_correct: true
- model: sim.answer
  pk: 40
  fields:
    question: 10
    text: Electrons behave neither like waves nor like particles.
    is_correct: false

Load the data into your database

  • Run the below to load the data into your Django database. It will overwrite any existing data:
python manage.py loaddata quiz_data.yaml

For more information on loading data into your Django database, see the Django docs page on providing data for models

Q. I want to export my Django database to yaml to add some data manually. How can I do that nicely?

This easy to do. Run the below to export your database's content to a yaml file. This will overwrite the file quiz_data.yaml with the data from your database.

python manage.py dumpdata --natural-foreign --natural-primary --exclude=auth --exclude=contenttypes --indent=4 --format=yaml > quiz_data.yaml

You can then add data manually to the yaml file, and then load it back into your database using the loaddata command.

See the page about loading data into your Django database in the docs for more information

Run our app ๐Ÿ‘จโ€๐Ÿš€

If running locally

If you're running the app locally, run the below to start the server:

python manage.py runserver

If running on Replit

If you're using Replit (like I am in the video), do the following:

  1. Search your files with the search bar for your .replit file. Paste in the below:
entrypoint = "manage.py"
modules = ["python-3.10:v18-20230807-322e88b"]
run = "python manage.py runserver 0.0.0.0:3000"

[nix]
channel = "stable-23_05"

[unitTest]
language = "python3"

[gitHubImport]
requiredFiles = [".replit", "replit.nix"]

[deployment]
run = "python3 manage.py runserver 0.0.0.0:3000"
deploymentTarget = "cloudrun"

[[ports]]
localPort = 3000
externalPort = 80
  1. Update your core/settings.py file to:
  • Change your allowed hosts to this:
ALLOWED_HOSTS = ['.replit.dev']
  • Add the line:
CSRF_TRUSTED_ORIGINS = ['https://*.replit.dev']
  1. Run the app by clicking the green "Run" button at the top of the screen.
  2. Click the "Open in a new tab" button to see your app running.

Your running app

Bonus: Generate good quiz data using an LLM

I used ChatGPT to generate more quiz data. It worked well. My approach:

  1. Get a sample of existing quiz data in yaml format (Such as I gave you above)
  2. Give the sample to ChatGPT and ask it to generate more data

I used the below prompt to generate more quiz data:

I'm creating a quiz app with questions and answers. Here's a sample of data in yaml format.
Write an additional quiz on fundamental laws of computer science, which would benefit every programmer. Please use the same yaml format.

---
-   model: sim.quiz
    pk: 1
    fields:
        name: Fundamental laws
-   model: sim.question
    pk: 1
    fields:
        quiz: 1
        text: What does Newton's First Law of Motion state?
-   model: sim.answer
    pk: 1
    <...>

Vary the prompt. E.g., I later added: "Avoid questions which just check the name of something" as I wanted questions to test understanding, not memorization.

Just as before, load the data into your database using the loaddata command as above.

This will give you interesting quiz data to use in your app, potentially to learn a new topic ๐Ÿ™‚

Here's a clip of the quiz using the output I get from ChatGPT:

Some raw output from ChatGPT:

Congrats - You've created a quiz app with HTMX and Django ๐ŸŽ‰

You've just built a dynamic, multi-stage quiz application using Django and HTMX.

As you can imagine, you can use this approach to build all sorts of multi-stage forms, not just quizzes.

Future things you could add include:

P. S - Build your Django frontend even faster

I want to release high-quality products as soon as possible. Probably like you, I want to make my Django product ideas become real as soon as possible.

That's why I built Photon Designer - an entirely visual editor for building Django frontend at the speed that light hits your eyes. Photon Designer outputs neat, clean Django templates ๐Ÿ’ก

Subscribe to my free newsletter

Get updates on AI, software, and business.