Skip to content
Build a Blog CMS with Django (Step by Step)

Build a Blog CMS with Django (Step by Step)

DodaTech Updated Jun 20, 2026 10 min read

Build a blog CMS with Django featuring post creation and editing, categories and tags, markdown content with syntax highlighting, image uploads, pagination, an RSS feed, full-text search, and author authentication through Django’s admin interface.

What You’ll Build

You’ll build a complete blog CMS where authors write posts in Markdown with live preview, organize content by categories and tags, upload images, and publish instantly. Readers can search, browse by category, subscribe via RSS, and navigate through paginated archives. This same CMS architecture powers the DodaTech documentation blog and knowledge base.

Why Build a Blog CMS?

A blog CMS teaches you content modeling, file upload handling, search implementation, feed generation, and admin customization — all core patterns for any content-driven application. Django’s “batteries included” philosophy means you’ll learn how to leverage built-in features (admin, auth, ORM, syndication) rather than reinventing them.

Prerequisites

Step 1: Project Setup

mkdir blog-cms
cd blog-cms
python -m venv venv
source venv/bin/activate
pip install django django-markdownx django-taggit pillow
django-admin startproject config .
python manage.py startapp blog

Step 2: Models

# blog/models.py
from django.db import models
from django.urls import reverse
from django.contrib.auth.models import User
from markdownx.models import MarkdownxField
from taggit.managers import TaggableManager
import markdown
from django.utils.html import strip_tags

class Category(models.Model):
    name = models.CharField(max_length=100, unique=True)
    slug = models.SlugField(max_length=100, unique=True)
    description = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        verbose_name_plural = "categories"

    def __str__(self):
        return self.name

class Post(models.Model):
    STATUS_CHOICES = (
        ("draft", "Draft"),
        ("published", "Published"),
    )

    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="blog_posts")
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, related_name="posts")
    tags = TaggableManager()
    content = MarkdownxField()
    excerpt = models.TextField(max_length=500, blank=True, help_text="Short summary for listings")
    featured_image = models.ImageField(upload_to="blog/images/%Y/%m/", blank=True)
    status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="draft")
    published_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-published_at"]

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("blog:detail", args=[self.slug])

    def get_html_content(self):
        return markdown.markdown(
            self.content,
            extensions=["fenced_code", "codehilite", "tables", "nl2br"],
        )

    def get_plain_excerpt(self):
        html = self.get_html_content()
        return strip_tags(html)[:300] + "..."

    def save(self, *args, **kwargs):
        if not self.excerpt and self.content:
            html = markdown.markdown(self.content)
            self.excerpt = strip_tags(html)[:300]
        super().save(*args, **kwargs)

Step 3: Admin Customization

# blog/admin.py
from django.contrib import admin
from .models import Post, Category
from markdownx.admin import MarkdownxModelAdmin

@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    prepopulated_fields = {"slug": ("name",)}
    list_display = ("name", "created_at")

@admin.register(Post)
class PostAdmin(MarkdownxModelAdmin):
    list_display = ("title", "author", "category", "status", "published_at", "created_at")
    list_filter = ("status", "category", "tags", "published_at")
    search_fields = ("title", "content", "excerpt")
    prepopulated_fields = {"slug": ("title",)}
    date_hierarchy = "published_at"
    actions = ["publish_posts", "unpublish_posts"]

    def publish_posts(self, request, queryset):
        from django.utils import timezone
        queryset.update(status="published", published_at=timezone.now())
    publish_posts.short_description = "Publish selected posts"

    def unpublish_posts(self, request, queryset):
        queryset.update(status="draft", published_at=None)
    unpublish_posts.short_description = "Unpublish selected posts"

    def save_model(self, request, obj, form, change):
        if not obj.author_id:
            obj.author = request.user
        super().save_model(request, obj, form, change)

Step 4: Views

# blog/views.py
from django.shortcuts import render, get_object_or_404
from django.core.paginator import Paginator
from django.db.models import Q
from django.views.generic import DetailView, ListView
from .models import Post, Category

