Django Basics #5: Templates and Static Files

8 min read

In #4 URL and Views, the views returned HTML through calls like render(request, "blog/post_list.html", {"posts": posts}). This post unpacks what that HTML really is — Django template syntax, and how the static files (CSS, JS, images) the templates reference are handled.

Template directory structure #

Django looks for templates in two places — each app’s templates/ and the project-level templates/.

recommended structure
myblog/
├── config/
│   └── settings.py
├── templates/                       # project-wide (base.html, etc.)
│   └── base.html
└── blog/
    ├── views.py
    └── templates/
        └── blog/                    # one more level deep, by convention
            ├── post_list.html
            └── post_detail.html

The reason for nesting the app name one level deeper — blog/templates/blog/post_list.html — is to avoid name collisions with templates in other apps. If two apps both have post_list.html, you don’t know which one wins.

TEMPLATES setting in settings.py #

config/settings.py
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],     # shared template directory
        "APP_DIRS": True,                      # auto-search each app's templates/
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

DIRS for shared directories, APP_DIRS=True for auto-search inside each app.

Variable output — {{ }} #

blog/templates/blog/post_detail.html
<h1>{{ post.title }}</h1>
<p>Author: {{ post.author.username }}</p>
<p>Created: {{ post.created_at }}</p>
<div>{{ post.content }}</div>

{{ var }} outputs a variable. Dot notation is tried in this order:

  1. dict key (var["title"])
  2. object attribute (var.title)
  3. method (var.title()) — must be callable without arguments
  4. list index (var[0])

This automatic resolution is why templates only allow simple expressions — no Python code directly. That’s the Django template philosophy.

Auto-escape — XSS defense by default #

Django templates automatically HTML-escape every value rendered with {{ }}. If a variable contains <script>alert(1)</script>, it’s converted to &lt;script&gt;....

To turn it off (for example, pre-sanitized HTML), use the |safe filter:

Disable escaping
{{ post.content_html|safe }}

Use |safe only on data you truly trust. Slapping it on user input creates an XSS vulnerability.

Tags — {% %} #

Tags handle logic. The most-used ones.

{% if %} #

conditionals
{% if post.is_published %}
  <span class="badge">Published</span>
{% elif post.status == "draft" %}
  <span class="badge">Draft</span>
{% else %}
  <span class="badge">Other</span>
{% endif %}

{% if posts %}
  <p>{{ posts|length }} total</p>
{% else %}
  <p>No posts yet.</p>
{% endif %}

Comparison operators — ==, !=, <, >, <=, >=, in, not in, is, is not. Logical operators — and, or, not.

{% for %} #

loops
<ul>
{% for post in posts %}
  <li>
    <a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
    <small>by {{ post.author.username }}</small>
  </li>
{% empty %}
  <li>No posts to show.</li>
{% endfor %}
</ul>

{% empty %} branches when the collection is empty. Used very often.

Variables available inside the loop:

VariableMeaning
forloop.counter1-based index
forloop.counter00-based index
forloop.firstTrue on the first iteration
forloop.lastTrue on the last iteration
forloop.revcountercounts from end down to 1
forloop in action
{% for post in posts %}
  <div class="{% if forloop.first %}featured{% endif %}">
    {{ forloop.counter }}. {{ post.title }}
  </div>
{% endfor %}

{% url %} #

URL generation
<a href="{% url 'blog:post_list' %}">List</a>
<a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
<a href="{% url 'blog:post_detail' post_id=post.id %}">{{ post.title }}</a>

The template version of named URLs from #4.

{% include %} #

Include another template
{% include "blog/_post_card.html" %}
{% include "blog/_post_card.html" with post=post show_author=True %}

Reuse partial templates. The convention for partials is to start the filename with _.

{% csrf_token %} #

A tag that must always be in forms.

form
<form method="post">
  {% csrf_token %}
  <input name="title" />
  <button>Save</button>
</form>

If you forget it, the request is blocked with 403 Forbidden. Details in #6.

Filters — | #

Filters transform variables. {{ value|filter }} or {{ value|filter:"arg" }}.

