Wagtail Django-CMS CMS

Wagtail vs Django CMS: Technical Comparison & Editor Experience

Both are mature Django CMSs, both are open-source, and both have real production use. But they make opposite bets on what a CMS should be: Wagtail bets on structured content defined in Python; django-CMS bets on flexible in-context page editing in the browser. This post works through both from the inside — models, editor UX, plugin systems, routing, search, and the honest tradeoffs developers face when picking one.

1. TL;DR Summary

If you want a quick decision table before diving into the detail:

┌─────────────────────────────┬─────────────────────────┬────────────────────────────┐
│ Dimension                   │ Wagtail                 │ django-CMS                 │
├─────────────────────────────┼─────────────────────────┼────────────────────────────┤
│ Content modelling           │ Python models (strict)  │ Plugins + placeholders     │
│ Editor UI                   │ Separate admin          │ Inline frontend editing    │
│ Learning curve (dev)        │ Moderate — new concepts │ Familiar Django admin feel │
│ Learning curve (editor)     │ Low — clean, guided     │ Moderate — toolbar overlay │
│ Multi-site                  │ Via Sites framework     │ First-class built-in       │
│ i18n / translation          │ Via wagtail-localize    │ Built-in (djangocms-trans) │
│ Headless / API              │ Built-in Wagtail API    │ Via REST framework         │
│ Search                      │ First-class (ES/DB)     │ Via django-cms-search      │
│ Image management            │ Excellent (Willow)      │ Plugin-based               │
│ Workflow / versioning       │ Built-in (Wagtail 4+)   │ Via versioning plugin      │
│ Community / ecosystem       │ Large, active           │ Mature, smaller            │
│ Best for                    │ Content-heavy sites     │ Marketing / agency sites   │
└─────────────────────────────┴─────────────────────────┴────────────────────────────┘

2. Architecture Philosophy

Understanding why these two CMSs feel so different starts with understanding what problem each was designed to solve.

Wagtail was built by Torchbox in 2014 for content-rich editorial sites — newspapers, government services (GOV.UK), universities. Its core idea is that a developer defines the content structure in Python models, editors fill in that structure through a clean admin UI, and the template renders whatever the developer designed. Content is typed, structured, and version-controlled. Editors cannot break the layout because the layout lives in code.

django-CMS was built by Divio in 2009 for agency-style web projects where clients want to drag content into pages without developer involvement. Its core idea is placeholders — named slots in templates that editors can fill with plugins from a toolbar overlay directly on the live page. The template defines where content can go; editors decide what goes there.

Neither approach is wrong. They solve different problems for different audiences. The mistake is picking one when your project belongs with the other.

WAGTAIL · MODEL FIRST Python Model BlogPage(Page) StreamField ① define Wagtail Admin content_panels block picker ② fill schema Template {% include_block %} typed output ③ render Layout lives in Python. Editors fill structured fields. They cannot change the page structure. DJANGO-CMS · TEMPLATE FIRST Template {% placeholder %} "hero" "content" ① define slots Plugin Pool TextPlugin HeroPlugin ② pick plugins Frontend toolbar overlay WYSIWYG ③ edit on-page Template defines named slots. Editors choose which plugins fill each one directly in the browser.
Wagtail is model-first: structure defined in Python, editors fill the schema. django-CMS is template-first: slots defined in HTML, editors add plugins freely.

3. Page Modelling: StreamField vs Placeholders

Wagtail: Python Models + StreamField

In Wagtail, every page type is a Django model subclassing wagtail.models.Page. A blog post is a BlogPage model. An event is an EventPage model. The developer controls every field.

# Wagtail page model
from django.db import models
from wagtail.models import Page
from wagtail.fields import RichTextField, StreamField
from wagtail.blocks import CharBlock, RichTextBlock
from wagtail.images.blocks import ImageChooserBlock
from wagtail.admin.panels import FieldPanel


