A Simple Blog - from start to deployment

Module 1 — Foundations: Build the Core Application

About This Project

This project is meant to show you the full development path — it follows a Django project from the basics all the way to production-grade deployment. If you’ve followed a few Django tutorials before, you’ll likely notice some differences in how we approach things here. Rather than introducing features in isolation, everything here is built as part of a system that evolves step by step.

We focus on:

  • understanding how Django components interact
  • building patterns that scale as the application grows
  • introducing small decisions early that prevent refactoring later

The goal is not to make things more complex, but to make them clearer — so the application can evolve without constantly being rewritten.


This module is part of the full learning path:

Learn Django — From First Project to Production.


In this module, we focus on building a solid foundation — understanding how Django projects are structured and how the core pieces fit together and gradually evolving it into a structured system.

Along the way, you’ll learn the core Django fundamentals:

  • views
  • urls
  • models
  • templates
  • forms
  • authentication

As the project grows, we’ll extend it with more advanced features.

This guide assumes a basic understanding of Python, HTML, and CSS.

Whenever we touch on more advanced topics, we’ll link to focused explanations so you can go deeper where needed.

Basic set up

Create a directory as the base for your project. I'll refer to this directory as the base directory from now on. Open it in VSCode (or another editor / IDE) and let's get started.

Set up a virtual environment

A virtual environment isolates this project's Python packages from your global system installation. That helps with clear project separation and later deployment.

We’ll use VSCode here, but any editor works.

In VSCode, setting up a virtual environment is straightforward:

  1. Open View → Command Palette (Ctrl + Shift + P)
  2. Search for: Python: Create Environment
  3. Choose one of the following:

  4. Quick Create
    VSCode selects the Python version automatically.

  5. venv
    Choose your Python version manually, set a name (.venv in this guide), and skip PyPI package installation.

  6. After creation, a new folder named .venv should appear.

  7. Activate the environment if VSCode did not do so automatically:

  8. Windows (cmd): .venv\Scripts\activate

  9. Linux / Debian: source .venv/bin/activate

  10. After activation, your terminal should begin with: **(.venv)''

A guide on virtual environments on Windows systems can be found here: Virtual Environments on Windows

And a guide on virtual environments on Linux Systems can be found here: Virtual Environmetns on Linux


Production Note:

Using a virtual environment for every Python project is standard practice.

It prevents dependency conflicts between projects, keeps deployments reproducible, and avoids polluting your global Python installation.

Even small local projects should start with an isolated environment.


Install and Set Up Django

With your virtual environment activated:

Install Django
bash

1
pip install django
Set up a basic project

To set up a basic project, after installation, type in command line:

bash

1
django-admin startproject main . 

Let's go over this command:

  • django-admin
    • Comes with the django pip package is the utility for administrative tasks (if you want to know all of its capabilities, run: ´´´bash django-admin help ´´´
  • startproject
    • As the name suggests command to start a new django project
  • main
    • The name of the new project. You can freely pick any name you want, I always use main to keep it consistent throughout my projects
  • .
    • The target directory. '.' will tell Django to put the project directories and a new manage.py file (more on that later) in the current working directory (the same directory your .venv is located). You can choose a sub directory if you want, but I keep it in the top directory to have manage.py in the top-level directory as well.

If the command ran successfully you will now see a new directory 'main' and a new file 'manage.py' inside your base directory. manage.py works similarly to django-admin with the exception that it points to your main/settings.py file and does not run universally like django-admin.

More detail on manage.py and the main project folder here: The base for any Django project

To check if the installation was successful, run:

bash

1
python manage.py runserver

The command in detail:

  • python -> Executes the Python script
  • manage.py -> Script for administrative tasks, bound to this Django project
  • runserver -> Starts Djangos built-in development server. By default on localhost (127.0.0.1) and port 8000

The command line should now look similar to this:

bash

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).

You have 18 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.
April 21, 2026 - 12:33:43
Django version 6.0.4, using settings 'main.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CTRL-BREAK.

WARNING: This is a development server. Do not use it in a production setting. Use a production WSGI or ASGI server instead.
For more information on production servers see: https://docs.djangoproject.com/en/6.0/howto/deployment/
[21/Apr/2026 12:33:44] "GET / HTTP/1.1" 200 12068

Some warnings are expected at this stage. We’ll address them shortly.

Ctrl + Click on the link provided in the command line (http://127.0.0.1:8000/)

The browser should open, showing a window like this:

Django start page after successful installation

This confirms that the project was set up successfully.

Now go back to VSCode's command line and hit Ctrl + C to end the development server.

You should also notice a new file called 'db.sqlite3'. This file was created by Django and is the default database for your project. By default, Django uses sqlite3. This database will contain all data related to your project, from Django core data like sessions and data specific to your project, but more on that later.

If you want to know more about sqlite3, check this article: sqlite3 Basics


Production Note:

Django’s built-in development server is intended for local development only.

It is convenient for testing, auto-reloads code changes, and helps during early builds — but it is not designed for performance, security, or production traffic.

Real deployments typically use dedicated application servers such as Gunicorn or Uvicorn behind a reverse proxy like Nginx.

We’ll cover proper deployment later in Module 3.


Chapter Recap

In this chapter, we prepared the foundation for every Django project.

We covered:

  • creating a dedicated base directory for the project
  • setting up and activating a Python virtual environment
  • installing Django inside the isolated environment
  • creating a new Django project with django-admin startproject
  • understanding the purpose of manage.py
  • starting Django’s built-in development server
  • verifying the installation in the browser
  • identifying the default db.sqlite3 database file

Key Takeaways

  • Use a virtual environment for every Python project
  • manage.py is the main command utility for project-specific Django tasks
  • runserver is for development only
  • Django ships with SQLite by default for quick local setup
  • A clean setup early prevents confusion later

Next Step

Now that the project is running, we can start talking about the structure of most Django projects and how the first application is added.

Set up the app

Now that the basic project is ready, we set up our blog app to add functionality to our project.

In Django, apps represent self-contained modules that handle a specific piece of functionality within the project.

Think of a Django project as a whole website, and apps as the website's building blocks. Each app focuses on one responsibility.

For example our blog could be structured like this:

  • blog app -> handles articles
  • comments app -> handles user comments
  • accounts app -> handles user authentication

We'll start with the base for our blog - the blog app.

In order to set up an app, run:

bash

1
python manage.py startapp blog

The command in detail:

  • python -> Executes the Python script
  • manage.py -> Script for administrative tasks, bound to our app
  • startapp -> As it states, command to start a new app
  • blog -> The new app's name. You can pick any name you want here, though it should be a short, yet descriptive name.

After the command ran, you should see a new directory called 'blog'. This directory holds a number of files and we'll cover what they do individually in the next section. We will also have to add a few files to this new directory as well.

Do you want to get to know Django apps in detail? Check this article right here: Django Apps in Detail

After starting the app we have to register it with Django. That way Django can find the app’s models, configs, templates, static files, and migrations and what to load when the application starts.

We register the new app inside main/settings.py:

Under INSTALLED_APPS we add the name of the app near the end of the list. In many projects this is a common convention, though exact ordering only matters in certain cases.

python

1
2
3
4
5
6
7
8
9
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'blog'
]

If you don't register your app in settings it simply won't load on startup.


Production Note:

Use apps to separate business domains, not to create artificial complexity.

A few well-structured apps usually scale better than many tiny apps with unclear boundaries.

Module 2 will cover app structure and organizing projects in more detail. In this Module we will introduce more apps as we add more functionalities.


Render web pages through views, urls, and templates

Now we're all set to build our first actual blog page. As most web development projects, we'll start with the homepage - in most web dev projects called index.

In order to do that we first have to write our structure in HTML and do a little styling using CSS.

Let's talk about HTML first.

Templates

When it comes to HTML pages, in Django they're referred to as templates.

We have two basic ways to store these in our project:

1. Inside the app directory

This way we create a directory called 'templates' inside the app's directory and store the templates there. That way each app inside the project will have it's own templates directory.

The folder structure looks like:

BASE
    |-blog
        |-templates
            |-blog_index.html
            |-something_else.html
    |-comments
        |-templates
            |-comments_index.html
            |-something_else.html
    .
    .
    .
2. One templates directory

In this approach we only create one templates folder inside the Base directory and sort the HTML files in the templates directory by app.

The folder structure looks like:

BASE
    |-blog
    |-comments
    |-templates
        |-blog
            |-blog_index.html
            |-something_else.html
        |-comments
            |-comments_index.html
            |-something_else.html
    .
    .
    .

Regardless of what approach you choose, the directory should be called templates in order for Django to recognize it as the place where to find our HTML. You can tell Django that you named it something else, however I would not recommend it since your code will be harder to read for other devs.

There is no real advantage to each approach and both are totally valid. It comes down to preference really. In most of my projects I prefer the second approach. Most of my projects consist of multiple apps that all require their own templates. That way I have one centralized place to store all my templates without having to go through all app directories.

Though, the first approach would be fine here for now (only one app, so only one templates directory). In this project we'll also use the second approach, since we'll expand the application later on. So we create a directory inside the base directory called templates and another directory inside templates called blog (like our app name).

We have to tell Django where to find our templates. If you chose the first approach (storing templates inside the app directory) you can omit this step, since Django by default will look inside each app's directory for templates.

If you chose the second approach, we have to edit main/settings.py TEMPLATES:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

Under DIRS we add the location of our templates directory, so Django knows that it will find our HTML there.


One of the biggest advantages of using Django is its built-in render engine. It allows for HTML templates that can be dynamically filled with whatever the backend provides. Another advantage is one template can be used as basis for any number of following templates. That way we only have to write the basic HTML structure ones.

Base Templates

To do that we'll create a file called base.html (can be called anything, however convention calls it base). Inside this file is where our base HTML structure and everything that following pages need (like navigation and footer) will live.

BASE
    |-blog
    |-comments
    |-templates
        |-blog
            |-base.html

base.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>

</body>
</html>

If you're using VSCode, inside a blank HTML file you can type ! and than hit tab to autocomplete a basic HTML layout as shown above.

Now we have to use something called templatetags.

Templatetags make the HTML file a template.

These tags will be replaced / filled by the render engine with HTML lines. That's how the base.html file works. We will tell the render engine to use the base.html file as foundation and then have the render engine replace the tags with whatever is written in the template that's using the base.html file.

Generally templatetags are formatted like

{%%}

or

{{}}

tags with:

{%%}

are used to mark blocks or to run code, like for loops or conditionals (later more on loops and conditionals).

tags with:

{{}}

are used to insert variables and run filters (more on that later).

For now we're only interested in:

{%%}

since we'll use this to mark positions inside our base.html file the render engine can later replace.

Let's add those tags to our HTML inside the base.html file:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>
        {% block title %}

        {% endblock %}
    </title>
</head>
<body>
    {% block content %}

    {% endblock %}
</body>
</html>

As you can see, we added:

{% block title %}

{% endblock %}

and

{% block content %}

{% endblock %}

These templatetags have an opening tag i.e. 'block title' and a closing tag 'endblock'. The wording hints at what they do. They mark a block that's to be replaced. And that's exactly what's going to happen when templates get rendered -> The render engine will replace these tags with what's in subsequent files.

Whatever comes after the 'block' keyword is the name of this block. The name can be chosen freely and we can add as many blocks to a template as we want and we can later use the blocks as we see fit. In this case convention suggests calling the block inside the tags 'title' to make it obvious what it does. Same with 'content', since this blocks marks the actual content position.

Every block tag has to be closed with endblock, regardless of what the block is called. Otherwise Django will raise an exception.

Since our blog will need a navigation on each page, we'll add a basic nav to our base.html page with some placeholders until we have actual subpages to link to:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>
        {% block title %}

        {% endblock %}
    </title>
</head>
<body>
    <nav class="main-nav">
        <a href="">Home</a>
        <div class="link-container">
            <a href="" class="nav-link">Link 1</a>
            <a href="" class="nav-link">Link 1</a>
            <a href="" class="nav-link">Link 1</a>
            <a href="" class="nav-link">Link 1</a>
        </div>
    </nav>
    {% block content %}

    {% endblock %}
</body>
</html>

That's our very basic base.html file done.

Now to write the first actual page for our blog. And as previously hinted, we'll start with the index.

Inside templates/blog we create a file called index.html

Inside the file we'll add these lines:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{% extends 'blog/base.html' %}

{% block title %}

{% endblock %}


{% block content %}

{% endblock %}

Doesn't look much like HTML, right? But that's exactly the point. We don't have to re-write full HTML for every page. Django is going to take care of that.

Let's talk about what's in that file so far:

  1. {% extends 'blog/base.html' %}

    • This is the tag that tells Django what template to use as foundation
    • The extends keyword tells Django 'extend the specified template with the following content'
    • 'blog/base.html' specifies what template to extend. The path is relative to the template path. Since base.html lives inside templates/blog -> blog/base.html is enough for Django to find it.
    • Since 'blog/base.html' is a path, it has to be enclosed by '' or "" for Django to recognize it as such.
    • The extends tag has to be the first line in a file that utilizes it. Otherwise Django will raise an exception
  2. {% block title %}, {% block content %}

    • As you remember, we have used the same exact tags inside our base.html file
    • In subsequent files to base.html these tags now mark the beginning and end of the HTML that will be placed inside the base.html file during render.
    • Inside base.html, these tags serve as placeholders for what will be inside these tags in subsequent files.

To illustrate this more, let's fill this tags inside index.html with some HTML:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{% extends 'blog/base.html' %}

{% block title %}
    My first blog app written in Django
{% endblock %}


{% block content %}
<article>
    <header>
        <h1>Welcome to my blog!</h1>
    </header>
    <main>
        <p>
            Some awesome text and a few sponsored posts
        </p>
    </main>
    <footer>
        <p>
            Thanks for reading! See you soon!
        </p>
    </footer>
</article>
{% endblock %}

That's how these tags work. base.html specifies their names and holds them as placeholders. Subsequent files use the same tags but fill them with content.

So far, so good. We've written basic HTML. Now let's get into how to render the files and how we can access our pages from the browser.

Views

In order to do that, we'll first have to edit blog/views.py.

Webpages in Django are called views, hence the name of the file views.py. It holds the logic necessary to turn code and templates into actual HTML pages.

Our first view is going to be index.

In this project we'll define our views as functions.

Basic view in blog/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.shortcuts import render



def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'blog/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])

I use Python type hints throughout my projects. If you're not familiar with that, here's an article covering this topic: Python Type Hints

Let's go over these few lines of code, skipping the imports

  • def index(request: WSGIRequest) -> HttpResponse:
    • We define a standard function-based view that takes a request and returns a response.
    • The only parameter for this view is request. This is the request sent by the client to the server requesting this page (view). Aside from the actual HTTP request it also holds session information, authentication status and so on (more on that later)
    • Here the request is a WSGIRequest, since the Django dev server uses WSGI
    • The request parameter is the first parameter in every view you write, regardless of context. It holds the reference to the requester and all accompanying details. The request object carries all information about the incoming request, including headers, session data, and authentication state.
    • This view will return an HttpResponse, containing the rendered index.html
  • if request.method == 'GET':
    • We check the HTTP request method here. As you can see, the method for every request is stored in the .method property of the request object.
    • This explicitly defines how the view is allowed to be used and prevents unintended request methods from reaching the application.
    • Only if the request method is GET, we return the requested page.
    • In simple examples this check is often omitted, but defining allowed methods early helps avoid unexpected behavior as the application grows.
  • return render(request, 'blog/index.html')
    • The render function does what the name suggests and what I mentioned plenty of times already. It renders the templatetags into actual HTML
    • The rendered HTML is then returned in form of an HttpResponse to the client
    • The request argument is needed so Django knows what was requested and where to send the response to
  • else: return HttpResponseNotAllowed(['GET'])
    • In case the request used any method other than GET we return a HttpResponseNotAllowed() (405 status code) response to explicitly signal that the requested HTTP method is not supported by this endpoint.
    • The ['GET'] argument adds the allowed methods to the response

