Create a quiz app with HTMX and Django in 8 mins ☑️

Updated: Fri 29 March 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
  2. 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
  2. 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>
  <br>
  <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

The slow way (Not recommended)

  • 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

The fast way (Recommended)

  • 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](https://docs.djangoproject.com/en/5.0/howto/initial-data/) 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:
  2. 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 💡

Let's get visual.

Do you want to create beautiful django frontends effortlessly?
Click below to book your spot on our early access mailing list (as well as early adopter prices).