POSTS_PER_PAGE = 6

class PostListView(ListView):
    model = Post
    template_name = "blog/post_list.html"
    context_object_name = "posts"
    paginate_by = POSTS_PER_PAGE

    def get_queryset(self):
        return Post.objects.filter(status="published").select_related("author", "category")

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["categories"] = Category.objects.all()
        context["recent_posts"] = Post.objects.filter(status="published")[:5]
        return context

post_list = PostListView.as_view()

class PostDetailView(DetailView):
    model = Post
    template_name = "blog/post_detail.html"
    context_object_name = "post"

    def get_queryset(self):
        return Post.objects.filter(status="published").select_related("author", "category")

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        post = self.get_object()
        context["related_posts"] = Post.objects.filter(
            category=post.category, status="published"
        ).exclude(id=post.id)[:3]
        return context

post_detail = PostDetailView.as_view()

class CategoryView(ListView):
    model = Post
    template_name = "blog/post_list.html"
    context_object_name = "posts"
    paginate_by = POSTS_PER_PAGE

    def get_queryset(self):
        self.category = get_object_or_404(Category, slug=self.kwargs["slug"])
        return Post.objects.filter(
            status="published", category=self.category
        ).select_related("author", "category")

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["categories"] = Category.objects.all()
        context["current_category"] = self.category
        return context

category_view = CategoryView.as_view()

class SearchView(ListView):
    model = Post
    template_name = "blog/post_list.html"
    context_object_name = "posts"
    paginate_by = POSTS_PER_PAGE

    def get_queryset(self):
        query = self.request.GET.get("q", "").strip()
        if query:
            return Post.objects.filter(
                Q(title__icontains=query) |
                Q(content__icontains=query) |
                Q(excerpt__icontains=query) |
                Q(tags__name__icontains=query)
            ).filter(status="published").distinct().select_related("author", "category")
        return Post.objects.filter(status="published").select_related("author", "category")

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["categories"] = Category.objects.all()
        context["search_query"] = self.request.GET.get("q", "")
        return context

search = SearchView.as_view()

Step 5: RSS Feed

# blog/feeds.py
from django.contrib.syndication.views import Feed
from django.urls import reverse
from .models import Post

class LatestPostsFeed(Feed):
    title = "DodaTech Blog"
    link = "/blog/"
    description = "Latest posts from DodaTech Blog"

    def items(self):
        return Post.objects.filter(status="published")[:10]

    def item_title(self, item):
        return item.title

    def item_description(self, item):
        return item.get_plain_excerpt()

    def item_link(self, item):
        return item.get_absolute_url()

    def item_pubdate(self, item):
        return item.published_at

Step 6: URLs

# blog/urls.py
from django.urls import path
from . import views
from .feeds import LatestPostsFeed

app_name = "blog"

urlpatterns = [
    path("", views.post_list, name="list"),
    path("search/", views.search, name="search"),
    path("category/<slug:slug>/", views.category_view, name="category"),
    path("feed/", LatestPostsFeed(), name="rss_feed"),
    path("<slug:slug>/", views.post_detail, name="detail"),
]
# config/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path("admin/", admin.site.urls),
    path("blog/", include("blog.urls")),
    path("markdownx/", include("markdownx.urls")),
]

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

Step 7: Templates