And that's all for a very basic function-based view.

At this point, we’ve defined not just what this view returns, but how it is allowed to behave. This distinction becomes important as applications grow and multiple components interact.

If you're not familiar with HTTP request methods, here's a cheat sheet: Request Methods Cheat Sheet

If you're not familiar with HTTP response status codes, here's a cheat sheet: Response Codes Cheat Sheet


Production Note:

Explicitly restricting HTTP methods ensures that endpoints behave predictably and reject unintended requests. In larger systems, this is often enforced consistently across views or middleware layers. Skipping this early can lead to inconsistent APIs and harder-to-debug issues as the application grows.


Now we have to define the routing, or in other words what we have to type into our browser to request this view. That's done by using urls.

urls

In order to set urls, we first need a file inside blog called urls.py. We have to manually create the file since Django does not do that on app start.

In theory, we can name this file whatever we want, but urls.py is the convention.

Now, in blog/urls.py:

python

1
2
3
4
5
6
7
8
9
from django.urls import path
from . import views


app_name = 'blog'

urlpatterns = [
    path('', view=views.index, name='index'),
]

The file has to contain a list called urlpatterns. Otherwise Django will not recognize the defined paths as URLs!

Let's go over this:

  • app_name = 'blog' -> Not necessary for single-app projects. However since we will expand this project later, we'll add this now so we'll be able to cleanly distinguish different urls.py files right from the start. You don't have to name it the same your app is named, but it's recommended.
  • urlpatterns
    • The list holding all urls for your app
    • As already mentioned, this name is mandatory
  • path('', view=views.index, name='index')
    • The path function turns the parameters into a usable URL
      • '' -> the actual URL you have to type in your browser. '' means that this is the index / homepage that should render when users just type the domain (i.e. https://bytestaq.com)
      • view=views.index -> The reference to what function to call when the URL is requested. In this case if someone request the domain, the request gets routed to the index view which will return the rendered HttpResponse
      • name='index' -> The internal name for this URL. This name can be used internally to refer to this URL. Mostly used inside templates for linking. We will use this attribute shortly.

Now the app is all ready to receive requests and return responses. There is only one thing left to do: We have to tell the project that this urls.py file exists and that requests should be routed here. We do that by editing main/urls.py.

Inside the main/urls.py

python

1
2
3
4
5
6
7
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('blog.urls', namespace='blog'))
]
  • In line 2 we added include to the import
  • Line 6 references our recently created blog/urls.py file
    • '' in the beginning represents the prefix for all URLs specified in blog/urls.py. Since the blog is supposed to be the main content and it's supposed to be returned when users request the domain, we don't use any prefix.
    • include('blog.urls', namespace='blog') -> The include function automatically resolves all urls specified in blog/urls.py into main/urls.py. namespace takes the name of the app_name variable specified in blog/urls.py. This allows for clean association.

Production Note:

Namespacing URLs prevents collisions between apps and keeps routing predictable as the project grows.

This becomes especially important once multiple apps or reusable modules are introduced, where overlapping route names can otherwise cause subtle bugs.

Setting this up early avoids this issue entirely.


Now set up is done and we can test our base set up.

Run:

bash

1
python manage.py runserver

Click the link in the command line and you should see something like this: First index page without any styling

And this confirms that the templatetags work as expected. The navbar shows in our index even though it was only set in base.html and the content and title block are rendered as written in index.html

Now this is obviously looking quite terrible, since we haven't added any styling yet. Let's change that!

Static Files

First, we have to talk about how Django handles static files:

Similar to the templates we'll also have to store our static files (images, css, etc.) somewhere. For those files we have the exact same options as we had for our templates with the exception that the directory is not called templates but static:

1. Inside the app directory

The folder structure looks like:

BASE
    |-blog
        |-static
            |-css
            |-img
    |-comments
        |-static
            |-css
            |-img
    .
    .
    .
2. One static directory

The folder structure looks like:

BASE
    |-blog
    |-comments
    |-static
        |-blog
            |-css
            |-img
        |-comments
            |-css
            |-img
    .
    .
    .

Regardless of what approach you choose, the directory should be called static in order for Django to recognize it as the place where to find static files. You can tell Django that you named it something else, however I would not recommend it since your code will be harder to read for other devs.

Same with the templates, there is no real advantage over either approach but for the same reasons as with the templates I prefer the second approach and we'll choose the second approach here as well here since we plan to expand the app later on.

In analogy to the templates directory, we'll have to tell Django where to find our static files.

If you chose the first approach, again you can omit this step, since by default Django will look for static files in each app's directory.

If you chose the second approach edit main/settings.py:

Under STATIC_URL add:

python

1
2
3
STATICFILES_DIRS = [
    os.path.join(BASE_DIR, 'static/'),
]

This way you can add locations for Django to look for static files. Since we store our static files centralized, we only need the location of our static dir inside our base directory.

Now that Django knows where to look for our static files, we'll make add the static directory structure.

First the main static directory inside the base folder called static. Inside static, another folder called blog and another directory inside blog called css:

BASE
    |-blog
    |-comments
    |-static
        |-blog
            |-css

Inside the css directory, we add a style sheet, I'll call it main-style.css

Now to load the file inside our HTML we will have to use the static templatetag. This is the first templatetag we'll have to explicitly load inside the file before we can use it.

If this is getting confusing, you can read more on templatetags here: Django's templatetags explained

You can also check out the cheat sheet on templatetags: Django templatetags cheat sheet

If you need to load a templatetag, use the load keyword. The updated base.html:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>
        {% block title %}

        {% endblock %}
    </title>
</head>
<body>
    <nav class="main-nav">
        <a href="">Home</a>
        <div class="link-container">
            <a href="" class="nav-link">Link 1</a>
            <a href="" class="nav-link">Link 1</a>
            <a href="" class="nav-link">Link 1</a>
            <a href="" class="nav-link">Link 1</a>
        </div>
    </nav>
    {% block content %}

    {% endblock %}
</body>
</html>

We added {% load static %} at the very top of the file. This makes the {% static %} tag available throughout the base.html file.

Important: Load only happens inside the file it is called in. So even though base.html serves as the foundation for all other files in the blog app and it runs {% load static %} we will still have to run {% load static %} in every subsequent file again if they use the static tag themselves.

Now to load our main-style.css file into base.html we add another line with a templatetag:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="{% static 'blog/css/main-style.css' %}">
    <title>
        {% block title %}

        {% endblock %}
    </title>
</head>
<body>
    <nav>
        <a href="">Home</a>
        <div class="link-container">
            <a href="" class="nav-link">Link 1</a>
            <a href="" class="nav-link">Link 1</a>
            <a href="" class="nav-link">Link 1</a>
            <a href="" class="nav-link">Link 1</a>
        </div>
    </nav>
    {% block content %}

    {% endblock %}
</body>
</html>

Line 8 now holds the templatetag to load our newly created css file.

What this tag does:

  • Look in the path specified in settings.py
  • Find the file with the specified sub-path after the static keyword

The subpath (blog/css/main-style.css in this case) has to be enclosed with either '' for it to be recognized as a path string.

The css file will now be available in all files using base.html as their foundation!

Now to write some basic css for our project in static/blog/css/main-style.css:

css

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/* General */

* {
    box-sizing: border-box;
}

html {
    font-family: 'SpaceGrotesk', Arial, Helvetica, sans-serif;
    line-height: 1.7;
    letter-spacing: 0.015em;
    font-size: 1.1rem;
    word-spacing: 0.02em;
    font-weight: 500;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

body {
    padding: 0;
    margin: 0;
    width: 100vw;
    background: rgb(3, 3, 10);
    color: white;
}


/* Navigation */
.main-nav {
    width: 100%;
    display: flex;
    justify-content: space-between;
    padding: 3rem 1rem;
    position: sticky;
    top: 0;
    border-bottom: 1px solid grey;
}

.main-nav  {
    & .link-container {
        width: 50%;
        display: flex;
        justify-content: space-evenly;
    }
}

I use nested css. I find it easier to read and there is less repetition when addressing deeply nested items. If you're unfamiliar with that:

.main-nav {
    & .link-container{
        ...
    }
} 


is the same as 


.main-nav .link-container {
    ...
}

Now refresh the page and the index should look a little better since our basic styling is applied.

First index page with applied basic styling


Production Note

In case your styling is not applied, hit F12 to open the developer tools. Then switch to console. If you see an error regarding your .css file (i.e. 404 not found) go back and make sure your static paths have been set correctly in settings.py and inside the HTML. If there is no error, ensure that you have no syntax errors in your .css file and that you referenced the class names inside your HTML structure correctly.


Chapter Recap

In this chapter, we built the first visible part of our Django project and connected the core pieces required to render web pages.

We covered:

  • how Django uses templates for HTML pages
  • different ways to organize template directories
  • configuring Django to find templates
  • creating a reusable base.html layout
  • using template tags such as block and extends
  • creating our first function-based view
  • handling allowed HTTP methods with GET
  • returning rendered HTML with render()
  • defining app-level routes in urls.py
  • connecting app URLs to the main project router
  • organizing and loading static files
  • linking CSS files through the {% static %} template tag

Key Takeaways

  • Templates separate presentation from backend logic
  • Base templates prevent duplicated HTML across pages
  • Views receive requests and return responses
  • URLs connect browser requests to views
  • Static files (CSS, images, JS) should be organized intentionally
  • Small structural decisions early make future growth easier

What We Built

At this point, the project can now:

  • receive browser requests
  • route them correctly
  • render HTML pages
  • reuse layouts across pages
  • apply styling through static assets

Next Step

Now that the project can render pages correctly, we can begin adding real application data and dynamic content using Django models and databases.

Django models

Models are a core pillar of Django. They represent database tables inside your database.

We need models in our project if we want to store, edit or generally handle data. In the beginning I mentioned a file db.sqlite3 that appeared in your base directory. That's the database Django will write tables to when setting up models. Now for our project a model will be the foundation for our blog posts. In contrast to traditional, static websites we don't have to write an individual page for each post and store that. We'll write a template (similar to our index.html) and populate that template dynamically with the post data requested by the user.

In order to do achieve that we'll need a model to store our posts in. Then, whenever a user requests that post, we'll load its content from the model and pass that data to the render engine.

Basic model

We set models inside each app directory inside a file called models.py. In our blog it's blog/models.py

Now, to set a model, we have to define a class and fill that class with details about our model or to be more precise, details about the data we're planning to store in it.

In blog/models.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from django.db import models



class BlogPostModel(models.Model):
    title = models.CharField(max_length=100, default='')
    slug = models.SlugField(unique=True)
    url = models.CharField(max_length=200, default=None, unique=True, null=True)
    summary = models.CharField(max_length=300, blank=True)
    content = models.TextField()
    published = models.DateTimeField(auto_now_add=True)

Let's go over our new model line-by-line:

  • class BlogPostModel(models.Model):
    • The basic class definition as a subclass of django's models.Model
    • We have to use models.Model as our parent class in this case in order for Django to recognize this class as a model
    • The name for any model can be chosen freely. However, I always add 'Model' to the end of every class name. We have to import this model later in various places. By tagging 'Model' to the end of it it's always obvious that we're dealing with a model class.
  • After the class definition we define the table columns (what data we're planning to store and the column name). The column name is equal to the variable name (first definition 'title' will be the column name) and the data type for that column is defined by using models.XXXField
    • title = models.CharField(max_length=100)
      • 'title' column. Will hold the post's title
      • Data type string (models.CharField)
      • maximum length of 100 (maximum number of characters for this column)
    • slug = models.SlugField(unique=True)
      • 'slug' column. Will hold the post's slug (a Slug is the unique identifying part of the URL, typically at the end of the URL)
      • Data type string (models.SlugField), however it differs from CharField since Django sets parameters like max_length automatically
      • unique=True to ensure that each slug is unique.
    • summary = models.CharField(max_length=300, blank=True)
      • 'summary' column. Will store a short summary of our post.
      • Data type string (models.CharField)
      • maximum length of 300 (maximum number of characters for this column)
      • blank=True; meaning this CharField is optional. In case we have a very short post a summary might be covering too much. In case no value is supplied, Django will set it's value to an empty string.
    • content = models.TextField()
      • 'content' column. This is where our actual blog post lives
      • Data type text (models.TextField)
      • No limit on characters
    • published = models.DateTimeField(auto_now_add=True)
      • 'published' column. Holds the timestamp of when the post was published.
      • Data type timestamp. Important here is that Django stores timestamps as timezone aware. Naive datetimes can cause issues when USE_TZ=True. Prefer Django timezone utilities such as timezone.now().
      • auto_now_add=True; when data is inserted, the timestamp is automatically inserted with the current timestamp as its value.

This is our basic blog post model that will store our posts.

Now to add our model to the database, so we can start writing posts!

In the beginning I told you to ignore any warnings when we first started the development server. Now we'll deal with the main warning in that message:

bash

1
2
You have 18 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.

Django requires two commands whenever changes to the database or models are made:

  1. makemigrations
  2. migrate

makemigrations

This command detects changes in your models.py files and turns classes inside models.py into migration files.

makemigrations translates model changes into versioned instructions for the database

What it does in steps:

  • Compare current models to the last saved state
  • Generate a migration file (Python code) describing the changes
  • Store migration files in your app’s migrations/ folder

In case the development server is still running hit Ctrl + C to quit it.

Now if we run makemigrations, Django will detect the changes we made to blog/models.py (adding class BlogPostModel) and generate a migrations file inside blog/migrations/

bash

1
python manage.py makemigrations

The output of that command should look similar to this:

bash

1
2
3
Migrations for 'blog':
  blog\migrations\0001_initial.py
    + Create model BlogPostModel

Production Note:

Treat migrations as source code.

Migration files should be committed to version control and reviewed like any other code change, since they define how production databases evolve safely over time.

Avoid changing or deleting old migrations manually once shared with others.

Instead, create new migrations that evolve the schema forward in a controlled way.


However, makemigrations is just the first step.

migrate

The changes you made and registered by running makemigrations have to be applied. This is what migrate does.

migrate executes the migration files to update the database schema

What it does in steps:

  • Look at all migration files in your apps
  • Check which ones have already been applied
  • Run any pending migrations in order
  • Update the database (create tables, add fields, etc.)

So in order to apply any changes to your database you have to run:

bash

1
python manage.py migrate

The output of that command should look similar to this:

bash

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying blog.0001_initial... OK
  Applying sessions.0001_initial... OK

After this, all changes to your models will have been applied to your database and your newly created model will be ready to be filled with data.

This is also why we had the warning message after starting our project. When starting a project, Django generates migration files for database tables that come with Django by default (admin, auth, contenttypes, sessions). However, these migrations weren't applied until now since we hadn't run the migrate command.

When starting the development server now, the aforementioned message about unapplied migrations should not appear anymore.

Chapter Recap

In this chapter, we introduced one of Django’s most important building blocks: models.

Models define how application data is structured and stored inside the database.

We covered:

  • what Django models are and why they matter
  • how models map Python classes to database tables
  • creating our first BlogPostModel
  • defining fields such as text, slugs, and timestamps
  • why dynamic websites use databases instead of static pages
  • how Django tracks database changes through migrations
  • the difference between makemigrations and migrate
  • applying our model to the default SQLite database

Key Takeaways

  • Models are the foundation for storing structured application data
  • Each model field becomes part of the database schema
  • Django models let us work with Python objects instead of raw SQL
  • makemigrations creates change instructions
  • migrate applies those instructions to the database
  • Schema changes should always go through migrations

What We Built

At this point, the project now has:

  • a real database-backed model for blog posts
  • a database table managed by Django
  • the ability to store and retrieve post data later in the project

Next Step

Now that the database structure exists, we can begin creating, loading, and displaying real blog posts inside our templates and views.

Django's admin panel

Now to make our first post! However you might rightfully ask: How? There is no place to enter anything.

That's where we'll use Django's admin panel.

You may have noticed line 2 when we added our urls to main/urls.py:

python

1
2
3
4
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('blog.urls', namespace='blog'))
]

