Build a Blog CMS with Django (Step by Step)
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
- Python 3.10+ installed
- Basic Django knowledge — models, views, URLs
- Familiarity with HTML and CSS
- Understanding of SQL databases
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 blogStep 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_atStep 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 runserverExpected 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
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