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 must not be a link column. By default the first column is the link to the detail page, so either put the editable field after it or set list_display_links explicitly. A pattern that works well: a boolean like is_published plus a sort_order number, both editable in the list, with the title as the link.
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.
How do you manage multilingual content in Django admin?
When content needs to exist in fifteen languages, adding a field per language bloats the model. There are two common approaches.
Option 1: your own translation model, edited through an inline. This is a generic sketch, not a specific project:
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:
constraints = [
models.UniqueConstraint(
fields=["route", "language"], name="one_translation_per_language"
),
]
class RouteTranslationInline(admin.TabularInline):
model = RouteTranslation
extra = 0The constraint prevents the same language being added twice at the database level, and editors see every language on one screen.
Option 2: django-parler. This is what I use on a multilingual client project. Each translatable model gets its own *_translation table, so adding a language needs no migration, and TranslatableAdmin gives the edit form one tab per language. Missing translations fall back to a default language through PARLER_LANGUAGES.
from parler.admin import TranslatableAdmin
@admin.register(BlogPost)
class BlogPostAdmin(TranslatableAdmin):
list_display = ("__str__", "author", "is_published", "published_at")
list_editable = ("is_published",)
search_fields = ("translations__title", "translations__slug", "author")A few things I ran into with it:
- Search goes through the relation. Translated fields live on the translation table, so
search_fieldsneedstranslations__title, nottitle. prepopulated_fieldsdoes not fit. The slug is per language, so it is typed by hand in each language tab instead of being generated from the title.- Post-save hooks belong in
save_related. If something must run after a save (refreshing a cache on the frontend, say),save_relatedis the safest place: Django calls it after the main object and its related data have been saved. - Third-party admin themes may not know about parler. I combined a themed
ModelAdminwithTranslatableAdminin one base class, with the theme first in the inheritance order so its templates win, then fixed the translation form's widget styling by hand inget_form. Check this before choosing a theme.
How do you validate before save in Django admin?
Content editors won't remember every rule. Encoding the rule in the model turns it into an automatic admin error (a generic example; adapt the fields to your own model):
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.
Two more settings that save editors time
autocomplete_fields replaces a dropdown with thousands of options by a search box. It only works if the related model's admin defines search_fields; without it Django refuses to start the admin with a system check error. For a route that links an origin and a destination place, autocomplete_fields = ("origin", "destination") keeps the form usable.
readonly_fields is the right tool for records that arrive from outside, such as contact-form messages. Make every submitted field read-only and leave a single is_read flag in list_editable. Editors can triage the list without being able to rewrite what a customer wrote.
What permissions should content editors have?
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 is Django admin not 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.