Add a blog to Django
Build a Django blog with published posts, safe Markdown, authorized drafts, cursor pagination, caching, sitemap, and RSS.
Django can own the reader-facing routes while Cli Blog owns content and editorial workflow. The result feels like a native part of your application, without adding blog tables or an admin publishing model.
What you will build
- A cached list of published posts.
- A locale-aware slug page with safe Markdown and SEO fields.
- Cursor-based navigation.
- A permission-protected endpoint that creates drafts.
- Sitemap and RSS proxy views.
Prerequisites
You need Python 3.11 or newer, a Django project, and public and private API keys for a Cli Blog organization.
Install dependencies
python -m pip install requests Markdown bleachPin the versions in your normal requirements or lock file.
Configure the environment
Set these values in your deployment environment:
CLI_BLOG_PUBLIC_KEY=<public-api-key>
CLI_BLOG_API_KEY=<private-api-key>
CLI_BLOG_BASE_URL=https://api.cli-blog.comRead them in settings.py and fail startup if a required value is missing:
import os
CLI_BLOG_BASE_URL = os.getenv("CLI_BLOG_BASE_URL", "https://api.cli-blog.com")
CLI_BLOG_PUBLIC_KEY = os.environ["CLI_BLOG_PUBLIC_KEY"]
CLI_BLOG_API_KEY = os.environ["CLI_BLOG_API_KEY"]Never pass the private key into a template, browser setting, error page, or committed settings module.
Create an API service
Create blog/services.py:
from urllib.parse import quote
import requests
from django.conf import settings
def cli_blog_request(method, path, *, private=False, params=None, json=None):
key = settings.CLI_BLOG_API_KEY if private else settings.CLI_BLOG_PUBLIC_KEY
response = requests.request(
method,
f"{settings.CLI_BLOG_BASE_URL}{path}",
headers={"x-api-key": key, "accept": "application/json"},
params=params,
json=json,
timeout=(3.05, 10),
)
response.raise_for_status()
return response.json()
def post_path(slug):
return f"/v1/posts/{quote(slug, safe='')}"Catch requests.HTTPError in the view layer so your application can map the upstream status to an appropriate local response.
List published posts
import hashlib
from django.core.cache import cache
from django.shortcuts import render
from .services import cli_blog_request
def post_list(request):
after = request.GET.get("after", "")
cache_key = "blog:list:" + hashlib.sha256(after.encode()).hexdigest()
result = cache.get(cache_key)
if result is None:
result = cli_blog_request("GET", "/v1/posts", params={
"status": "published",
"locale": "en-US",
"fields": "summary,seo",
"include": "authors,tags",
"limit": 20,
"after": after or None,
})
cache.set(cache_key, result, 300)
return render(request, "blog/list.html", {"result": result})Field groups keep list responses lean. summary supplies card fields, seo adds metadata, and includes embed related authors and tags.
Render the list and cursor
{% for post in result.data %}
<article>
<h2><a href="{% url 'blog:detail' post.slug %}">{{ post.title }}</a></h2>
<p>{{ post.excerpt }}</p>
</article>
{% empty %}
<p>No posts have been published yet.</p>
{% endfor %}
{% if result.has_more and result.next_cursor %}
<a href="?after={{ result.next_cursor|urlencode }}">More posts</a>
{% endif %}Django templates escape text by default. Treat next_cursor as opaque, preserve the same filters between requests, and never derive meaning from the cursor. The maximum limit is 100. Cursor pagination is preferred because numbered pages must calculate exact totals.
Retrieve a post by slug
from django.http import Http404
from requests import HTTPError
from .services import cli_blog_request, post_path
def post_detail(request, slug):
try:
post = cli_blog_request("GET", post_path(slug), params={
"locale": "en-US",
"fields": "summary,content,seo",
"include": "authors,categories,tags,media",
})
except HTTPError as error:
if error.response.status_code == 404:
raise Http404 from error
raise
return render(request, "blog/detail.html", {"post": post})Request content only on detail pages. Slug lookup is locale-aware, so use the same locale in the index and detail requests.
Render Markdown safely
Convert and sanitize on the server before marking output safe:
import bleach
import markdown
from django.utils.safestring import mark_safe
raw_html = markdown.markdown(post["body_markdown"], extensions=["fenced_code", "tables"])
clean_html = bleach.clean(
raw_html,
tags={"p", "h2", "h3", "h4", "ul", "ol", "li", "blockquote", "pre", "code", "a", "strong", "em", "table", "thead", "tbody", "tr", "th", "td"},
attributes={"a": ["href", "title"]},
protocols={"http", "https", "mailto"},
strip=True,
)
post["article_html"] = mark_safe(clean_html)Only mark the sanitized result safe. Let Django continue escaping titles, descriptions, alt text, and bylines.
Add SEO metadata
In the detail template, use post.seo_title|default:post.title for <title> and post.seo_description|default:post.excerpt for the description. Emit a canonical link only when canonical_url exists. Request the seo field group for robots, social, and schema overrides.
Create a trusted draft view
Use Django authentication, permissions, CSRF protection, and validation before the private request:
import json
from django.contrib.auth.decorators import login_required, permission_required
from django.http import JsonResponse
from django.views.decorators.http import require_POST
@require_POST
@login_required
@permission_required("blog.add_external_post", raise_exception=True)
def create_draft(request):
payload = json.loads(request.body)
title = str(payload.get("title", "")).strip()
body = str(payload.get("markdown", ""))
if not title or not body:
return JsonResponse({"error": "title and markdown are required"}, status=400)
draft = cli_blog_request("POST", "/v1/posts", private=True, json={
"title": title,
"body_markdown": body,
"locale": "en-US",
"status": "draft",
})
return JsonResponse({"id": draft["id"], "status": draft["status"]}, status=201)Define the permission in your application or replace it with your existing editor policy. Do not accept a published status from the request.
Add sitemap and feed views
Request GET https://api.cli-blog.com/v1/sitemap?locale=en-US&limit=100 with the public key and return the raw body in an HttpResponse with application/xml. Repeat for GET /v1/feed?locale=en-US&limit=20 with application/rss+xml. Cache these views and expose them as /sitemap.xml and /feed.xml in urls.py.
Handle failures and caching
Map 404 to Http404. A 401 means the key is missing or invalid; 403 means the key type or permissions do not allow the operation; 429 should be retried later. Retry transient requests in a background task or bounded service wrapper, not an unbounded view loop.
Cache only published content. Invalidate the index, changed slug, sitemap, and feed after your approved publish workflow. Never share-cache drafts or authenticated preview responses.
Production checklist
- Store keys in deployment secrets and keep private values out of Django templates.
- Protect writes with login, permission, CSRF, validation, and rate limits.
- Use timeouts and bounded retries for outbound requests.
- Request lean field groups and preserve cursor filters.
- Sanitize Markdown before
mark_safeand retain template autoescaping elsewhere. - Test empty, 404,
401,403,429, sitemap, feed, and cache invalidation behavior.