Line 2 is the URL to the admin panel, Django provides by default.


Production Note

The generic 'admin' URL is fine in development.

Many production systems either comment out the admin line (to remove access it entirely, if the admin console is not needed in production) or they change the default admin path to reduce automated noise, but real security comes from authentication, MFA, IP restrictions, monitoring, and proper configuration.

You should also take one of these measures but always keep in mind that obfuscation does not mean security! Just because something is hidden does not mean that it's safe.

We'll talk about this in Module 3 when properly securing our project for deployment.


So start the development server again, by running:

bash

1
python manage.py runserver

Now enter the URL http://127.0.0.1:8000/admin

You should see this window: Django's admin panel login view

This is the login for our admin panel. The problem right now is, that we don't have a user we can log in as. Let's fix that.

Stop the development server again (Ctrl + C) and run the following command:

bash

1
python manage.py createsuperuser

This will create a superuser (admin) for our project. After running the command you will be prompted for a username, email and password.

For this example, I will choose the username 'bytestaq', leave the email field blank (just hit Enter), and choose a password.

We can now log in, using these credentials!

Start the development server again, by running:

bash

1
python manage.py runserver

and, again navigate to: http://127.0.0.1:8000/admin

Now type in your credentials and hit the 'Log in' button.

You will be redirected to this view: Django's admin panel index

In the left column under 'Site administration' you will see registered models available in the admin. Under 'Authentication and Authorization' are users and groups. If you click on 'Users' you see all registered users for your project, their, details, status, etc.

Django's admin panel login view

Clicking on a username (bytestaq in this example) loads the entire row from the database and opens a new, more detailed view. This allows you to modify data and delete the entire user, if you wanted to.

If you go back to the main admin panel site (clicking the 'Django administration' text in the top left will get you there quickly), you will notice that our blog app and our BlogPostModel are missing from this view. This is because we haven't told Django to display those here. We have to explicitly tell Django what models to display inside the admin view. Rather than listing all models by default, Django provides us with the option to pick and choose what models should be modifiable from the admin view.


Production Note:

Django’s admin panel is primarily an internal management tool.

It is excellent for staff operations, moderation, and data maintenance, but public-facing customer workflows usually require custom interfaces tailored to users. We'll do that later in this Module.


To have a model show up in Django's admin panel we have to edit the admin.py file inside the app's directory.

In our example blog/admin.py

In blog/admin.py

python

1
2
3
4
5
6
from django.contrib import admin
from . models import BlogPostModel



admin.site.register(BlogPostModel)
  1. We import the BlogPostModel from the apps models.py
  2. The we use admin.site.register(BlogPostModel) to register the model with the admin Panel

Now save the file (Ctrl + S). The development server reloads automatically on file changes (as long as the saved file has been imported somewhere in the project).

If we go back to http://127.0.0.1:8000/admin and refresh the page, we now see our app and the BlogPostModel: Django's admin panel index with the blog app showing

If we click the 'Blog post models' we get redirected to a different view that shows a list of our BlogPostModel objects (0, since we haven't made any posts yet.) and a button in the top right to add a new post (ADD BLOG POST MODEL +). If you click that button, you'll be redirected here:

Django's admin panel blog post entry

This is where we'll make our first post!

- Title: My first post
- Slug: my-first-post
- Summary: My very first post in this Django project
- Content: Just built my first project with Django 
           It’s a simple blog app where I can create, edit, and view posts. Learned a lot about models, migrations, and how Django structures apps to keep everything clean and scalable.
           Next step: render posts in views

Now hit save and your first post has been made!

You'll be redirected to the previous list view, now showing the first post:

Django's admin panel, first blog post entry in list view

The only problem with that is the way it's displayed: 'BlogPostModel object (1)'.

That may be fine for now since there's only one post. But as the blog grows to hundreds of posts, you probably want to immediately be able to tell what that post is.

In order to modify this behavior, we have to modify our BlogPostModel inside models.py.

More specifically, we have to define the str method to set the str representaion for each object.

Inside blog/models.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from django.db import models



class BlogPostModel(models.Model):
    title = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)
    summary = models.CharField(max_length=500, default='')
    content = models.TextField()
    published = models.DateTimeField(auto_now_add=True)


    def __str__(self):
        return self.title

As we would with any other Python class, we simply define the dunder method inside the class. Here we choose the title as the object's name. You can pick any parameter or combination thereof you want.

Even though we made changes to models.py we don't have to run makemigrations or migrate. The change we made has no effect on the database, just the python string representation of the object. No database interaction -> no migrations to apply

Save the file (the development server reloads automatically again) and navigate back to http://http://127.0.0.1:8000/admin/blog/blogpostmodel/

Now the post's title is displayed inside the list, rather than 'BlogPostModel object (1)'.

Django's admin panel, first blog post entry in list view with the post's title

Chapter Recap

In this chapter, we used Django’s built-in admin panel to manage data inside our project.

This gave us our first practical way to create blog posts without building a custom backend interface yet.

We covered:

  • what the Django admin panel is
  • accessing the admin panel through /admin/
  • creating an administrator account with createsuperuser
  • logging into the admin interface
  • understanding built-in models such as users and groups
  • registering our BlogPostModel in admin.py
  • creating the first blog post through the admin UI
  • improving model display names with __str__()

Key Takeaways

  • Django admin is a built-in management interface for project data
  • Superusers have elevated access to manage models and users
  • Models must be registered before they appear in the admin panel
  • __str__() improves readability across the admin interface
  • The admin panel is ideal for internal management during development and operations

What We Built

At this point, the project now has:

  • a working admin login
  • an administrator account
  • blog posts stored in the database
  • a management interface for editing post data

Next Step

Now that real post data exists in the database, we can begin loading that data into views and rendering dynamic blog pages for visitors.

List all our posts

In order for users to access our posts we have to show visitors to our blog that posts exist. To achieve that we'll create a simple list view, displaying all posts we made.

Whenever we want to render anything (like our index before) we need three components:

  1. template
  2. view
  3. URL

Let's start with the template

First create a file inside templates/blog called posts_overview.html

Then inside templates/blog/posts_overview.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{% extends 'blog/base.html' %}

{% block title %}
    Posts Overview
{% endblock %}

{% block content %}
<div class="posts-overview">
    <header>
        <h2>All posts we've ever made!
    </header>
    <main class="posts">

    </main>
</div>
{% endblock %}

Just as before, we reference our base.html file at the top and use the blocks we defined inside base.html

We'll have to make some additions to this file to dynamically add our posts, but we'll do that once view and URL are done.

View and ORM

Now we'll edit views.py. We have to add another function-based view, just as we did when setting up the index. The difference here is that we'll have to query our posts from the database.

Django provides comprehensive ORM (Object-Relational Mapping) functionality through its model system. That way we can query the database using plain Python and Django models without SQL.

We'll use an ORM call to retrieve all posts from the database. Then we'll pass all posts from our view to our render engine.

A standard Django ORM call is structured like this:

python

1
Model.objects.method()
  1. Model -> The model (database table) we defined inside models.py
  2. objects -> Model property representing the objects (rows inside the table)
  3. method() -> Representing the actual database operations. There are methods corresponding to SQL queries
    • .filter(key=value) -> SELECT WHERE key = value;
    • .all() -> SELECT *;
    • and so on...

Production Note:

Using .all() is fine for small datasets and early development.

As data grows, production systems usually add ordering, filtering, pagination, and selective fields to avoid loading unnecessary records.

This improves page loads and general performance.


In blog/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.shortcuts import render
from . models import BlogPostModel



def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'blog/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])


def post_overview(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        all_posts = BlogPostModel.objects.all()

        return render(request, 'blog/posts_overview.html', {'all_posts': all_posts})

    else:
        return HttpResponseNotAllowed(['GET'])

Let's look at what we've done:

  1. Line 4 we import the BlogPostModel
  2. Starting on line 16, we define the post_overview view.
    • Same as before we define a function and pass in the request as the first parameter.
    • Then again, we check the request method and only return our template if the method is 'GET'. In every other case we return HttpResponseNotAllowed(['GET']); 405
    • Line 18 is where the ORM call happens. We use the BlogPostModel's objects attribute and then the all() method to retrieve all objects from the BlogPostModel.
    • Line 20 we pass the request, the template path and something called context to the render engine {'all_posts': all_posts}

context inside the render function refers to the dict at the end of the function invocation. The context is always Mapping[str: any]. The str (key) will be the reference to the value whenever we want to access the context variable inside the template


Production note:

In larger applications, patterns like this (i.e. method validation) are often abstracted into reusable utilities, class-based views, or API layers to keep logic consistent across endpoints.


In our example, we can access the 'all_posts' object inside our template by referencing 'all_posts' inside templatetags.

Variables, like 'all_posts' are accessed inside templates by using {{}}

Let's do that in our templates/blog/posts_overview.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{% extends 'blog/base.html' %}

{% block title %}
    Posts Overview
{% endblock %}

{% block content %}
<div class="posts-overview">
    <header>
        <h2>All posts we've ever made!
    </header>
    <main class="posts">
        {{all_posts}}
    </main>
</div>
{% endblock %}

To see what we've done so far, we have to set up the routing.

URL routing

So we edit blog/urls.py like this:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from django.urls import path
from . import views


app_name = 'blog'

urlpatterns = [
    path('', view=views.index, name='index'),
    path('posts-overview/', view=views.post_overview, name='posts_overview')
]

We added line 9 again, setting the URL, referencing the view function and assigning a name to the URL. That's the URL done.

Now we have to add the url to our menu so we actually access it through a link, rather than having to type it in manually. And since we're already editing the base.html file, we'll also add a link back to the index to the menu.

Edit templates/blog/base.html:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="{% static 'blog/css/main-style.css' %}">
    <title>
        {% block title %}

        {% endblock %}
    </title>
</head>
<body>
    <nav class="main-nav">
        <a href="{% url 'blog:index' %}">Home</a>
        <div class="link-container">
            <a href="{% url 'blog:posts_overview' %}" class="nav-link">Posts Overview</a>
            <a href="" class="nav-link">Link 1</a>
            <a href="" class="nav-link">Link 1</a>
            <a href="" class="nav-link">Link 1</a>
        </div>
    </nav>
    {% block content %}

    {% endblock %}
</body>
</html>

We add links in templates by using templatetags with the url keyword {% url %}. The argument after the url keyword is the app_name (set in urls.py) and the url's name. We set that name inside the urls.py files. For our index the name is 'index', for the posts overview the name is 'posts_overview'. So the url argument is built like: 'app_name:url_name'.

The reason we include the app_name inside the url tag is to ensure always correct routing. If you're as bad at naming things as I am, you will end up with similarly named url names across your apps. To avoid accidently using the wrong routing, we include the app name inside the template tag. This way we clearly tell Django: look inside app_name for url_name and nowhere else. The URL name has to be enclosed by '' since it's a string argument. We'll talk about arguments inside url-tags in more detail in the next chapter.

These template tags will be resolved by the render engine to relative urls as specified inside urls.py. I.e. {% url 'blog:posts_overview' %} will be resolved to 'posts-overview/'

Now, that we set the url to our posts_overview view, let's click the link in the top menu named 'Posts Overview' and see what gets rendered:

First draft of the posts_overview view with basic posts QuerySet being displayed

QuerySets

Where we set {{all_posts}} inside posts_overview.html, Django renders: ]>

This is not a bug or unexpected. This is exactly what we want. Django tells us that we passed a QuerySet (think of it as a Python list holding database objects) containing our first post (). We can access items within the QuerySet inside the template just the way we would in Python. We use the item's index. The only difference is the syntax. In standard Python we use all_posts[0] inside the template we access the index like this : all_posts.0

Let's do that and see what gets rendered:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{% extends 'blog/base.html' %}

{% block title %}
    Posts Overview
{% endblock %}

{% block content %}
<div class="posts-overview">
    <header>
        <h2>All posts we've ever made!
    </header>
    <main class="posts">
        {{all_posts.0}}
    </main>
</div>
{% endblock %}

Second draft of the posts overview now only showing the item at index 0 inside all_posts

As you can see, django now renders 'My first post'. The string representation of our BlogPostModel (defined in str).

Instead of having to manually access all posts inside a QuerySet manually via the index, we can loop over the QuerySet. As it would be in Python a QuerySet (list) is an iterable. So looping over it works virtually the same with just some differences in syntax.

We'll use the 'for' keyword inside a templatetag to indicate a for loop block, the rest is standard python:

{% for post in all_posts %}

Then, to display the post we use the variable tag: {{post}}

The biggest difference here is that the end of the for loop has to be marked with {% endfor %}. Otherwise Django will raise an exception.

The for loop implemented in templates/posts_overview.html:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{% extends 'blog/base.html' %}

{% block title %}
    Posts Overview
{% endblock %}

{% block content %}
<div class="posts-overview">
    <header>
        <h2>All posts we've ever made!
    </header>
    <main class="posts">
        {% for post in all_posts %}
            {{post}}

        {% endfor %}
    </main>
</div>
{% endblock %}

Renders the exact same way as it did when we accessed the post using the object's index. However, once you have more posts, the loop will automatically show all posts, without having to touch the template's code again. So in instances like this, where you expect to add an arbitrary number of objects to the model, use a for loop right from the beginning.

We can't just loop over an iterable and render the item. We can also loop over an iterable and repeat HTML code without having to write it over and over again. Here, for each post I want a div, containing an h3 tag for the title and a p tag for the summary.

The revised posts_overviews.html now looks like this:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{% extends 'blog/base.html' %}

{% block title %}
    Posts Overview
{% endblock %}

{% block content %}
<div class="posts-overview">
    <header>
        <h2>All posts we've ever made!
    </header>
    <main class="posts">
        {% for post in all_posts %}
            {{post}}
            <div class="post-container">
                <h3 class="post-title"></h3>
                <p class="post-summary"></p>
            </div>

        {% endfor %}
    </main>
</div>
{% endblock %}

To illustrate this a bit better, I added two more posts, using the admin panel.

The render now looks like this:

Third of the posts_overview view showing the looped result rendered and as HTML in dev tools

I also opened the dev tools (F12) to show the rendered HTML. As you can see we have three .post-container divs; one for each item in the QuerSet we just looped over.

Now this isn't very useful. We don't just want to show the object's name as returned by str, we want to access the title cleanly and we also want to show the summary.

In order to achieve that, we have to access the models properties inside the templatetag. Luckily, this couldn't be simpler. As you remember, we set the model fields (title, summary, slug, etc.) when setting the model. These are the properties we need to access. This is done by using the variable name and the property name. In our example, to access and render the title inside our for loop: {{post.title}}. Same for any property in the model. For the summary, we use: {{post.summary}}

Let's modify the posts_overview.html:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{% extends 'blog/base.html' %}

{% block title %}
    Posts Overview
{% endblock %}

