Simple To-Do List With Django, Alpine.Js, Tailwidcss, And Axios

While back I wrote a tutorial on how to make a todo app with Django, Alpine.js, and Axios, however, there was some room for improvement as I was just beginning with Alpine, so I decided to write a new version.

In this tutorial, I will show you how to create a simple and beautiful ToDo app with

  • Django
  • TailwindCSS
  • Alpine.js
  • Axios

We will divide this into 2 parts

  • Building The Backend with Django
  • Building the Front-end and connecting it with the Backend using TailwindCSS, Alpine.js, and Axios

Check the finished project on GitHub

Back-end

Let's start by creating a new project and its virtualenvironment, and installing Django

pip install Django

Then create a Django project, an app called tasks

django-admin startproject todo_list .
django-admin startapp tasks

Add tasks applications to INSTALLED_APPS in the settings.py file.

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'tasks' # Add newly created app
]

Now to go the tasks app, and models.py file, and create a model for our task

from django.db import models

class Task(models.Model):
    title = models.CharField(max_length=255)
    completed = models.BooleanField(default=False)

To keep things simple we just use two fields title and completed

Next let's create views for reading, creating, deleting, and updating the status of the task.

from django.http import JsonResponse
from django.shortcuts import get_object_or_404

from tasks.models import Task

def task_list(request):
    tasks = [{"id": task.id,
              "title": task.title,
              "completed": task.completed} for task in Task.objects.all()]
    return JsonResponse(status=200, data=tasks, safe=False)

def create_task(request):
    title = request.POST.get('title')
    if not title:
        return JsonResponse(status=400, data={'error': 'title is required'})
    task = Task.objects.create(title=title)
    return JsonResponse(status=201, data={'title': task.title,
                                          'completed': task.completed,
                                          'id': task.id}, safe=False)

def delete_task(request, task_id):
    task = get_object_or_404(Task, pk=task_id)
    task.delete()
    return JsonResponse(status=204, data={'message': 'task deleted'})

def update_task_status(request, task_id):
    task = get_object_or_404(Task, pk=task_id)
    status = request.POST.get('status')
    if not status:
        return JsonResponse(status=400, data={'error': 'status is required'})
    task.completed = int(status)
    task.save()
    return JsonResponse(status=204, data={'message': 'task status updated'})

Now let's create urls.py file inside our tasks app, and create url routes for our views.

from django.urls import path

from . import views

urlpatterns = [
    path('tasks/', views.task_list, name='task_list'),
    path('tasks/create/', views.create_task, name='create_task'),
    path('tasks/<int:task_id>/delete/', views.delete_task, name='delete_task'),
    path('task/<int:task_id>/update/', views.update_task_status, name='update_task')
]

Now go to the main urls.py file of our application and include the tasks app urls.

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path(, include('tasks.urls')),
]

Now run the migrations, and start our app

python manage.py makemigratinos
python manage.py migrate
python manage.py runserver

Now using HTTP client ( i am using httpie, but you can use any other tool like postman or insomia)

test our endpoints. Here are the httpie commands I am using.

# Making Get request to retrive task list
http http://127.0.0.1:8000/tasks

# To test the POST/DELETE request you will need to add @csrf_exempt 
# decorator to the views, to bypass the csrf token check, 
# so you don't need to pass csrf token in http client.
# Once we will be building front-end part you can delete the decorator

# Making POST request to create a task 
http -f POST http://127.0.0.1:8000/tasks/create/ title="Clean the dishes"

# Making task Delete request 
http -f DELETE http://127.0.0.1:8000/tasks/1/delete/

# Making POST request to update a task status
http -f POST http://127.0.0.1:8000/tasks/2/update/ status=1

Create a few tasks to populate our database.

Before moving to the front-end let's create last view for the home page. Go to views.py file and create a simple view.

def index(request):
    return render(request, 'index.html')

Finally add it to the urls.py of the tasks app.

urlpatterns = [
    path(, views.index, name='index'),
    path('tasks/', views.task_list, name='task_list'),
    path('tasks/create/', views.create_task, name='create_task'),
    path('tasks/<int:task_id>/delete/', views.delete_task, name='delete_task'),
    path('tasks/<int:task_id>/update/', views.update_task_status, name='update_task')
]