class BlogPage(Page):
    intro    = RichTextField(blank=True)
    body     = StreamField([
        ('heading',  CharBlock(form_classname='full')),
        ('richtext', RichTextBlock()),
        ('image',    ImageChooserBlock()),
    ])  # use_json_field=True was required on Wagtail 3/4; default (and only mode) in Wagtail 5+
    date     = models.DateField()
    author   = models.ForeignKey('auth.User', null=True, on_delete=models.SET_NULL)

    content_panels = Page.content_panels + [
        FieldPanel('intro'),
        FieldPanel('body'),
        FieldPanel('date'),
        FieldPanel('author'),
    ]

StreamField is where Wagtail's content modelling shines. It stores a list of typed blocks as JSON. Editors can add any mix of heading, richtext, image, quote, or custom blocks in any order — but only the block types the developer defined. The schema is enforced; you can migrate it; you can query it. StreamField data is portable across templates and API consumers.

django-CMS: Placeholders + Plugins

In django-CMS, a page is created through the CMS admin. The template defines named placeholder slots, and editors fill those slots with plugins:

{# django-CMS template #}
{% load cms_tags %}

<div class="hero">
  {% placeholder "hero" %}
</div>

<div class="body">
  {% placeholder "content" %}
</div>

<aside>
  {% placeholder "sidebar" %}
</aside>

A plugin is a Django model + a CMS plugin class that defines its admin form. The built-in text plugin uses django-CKEditor. You can write custom plugins for anything:

# django-CMS custom plugin
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from cms.models.pluginmodel import CMSPlugin
from django.db import models


class HeroPlugin(CMSPlugin):
    heading  = models.CharField(max_length=200)
    subline  = models.CharField(max_length=400, blank=True)
    cta_text = models.CharField(max_length=60, blank=True)
    cta_url  = models.URLField(blank=True)


@plugin_pool.register_plugin
class HeroPluginPublisher(CMSPluginBase):
    model        = HeroPlugin
    name         = 'Hero Banner'
    render_template = 'plugins/hero.html'
    cache         = True

    def render(self, context, instance, placeholder):
        context['instance'] = instance
        return context

The plugin is stored independently of the page model in a CMSPlugin table, linked back to the placeholder slot. This means the content structure is more dynamic — an editor can add a hero banner to any page that has a matching placeholder, regardless of page type.

Key difference

Wagtail's StreamField is schema-first: the developer defines the data model and editors work within it. django-CMS's placeholder system is template-first: the template defines where content can go and editors choose what to put there. Wagtail's model is easier to query, migrate, and expose through an API. django-CMS's model is more flexible for editors who need to compose pages freely.

WAGTAIL — STREAMFIELD H CharBlock "About Our Team" RichTextBlock "We build digital products..." IMG ImageChooserBlock team-photo.jpg [focal point: center] CardGridBlock (StructBlock) 3 cards [click to expand] + Add block ▾ Only block types declared in models.py are shown DJANGO-CMS — PLACEHOLDERS {% placeholder "hero" %} HeroBannerPlugin heading · subline · CTA link + Add plugin to hero {% placeholder "content" %} TextPlugin Body copy text... ImagePlugin thumbnail.jpg + Add plugin to content {% placeholder "sidebar" %} + Add plugin to sidebar Any registered plugin can go into any placeholder slot
Wagtail StreamField (left): ordered typed blocks constrained to developer-defined types. django-CMS placeholders (right): named template slots filled with any plugin from the pool.

4. Editor Experience

Wagtail Admin

Wagtail ships a purpose-built admin UI at /cms/ (configurable). It is entirely separate from Django's admin. The editor experience is clean and opinionated:

  • Pages are navigated through a tree explorer — editors see the site hierarchy clearly
  • The editing form is generated from the model's content_panels definition
  • StreamField blocks are added and reordered with a block picker — editors only see block types the developer made available
  • Draft → In Review → Approved → Published workflow is built in (Wagtail 4+)
  • Page history and revision diff are built in — you can restore any previous version
  • Live preview panel alongside the edit form (configurable)

The Wagtail editor experience is consistently praised for being approachable for non-technical users. Because editors are constrained to the structure the developer defined, they cannot accidentally break layouts or introduce inconsistent content. The tradeoff is less flexibility — an editor cannot decide to add a sidebar to a page type that doesn't have one.

django-CMS Frontend Editing

django-CMS's headline feature is its frontend editing toolbar. When a logged-in editor visits any page on the site, a toolbar overlay appears at the top of the browser. From this toolbar, the editor can:

  • Switch between Live and Draft mode with a toggle
  • Double-click any placeholder to open an inline edit popover
  • Add, reorder, and remove plugins directly on the page — changes render immediately
  • Publish, unpublish, and schedule pages from the toolbar
  • Navigate to any CMS page without leaving the frontend

This WYSIWYG-style experience is compelling for editors who are used to page builders. Seeing content in its final context as you edit eliminates the "guess what it will look like" problem that backend-only editing creates.

The tradeoff is complexity: the toolbar overlay relies on injecting JavaScript and CSS into every page for authenticated users. On pages with their own complex CSS or heavy JavaScript, the toolbar occasionally conflicts. Plugin modals open in iframes, which can feel clunky on plugin-heavy pages.

Side-by-side verdict

For editorial teams producing articles, press releases, and structured content, Wagtail's admin is significantly better — faster to learn, less error-prone, and the workflow system is production-grade. For marketing teams who need to compose landing pages freely and want instant visual feedback, django-CMS's frontend editing is genuinely useful and hard to match with Wagtail out of the box.

/cms/pages/edit/42/ W Pages Snippets Images Documents Settings About Our Team Title About Our Team Body (StreamField) H Heading — "About Our Team" RichText — "We build digital..." IMG Image — team-photo.jpg + Add block ▾ Save Draft Preview Publish ▶ DRAFT IN REVIEW PUBLISHED /about/ django CMS Page ▾ History PUBLISH DRAFT placeholder: hero Our Story Building digital products since 2014 + Add plugin to hero placeholder: content TextPlugin [edit] [delete] ImagePlugin [edit] [delete] + Add plugin to content placeholder: sidebar + Add plugin to sidebar Double-click any plugin to edit it inline Editing happens on the live page — no separate form
Left: Wagtail's backend admin — a clean form with a block picker, draft/review/publish workflow buttons, and status badges. Right: django-CMS frontend — a toolbar overlay on the live page with dashed placeholder slots editors fill inline.

5. Plugin & Extension Systems

Wagtail: StreamField Blocks + Snippets + Hooks

Extending Wagtail happens in three places:

StreamField blocks are the primary content extension point. You define a StructBlock, ListBlock, StreamBlock, or subclass Block directly for custom rendering logic. Blocks are composable — a CardGridBlock can contain a ListBlock of CardBlocks:

from wagtail.blocks import StructBlock, CharBlock, RichTextBlock, ListBlock


class CardBlock(StructBlock):
    heading = CharBlock()
    body    = RichTextBlock(features=['bold', 'italic', 'link'])

    class Meta:
        template = 'blocks/card.html'
        icon     = 'doc-empty'


class CardGridBlock(StructBlock):
    cards = ListBlock(CardBlock())

    class Meta:
        template = 'blocks/card_grid.html'
        icon     = 'grip'

Snippets are non-page models that editors can manage through Wagtail's admin. Use them for reusable content like authors, testimonials, or navigation items.

Hooks let you inject behaviour at defined points in the admin lifecycle — add menu items, register admin views, add edit handlers, intercept page publishing:

from django.http import HttpResponse
from wagtail import hooks
from wagtail.admin.menu import MenuItem


@hooks.register('register_admin_menu_item')
def register_reports_menu_item():
    return MenuItem('Analytics', '/analytics/', icon_name='chart-bar', order=500)


@hooks.register('before_publish_page')
def require_seo_title(request, page):
    # Returning any HttpResponse from before_publish_page blocks publication.
    # Raising an exception does NOT block it — the return value is what matters.
    if not page.seo_title:
        return HttpResponse('SEO title is required before publishing.', status=412)

django-CMS: Plugin Pool

All content extensions in django-CMS go through the plugin system. Third-party plugins are installed as Django apps that register themselves with plugin_pool. The ecosystem includes:

  • djangocms-text — rich text with CKEditor 5 (replaces the older djangocms-text-ckeditor which used CKEditor 4)
  • djangocms-picture — image plugin with responsive srcset
  • djangocms-link — internal and external link manager
  • djangocms-file — file attachment plugin
  • djangocms-video — YouTube / Vimeo embed plugin
  • djangocms-bootstrap4 — Bootstrap layout plugins (grid, cards, etc.)

The plugin pool is powerful but its quality is uneven. Core plugins (text, picture, link) are well-maintained. Many community plugins target older django-CMS versions and require patching. Always check last commit date and Django version compatibility before pulling in a third-party plugin.

One genuinely useful django-CMS feature that Wagtail lacks out of the box is plugin nesting — plugins can contain child plugins via allow_children = True. This lets editors compose column layouts, tabs, or accordion components entirely from the frontend toolbar without touching code.


6. Routing, Multi-site & i18n

Wagtail routing

Wagtail uses a single URL catch-all that routes requests through the page tree. Add wagtail_urls to the end of your URLconf and Wagtail handles the rest:

# urls.py
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls

urlpatterns = [
    path('django-admin/', admin.site.urls),          # Django's own admin
    path('cms/', include(wagtailadmin_urls)),         # Wagtail admin UI
    path('documents/', include(wagtaildocs_urls)),   # Document serving
    # Your own app views above wagtail_urls...
    path('', include(wagtail_urls)),  # Page routing — must be last (catch-all)
]

Multi-site in Wagtail is handled via Django's Sites framework. Each Site maps a hostname to a root page in the page tree. It works but requires deploying multiple Wagtail instances or careful hostname routing — there is no built-in multi-site dashboard grouping sites together. The wagtail-localize package adds robust translation workflows with PO file import/export, machine translation integration, and translation memory.

django-CMS routing

django-CMS has its own apphook system. You attach a Django app (with its own URLconf) to any CMS page. That page's URL then serves that app's views. This is how you mix CMS-managed pages with application views — a blog app hooked into /blog/, a shop hooked into /shop/:

# Attach your Django app to a CMS page via apphook
from cms.app_base import CMSApp
from cms.apphook_pool import apphook_pool


@apphook_pool.register
class BlogApp(CMSApp):
    app_name = 'blog'
    name     = 'Blog Application'

    def get_urls(self):
        return ['blog.urls']

Multi-site is first-class in django-CMS: you create multiple sites in the admin, assign pages to sites, and the toolbar switches context as you navigate between them. For agencies managing multiple client sites on one installation, this is a genuine advantage.

django-CMS ships with built-in i18n: pages exist per-language, and the toolbar lets editors switch between language versions. The djangocms-translations package integrates with professional translation services.


Wagtail search

Wagtail has a first-class search framework. The default backend uses the database; you can switch to Elasticsearch with a settings change and no code changes. Search fields are declared on models:

from wagtail.search import index

class BlogPage(Page):
    search_fields = Page.search_fields + [
        index.SearchField('intro',  boost=4),
        index.SearchField('body',   boost=1),
        index.FilterField('date'),
        index.AutocompleteField('title'),
    ]

You search with .search() on any queryset — it transparently dispatches to the configured backend. Wagtail also tracks search queries and hit rates in the database, surfacing them in the admin for content teams. See the full Elasticsearch setup in How to Set Up Wagtail with Elasticsearch.

django-CMS search

django-CMS does not include a search framework. The most common approach is django-cms-search (for Haystack) or wiring Elasticsearch directly. Because content lives in plugin tables rather than model fields, indexing is more complex — you have to gather text from plugin instances across multiple tables and denormalize it into a search document.

This is one of the areas where Wagtail's structured data model pays off: a single SearchField declaration on the model is enough to index everything. In django-CMS, you typically write a custom indexing strategy per page type.


8. Image & Media Handling

Wagtail images

Wagtail ships a full image management system built on the Willow library. Images are uploaded once and referenced anywhere. The template tag handles resizing, cropping, and WebP conversion at render time, with aggressive caching:

{# Wagtail image template tag #}
{% load wagtailimages_tags %}

{# Generate a 800×600 fill crop, served as WebP with JPEG fallback #}
{% image page.hero_image fill-800x600 as hero %}
<img src="{{ hero.url }}" width="{{ hero.width }}" height="{{ hero.height }}"
     alt="{{ page.hero_image.title }}" loading="lazy">

{# Responsive image with srcset #}
{% image page.hero_image width-400 as img_400 %}
{% image page.hero_image width-800 as img_800 %}
<img src="{{ img_400.url }}"
     srcset="{{ img_400.url }} 400w, {{ img_800.url }} 800w"
     sizes="(max-width:640px) 100vw, 800px"
     alt="{{ page.hero_image.title }}">

Editors use a focal point picker on upload so that automatic crops preserve the subject. Wagtail 4.x added WebP output by default. The image library integrates with Wagtail's permission system — you can restrict collections of images to specific editor groups.

django-CMS images

django-CMS delegates images entirely to plugins. The official djangocms-picture plugin handles uploads and provides a responsive image editor. Many projects integrate easy-thumbnails or Pillow-based solutions directly. The ecosystem is workable but less cohesive than Wagtail's built-in approach.


9. Headless & API Modes

Wagtail API

Wagtail ships a built-in read-only REST API (V2) that exposes pages, images, and documents. Enable it in three lines:

# settings.py
INSTALLED_APPS += ['wagtail.api.v2']

# api.py
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.images.api.v2.views import ImagesAPIViewSet

api_router = WagtailAPIRouter('wagtailapi')
api_router.register_endpoint('pages',  PagesAPIViewSet)
api_router.register_endpoint('images', ImagesAPIViewSet)

# urls.py
path('api/v2/', api_router.urls),

The API returns structured JSON with StreamField content serialized as a typed block array. React or Next.js frontends can render StreamField blocks by type without needing to parse HTML. For a write API, you add DRF and model serializers on top — Wagtail does not ship a write API.

The wagtail-headless-preview package adds preview endpoints so a decoupled frontend can render draft content from the Wagtail admin before publishing.

django-CMS headless

django-CMS's plugin model makes headless harder. Plugin content is stored across multiple tables, and the toolbar overlay is fundamentally a server-rendered HTML feature. The djangocms-rest package serialises page content to JSON but it reflects the plugin tree structure rather than clean typed content — each JSON node maps to a plugin instance.

For headless projects, Wagtail is the cleaner choice. django-CMS is designed around its frontend editing experience, and stripping that away removes its main differentiator.


10. Performance & Caching

Wagtail caching

Wagtail has no built-in page caching — it relies on your Django caching layer or a reverse proxy (nginx, Varnish, CloudFront). You typically add Django's cache_page decorator or CacheMiddleware to page views, and bust the cache on page publish via a Wagtail hook:

from wagtail import hooks
from django.core.cache import cache


@hooks.register('after_publish_page')
def bust_page_cache(request, page):
    # Bust the per-page cache key
    cache.delete(f'page_{page.pk}')
    # delete_pattern is only available with django-redis; skip on other backends
    if hasattr(cache, 'delete_pattern'):
        cache.delete_pattern('views.decorators.cache.cache_page.*')

For high-traffic sites, the pattern is: Wagtail → nginx cache → CDN. Database queries per page request depend entirely on your model design — be careful with select_related and prefetch_related on deep page trees.

django-CMS caching

django-CMS has a plugin-level cache attribute. Setting cache = True on a plugin tells django-CMS to cache its rendered output:

@plugin_pool.register_plugin
class StaticContentPlugin(CMSPluginBase):
    model           = StaticContent
    render_template = 'plugins/static.html'
    cache           = True    # Cache this plugin's rendered output

django-CMS's caching is more granular — you can cache individual plugins rather than the entire page. But any plugin with cache = False forces the entire placeholder to skip cache, which can silently degrade performance when you install third-party plugins that disable caching by default.


11. Developer Experience

Setup comparison

# Wagtail quickstart
pip install wagtail
wagtail start mysite
cd mysite
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
# django-CMS quickstart
pip install "django-cms>=4.0" djangocms-text
django-admin startproject mysite
cd mysite
# Add cms, menus, treebeard, sekizai, djangocms_text to INSTALLED_APPS
# Configure CMS_TEMPLATES, LANGUAGES, SITE_ID in settings
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver

Wagtail's project scaffold (wagtail start) gets you to a running CMS in under five minutes. django-CMS setup requires more manual configuration — adding apps, configuring template processors, and setting up middleware. The Divio Installer automates this, but the underlying complexity is real.

Testing

Wagtail ships test utilities including WagtailPageTests which gives you convenience methods for testing page creation, routing, and rendering:

from datetime import date
from wagtail.models import Page
from wagtail.test.utils import WagtailPageTests
from blog.models import BlogPage, BlogIndexPage


class BlogPageTests(WagtailPageTests):
    def test_can_create_under_index(self):
        self.assertCanCreateAt(BlogIndexPage, BlogPage)

    def test_cannot_create_at_root(self):
        self.assertCanNotCreateAt(Page, BlogPage)

    def test_page_renders(self):
        root = Page.objects.filter(depth=1).first()
        index = root.add_child(instance=BlogIndexPage(
            title='Blog', slug='blog'
        ))
        page = index.add_child(instance=BlogPage(
            title='Test', slug='test', date=date.today()
        ))
        response = self.client.get(page.url)
        self.assertEqual(response.status_code, 200)

django-CMS provides CMSTestCase with helpers for creating pages and placeholders in tests. Plugin testing requires creating plugin instances and attaching them to placeholders, which adds test setup overhead compared to Wagtail's model-based approach.

Migrations

Wagtail content lives in model fields, so changes go through standard Django migrations. Adding a field to BlogPage generates a migration exactly like any other model change. StreamField block additions are schema-transparent — adding a new block type does not require a migration because the JSON schema is implicit.

django-CMS plugin content is stored in plugin-specific Django models that subclass CMSPlugin — each plugin class has its own database table with proper Django fields. Plugin model changes migrate normally. However, renaming a plugin class breaks existing plugin instances because the class path is stored as a string in the database; you need a data migration to update those records. django-CMS v4 moved content versioning into the separate djangocms-versioning package, which uses a content snapshot approach rather than Wagtail's built-in revision system.


12. When to Pick Each One

Choose Wagtail when:

  • Your content has structure — articles, products, events, courses — not freeform marketing pages
  • You need a headless or API-first architecture
  • You want built-in Elasticsearch search with minimal configuration
  • Editorial workflow (draft → review → publish) and version history are important
  • Your editors are content producers, not page composers
  • You need tight image management with focal points and responsive output
  • You want a clean, modern admin that non-technical editors will actually enjoy
  • You are building for a large editorial team across multiple content types

Choose django-CMS when:

  • Your clients are marketing teams who need to compose and rearrange page layouts without developer involvement
  • You are an agency building many client sites from a single installation
  • Inline WYSIWYG editing and instant visual feedback are a hard requirement from stakeholders
  • You need first-class multi-site management from one Django installation
  • Pages are marketing landing pages, not structured content objects
  • Your team is already comfortable with the plugin model and has existing plugin code to reuse
WHICH CMS SHOULD I USE? New Django CMS project Structured content? (articles, products, events) YES Wagtail NO Headless or API-first? (React, Next.js frontend) YES Wagtail NO Editors compose layouts freely in the browser? YES django-CMS NO → Wagtail (use StreamField for flexibility)
Decision flowchart: most projects with structured content or API requirements belong in Wagtail. django-CMS shines when editors need to visually compose freeform page layouts in the browser.

What production experience teaches you

After running both in production, the pattern I see repeatedly: projects that start with django-CMS for its flexibility often grow into a place where they need Wagtail's structure. Marketing pages evolve into a content database. Editors stop composing pages and start filling templates. The plugin tree becomes hard to query for feeds, sitemaps, and APIs.

Projects that start with Wagtail occasionally hit the ceiling where an editor needs to compose a genuinely freeform landing page. The fix is almost always a well-designed set of StreamField blocks — a TwoColumnBlock, a HeroBlock, a CardGridBlock — that gives editors the flexibility they need within a structure the developer controls.

If you are uncertain, Wagtail is the lower-risk default for most teams. It scales from simple to complex better than django-CMS scales from flexible to structured.