{% block content %}
<div class="posts-overview">
    <header>
        <h2>All posts we've ever made!
    </header>
    <main class="posts">
        {% for post in all_posts %}
            <div class="post-container">
                <h3 class="post-title">{{post.title}}</h3>
                <p class="post-summary">{{post.summary}}</p>
            </div>

        {% endfor %}
    </main>
</div>
{% endblock %}

We remove the basic {{post}} and place the accessed properties where we want them to be: The title inside the h3 tag; the summary inside the p tag.

Now our overview looks a bit better:

Third of the posts_overview view showing the looped result with title and summary, rendered and as HTML in dev tools

Let's add some styling to this. As I said before, the main-style.css file is accessible to all templates using base.html as the foundation. So to add some styling to this we add to static/blog/cssmain-style.css

css

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/* Posts Overview */
.posts-overview {
    width: 100%;
    display: flex;
    flex-direction: column;

    & header {
        width: 100%;
        display: flex;
        justify-content: center;
        align-items: center;
        padding: 3rem 0;
        margin-bottom: 3rem;
    }

    & .posts {
        display: grid;
        grid-template-columns: repeat(2, 1fr);
        column-gap: 4rem;
        row-gap: 5rem;
        padding: 0 2rem;

        & .post-container {
            display: flex;
            flex-direction: column;
            padding: 1rem;
            border: 1px solid grey;

            & .post-title {
                font-size: 1.5rem;
                margin-bottom: 2rem;
                text-decoration: underline;
            }

            & .post-summary {
                font-style: italic;
                color: rgba(255, 255, 255, 0.527);
            }
        }
    }
}

Still not the prettiest sight, but since this is no frontend / design tutorial, this gets the job done:

Posts overview with posts sorted as a 2 X n grid

This is now showing title and summary, but the individual posts still can't be accessed. We' fix that in the next chapter.

Chapter Recap

In this chapter, we rendered real database content inside our Django project for the first time.

Instead of static placeholder text, our application now loads blog posts directly from the database and displays them dynamically.

We covered:

  • creating a dedicated template for listing posts
  • building a new function-based view
  • querying data with Django’s ORM using .all()
  • passing data from the view into the template through context
  • understanding what a QuerySet is
  • accessing context variables inside templates
  • looping over multiple posts with {% for %}
  • rendering model fields such as title and summary
  • linking new pages through Django’s URL system
  • applying basic styling to repeated content blocks

Key Takeaways

  • Views connect business logic with templates
  • QuerySets contain model data returned from the database
  • Context passes Python data into templates
  • Template loops allow repeated HTML output automatically
  • Dynamic pages scale far better than manually written static pages

What We Built

At this point, the project now has:

  • a public overview page for all posts
  • automatic rendering of stored blog content
  • reusable templates displaying database records
  • the foundation for individual post pages

Next Step

Right now visitors can see that posts exist.

Next, we’ll let them open individual posts through dynamic URLs and render full article pages based on the requested slug.


Want to Go Beyond the Basics?

This project's free modules teach how Django works by building a real project step by step.

If you want a more structured path with deeper production-grade patterns and detailed step-by-step explanations, the full learning path continues with:

  • cleaner project architecture
  • scalable query patterns
  • production-ready authentication flows
  • deployment and server setup
  • security hardening
  • maintainable large-project structure
  • advanced Django workflows used in real systems

Upcoming Premium Learning Path:
Learn Django — From First Project to Production

Built for developers who want more than tutorial-level knowledge.


Render individual posts

Now it's finally time to render our post, and make it accessible through our posts_overview.

Just as before, we'll need a template, a view and a URL.

Template Setup

As with previous templates, we'll extend the base.html file when using it as the foundation for our posts.

First we'll create a file called post.html in our templates/blog directory

templates/blog/post.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{% extends 'blog/base.html' %}

{% block title %}

{% endblock %}

{% block content %}
<article>
    <header></header>
    <main>

    </main>
</article>
{% endblock %}

Just as before, we reference our base.html file at the top and use the blocks we defined inside base.html

We'll have to make some additions to this file, but similar to posts_ovevriew.html, we'll do that once the view and URL are done.

No to add the post view.

Detail View

Inside blog/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.shortcuts import render
from . models import BlogPostModel



def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'blog/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])


def post_overview(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        all_posts = BlogPostModel.objects.all()

        return render(request, 'blog/posts_overview.html', {'all_posts': all_posts})

    else:
        return HttpResponseNotAllowed(['GET'])



def blog_post(request: WSGIRequest, slug: str) -> HttpResponse:
    if request.method == 'GET':
        try:
            post = BlogPostModel.objects.get(slug=slug)

            return render(request, 'blog/post.html', {'post': post})

        except BlogPostModel.DoesNotExist:
            return HttpResponseNotFound()

    else:
        return HttpResponseNotAllowed(['GET'])

As you can see, the new view blog_post adds something new: Path Parameters (or in official Django terms: 'Captured URL parameters') Here 'slug: str' is the Path parameter.

This argument inside the function definition allows the url path to become dynamic. This way we can route and render a multitude of requests through this view. This is especially relevant for something like this blog. You have a number of posts that are structured the same way (title, published, content, etc.) but only differ in what the post is about.


Production Note:

In theory, we can use any value as the path parameter to tell Django what post to load (the title for example) but in practice that's what the slug is for. It's a unique, URL-safe string we can use as a clean reference.

Using anything other than a slug can introduce avoidable problems in real systems. Sequential IDs for example are common and acceptable in many systems, but slugs often improve readability and can reduce obvious enumeration patterns.


Load data using path parameters

Aside from defining a unique path for each post, the path parameter serves another purpose: It's the reference for the ORM call on what post to load. Here we use the BlogPostModel.objects.get(key=value) method to retrieve exactly one value from the database; the post with this specific slug.


Production Note:

We wrapped the ORM call into a try - except block, catching BlogPostModel.DoesNotExist. The .get() method raises the DoesNotExist exception for each model if the .get() method can't find a value for the given key, value pair. This is a common issue when working with user-generated URLs or dynamic data. That's why, if the .get() method raises DoesNotExist we return 404 response, rather then ignoring the issue or have the app crash and return a generic 500 response

Note: All models come with their own DoesNotExist exception. Syntax is always Model.DoesNotExist


However, instead of having to catch the exception specifically inside a try-except block, Django provides us with a helper function for this exact problem with the .get() method: get_object_or_404

Here the refined blog/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound
from django.shortcuts import render, get_object_or_404
from . models import BlogPostModel



def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'blog/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])


def post_overview(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        all_posts = BlogPostModel.objects.all()

        return render(request, 'blog/posts_overview.html', {'all_posts': all_posts})

    else:
        return HttpResponseNotAllowed(['GET'])



def blog_post(request: WSGIRequest, slug: str) -> HttpResponse:
    if request.method == 'GET':
        post = get_object_or_404(BlogPostModel, slug=slug)

        return render(request, 'blog/post.html', {'post': post})

    else:
        return HttpResponseNotAllowed(['GET'])

We import the helper function in line 3 and replace the entire try - except logic inside the blog_post view. This performs the exact same way as before - it tries to load the object with the specified slug. If it can't find the entry it returns 404.

Now you may be wondering where the path parameter is passed into the view. The answer to that is the URL, hence the name 'Captured URL Parameters'. We have to define the URL inside urls.py to accept dynamic parameters.

blog/urls.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from django.urls import path
from . import views


app_name = 'blog'

urlpatterns = [
    path('', view=views.index, name='index'),
    path('posts-overview/', view=views.post_overview, name='posts_overview'),
    path('post/<slug:slug>/', view=views.blog_post, name='blog_post')
]

Line 10 now represents the URL for our blog_post view. Particularly, this: post//

  • The first part 'post' is the prefix for all posts
  • -> Our dynamic path parameter;
    • Syntax:
    • Here the TYPE is slug (Django provides this). If we wanted to pass a normal string, we'd use
    • NAME is the name of the path parameter as specified in the blog_post view; slug in this case.
    • Now Django knows to match any request that starts with 'post' followed by any slug-type parameter to the blog_post view

Production Note:

The reason for using the 'post' prefix is based in Django's way to resolve paths. We could omit the 'post' prefix and just do '/' as the URL, however this might lead to faulty routing:

In Django, converters like match almost any single path segment, making them very broad. Which is intended behavior, since they are meant to accept dynamic input. In return, this broad acceptance also leads to problems when resolving routes.

Why faulty routing happens: - Django resolves URLs top → bottom - A greedy pattern like / can match values meant for more specific routes - Result: specific paths (e.g. posts/) may never be reached because they are matched by the broader pattern first.

Example problem:

python

1
2
path("<str:name>/", view),
path("posts/", posts_view),

/posts/ → matched by → posts_view never runs

URL design rules (for most Django projects): - Order matters → place specific routes first, dynamic ones last
- Avoid broad converters early → treat as a fallback
- Prefer stricter converters → e.g. instead of - Use prefixes → e.g. user// instead of /

In larger applications, URL structures are often designed upfront to avoid conflicts between apps and features. Consistent prefixes and clear routing patterns make it easier to extend the system without breaking existing endpoints.


All that's left to do now is to place a link inside our posts_overview.html template. We'll again use a templatetag with the url keyword, but this time we'll pass a variable to it.

templates/blog/posts_overview.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{% extends 'blog/base.html' %}

{% block title %}
    Posts Overview
{% endblock %}

{% block content %}
<div class="posts-overview">
    <header>
        <h2>All posts we've ever made!</h2>
    </header>
    <main class="posts">
        {% for post in all_posts %}
            <div class="post-container">
                <a href="{% url 'blog:blog_post' post.slug %}"><h3 class="post-title">{{post.title}}</h3></a>
                <p class="post-summary">{{post.summary}}</p>
            </div>

        {% endfor %}
    </main>
</div>
{% endblock %}

Line 15 is now the link to each post: - Just as before; a templatetag using the url keyword - Next 'app_name:url_name'; here 'blog:blog_post' - The next argument is the variable, the slug in our example

We again access the post object's property, just like before (post.title) and by placing post.slug inside the templatetag, Django passes its value as the argument for the slug parameter defined in the URL.


Production Note:

By not enclosing post.slug in quotes, Django treats it as a variable. During rendering, the template engine replaces it with its actual value. If we were to enclose post.slug in quotes, Django would treat it as a string literal and not resolve it.

We can pass any number of path parameters into our view, url and url-templatetag. Important to remember is that they get resolved in the order they were passed in.

For example, if we had a url:

python

1
path('user/<str:user_name>/<str:user_id>/', view=views.users, name='users')

The templatetag has to be structured in the same order:

html

1
{% url 'users' user_name user_id %}

Note: No delimiters (like ,) when passing multiple parameters!

The same principles apply when path parameters are non-consecutive:

python

1
path('user/<str:user_name>/existing/<str:user_id>/', view=views.users, name='users')

The templatetag looks the same:

html

1
{% url 'users' user_name user_id %}

Django will only resolve the dynamic segments of the URL!

Positional arguments work well for simple cases, but they become harder to maintain as URLs grow more complex. A better approach than relying on positional ordering is to use named parameters.

Django also allows named arguments in the url templatetag as opposed to simple ordering. This makes the code more explicit and avoids issues if the order of parameters changes later.

This changes the templatetag to:

html

1
{% url 'users' user_name=user_name user_id=user_id %}

In larger applications, named arguments are often preferred over positional ones to make templates more readable and less error-prone when URL structures evolve. And as applications grow, URL generation is often centralized through reusable components or template partials to avoid duplication.


Every post title shown in posts_overview.html is now wrapped in an a tag:

Posts overview with posts titles wrapped in a tags

As you can see in the dev tools on the left, each a tag's href attribute now holds a value consisting of 'post/' and the post's slug property as defined in our model.

Layout and Styling

The last step is to finalize the blog_post.html to properly render our individual posts.

As before we'll use {{}} tags to insert variables and populate these tags with our post's properties. Inside the blog_post view, we pass the post object to the template through the context as 'post'.

So our templates/blog/blog_post.html will now look like this:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{% extends 'blog/base.html' %}

{% block title %}
    {{post.title}}
{% endblock %}

{% block content %}
<article class="post">
    <header class="post-header">
        <h2>{{post.title}}</h2>
        <p>{{post.summary}}</p>
        <div class="published">
            <p>Date published: {{post.published}}</p>
        </div>
    </header>
    <main class="main-post">
        {{post.content}}
    </main> 
</article>
{% endblock %}

Here we also see another advantage of blocks: We can set the page title dynamically by inserting {{}} templatetags (Line 4)

And to apply a little styling (static/blog/css/main-style.css):

css

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* Posts */
.post {
    width: 100%;
    display: flex;
    flex-direction: column;

    & .post-header {
        width: 100%;
        display: flex;
        flex-direction: column;
        align-items: center;
        padding: 2rem 1rem;
        border-bottom: 1px solid grey;

        & .published {
            color: grey;
            font-size: .6rem;
            display: flex;
            justify-content: end;
            width: 100%;
        }
    }

    & .main-post {
        margin: 3rem 5rem;
    }
}

This is our basic blog post render:

First fully rendered blog post

If you go back to the overview and click through the posts, each link should now render the individual post.


Production Note:

When rendering content like {{post.content}}, Django escapes HTML by default to prevent security issues such as XSS (Cross-Site Scripting).

If you intentionally want to render HTML content (for example from a rich text editor), you must explicitly mark it as safe.

This is done by adding |safe to the templatetag: {{post.content|safe}}

Now Django renders the content as HTML instead of escaping it as plain text.

Only use |safe with trusted or properly sanitized content. Rendering untrusted user input can introduce serious security vulnerabilities! In production systems, content is often sanitized before rendering to safely allow a subset of HTML without exposing the application to XSS vulnerabilities.

Additionally, templates should focus on presentation rather than logic. Keeping business logic in views or services makes applications easier to maintain as they grow.


Chapter Recap

In this chapter, we turned our blog posts into fully accessible pages.

Visitors can now open individual posts through dynamic URLs, while Django loads the correct database entry automatically based on the requested slug.

We covered:

  • creating a dedicated template for single blog posts
  • building a detail view for individual posts
  • using captured URL parameters (<slug:slug>)
  • understanding how dynamic routing works in Django
  • loading one database object with .get()
  • handling missing entries with get_object_or_404()
  • generating dynamic links inside templates with {% url %}
  • passing model objects into templates through context
  • rendering fields such as title, summary, content, and publish date
  • understanding Django’s default HTML escaping behavior

Key Takeaways

  • Dynamic URLs allow one view to serve many pages
  • Slugs create readable and structured links
  • get_object_or_404() is the standard pattern for detail views
  • Templates can generate URLs automatically from named routes
  • Model objects can be rendered directly through their fields
  • Django escapes HTML by default for security reasons

What We Built

At this point, the project now has:

  • a public post overview page
  • clickable post titles
  • unique URLs for each article
  • fully rendered detail pages backed by database content

Next Step

Now that visitors can browse content, the next step is improving how content is created and managed — including cleaner forms, editing workflows, authentication, and backend tooling.

Custom backend to make and manage posts

So far, we've made posts using Django's admin panel. That's fine, but it limits our capabilities. And as mentioned before, the admin panel is meant to be an administrative tool rather than being responsible for managing user-facing content.

Plus, we maybe we want to change the layout of our backend, have a dashboard showing how many posts were made or how many times each post has been read.

For these reasons we'll build our own backend.

The biggest restraint here is that we have to limit access to this new backend. Since it allows for adding, editing and deleting posts, we only want to grant access to authorized users, just like with the Django admin panel.

Backend App

As mentioned before, Django apps come into play if the project serves different capabilities and these capabilities need to be separated.

This is the scenario we have in this project:

  1. blog app
    • Serving public content to anybody visiting the site
  2. backend app
    • Serving admin-style content, like adding, editing, deleting posts
    • Only available to authenticated users

In case the development server is still running, stop it using Ctrl + C and then run this command:

bash

1
python manage.py startapp backend

Just as before with our blog app, Django added a new directory named 'backend' inside our base directory. And just as before we have to add a urls.py file inside the backend directory and we have to register the app inside main/setting.py and include the backend/urls.py file in main/urls.py

Create the file backend/urls.py

Inside the file:

python

1
2
3
4
5
6
7
8
9
from django.urls import path
from . import views


app_name='backend'

urlpatterns = [

]

Now register the app:

main/settings.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'blog',
    'backend'
]

Include backend/urls.py in main/urls.py:

main/urls.py

python

1
2
3
4
5
6
7
8
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('blog.urls', namespace='blog')),
    path('backend/', include('backend.urls', namespace='backend'))
]