Front-end

In the tasks application create a directory called templates and inside a file called index.html

Include CDNs for TailwindCSS, Alpine.js and Axios in the <head>

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Simple ToDo List</title>

    <link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js" defer></script>
    <script src="https://cdn.jsdelivr.net/gh/alpinejs/[email protected]/dist/alpine.min.js" defer></script>

  </head>

  <body>

  </body>
</html>

We need to create a simple form for adding tasks, and task list component.

Let's start with the form. Add the input and button for adding tasks inside the body

<div class="max-w-4xl mx-auto mt-6">

      <div class="text-5xl font-extrabold leading-none tracking-tight text-center">
          <h1 class="bg-clip-text text-transparent bg-gradient-to-r from-indigo-500 via-pink-600 to-purple-900">Simple To-Do List</h1>
      </div>

      <!-- Task Input -->
      <div id="task-input" class="mt-4 flex justify-center">
          <div class="m-4 flex">
              <input class="rounded-l-lg p-4 border-t mr-0 border-b border-l text-gray-800 border-gray-200" placeholder="Task Title"/>
              <button class="px-8 rounded-r-lg bg-purple-800 text-gray-100 font-bold p-4 uppercase">Add Task</button>
          </div>
      </div>

  </div>

Task Input Screenshot

Now let's create a task component, we need one for a completed task and one for an uncompleted one.

<!-- Task Input -->
<div id="task-input" class="mt-4 flex justify-center">
    <div class="m-4 flex">
        <input class="rounded-l-lg p-4 border-t mr-0 border-b border-l text-gray-800 border-gray-200" placeholder="Task Title"/>
        <button class="px-8 rounded-r-lg bg-purple-800 text-gray-100 font-bold p-4 uppercase">Add Task</button>
    </div>
</div>

<!-- Task List -->
<div id="task-list" class="max-w-md mx-auto grid grid-cols-1 gap-2 mt-6">

    <!-- Task in progress -->
    <div class="p-4 bg-white hover:bg-gray-100 cursor-pointer flex justify-between items-center border rounded-md">
        <p>Uncompleted Task</p>
        <button type="button">
            <svg class="h-6 w-6 text-gray-500 hover:text-green-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
            </svg>
        </button>
    </div>

    <!-- Completed Task -->
    <div class="p-4 bg-white hover:bg-gray-100 cursor-pointer flex justify-between items-center border rounded-md">
        <p class="line-through">Completed Task</p>
        <svg class="h-6 w-6 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
        </svg>
    </div>

</div>

Task List Screenshot

Ok now we have our HTML, ready let's connect it all together using Alpine.js and Axios

Let's start by populating the list with the initial tasks, we created earlier.

Open a <script> tag before closing the body tag and create a function for Alpine components.

We will create a tasks array to hold our tasks, and couple functions equavilent to the endpoints we created earlier in views.py

<script>
    function todos() {
        return {
            tasks: [],
            loadTasks() {},
            addTask() {},
            deleteTask(taskId) {},
            updateTask() {},
        }
    }
  </script>
</body>

Let's start with loadTasks()

loadTasks() {
    let self = this;
    axios.get('http://127.0.0.1:8000/tasks/')
      .then(function (response) {
        // handle success
        self.tasks = response.data;
      })
      .catch(function (error) {
        // handle error
        console.log(error);
      });
}

Now let's go back to HTML, and add x-data properties and x-init to the div enclosing the task input and task list.

<div x-data="todos()" x-init="loadTasks()" class="max-w-4xl mx-auto mt-6">

x-init is used to run a function once a component is initialized, so we will get the list of the tasks we have. Now go to the task list div and inside create a <template> tag enclosing the task component, and add x-for property to iterate through all tasks on the list

<!-- Task List -->
<div id="task-list" class="max-w-md mx-auto grid grid-cols-1 gap-2 mt-6">
    <template x-for="task in tasks">
        <div class="p-4 bg-white hover:bg-gray-100 cursor-pointer flex justify-between items-center border rounded-md">