$ cli-blog docs
Examples & Guides

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 bleach

Pin 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.com

Read 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_safe and retain template autoescaping elsewhere.
  • Test empty, 404, 401, 403, 429, sitemap, feed, and cache invalidation behavior.

On this page