Like our blog app we added the app's urls.py to the main urls and also defining the namespace. However this time we also use a prefix for the new app called 'backend'. So whenever we want to access any view inside the backend app we have to start the URL with 'backend/' -> http://127.0.0.1:8000/backend/url-defined-in-backend-urls/

Here, we gave the prefix the same name as the app, but that's not necessary. You can name the prefix whatever you want.

That's the basic set up for the app done.

Let's continue by building a base index (dashboard) for the backend.

Basic Backend Layout and Structure

As with any other page we've built so far, we need three basic components:

  • template
  • view
  • url

To get started with the template, we add new directories to templates and static where we can store our HTML and static files.

Add directories:

templates
    |-backend

static
    |-backend
        |-css

Then again, since we don't want to re-write full HTML for every page in our backend app, we'll add a base.html file we can use as foundation for each backend view

templates/backend/base.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <link rel="stylesheet" href="{% static 'backend/css/main-style.css' %}">
    <title>
        {% block title %}
        {% endblock %}
    </title>
</head>
<body>
    <nav class="main-nav">
        <a href="{% url 'backend:index' %}">Dashboard</a>
        <div class="link-container">
            <a href=""></a>
        </div>
    </nav>
    {% block content %}
    {% endblock %}
</body>
</html>

Production Note:

When building backend services like this one, make sure they stay off search engines. They are not public, so you don't want them listed.

In order to do that we add this line:

html

1
<meta name="robots" content="noindex, nofollow">

This meta tag tells crawlers (like Google) to not index the page nor to follow any links on this site.

Adding this line now ensures that you won't forget and adding it inside the base.html file ensures that none of the backend files will be crawled or listed (as long as their built upon this base.html file).


Add some basic styling for our backend/base.html. Create a new css file in static/backend/css called main-style.css and add:

css

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/* General */

* {
    box-sizing: border-box;
}

html {
    font-family: 'SpaceGrotesk', Arial, Helvetica, sans-serif;
    line-height: 1.7;
    letter-spacing: 0.015em;
    font-size: 1.1rem;
    word-spacing: 0.02em;
    font-weight: 500;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

body {
    padding: 0;
    margin: 0;
    width: 100vw;
    background: rgb(3, 3, 10);
    color: white;
}


/* Navigation */
.main-nav {
    width: 100%;
    display: flex;
    justify-content: space-between;
    padding: 3rem 1rem;
    position: sticky;
    top: 0;
    border-bottom: 1px solid grey;
}

.main-nav  {
    & .link-container {
        width: 50%;
        display: flex;
        justify-content: space-evenly;
    }
}

Now our basic index file. Our index dashboard serves as a placeholder for now. In Module 2 we'll build this into a fully functioning dashboard, giving us insights into our blog's status and performance.

templates/backend/index.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{% extends 'backend/base.html' %}

{% block title %}
    Blog Dashboard
{% endblock %}


{% block content %}

{% endblock %}

Now add the index view

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.shortcuts import render
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse



def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])

Now add the url

backend/urls.py

python

1
2
3
4
5
6
7
8
9
from django.urls import path
from . import views


app_name='backend'

urlpatterns = [
    path('', view=views.index, name='index')
]

Let's test if everything is working so far. Start the development server

bash

1
python manage.py runserver

And enter the URL for the backend dashboard: http://127.0.0.1:8000/backend/

Remember: Every URL we set inside backend/urls.py has the prefix 'backend/' (as defined in main/urls.py). So when we set '' as the URL, the full URL resolves to http://127.0.0.1:8000/backend/

It should render the basic backend/index view


Production Note:

Even with this simple project it already pays to use namespacing for urls.py files. We already have overlapping urls ('' in blog and backend) with equal naming (index).

If there is no namespacing to clearly separate these urls you will run into problems as your applications grows. And the larger the application becomes, the harder it will be to debug faulty routing.


Everything we've done so far is just the same as we did in our blog app. That's why as of now the views for our backend app are not protected and accessible to anyone.

Let's change that.

Authentication Flow

Restricting Access to Views

In previous chapters, we logged into the Django admin panel. In order to test whether or not the view requires authentication, we first have to log out. If you navigate to http://127.0.0.1:8000/admin/ and see the login page, you're all set. However if you see the Django administration page listing all models, hit the 'LOG OUT' button in the top right corner.

In order to only allow access to authenticated users we have to validate the authentication status of the user requesting the page. We do that inside the view:

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from django.shortcuts import render
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed



def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        if request.user.is_authenticated:
            return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])

Line 9 is the authentication check. The request object includes a user attribute representing the current user. The user object stores information about the user. Here we check the is_authenticated property. If a user is authenticated (logged in) it returns True, else it returns False.

If we now enter the URL http://127.0.0.1:8000/backend/ we should see an error page:

Error page for unauthenticated users

That happens because the view does not return a response when the user is not authenticated, causing Django to raise an error.

For now, let's return a simple HttpResponse by adding:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from django.shortcuts import render
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed



def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        if request.user.is_authenticated:
            return render(request, 'backend/index.html')

        else:
            return HttpResponse('Unauthorized', status=401)

    else:
        return HttpResponseNotAllowed(['GET'])

Now if we reload http://127.0.0.1:8000/backend/ instead of the error page we get our HTTP 401 response:

Page showing unauthorized

Of course that's not what we want. A 401 response is technically correct, but in web applications it’s more common to redirect users to a login page. If a user accesses a resource that's behind an authentication layer, we want to give users the option to log in, not just tell them that they are unauthorized.

To achieve that, we add a login view.

Login View

So just like before:

  • template
  • view
  • url

templates/backend/login.html:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <link rel="stylesheet" href="{% static 'backend/css/auth-style.css' %}">
    <title>Login</title>
</head>
<body>
    <main class="main-login">
        <div class="text">
            <h3>Login | Blog Backend</h3>
        </div>
        <form action="" class="login-form">
            <div class="inp-wrapper">
                <label for="username">Username</label>
                <input type="text" name="username" id="username" required>
            </div>
            <div class="inp-wrapper">
                <label for="password">Password</label>
                <input type="password" name="password" id="password" required>
            </div>
            <button type="submit">Login</button>
        </form>
    </main>
</body>
</html>

static/backend/css/auth-style.css

css

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/* General */

* {
    box-sizing: border-box;
}

html {
    font-family: 'SpaceGrotesk', Arial, Helvetica, sans-serif;
    line-height: 1.7;
    letter-spacing: 0.015em;
    font-size: 1.1rem;
    word-spacing: 0.02em;
    font-weight: 500;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

body {
    padding: 0;
    margin: 0;
    width: 100vw;
    background: rgb(3, 3, 10);
    color: white;
    height: 100vh;
    width: 100vw;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
}



/* Login */
.main-login {
    width: 30rem;
    display: flex;
    flex-direction: column;
    border: 1px solid grey;

    & .text {
        width: 100%;
        text-align: center;
        padding: 2rem;
        border-bottom: 1px solid grey;
    }

    & .login-form {
        width: 100%;
        display: flex;
        flex-direction: column;
        align-items: center;
        padding: 2rem;

        & .inp-wrapper {
            width: 100%;
            display: flex;
            flex-direction: column;
            margin: 1rem 0;
        }

        & button {
            margin-top: 3rem;
        }
    }
}

Production Note:

Even though the login is part of the backend app, it must remain accessible to unauthenticated users.

Because of this, it is often implemented separately from protected templates to avoid dependencies on authenticated-only layouts.

This keeps the authentication flow simple and avoids issues where unauthenticated users cannot access required resources.


backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from django.shortcuts import render
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed




# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/login.html')

    elif request.method == 'POST':
        pass

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        if request.user.is_authenticated:
            return render(request, 'backend/index.html')

        else:
            return HttpResponse('Unauthorized', status=401)

    else:
        return HttpResponseNotAllowed(['GET'])

The login_user view adds a second allowed request method -> 'POST' For now we pass, but this is where we'll process any user information posted to this view using the form in templates/backend/index.html

backend/urls.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from django.urls import path
from . import views


app_name='backend'

urlpatterns = [
    path('', view=views.index, name='index'),
    path('login/', view=views.login_user, name='login')
]

Now to test the new login_user view, navigate to: http://127.0.0.1:8000/backend/login/

It should render the login view:

Backend login view

Redirect to login view

Now that we have a login view to show, we edit the views.py again. Instead of showing 'Unauthorized' to unauthenticated users we'll redirect them to our newly created login_user view

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from django.shortcuts import render, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed




# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/login.html')

    elif request.method == 'POST':
        pass

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        if request.user.is_authenticated:
            return render(request, 'backend/index.html')

        else:
            return redirect('backend:login')

    else:
        return HttpResponseNotAllowed(['GET'])

We import the redirect function in line 1 and use it in line 28 instead of the simple 'Unauthorized' response. The redirect function takes one argument: The app_name and the url name to redirect to (app_name:url_name). The app_name and url_name used, are the ones specified in your app's urls.py file.

Now if we navigate to http://127.0.0.1:8000/backend/ we automatically get redirected to the login form, as long as we're unauthenticated.

login_required decorator

There is one important improvement we can make to protected views: Checking request.user.is_authenticated directly works for simple cases, but Django provides built-in tools like the login_required decorator to standardize access control across views.

These approaches become important as the number of protected views increases, to drastically reduce repetition inside views.

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from django.shortcuts import render, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required




# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/login.html')

    elif request.method == 'POST':
        pass

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])

We import the decorator in line 4 and remove the if else block inside index entirely. Instead we decorate the view with: @login_required(login_url='backend:login') The login_url argument defines where users are redirected if they are not authenticated.

This exhibits the same behavior as the manual check, but removes the need to repeat authentication logic in every view, making the code easier to read and maintain.


Production Note:

Decorators like login_required centralize access control and reduce duplication across views. In larger applications, relying on these patterns ensures consistent behavior and makes it easier to enforce authentication rules across the system.


Login Form

Now that we've properly protected our index from unauthenticated access, we'll move onto the next step: Allow users to log in.

First we'll edit our login template:

templates/backend/login.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <link rel="stylesheet" href="{% static 'backend/css/auth-style.css' %}">
    <title>Login</title>
</head>
<body>
    <main class="main-login">
        <div class="text">
            <h3>Login | Blog Backend</h3>
        </div>
        <form action="{% url 'backend:login' %}" method="post" class="login-form">
            {% csrf_token %}
            <div class="inp-wrapper">
                <label for="username">Username</label>
                <input type="text" name="username" id="username" required>
            </div>
            <div class="inp-wrapper">
                <label for="password">Password</label>
                <input type="password" name="password" id="password" required>
            </div>
            <button type="submit">Login</button>
        </form>
    </main>
</body>
</html>

In line 17 we define the form attributes action and method. For action we simply use a templatetag with a the url keyword and for method we set post because that's the HTTP method our login_user view is expecting.

In line 18 we add something very important: {% csrf_token %} This tag tells the render engine to add a hidden input element to the form holding the CSRF token to prevent Cross-Site-Request Forgery (CSRF) attacks.

Whenever we make a post request to an endpoint from the frontend we have to send the CSRF token alongside the request. So we have to add {% csrf_token %} to every form as soon as it is intended to send data to the backend.


Production Note:

Cross-Site Request Forgery (CSRF) is an attack where a malicious site tricks a user’s browser into making a request to another site where they’re already authenticated. CSRF is dangerous because it exploits trusted browser behavior (auto-sent cookies).

csrf_token prevents this by requiring a secret verification value tied to the user’s session that must be included in state-changing requests.

Why it’s dangerous:

  • The browser automatically sends cookies (including session/auth cookies)
  • A forged request can perform actions as the user
  • Example: changing password, transferring money, posting data

A csrf_token is a unique, secret value tied to a user session that Django uses to verify requests are legitimate.

How it protects endpoints:

Django generates a token and stores it (cookie/session) The same token must be included in POST/PUT/DELETE requests

Server checks:

→ Does the request include the correct token?
    Yes → accept
    No → reject

An attacker cannot read or guess the token, so their forged request fails.

A detailed article covering csrf_token: csrf_token in Django forms An article explaining CSRF attacks can be found here: CSRF attacks explained


Now that the template is set to post information to our view, we have to edit the view to actually process the information:

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from django.shortcuts import render, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login




# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/login.html')

    elif request.method == 'POST':
        username = request.POST.get('username')
        password = request.POST.get('password')

        if not username or not password:
            return redirect ('backend:login')

        user = authenticate(request, username=username, password=password)

        if user:
            login(request, user)

            return redirect('backend:index')

        else:
            return redirect ('backend:login')

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])

Let's talk about this in detail:

  • Line 5
    • Import of Django's auth frameworks authenticate and login functions
  • Line 16, 17
    • The request object stores data sent via a POST request in its POST attribute. It behaves just like a standard Python dict.
    • We use request.POST.get() to retrieve the username and password from the POST request.
    • If username or password is missing we redirect back to login, so the user can enter the information again.
  • Line 22
    • The authenticate function validates the provided information (username and password)
    • If the username exists and the password is correct the authenticate function returns a User object; if not it returns None
  • Line 24:
    • We check what the authenticate function returned
    • If authenticate returns None, we know that username and / or password were incorrect so we redirect the user back to the login to try again.
    • If the authenticate function returns a User object we know the provided information was correct
  • Line 25
    • If the provided information was correct and the authenticate function returned a User object, we login the user using the login function.
    • This attaches the user to the session, allowing Django to recognize the user as authenticated on subsequent requests; our protected views will now be accessible to this user.
    • After login, we redirect the user to the backend/index view

Django handles password verification internally, including hashing and comparison, so raw passwords are never stored or compared directly.


Production Note:

Even though we set the username and password fields in our login form as required, this alone does not protect from missing / faulty information. HTML attributes can easily be tampered with. In case the parameters were not posted, we redirect back to login, rather than have the application crash due to a KeyError or ValueError.

That's why parameter validation and real security enforcement happen server-side; never client-side!

Typically, we have to sanitize untrusted user inputs to protect from SQLi (SQL injection) attacks.

However, Django's ORM is generally protected when it comes to SQLi since it: - Generates parameterized SQL queries - Escapes inputs safely - No raw SQL string concatenation is involved

However, this protection can be bypassed if raw SQL is used incorrectly.

In Module 3 we'll go into more detail on how to protect and harden our projects before deployment.


Messages

We redirect the user back to the login view if information is missing or faulty. That alone is a bit blunt since the user doesn't know the reason for the redirect. - Was there a server error? - Were the credentials wrong?

To improve user experience, we should inform users why they were redirected. We will tell the user the cause for the redirect, using Django's messages framework.