<!-- blog/templates/blog/post_list.html -->
<!DOCTYPE html>
<html>
<head>
    <title>Blog</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: system-ui, sans-serif; background: #f8f9fa; color: #333; }
        .container { max-width: 1100px; margin: 0 auto; padding: 40px 20px; display: flex; gap: 40px; }
        .main { flex: 1; }
        .sidebar { width: 280px; }
        h1 { font-size: 2em; margin-bottom: 30px; }
        .post-card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
        .post-card h2 { margin-bottom: 8px; }
        .post-card h2 a { color: #333; text-decoration: none; }
        .post-card h2 a:hover { color: #007bff; }
        .meta { font-size: 0.85em; color: #888; margin-bottom: 12px; }
        .meta span { margin-right: 16px; }
        .excerpt { color: #555; line-height: 1.6; }
        .pagination { display: flex; gap: 8px; margin-top: 30px; }
        .pagination a, .pagination span { padding: 8px 16px; border-radius: 6px; text-decoration: none; }
        .pagination a { background: white; border: 1px solid #ddd; color: #333; }
        .pagination .current { background: #007bff; color: white; }
        .sidebar-section { background: white; border-radius: 12px; padding: 20px; margin-bottom: 20px; }
        .sidebar-section h3 { margin-bottom: 12px; font-size: 1.1em; }
        .sidebar-section ul { list-style: none; }
        .sidebar-section li { margin-bottom: 8px; }
        .sidebar-section a { color: #555; text-decoration: none; }
        .sidebar-section a:hover { color: #007bff; }
        .search-form { display: flex; gap: 8px; }
        .search-form input { flex: 1; padding: 10px; border: 2px solid #ddd; border-radius: 8px; }
        .search-form button { padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 8px; cursor: pointer; }
        .rss-link { display: inline-block; margin-top: 10px; color: #f60; }
    </style>
</head>
<body>
    <div class="container">
        <div class="main">
            <h1>
                {% if current_category %}
                    Category: {{ current_category.name }}
                {% elif search_query %}
                    Search: "{{ search_query }}"
                {% else %}
                    Blog
                {% endif %}
            </h1>

            {% for post in posts %}
            <article class="post-card">
                <h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
                <div class="meta">
                    <span>{{ post.author.get_full_name|default:post.author.username }}</span>
                    <span>{{ post.published_at|date:"F j, Y" }}</span>
                    {% if post.category %}<span>{{ post.category.name }}</span>{% endif %}
                </div>
                <div class="excerpt">{{ post.excerpt|truncatewords:50 }}</div>
            </article>
            {% empty %}
            <p>No posts found.</p>
            {% endfor %}

            {% if is_paginated %}
            <div class="pagination">
                {% if page_obj.has_previous %}
                    <a href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}">← Previous</a>
                {% endif %}
                <span class="current">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
                {% if page_obj.has_next %}
                    <a href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}">Next →</a>
                {% endif %}
            </div>
            {% endif %}
        </div>

        <aside class="sidebar">
            <div class="sidebar-section">
                <h3>Search</h3>
                <form class="search-form" method="GET" action="{% url 'blog:search' %}">
                    <input type="text" name="q" placeholder="Search posts..." value="{{ search_query }}">
                    <button type="submit">Go</button>
                </form>
            </div>

            <div class="sidebar-section">
                <h3>Categories</h3>
                <ul>
                    {% for cat in categories %}
                    <li><a href="{% url 'blog:category' cat.slug %}">{{ cat.name }}</a></li>
                    {% endfor %}
                </ul>
            </div>

            <div class="sidebar-section">
                <h3>Recent Posts</h3>
                <ul>
                    {% for post in recent_posts %}
                    <li><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></li>
                    {% endfor %}
                </ul>
                <a class="rss-link" href="{% url 'blog:rss_feed' %}">Subscribe via RSS</a>
            </div>
        </aside>
    </div>
</body>
</html>

Step 8: Settings and Run

# config/settings.py additions
INSTALLED_APPS = [
    ...
    "blog",
    "markdownx",
    "taggit",
]

MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

LOGIN_URL = "/admin/login/"
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver

Expected output:

Watching for file changes with StatReloader
Performing system checks...
Django version 5.0, using settings 'config.settings'
Starting development server at http://127.0.0.1:8000/

Visit http://localhost:8000/admin/, log in, create categories and posts with Markdown content. The blog frontend is at http://localhost:8000/blog/. Write a post titled “Hello World” with # Hello World\n\nThis is **bold** text. as content — it renders as formatted HTML.

Architecture


flowchart LR
    A[Author] --> B[Django Admin]
    B --> C[(SQLite Database)]
    B --> D[Media Uploads]
    C --> E[Post List View]
    C --> F[Post Detail View]
    C --> G[RSS Feed]
    C --> H[Search View]
    E --> I[Paginated HTML]
    F --> J[Markdown Rendered HTML]
    G --> K[XML Feed]
    H --> L[Search Results]
    I --> M[Reader Browser]
    J --> M
    L --> M
    K --> N[RSS Reader]

Common Errors

1. Images not showing on the post detail page Make sure MEDIA_URL and MEDIA_ROOT are set correctly in settings.py. In development, Django only serves media files if DEBUG = True and you’ve added urlpatterns += static(...). In production, use a web server (NGINX, Apache) or cloud storage (S3) to serve media files.

2. Markdown content shows raw text without formatting The template must call {{ post.get_html_content|safe }} not {{ post.content }}. The |safe filter is required because Django auto-escapes HTML. Without it, the rendered <h1>, <p>, etc. tags are displayed as text.

3. “No such table: blog_post” on first run You forgot to run python manage.py migrate after adding the blog app. Migration order: create the app, add it to INSTALLED_APPS, then run makemigrations and migrate. If you already ran migrate before adding the app, run python manage.py makemigrations blog && python manage.py migrate.

4. Taggit field returns nothing in admin TaggableManager() requires the taggit app to be installed and migrated. Add taggit to INSTALLED_APPS and run python manage.py migrate taggit. The tags field appears as a text input where you type comma-separated tags.

Practice Questions

1. Why does Post.content use MarkdownxField instead of TextField? MarkdownxField stores raw Markdown text but provides a rich editing experience in the admin — a live preview panel renders HTML as you type. It also automatically uploads pasted/dragged images to the media directory. It’s stored as text in the database, just with better admin UX.

2. How does pagination work in the blog? Django’s Paginator class splits the queryset into pages of POSTS_PER_PAGE items. The ListView handles pagination automatically — page_obj contains the current page’s items, and is_paginated tells the template whether to show pagination controls.

3. What’s the purpose of get_absolute_url()? It returns the canonical URL for a post. Django admin uses it for “View on site” links. RSS feeds use it for item_link. It centralizes URL logic so changing a URL pattern doesn’t require updating every template that links to a post.

4. Challenge: Add post scheduling Add a scheduled_at DateTimeField to the Post model. Create a custom management command (python manage.py publish_scheduled) that publishes posts where scheduled_at <= now() and status == "draft". Set up a cron job to run it every minute.

5. Challenge: Add a newsletter subscription form Create a Subscriber model with email and subscribed_at fields. Add a form in the sidebar. On form submission, POST to a view that saves the subscriber and sends a confirmation email. Integrate with Mailchimp or SendGrid for the actual email delivery.

FAQ

How do I add syntax highlighting in code blocks?
The codehilite Markdown extension adds CSS classes to code blocks. Download Pygments CSS: pygmentize -S default -f html -a .codehilite > blog/static/blog/css/pygments.css. Include it in your base template. Wrap code blocks in triple backticks with a language: python ... .
How do I deploy this?
Collect static files with python manage.py collectstatic. Use Gunicorn as the WSGI server: gunicorn config.wsgi:application. Serve behind NGINX for static/media files. Use PostgreSQL instead of SQLite. Set DEBUG = False and SECRET_KEY from environment variables.
How do I add a WYSIWYG editor instead of Markdown?
Replace markdownx with django-ckeditor or django-tinymce. These provide rich text editing similar to WordPress. Install the package, add it to INSTALLED_APPS, and change the content field to RichTextField() from ckeditor.fields.

Next Steps

  • Add Docker containerization for deployment
  • Learn PostgreSQL for production database
  • Explore Redis caching for page speed
  • Build the File Upload Service project for advanced media handling

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro