Turning Django Admin Into a CMS Your Client Can Actually Use
One of the most expensive decisions on a client project is deciding to build a content management panel from scratch. Weeks disappear, and what you end up with does roughly what Django's built-in admin already did.
In most cases what you actually need isn't a new panel — it's the existing admin made comfortable for someone non-technical. This post covers the concrete settings that get you there.
Making the list view readable
By default the admin renders each row using __str__. Fine for ten records, unusable for thousands.
from django.contrib import admin
@admin.register(Listing)
class ListingAdmin(admin.ModelAdmin):
list_display = ("title", "city", "price", "is_published", "updated_at")
list_filter = ("is_published", "city", "category")
search_fields = ("title", "description")
ordering = ("-updated_at",)
list_per_page = 50Those five lines turn a pile of records into a workable table. list_filter adds a filter sidebar, search_fields adds a search box.
One caveat: piling fields into search_fields builds an OR chain on every search, which gets slow on large tables. Include only what people actually search by.
Editing straight from the list
The most frequent action for a content team is usually flipping one field — unpublishing a listing, say. Instead of opening the detail page every time:
list_editable = ("is_published",)Now they toggle it right in the list. Fields in list_editable must also appear in list_display, and cannot be the first column.
Bulk actions
Some tasks are too repetitive to do one at a time. A custom admin action solves it:
@admin.action(description="Publish selected records")
def publish_selected(modeladmin, request, queryset):
updated = queryset.update(is_published=True)
modeladmin.message_user(request, f"{updated} records published.")
class ListingAdmin(admin.ModelAdmin):
actions = [publish_selected]queryset.update() runs as a single query — far faster than looping and calling save(). But note: update() does not fire model signals. If you rely on a save signal (cache invalidation, for instance), you need the loop or an explicit call.
Splitting the form into sections
A model with twenty fields renders as a flat list of twenty rows, with no indication of what relates to what:
fieldsets = (
("Basics", {
"fields": ("title", "slug", "category"),
}),
("Pricing", {
"fields": ("price", "currency"),
}),
("SEO", {
"fields": ("meta_title", "meta_description"),
"classes": ("collapse",),
"description": "Leave blank to generate from the title and description.",
}),
)The collapse class starts a section folded, hiding fields people rarely touch. The description answers "do I have to fill this in?" before anyone asks.
Editing related records in place
Managing a listing's photos from a separate menu is tedious. Inlines fix that:
class ListingImageInline(admin.TabularInline):
model = ListingImage
extra = 1
fields = ("image", "alt_text", "order")
class ListingAdmin(admin.ModelAdmin):
inlines = [ListingImageInline]TabularInline gives a compact table; StackedInline renders each record as a stacked form. Few fields favor tabular, many favor stacked.
Managing multilingual content
When content needs to exist in fifteen languages, adding a field per language bloats the model. The common approach is a separate translation model attached via an inline:
class RouteTranslation(models.Model):
route = models.ForeignKey("Route", on_delete=models.CASCADE,
related_name="translations")
language = models.CharField(max_length=5, choices=LANGUAGES)
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
class Meta:
unique_together = ("route", "language")
class RouteTranslationInline(admin.TabularInline):
model = RouteTranslation
extra = 0unique_together prevents the same language being added twice at the database level. Editors see and edit every language from one screen.
Validating before save
Content editors won't remember every rule. Encoding the rule in the model turns it into an automatic admin error:
from django.core.exceptions import ValidationError
class Route(models.Model):
price_min = models.DecimalField(max_digits=10, decimal_places=2)
price_max = models.DecimalField(max_digits=10, decimal_places=2)
def clean(self):
if self.price_max < self.price_min:
raise ValidationError({
"price_max": "Maximum price cannot be lower than the minimum.",
})Raising with a dict makes the admin show the message under the relevant field. Raise a bare string and it appears at the top of the form, leaving the user to hunt for what to fix.
Making publication state scannable
In a system with draft/published states, colouring the status makes the list scannable:
from django.utils.html import format_html
@admin.display(description="Status")
def status_badge(self, obj):
color = "#0d7d72" if obj.is_published else "#94a3b8"
label = "Published" if obj.is_published else "Draft"
return format_html(
'<span style="color:{};font-weight:600">{}</span>', color, label
)Use format_html rather than string concatenation — it escapes interpolated values and closes off an XSS vector.
Don't skip permissions
Content editors shouldn't be able to delete users or change settings. Django's group system covers this: create a "Content Editor" group and grant only add, change, and view on the relevant models. Withholding delete is the right default in most setups — deleted content doesn't come back.
Moving the admin off its default URL also filters out most automated scanners:
# config/urls.py
urlpatterns = [
path("<unguessable-path>/", admin.site.urls),
]Remember this is not a real security layer — strong passwords, two-factor authentication, and correct ALLOWED_HOSTS are. Changing the path only reduces noise.
When it isn't enough
Django admin doesn't cover everything. You may need a custom interface when:
- Users need drag-and-drop ordering
- Several models must be edited together in a complex flow
- The panel will be exposed to end users, not staff
Outside those cases, the settings above cover most content management needs and save you weeks of development.