First, we'll implement the messages in our view:

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from django.shortcuts import render, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login
from django.contrib import messages




# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/login.html')

    elif request.method == 'POST':
        username = request.POST.get('username')
        password = request.POST.get('password')

        if not username or not password:
            messages.error(request, 'You didn\'t provide all necessary information. Please try again.')

            return redirect ('backend:login')

        user = authenticate(request, username=username, password=password)

        if user:
            login(request, user)

            return redirect('backend:index')

        else:
            messages.error(request, 'Username or Password incorrect. Please try again.')

            return redirect ('backend:login')

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])

We import the messages framework in line 6 and use it in lines 21 and 28. The .error function marks this as an error message. The first parameter is the request, the second parameter the message we want to return to the user.

Django now passes this message to the render engine. In order for this message to be displayed, we have to add the message display inside our login.html template

templates/backend/login.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <link rel="stylesheet" href="{% static 'backend/css/auth-style.css' %}">
    <title>Login</title>
</head>
<body>
    {% if messages %}
        <aside class="message-container">
            {% for m in messages %}
                <p class="msg">{{m}}</p>
            {% endfor %}
        </aside>
    {% endif %}

    <main class="main-login">
        <div class="text">
            <h3>Login | Blog Backend</h3>
        </div>
        <form action="{% url 'backend:login' %}" method="post" class="login-form">
            {% csrf_token %}
            <div class="inp-wrapper">
                <label for="username">Username</label>
                <input type="text" name="username" id="username" required>
            </div>
            <div class="inp-wrapper">
                <label for="password">Password</label>
                <input type="password" name="password" id="password" required>
            </div>
            <button type="submit">Login</button>
        </form>
    </main>
</body>
</html>

Lines 13 to 19 hold the section that will messages, in case there are any.

This also introduces a conditional templatetags.

They work exactly the same way as they would in standard Python (if -> condition, elif -> condition, else) with the exception that the if-conditional has to end with an {% endif %} tag. Then we use a for loop tag in case the backend returns more than one message.

Now whenever the user enters invalid / incomplete credentials the backend returns an error message saying 'Username or Password incorrect. Please try again.'. These messages will be rendered inside the .message-container we just added.

With some simple styling for the message-container:

static/backend/css/auth-style.css

css

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/* Messages */
.message-container {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;

    & .msg {
        margin: 1rem;
        border-bottom: 1px solid red;
        padding: 1rem;
        width: 30rem;
    }
}

Now if the user enters wrong credentials, a message gets rendered:

Backend login view with error message


Production Note:

In theory could check first if the username exists and return more detailed messages, like 'wrong username' instead of 'wrong username or password'

However, providing detailed error messages can unintentionally assist attackers during brute-force attempts.

If attackers can distinguish between 'username not found' and 'incorrect password', they can confirm valid usernames and focus only on guessing passwords.

Using a generic error message avoids leaking this information.

There are other, more effective ways to protect from brute-force attacks (like rate limiting) but this is already raises the difficulty for an attacker since with a generic error it's never certain what credential was wrong -> the username or the password

The trade-off however is the user experience. Users may get annoyed not knowing which credential was wrong. To me, that trade-off is valid.

In Module 3 we'll talk in more detail about protecting our project.


If the credentials are correct, the user gets redirected to the index.

We created a super user when we talked about the Django admin panel. You can use those credentials to log in to your backend.

Chapter Recap

In this chapter, we expanded the project beyond public pages and introduced a dedicated backend system for managing content.

Instead of relying only on Django’s built-in admin panel, we created our own protected backend application with its own routes, templates, and authentication flow.

We covered:

Backend Architecture

  • why public content and internal tools should be separated
  • creating a dedicated backend Django app
  • registering the app in INSTALLED_APPS
  • connecting backend routes through main/urls.py
  • using a URL prefix (/backend/) for internal functionality
  • building separate templates and static assets for backend pages
  • creating a reusable backend base.html

Access Control

  • restricting views to authenticated users only
  • checking request.user.is_authenticated
  • redirecting unauthenticated users to login
  • using Django’s login_required decorator
  • why centralized access control scales better than repeated checks

Authentication Flow

  • building a custom login page
  • handling GET and POST requests in one view
  • reading submitted form data through request.POST
  • authenticating users with authenticate()
  • creating sessions with login()
  • redirecting successful logins to the dashboard

Security Concepts

  • why backend pages should not be indexed by search engines
  • protecting forms with {% csrf_token %}
  • what CSRF attacks are and how tokens prevent them
  • why validation must happen server-side
  • why generic login errors can reduce credential enumeration

User Experience

  • improving failed logins with Django’s messages framework
  • displaying feedback messages inside templates
  • building a cleaner login flow instead of raw 401 responses

What We Built

At this point, the project now has:

  • a dedicated backend application
  • a protected dashboard route
  • a custom login system
  • authenticated session handling
  • user feedback messages
  • cleaner separation between public and internal features

Key Takeaways

  • Internal tools should be separated from public-facing features
  • Authentication is both a security and usability concern
  • Django provides built-in tools that simplify secure access control
  • Good systems balance security, maintainability, and user experience
  • Structure matters more as projects grow

Next Step

We'll improve on how login data is handled and processed by introducing Django forms

Django Forms

Right now, we manually extract and validate form data using request.POST. As forms grow more complex (more fields, validation rules, error handling), checking the existence and validity for each input manually becomes harder to maintain.

Django provides a built-in solution for handling form data, validation, and error messages in a structured way: Django Forms.

To get started with forms we first have to add a file to our app's directory called forms.py Then we'll add our basic login form to that file

backend/forms.py

python

1
2
3
4
5
6
from django import forms


class LoginForm(forms.Form):
    username = forms.CharField(max_length=50)
    password = forms.CharField(max_length=50)

Similar to models, we define a class as subclass from Django's default class (forms.Form) here. Then we specify our form fields. Both inputs represent string data, so we use CharField with a maximum length of 50 characters each.

Now we can use this form in two ways inside each view:

  1. In a GET request to render the input form
  2. In a POST request to validate the submitted form data

Let's start with rendering the form through our GET condition:

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
from django.shortcuts import render, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login
from django.contrib import messages

from . forms import LoginForm




# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = LoginForm()
        return render(request, 'backend/login.html', {'form': form})

    elif request.method == 'POST':
        username = request.POST.get('username')
        password = request.POST.get('password')

        if not username or not password:
            messages.error(request, 'You didn\'t provide all necessary information. Please try again.')

            return redirect ('backend:login')

        user = authenticate(request, username=username, password=password)

        if user:
            login(request, user)

            return redirect('backend:index')

        else:
            messages.error(request, 'Username or Password incorrect. Please try again.')

            return redirect ('backend:login')

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])

In line 8 we import LoginForm from forms.py

Then in line 16 we instantiate the form and in line 18 we pass it through the context to the render engine.

Now we change the login.html template to use the passed in form:

templates/backend/login.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <link rel="stylesheet" href="{% static 'backend/css/auth-style.css' %}">
    <title>Login</title>
</head>
<body>
    {% if messages %}
        <aside class="message-container">
            {% for m in messages %}
                <p class="msg">{{m}}</p>
            {% endfor %}
        </aside>
    {% endif %}

    <main class="main-login">
        <div class="text">
            <h3>Login | Blog Backend</h3>
        </div>
        <form action="{% url 'backend:login' %}" method="post" class="login-form">
            {% csrf_token %}

            {{form}}

            <button type="submit">Login</button>
        </form>
    </main>
</body>
</html>

Instead of having to define all input fields and labels ourselves, Django now renders the form fields automatically based on the LoginForm definition:

Login view rendered from Django forms with dev tools open

One thing that's problematic with this is the type of the password field. When defining the LoginForm we classified both fields as CharFields, which is technically correct since both handle strings. The problem here is, that shows the users input as plain text, which is not suitable for sensitive input.

So in order to fix this, we have to tell Django to set the type of this input field to "password"

backend/forms.py

python

1
2
3
4
5
6
7
8
9
from django import forms


class LoginForm(forms.Form):
    username = forms.CharField(max_length=50)
    password = forms.CharField(
        max_length=50,
        widget=forms.PasswordInput()
    )

By defining the widget attribute as forms.PasswordInput() Django now knows to set the input type to password:

Login view rendered from Django forms and correct input types set with dev tools open


Production Note

Django passwords can exceed a max_length of 50 depending on auth backend use cases.

Better to omit restrictive max_length.


When using {{form}} in our template, Django renders form fields with basic HTML structure by default.

We can manually access each field instead of relying on the default rendered HTML. This requires writing slightly more HTML, but gives much better control over structure and styling.

If we want to access each form element manually, our template changes to:

templates/backend/login.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <link rel="stylesheet" href="{% static 'backend/css/auth-style.css' %}">
    <title>Login</title>
</head>
<body>
    {% if messages %}
        <aside class="message-container">
            {% for m in messages %}
                <p class="msg">{{m}}</p>
            {% endfor %}
        </aside>
    {% endif %}

    <main class="main-login">
        <div class="text">
            <h3>Login | Blog Backend</h3>
        </div>
        <form action="{% url 'backend:login' %}" method="post" class="login-form">
            {% csrf_token %}

            <div class="inp-wrapper">
                {{form.username.label_tag}}
                {{form.username}}
            </div>
            <div class="inp-wrapper">
                {{form.password.label_tag}}
                {{form.password}}
            </div>

            <button type="submit">Login</button>
        </form>
    </main>
</body>
</html>

This allows us to reuse our existing styling while keeping the benefits of Django Forms.


Production Note:

Rendering forms using {{form}} is useful for quick prototypes, but most production applications render fields manually to maintain full control over layout, styling, and accessibility.

This becomes especially important when integrating forms into complex UI structures.


Now that we talked about rendering forms we can move to the core purpose of django forms: Input validation and providing cleaned data.

Instead of manually extracting values from request.POST, we can pass the POST data directly into the form and let Django handle the validation automatically:

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
from django.shortcuts import render, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login
from django.contrib import messages

from . forms import LoginForm




# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = LoginForm()

        return render(request, 'backend/login.html', {'form': form})

    elif request.method == 'POST':
        form = LoginForm(request.POST)

        if form.is_valid():
            username = form.cleaned_data['username']
            password = form.cleaned_data['password']

            user = authenticate(request, username=username, password=password)

            if user:
                login(request, user)

                return redirect('backend:index')

            else:
                messages.error(request, 'Username or Password incorrect.')

                return redirect('backend:login')

        else:
            messages.error(request, 'Username or Password incorrect. Please try again.')

            return redirect ('backend:login')

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])

We replace extracting from raw post data (request.POST.get()) and manual validation of the credentials with:

python

1
form = LoginForm(request.POST)

This instantiates the LoginForm with the post data as the argument. Django now validates the submitted data by comparing it to the form definition in forms.py.

In this case it validates that username and password were submitted (we manually checked this with: if not username or not password) and it ensures that the submitted data matches the expected field definitions (e.g., required fields and maximum length).

Form validation checks field presence/format. It does not validate the credentials! We still have to do that using authenticate()

If we instantiate any form class using the post data as its argument we can call the is_valid method on the object (Line 23). If the validation was successful (matching field specifications) this method returns True, otherwise it returns False.

If it returns False, we know that the submitted data was faulty in either content or type, so we redirect back to login.

If the validation was successful and the is_valid method returned True, we know that the provided data matches what we specified inside forms.py Once the data is validated, we can safely access it from the form object:

python

1
2
username = form.cleaned_data['username']
password = form.cleaned_data['password']

The cleaned_data property contains validated and normalized input data, ready to be used safely in the application. That's why we don't use the raw post data anymore, but the form.cleaned_data

Note: cleaned_data ensures the data matches the form’s validation rules, but it does not automatically make it safe for all contexts (e.g., rendering HTML). Context-specific handling is still required where necessary.

We can access each value inside the cleaned_data property just like data in any other Python dict. The keys inside cleaned_data are named after the form fields we specified. After data passed the form validation we can use it, just like before, to authenticate and login the user if the provided credentials are valid.

Soon, we’ll connect forms directly to our models using Django’s ModelForms. This allows us to create and update database records with minimal boilerplate.


Production Note:

Django Forms centralize validation logic and ensure that input data is validated before being used.

This reduces duplication and prevents common issues such as missing fields, invalid data, or inconsistent validation across views.

In larger applications, keeping validation logic inside forms (rather than views) improves maintainability and keeps business logic consistent across the system.


Blog Post Management

Up to this point, our backend only handled authentication and basic navigation. Now we move to its actual purpose: managing content.

This means creating, updating, and deleting blog posts — and more importantly, defining how data flows from user input into the database.

Make Posts

Just like before we need three basic things to get started:

  • template
  • view
  • url

First the template. We'll add a sub-directory to templates/backend called 'manage_posts'. We can add any number of sub-directories to our templates directory. This allows us to organize templates by for example category, rather than having one cluttered directory.

Then we'll add a base template to this new directory called 'make_post.html'

templates/backend/make_posts/make_post.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{% extends 'backend/base.html' %}

{% block title %}
    Blog - Make Post
{% endblock %}


{% block content %}
<main class="main-post">
    <h3>Make a new Blog Post</h3>
</main>
{% endblock %}

Now we add a view for making posts:

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
from django.shortcuts import render, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login
from django.contrib import messages

from . forms import LoginForm




# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = LoginForm()

        return render(request, 'backend/login.html', {'form': form})

    elif request.method == 'POST':
        form = LoginForm(request.POST)

        if form.is_valid():
            username = form.cleaned_data['username']
            password = form.cleaned_data['password']

            user = authenticate(request, username=username, password=password)

            if user:
                login(request, user)

                return redirect('backend:index')

            else:
                messages.error(request, 'Username or Password incorrect.')

                return redirect('backend:login')

        else:
            messages.error(request, 'Username or Password incorrect. Please try again.')

            return redirect ('backend:login')

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])



# --- Manage Posts ---
@login_required(login_url='backend:login')
def make_blog_post(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':


        return render(request, 'backend/manage_posts/make_post.html')

    elif request.method == 'POST':
        pass

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])

We added make_blog_post view, used the login_required decorator and prepared it for GET and POST requests. Again, this view handles two responsibilities:

  • Rendering the form (GET)
  • Processing submitted data (POST)

Now for the URL:

backend/urls.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from django.urls import path
from . import views


app_name='backend'

urlpatterns = [
    path('', view=views.index, name='index'),
    path('login/', view=views.login_user, name='login'),
    path('make-post/', view=views.make_blog_post, name='make_post')
]

Nothing unusual here, just a standard URL.

Now we'll edit our base.html file so we have a link in our main navigation to access our new view. We also add a messages container (similar to the login.html template), so we can send messages to the user on certain events. However this time we'll utilize the fact that we can send success messages and error messages. We'll check what type was returned and set an appropriate font color.

templates/backend/base.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <link rel="stylesheet" href="{% static 'backend/css/main-style.css' %}">
    <title>
        {% block title %}
        {% endblock %}
    </title>
</head>
<body>
    <nav class="main-nav">
        <a href="{% url 'backend:index' %}">Dashboard</a>
        <div class="link-container">
            <a href="{% url 'backend:make_post' %}">Make Post</a>
        </div>
    </nav>
    {% if messages %}
        <aside class="message-container">
            {% for m in messages %}
                {% if m.tags == 'error' %}
                    <p class="msg error">{{m}}</p>

                {% elif m.tags == 'success' %}
                    <p class="msg success">{{m}}</p>

                {% endif %}
            {% endfor %}
        </aside>
    {% endif %}

    {% block content %}
    {% endblock %}