commonly used filters
{{ post.title|upper }}                    {# uppercase #}
{{ post.title|lower }}
{{ post.title|truncatechars:50 }}         {# cut at 50 chars and ... #}
{{ post.title|truncatewords:10 }}
{{ post.created_at|date:"Y-m-d H:i" }}    {# date format #}
{{ post.created_at|timesince }}            {# "3 days ago" #}
{{ post.content|linebreaks }}              {# newlines → <p>, <br> #}
{{ post.content|striptags }}               {# strip HTML tags #}
{{ posts|length }}
{{ post.content|default:"No content" }}
{{ value|yesno:"yes,no,maybe" }}
{{ price|floatformat:2 }}                  {# 1234.567 → 1234.57 #}
{{ name|capfirst }}                        {# only first letter uppercase #}
{{ posts|first }}
{{ posts|last }}
{{ posts|slice:":5" }}

Filters can be chained{{ post.title|truncatechars:50|upper }}.

Template inheritance — {% extends %} / {% block %} #

A pattern that keeps the common skeleton of a page (header, footer, sidebar) in one place and fills in only the parts that change per page.

templates/base.html — common skeleton
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{% block title %}My Blog{% endblock %}</title>
  {% load static %}
  <link rel="stylesheet" href="{% static 'css/main.css' %}">
</head>
<body>
  <header>
    <a href="{% url 'blog:post_list' %}">Home</a>
    {% if user.is_authenticated %}
      <span>{{ user.username }}</span>
      <a href="{% url 'logout' %}">Logout</a>
    {% else %}
      <a href="{% url 'login' %}">Login</a>
    {% endif %}
  </header>

  <main>
    {% block content %}{% endblock %}
  </main>

  <footer>
    <p>&copy; 2026 My Blog</p>
  </footer>
</body>
</html>
blog/templates/blog/post_list.html — child
{% extends "base.html" %}

{% block title %}Posts - My Blog{% endblock %}

{% block content %}
  <h1>Posts</h1>
  <ul>
    {% for post in posts %}
      <li>
        <a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
        <small>{{ post.created_at|date:"Y-m-d" }}</small>
      </li>
    {% empty %}
      <li>No posts yet.</li>
    {% endfor %}
  </ul>
{% endblock %}

Rules:

  • The first line of a child template is {% extends "..." %}
  • The parent’s {% block name %}...{% endblock %} is filled by a same-named block in the child
  • If the child leaves a block empty, the parent’s default is shown as-is
  • Inside a block, {{ block.super }} lets you combine the parent’s content with the child’s

Multi-level inheritance #

example of 3-level inheritance
base.html               (site skeleton)
blog/_layout.html       (blog-wide, adds sidebar)
blog/post_list.html     (the actual page)

Each level extends the one above it. Be careful not to over-nest.

Static files — CSS, JS, images #

settings and directories #

config/settings.py
STATIC_URL = "static/"

# Extra search paths during development
STATICFILES_DIRS = [
    BASE_DIR / "static",
]

# Output location for collectstatic in production
STATIC_ROOT = BASE_DIR / "staticfiles"

Each app’s static/ directory is also auto-searched (when django.contrib.staticfiles is enabled — the default).

static file locations
myblog/
├── static/                        # project-wide
│   └── css/
│       └── main.css
└── blog/
    └── static/
        └── blog/                  # app name once more (same convention as templates)
            ├── css/
            │   └── post.css
            └── images/
                └── logo.png

{% static %} in templates #

using static
{% load static %}

<link rel="stylesheet" href="{% static 'css/main.css' %}">
<link rel="stylesheet" href="{% static 'blog/css/post.css' %}">
<img src="{% static 'blog/images/logo.png' %}" alt="logo">
<script src="{% static 'blog/js/post.js' %}"></script>

You must declare {% load static %} at the top of the template once. Usually near the top of base.html’s <head>.

Production deployment — collectstatic #

runserver (the dev server) auto-serves static files. In production, collectstatic gathers all static files into one directory (STATIC_ROOT), and a web server like Nginx serves them directly.

collectstatic
uv run python manage.py collectstatic

What this command does:

  • Copies files from every app’s static/ and from STATICFILES_DIRS into STATIC_ROOT
  • If a name collides, the file from the first search path wins

The detailed production flow (WhiteNoise, CDN, manifest storage) is in Intermediate #6 Static/Media in production.

Media files — user uploads #

STATIC is developer-authored files (CSS, JS); MEDIA is user-uploaded files (profile images, attachments). They’re different places.

config/settings.py
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
config/urls.py — development only
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    # ...
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Don’t serve user uploads directly in production — use external storage like S3. Also covered in Intermediate #6.

Django templates vs Jinja2 — one-line comparison #

Django templates are a deliberately limited language. You can’t write Python code directly, and arbitrary function calls are hard (only the tags/filters you {% load %}). That’s the way it preserves security and designer-friendliness.

Jinja2 is more powerful — almost any Python expression works inside the template, which also means business logic leaks into templates more easily.

You can use Jinja2 as a backend in Django (change BACKEND in TEMPLATES), but Admin, Form, and other built-ins assume Django templates, so you end up mixing both. Sticking with Django templates is the safer default to start with.

Custom tags / filters #

If you have an expression you use often, you can build your own.

blog/templatetags/blog_extras.py
from django import template

register = template.Library()


@register.filter
def markdown_to_html(value: str) -> str:
    import markdown
    return markdown.markdown(value)


@register.simple_tag
def current_year():
    from datetime import datetime
    return datetime.now().year
template
{% load blog_extras %}

<div>{{ post.content|markdown_to_html|safe }}</div>
<footer>&copy; {% current_year %}</footer>

A templatetags/ directory with an __init__.py is required. Without it, Django won’t find the tags.

Recap #

What this post nailed down:

  • Template locations — per-app templates/<app>/... + project-wide templates/
  • TEMPLATES settings: DIRS / APP_DIRS
  • Variables — {{ }}, automatic HTML escaping (XSS defense)
  • Tags — {% if %}, {% for %} (with {% empty %}), {% url %}, {% include %}, {% csrf_token %}
  • Filters — |date, |truncatechars, |linebreaks, |safe, …
  • Template inheritance — {% extends %}, {% block %}, {{ block.super }}
  • Static files — STATIC_URL, STATICFILES_DIRS, {% load static %}, {% static %}
  • collectstatic is for production
  • Media files are separate (MEDIA_URL / MEDIA_ROOT)
  • Django templates are deliberately limited — security/simplicity
  • Custom tags/filters — templatetags/

In the next post (#6 Forms and ModelForm), you’ll handle forms inside these templates the standard way — using Form / ModelForm to validate, manage CSRF, and display errors all at once.

X