</body>
</html>

We check the message type by comparing the tags property against strings (enclosed by '')

Basic styling for the messages:

css

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Messages */
.message-container {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;

    & .msg {
        margin: 1rem;
        border-bottom: 1px solid grey;
        padding: 1rem;
        width: 30rem;
    }

    & .error {
        color: red;
    }

    & .succes {
        color: green;
    }
}

Now here's where it gets interesting: In order to make a post, we need a form. For that we'll use Django's forms again. However, this time we have to save the submitted data to our database -> Inside our BlogPostModel. This is where Django’s form system becomes especially powerful: We can derive a form from our models. When data gets submitted and the form is valid, Django will save the data to our model.

Instead of manually mapping input fields to model fields, ModelForms connect forms directly to models and handle validation and saving automatically.

Let's define our BlogPostForm

backend/forms.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from django import forms

from blog.models import BlogPostModel




class LoginForm(forms.Form):
    username = forms.CharField(max_length=50)
    password = forms.CharField(
        max_length=50,
        widget=forms.PasswordInput()
    )


class BlogPostForm(forms.ModelForm):
    class Meta:
        model = BlogPostModel
        fields = '__all__'
  • First, in line 3 we import the model. Remember that the model lives inside the blog app. So we import from there.
  • Then we define the BlogPostForm:
    • As a subclass of forms.ModelForm to signal Django that there will be a model relation
    • Inside the form's meta class we define the exact relation to the model:
      • model = BlogPostModel -> What model to use for this form
      • fields = 'all' -> What model fields are relevant for this form. Here we use all to signal that we want to use all model fields.

The key idea here is the Meta class:

  • model defines what data we are working with
  • fields defines which parts of that model are exposed through the form

From this, Django generates: - form fields - validation rules - and save behavior


Production Note:

When defining the relevant model fields, using 'all' is quite useful since we don't have to name every field manually. Additionally, when the model changes (a column is removed or added) the form changes with it automatically.

However, using all comes with a problem: If you add meta fields, that shouldn't appear in any form (i.e. how many times a post has been read) all will display this field as well.

Django has a built-in layer of protection: When using immutable fields in models, all will omit this fields automatically in forms. Since we defined the published property with auto_add_now=True inside our blog/models.py it won't be displayed because this field is not supposed to be edited.

Naming fields explicitly adds a safety net that prevents unintentionally exposing meta fields to any form that are not covered by this built-in protection.

In our example this would change the BlogPostForm to:

python

1
2
3
4
class BlogPostForm(forms.ModelForm):
    class Meta:
        model = BlogPostModel
        fields = ['title', 'slug', 'summary', 'content']

Note that we did not list 'published' here. Django will raise an exception when trying to add immutable fields to a form.


Now to add the form to our views.py and pass it to the template, just like we did for our login:

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
from django.shortcuts import render, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login
from django.contrib import messages

from . forms import LoginForm, BlogPostForm




# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = LoginForm()

        return render(request, 'backend/login.html', {'form': form})

    elif request.method == 'POST':
        form = LoginForm(request.POST)

        if form.is_valid():
            username = form.cleaned_data['username']
            password = form.cleaned_data['password']

            user = authenticate(request, username=username, password=password)

            if user:
                login(request, user)

                return redirect('backend:index')

            else:
                messages.error(request, 'Username or Password incorrect.')

                return redirect('backend:login')

        else:
            messages.error(request, 'Username or Password incorrect. Please try again.')

            return redirect ('backend:login')

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])



# --- Manage Posts ---
@login_required(login_url='backend:login')
def make_blog_post(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = BlogPostForm()

        return render(request, 'backend/manage_posts/make_post.html', {'form': form})

    elif request.method == 'POST':
        pass

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])

Now we'll access the form inside our template. Another useful feature of Django forms: Django forms are iterable, allowing us to dynamically render all fields without explicitly defining each one.

This is useful for rapid development, though in more complex interfaces, fields are often rendered manually for finer control.

For our simple form we'll do that here:

templates/backend/manage_posts/make_post.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{% extends 'backend/base.html' %}

{% block title %}
    Blog - Make Post
{% endblock %}


{% block content %}
<main class="main-post">
    <h3>Make a new Blog Post</h3>

    <form action="{% url 'backend:make_post' %}" method="post">
        {% csrf_token %}

        {% for f in form %}
            <div class="inp-wrapper">
                {{f.label_tag}}
                {{f}}
            </div>
        {% endfor %}

        <button type="submit">Make Post</button>
    </form>
</main>
{% endblock %}

We added a form tag with action and method defined as before and we added the csrf_token

Then we iterate over the form object and add each form element's label and input element.

After adding some simple styling:

static/backend/css/main-style.css

css

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Make Post */
.main-post {
    width: 80%;
    margin: 2rem auto;
    display: flex;
    flex-direction: column;
    align-items: center;

    & form {
        display: flex;
        flex-direction: column;
        align-items: center;
        padding: 1rem;
        border: 1px solid grey;

        & .inp-wrapper {
            display: flex;
            flex-direction: column;
            margin: 1rem;
        }
    }
}

Our template now renders to:

Rendered make post form

We've now prepared the frontend for making posts. Now let's finalize the view so the submitted data will be handled properly:

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
from django.shortcuts import render, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login
from django.contrib import messages

from . forms import LoginForm, BlogPostForm




# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = LoginForm()

        return render(request, 'backend/login.html', {'form': form})

    elif request.method == 'POST':
        form = LoginForm(request.POST)

        if form.is_valid():
            username = form.cleaned_data['username']
            password = form.cleaned_data['password']

            user = authenticate(request, username=username, password=password)

            if user:
                login(request, user)

                return redirect('backend:index')

            else:
                messages.error(request, 'Username or Password incorrect.')

                return redirect('backend:login')

        else:
            messages.error(request, 'Username or Password incorrect. Please try again.')

            return redirect ('backend:login')

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])



# --- Manage Posts ---
@login_required(login_url='backend:login')
def make_blog_post(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = BlogPostForm()

        return render(request, 'backend/manage_posts/make_post.html', {'form': form})

    elif request.method == 'POST':
        form = BlogPostForm(request.POST)

        if form.is_valid():
            messages.success(request, 'Your blog post has been made!')
            form.save()

            return redirect('backend:index')

        else:
            messages.error(request, 'There was an error making the blog post. Please try again.')

            return redirect('backend:make_post')        

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])

Now we instantiate the BlogPostForm with the submitted data by passing request.POST as the argument.

To reiterate: By passing request.POST into the form, Django handles both validation and data cleaning in one step. This ensures that only validated data reaches the database.

Then we again call the is_valid method to check if the submitted data matches our model specification. If so, we return a success message and use the save method on the form. Calling form.save() creates and persists a new BlogPostModel instance using the validated form data.

In case the form was invalid, we return an error message.

Now if we make a new post and navigate to http://127.0.0.1:8000/, our new post will be showing just like the ones we made from the admin panel.


Production Note:

Using Django forms in conjunction with Django models is especially valuable on large models that require multiple inputs. In theory, we could manually extract and use the submitted data (just like we initially did in the login_user view), but imagine a model requiring 20 different inputs. Manually running request.POST.get() on all of those inputs and then feeding all inputs into the model one-by-one would quickly become repetitive and error-prone

Django Forms automate this process in a way that removes most causes for error and reduces repetition.

Another thing to keep in mind is that as applications evolve, models often change. When that happens, adapting forms is much simpler than manual data processing in each view. That's why using Django forms is useful even for small models.


At this point, we’ve established a complete data flow:

user input → form → validation → model → database

This pattern is reused throughout Django applications whenever user input needs to be persisted.

Edit posts

Now that we can make posts, let's add a way for us to edit posts.

We'll add a link to our main navigation so we can access our existing views:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <link rel="stylesheet" href="{% static 'backend/css/main-style.css' %}">
    <title>
        {% block title %}
        {% endblock %}
    </title>
</head>
<body>
    <nav class="main-nav">
        <a href="{% url 'backend:index' %}">Dashboard</a>
        <div class="link-container">
            <a href="{% url 'backend:make_post' %}">Make Post</a>
            <a href="{% url 'backend:posts_overview' %}">Posts Overview</a>
        </div>
    </nav>
    {% if messages %}
        <aside class="message-container">
            {% for m in messages %}
                {% if m.tags == 'error' %}
                    <p class="msg error">{{m}}</p>

                {% elif m.tags == 'success' %}
                    <p class="msg success">{{m}}</p>

                {% endif %}
            {% endfor %}
        </aside>
    {% endif %}

    {% block content %}
    {% endblock %}
</body>
</html>

In order to edit posts, we'll need two additional views: - An overview that let's us select the posts we want to edit - Another view that will let us edit the selected post

We'll start by adding the overview. This one is very similar to the overview we built for the blog app.

templates/backend/manage_posts/posts_overview.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{% extends 'backend/base.html' %}

{% block title %}
    Blog - Post Overview
{% endblock %}


{% block content %}
<main class="main-post-overview">
    <h3>Post Overview</h3>
    <table>
        <thead>
            <th>Nr.</th>
            <th>Title</th>
            <th>Summary</th>
            <th></th>
        </thead>
        <tbody>
            {% for p in posts %}
                <tr>
                    <td>{{forloop.counter}}</td>
                    <td>{{p.title}}</td>
                    <td>{{p.summary}}</td>
                    <td>
                        <a href=""><button type="button">Edit</button></a>
                    </td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
</main>
{% endblock %}

Some styling:

static/backend/css/main-style.css

css

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
.main-post-overview {
    width: 100%;
    display: flex;
    flex-direction: column;
    padding: 1rem;

    & table {
        border-collapse: collapse;

        & thead {
            border-bottom: 1px solid grey;
            font-weight: 600;
            font-size: 1.2rem;

            & th {
                text-align: start;
            }
        }
    }
}

The posts overview view:

backend/views.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
from django.shortcuts import render, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login
from django.contrib import messages

from . forms import LoginForm, BlogPostForm
from blog.models import BlogPostModel



# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = LoginForm()

        return render(request, 'backend/login.html', {'form': form})

    elif request.method == 'POST':
        form = LoginForm(request.POST)

        if form.is_valid():
            username = form.cleaned_data['username']
            password = form.cleaned_data['password']

            user = authenticate(request, username=username, password=password)

            if user:
                login(request, user)

                return redirect('backend:index')

            else:
                messages.error(request, 'Username or Password incorrect.')

                return redirect('backend:login')

        else:
            messages.error(request, 'Username or Password incorrect. Please try again.')

            return redirect ('backend:login')

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])



# --- Manage Posts ---
@login_required(login_url='backend:login')
def make_blog_post(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = BlogPostForm()

        return render(request, 'backend/manage_posts/make_post.html', {'form': form})

    elif request.method == 'POST':
        form = BlogPostForm(request.POST)

        if form.is_valid():
            messages.success(request, 'Your blog post has been made!')
            form.save()

            return redirect('backend:index')

        else:
            messages.error(request, 'There was an error making the blog post. Please try again.')

            return redirect('backend:make_post')        

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])


@login_required(login_url='backend:login')
def posts_overview(request:WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        posts = BlogPostModel.objects.all()

        return render(request, 'backend/manage_posts/posts_overview.html', {'posts': posts})

    else:
        return HttpResponseNotAllowed(['GET'])

Production Note:

For simplicity, we retrieve all posts here.

In production systems, this is typically combined with pagination or filtering to avoid loading large datasets into memory and to keep response times predictable.


And lastly the URL:

backend/urls.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.urls import path
from . import views


app_name='backend'

urlpatterns = [
    path('', view=views.index, name='index'),
    path('login/', view=views.login_user, name='login'),
    path('make-post/', view=views.make_blog_post, name='make_post'),
    path('posts-overview/', view=views.posts_overview, name='posts_overview'),
]

Now we have a list of our existing posts alongside an edit button:

Posts overview in backend app

Now we'll add the view that will allow us to edit each post.

First, as always, we start with a template:

templates/backend/manage_posts/edit_post.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{% extends 'backend/base.html' %}

{% block title %}
    Blog - Edit Post
{% endblock %}


{% block content %}
<main class="main-post">
    <h3>Edit Post: {{title}}</h3>

    <form action="{% url 'backend:edit_post' slug %}" method="post">
        {% csrf_token %}

        {% for f in form %}
            <div class="inp-wrapper">
                {{f.label_tag}}
                {{f}}
            </div>
        {% endfor %}

        <button type="submit">Save Changes</button>
    </form>
</main>
{% endblock %}

The form's action holds an argument just like we did in our blog app when setting up links to render individual posts. We again use the post's slug as the path parameter.

As you may have noticed, this template looks a lot like the make_post template. That's because we can re-use most assets. We'll even use the same form as we did when we made a new post:

backend/views.py

python

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
from django.shortcuts import render, redirect, get_object_or_404
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login
from django.contrib import messages

from . forms import LoginForm, BlogPostForm
from blog.models import BlogPostModel



# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = LoginForm()

        return render(request, 'backend/login.html', {'form': form})

    elif request.method == 'POST':
        form = LoginForm(request.POST)

        if form.is_valid():
            username = form.cleaned_data['username']
            password = form.cleaned_data['password']

            user = authenticate(request, username=username, password=password)

            if user:
                login(request, user)

                return redirect('backend:index')

            else:
                messages.error(request, 'Username or Password incorrect.')

                return redirect('backend:login')

        else:
            messages.error(request, 'Username or Password incorrect. Please try again.')

            return redirect ('backend:login')

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])



# --- Manage Posts ---
@login_required(login_url='backend:login')
def make_blog_post(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = BlogPostForm()

        return render(request, 'backend/manage_posts/make_post.html', {'form': form})

    elif request.method == 'POST':
        form = BlogPostForm(request.POST)

        if form.is_valid():
            messages.success(request, 'Your blog post has been made!')
            form.save()

            return redirect('backend:index')

        else:
            messages.error(request, 'There was an error making the blog post. Please try again.')

            return redirect('backend:make_post')        

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])


@login_required(login_url='backend:login')
def posts_overview(request:WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        posts = BlogPostModel.objects.all()

        return render(request, 'backend/manage_posts/posts_overview.html', {'posts': posts})

    else:
        return HttpResponseNotAllowed(['GET'])


@login_required(login_url='backend:login')
def edit_blog_post(request: WSGIRequest, slug: str) -> HttpResponse:
    if request.method == 'GET':
        post = get_object_or_404(BlogPostModel, slug=slug)
        form = BlogPostForm(instance=post)

        return render(
            request, 
            'backend/manage_posts/edit_post.html',
            {
                'form': form,
                'title': post.title,
                'slug': post.slug
            }
        )

    elif request.method == 'POST':
        post = get_object_or_404(BlogPostModel, slug=slug)
        form = BlogPostForm(request.POST, instance=post)

        if form.is_valid():
            messages.success(request, f'The edit of blog post {form.cleaned_data['title']} has been saved!')
            form.save()

            return redirect('backend:index')

        else:
            messages.error(request, 'There was an error editing the blog post. Please try again.')

            return redirect('backend:edit_post', slug=slug)        

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])

Here we can see another strength of Django forms: We can instantiate it with a model object as argument. Since the BlogPostForm is associated with the BlogPostModel, we can pass a BlogPostModel object in as instance key word argument. That way Django will fill all form fields with the content that was saved in this object.

In the POST branch you we validate the form against what was submitted by the user. In order to tell Django to check against an existing object, we pass the request.POST data and the BlogPostModel object in as instance keyword. Django than checks if the changes to the object are valid.

If so, we call .save() on the form instance to save any changes that were made. We also return a success message, showing the post title.

This view also shows the first context we use containing multiple elements and the redirect function with a path parameter.

When using redirect for a dynamic path (path parameter), we have to pass the path parameter as keyword argument to the redirect function.


Production Note:

When working with forms tied to models, Django distinguishes between creating and updating objects based on whether an instance is provided.

If no instance is passed, form.save() will create a new database entry. If an instance is provided, Django updates that specific record instead.

In larger systems, this distinction is crucial. Failing to bind forms to existing instances can silently introduce duplicate records, especially in workflows where users repeatedly submit similar data.

Explicitly passing instance=post ensures that the operation is deterministic: the existing object is updated, not replaced or duplicated


Now the URL with a dynamic path parameter:

backend/urls.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from django.urls import path
from . import views


app_name='backend'

urlpatterns = [
    path('', view=views.index, name='index'),
    path('login/', view=views.login_user, name='login'),
    path('make-post/', view=views.make_blog_post, name='make_post'),
    path('posts-overview/', view=views.posts_overview, name='posts_overview'),
    path('edit-post/<slug:slug>/', view=views.edit_blog_post, name='edit_post')
]

And finally add the edit_post link to our posts_overview.html:

templates/backend/manage_posts/posts_overview.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{% extends 'backend/base.html' %}

{% block title %}
    Blog - Post Overview
{% endblock %}


{% block content %}
<main class="main-post-overview">
    <h3>Post Overview</h3>
    <table>
        <thead>
            <th>Nr.</th>
            <th>Title</th>
            <th>Summary</th>
            <th></th>
        </thead>
        <tbody>
            {% for p in posts %}
                <tr>
                    <td>{{forloop.counter}}</td>
                    <td>{{p.title}}</td>
                    <td>{{p.summary}}</td>
                    <td>
                        <a href="{% url 'backend:edit_post' p.slug %}"><button type="button">Edit</button></a>
                    </td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
</main>
{% endblock %}

Now if we navigate to http://http://127.0.0.1:8000/backend/posts-overview/ and click one of the edit buttons we'll see our rendered edit view:

Edit post form in backend app

As you can see, the form has been pre-filled by Django and we can now edit and save our post.

Delete Post

The final step in managing content is removing it.
Unlike creating or editing posts, deletion is a purely operational action — it doesn’t render content, it modifies state.

Plenty of times before I mentioned that a view needs three basic components: template, view and URL

However this is the first view we'll make that does not require a template. Since this view serves as a purely operational endpoint it doesn't require its own template.

So this time we'll start with the view

backend/views.py

python

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
from django.shortcuts import render, redirect, get_object_or_404
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login
from django.contrib import messages

from . forms import LoginForm, BlogPostForm
from blog.models import BlogPostModel



# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = LoginForm()

        return render(request, 'backend/login.html', {'form': form})

    elif request.method == 'POST':
        form = LoginForm(request.POST)

        if form.is_valid():
            username = form.cleaned_data['username']
            password = form.cleaned_data['password']

            user = authenticate(request, username=username, password=password)

            if user:
                login(request, user)

                return redirect('backend:index')

            else:
                messages.error(request, 'Username or Password incorrect.')

                return redirect('backend:login')

        else:
            messages.error(request, 'Username or Password incorrect. Please try again.')

            return redirect ('backend:login')

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])



# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])



# --- Manage Posts ---
@login_required(login_url='backend:login')
def make_blog_post(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = BlogPostForm()

        return render(request, 'backend/manage_posts/make_post.html', {'form': form})

    elif request.method == 'POST':
        form = BlogPostForm(request.POST)

        if form.is_valid():
            messages.success(request, 'Your blog post has been made!')
            form.save()

            return redirect('backend:index')

        else:
            messages.error(request, 'There was an error making the blog post. Please try again.')

            return redirect('backend:make_post')        

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])


@login_required(login_url='backend:login')
def posts_overview(request:WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        posts = BlogPostModel.objects.all()

        return render(request, 'backend/manage_posts/posts_overview.html', {'posts': posts})

    else:
        return HttpResponseNotAllowed(['GET'])


@login_required(login_url='backend:login')
def edit_blog_post(request: WSGIRequest, slug: str) -> HttpResponse:
    if request.method == 'GET':
        post = get_object_or_404(BlogPostModel, slug=slug)
        form = BlogPostForm(instance=post)

        return render(
            request, 
            'backend/manage_posts/edit_post.html',
            {
                'form': form,
                'title': post.title,
                'slug': post.slug
            }
        )

    elif request.method == 'POST':
        post = get_object_or_404(BlogPostModel, slug=slug)
        form = BlogPostForm(request.POST, instance=post)

        if form.is_valid():
            messages.success(request, f'The edit of blog post {form.cleaned_data['title']} has been saved!')
            form.save()

            return redirect('backend:index')

        else:
            messages.error(request, 'There was an error editing the blog post. Please try again.')

            return redirect('backend:edit_post', slug=slug)        

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])


@login_required(login_url='backend:login')
def delete_blog_post(request: WSGIRequest, slug: str) -> HttpResponse:
    if request.method == 'POST':
        post = get_object_or_404(BlogPostModel, slug=slug)
        post.delete()

        messages.success(request, f'Post - {post.title} - has been deleted!')

        return redirect('backend:posts_overview')

    else:
        return HttpResponseNotAllowed(['POST'])

We again use the slug as the path parameter for the delete view.

We retrieve the target object and call its delete() method, which removes the corresponding database record.

Since this action does not render content, we don’t need a dedicated template — only a view that performs the operation.


Production Note:

There are two important considerations when implementing delete operations:

1. User confirmation

Deleting data is irreversible, so most applications include a confirmation step (e.g. 'Are you sure?') to prevent accidental data loss.

A common approach in Django would be to use a dedicated confirmation page, which requires additional templates and endpoints.
For now, we keep the implementation simple and will introduce a client-side confirmation dialog in Module 2 to improve the user experience without adding unnecessary server-side complexity.

2. HTTP method semantics

A common beginner approach is to trigger deletion via a simple link ( tag), triggering a GET request.

However, deletion should never be triggered via GET requests.

GET requests are intended to be safe and must not modify server state. Using GET for destructive actions introduces real-world risks: - crawlers or bots triggering deletions - browser prefetching executing the request - users accidentally deleting data by visiting a URL

For this reason, deletion should always be handled via POST (or DELETE in APIs) and protected with CSRF tokens.


The delete_post URL:

backend/urls.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from django.urls import path
from . import views


app_name='backend'

urlpatterns = [
    path('', view=views.index, name='index'),
    path('login/', view=views.login_user, name='login'),
    path('make-post/', view=views.make_blog_post, name='make_post'),
    path('posts-overview/', view=views.posts_overview, name='posts_overview'),
    path('edit-post/<slug:slug>/', view=views.edit_blog_post, name='edit_post'),
    path('delete-post/<slug:slug>/', view=views.delete_blog_post, name='delete_post'),
]

Now to add a 'Delete Post' button to our edit_post.html template

templates/backend/manage_posts/edit_post.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{% extends 'backend/base.html' %}

{% block title %}
    Blog - Edit Post
{% endblock %}


{% block content %}
<main class="main-post">
    <h3>Edit Post: {{title}}</h3>

    <form action="{% url 'backend:edit_post' slug %}" method="post">
        {% csrf_token %}

        {% for f in form %}
            <div class="inp-wrapper">
                {{f.label_tag}}
                {{f}}
            </div>
        {% endfor %}

        <button type="submit">Save Changes</button>
    </form>
    <form action="{% url 'backend:delete_post' slug %}" method="post">
        {% csrf_token %}
        <button type="submit">Delete Post</button>
    </form>
</main>
{% endblock %}

If we click the 'Delete Post' button, the post will be deleted, and we get redirected to our posts_overview page.

This last features now gives us full control over our blog project: we can make, edit and delete posts

Logout User

There is one last thing to do in this Module: We need to be able to log out.

This will be another purely operational view, so we won't need a template here either.

So will start by adding a new view (under the # --- AUTH --- section) at the top:

backend/views.py

python

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
from django.shortcuts import render, redirect, get_object_or_404
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseNotAllowed
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login, logout
from django.contrib import messages

from . forms import LoginForm, BlogPostForm
from blog.models import BlogPostModel



# --- Auth ---
def login_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = LoginForm()

        return render(request, 'backend/login.html', {'form': form})

    elif request.method == 'POST':
        form = LoginForm(request.POST)

        if form.is_valid():
            username = form.cleaned_data['username']
            password = form.cleaned_data['password']

            user = authenticate(request, username=username, password=password)

            if user:
                login(request, user)

                return redirect('backend:index')

            else:
                messages.error(request, 'Username or Password incorrect.')

                return redirect('backend:login')

        else:
            messages.error(request, 'Username or Password incorrect. Please try again.')

            return redirect ('backend:login')

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])


@login_required(login_url='backend:login')
def logout_user(request: WSGIRequest) -> HttpResponse:
    if request.method == 'POST':
        logout(request)

        messages.success(request, 'You\'ve been logged out')

        return redirect('backend:login')

    else:
        return HttpResponseNotAllowed(['GET'])




# --- Views ---
@login_required(login_url='backend:login')
def index(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        return render(request, 'backend/index.html')

    else:
        return HttpResponseNotAllowed(['GET'])



# --- Manage Posts ---
@login_required(login_url='backend:login')
def make_blog_post(request: WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        form = BlogPostForm()

        return render(request, 'backend/manage_posts/make_post.html', {'form': form})

    elif request.method == 'POST':
        form = BlogPostForm(request.POST)

        if form.is_valid():
            messages.success(request, 'Your blog post has been made!')
            form.save()

            return redirect('backend:index')

        else:
            messages.error(request, 'There was an error making the blog post. Please try again.')

            return redirect('backend:make_post')        

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])


@login_required(login_url='backend:login')
def posts_overview(request:WSGIRequest) -> HttpResponse:
    if request.method == 'GET':
        posts = BlogPostModel.objects.all()

        return render(request, 'backend/manage_posts/posts_overview.html', {'posts': posts})

    else:
        return HttpResponseNotAllowed(['GET'])


@login_required(login_url='backend:login')
def edit_blog_post(request: WSGIRequest, slug: str) -> HttpResponse:
    if request.method == 'GET':
        post = get_object_or_404(BlogPostModel, slug=slug)
        form = BlogPostForm(instance=post)

        return render(
            request, 
            'backend/manage_posts/edit_post.html',
            {
                'form': form,
                'title': post.title,
                'slug': post.slug
            }
        )

    elif request.method == 'POST':
        post = get_object_or_404(BlogPostModel, slug=slug)
        form = BlogPostForm(request.POST, instance=post)

        if form.is_valid():
            messages.success(request, f'The edit of blog post {form.cleaned_data['title']} has been saved!')
            form.save()

            return redirect('backend:index')

        else:
            messages.error(request, 'There was an error editing the blog post. Please try again.')

            return redirect('backend:edit_post', slug=slug)        

    else:
        return HttpResponseNotAllowed(['GET', 'POST'])


@login_required(login_url='backend:login')
def delete_blog_post(request: WSGIRequest, slug: str) -> HttpResponse:
    if request.method == 'POST':
        post = get_object_or_404(BlogPostModel, slug=slug)
        post.delete()

        messages.success(request, f'Post - {post.title} - has been deleted!')

        return redirect('backend:posts_overview')

    else:
        return HttpResponseNotAllowed(['POST'])

We import the logout function in line 5

Then we use it in line 51. The only argument is the request

The logout function invalidates the current session and removes the authenticated user, effectively logging them out.

Internally, Django: - clears all session data (request.session.flush()) - resets request.user to AnonymousUser - invalidates the session cookie

The log_out URL:

backend/urls.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from django.urls import path
from . import views


app_name='backend'

urlpatterns = [
    path('', view=views.index, name='index'),
    path('login/', view=views.login_user, name='login'),
    path('logout/', view=views.logout_user, name='logout'),
    path('make-post/', view=views.make_blog_post, name='make_post'),
    path('posts-overview/', view=views.posts_overview, name='posts_overview'),
    path('edit-post/<slug:slug>/', view=views.edit_blog_post, name='edit_post'),
    path('delete-post/<slug:slug>/', view=views.delete_blog_post, name='delete_post'),
]

And lastly a logout link in our main navigation:

templates/backend/base.html

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <link rel="stylesheet" href="{% static 'backend/css/main-style.css' %}">
    <title>
        {% block title %}
        {% endblock %}
    </title>
</head>
<body>
    <nav class="main-nav">
        <a href="{% url 'backend:index' %}">Dashboard</a>
        <div class="link-container">
            <a href="{% url 'backend:make_post' %}">Make Post</a>
            <a href="{% url 'backend:posts_overview' %}">Edit Posts</a>
        </div>
        <form action="{% url 'backend:logout' %}" method="post" style="display:inline;">
            {% csrf_token %}
            <button type="submit">LOGOUT</button>
        </form>
    </nav>
    {% if messages %}
        <aside class="message-container">
            {% for m in messages %}
                {% if m.tags == 'error' %}
                    <p class="msg error">{{m}}</p>

                {% elif m.tags == 'success' %}
                    <p class="msg success">{{m}}</p>

                {% endif %}
            {% endfor %}
        </aside>
    {% endif %}

    {% block content %}
    {% endblock %}
</body>
</html>

Now when the user clicks the LOGOUT link, Django runs the logout steps and redirects back to the login page.

At this point, we have full control over authentication state — from establishing a session (login) to invalidating it (logout).


Production Note:

Logging out is a state-changing operation and should be handled with the same care as other authenticated actions.

In production systems, logout endpoints are typically protected against unintended execution by enforcing POST requests and CSRF protection. This prevents third-party sites or automated processes from triggering logout requests without user intent.

Additionally, relying solely on users to manually log out is not sufficient for security. Real-world applications often implement session expiration and automatic logout mechanisms to:

  • limit the lifetime of authenticated sessions
  • reduce the risk of unauthorized access on shared or unattended devices
  • enforce consistent session cleanup

We’ll cover these mechanisms in more detail in Module 3 when preparing the application for production.

Module Recap:

At this point, we have built a fully functional Django application that goes beyond a simple demo project:

  • Public-facing blog (read-only content)
  • Authenticated backend system
  • Complete authentication flow (login → protected views → logout)
  • Full CRUD operations for blog posts
  • Structured form handling and validation
  • Clear separation of concerns using multiple apps

Each part was built with an eye toward real-world usage, not just getting something to run locally.

This forms the foundation for more advanced features such as permissions, scaling, and deployment.

While this application is fully functional, it intentionally stops short of being production-ready.

Real-world systems require additional layers that go beyond core functionality, such as:

  • security hardening
  • stricter input validation and data handling
  • proper deployment configuration
  • performance and scalability considerations
What’s Next

In this module, we focused on building a working system and understanding the core mechanics behind it.

To keep the foundation clear, we deliberately avoided adding complexity too early. That’s why certain areas were simplified:

  • No file uploads (media handling)
  • No advanced permissions or user roles
  • No pagination or performance optimizations
  • No deployment or security hardening

In the next modules, we will build on this foundation and evolve this project into a production-ready system — step by step, without skipping the reasoning behind each decision.


This module is part of the full learning path Learn Django — From First Project to Production

The full learning path expands this by explaining steps in greater detail and gives insights in production-grade structuring.

  • turning this project into a feature-complete system
  • structuring and scaling larger applications
  • preparing and deploying Django projects for real-world use

If you’re tired of jumping between isolated tutorials and want a structured path where each step builds on the last, you can continue here.

The full learning path will be available soon. Subscribe to our Newsletter to get notified upon its release.