Wagtail's built-in API v2 is powerful out of the box, but real-world applications demand custom serializers, filtered endpoints, role-based access, and pagination strategies that match the client's UX. This lesson teaches you to extend Wagtail's API framework with custom serializers, advanced filtering via django-filter, three pagination strategies, token authentication with permission classes, and custom APIView endpoints for aggregated data.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Configure and mount Wagtail's built-in v2 API router with custom URL patterns
- Build custom API endpoints using DRF
APIViewandModelViewSetalongside Wagtail's own API - Write custom serializers that expose custom fields from Wagtail page models and snippets
- Implement pagination strategies — page-based, limit-offset, and cursor pagination
- Add custom filtering and search to API responses using
django-filterand DRF search/filter backends - Secure API endpoints with token authentication and permission classes
- Register custom endpoints in a Wagtail API router and test them with
curl
2. Why This Matters (Real-World Scenario)
Your team is building a mobile news app that pulls content from a Wagtail CMS backend. The default /api/v2/pages/ endpoint returns everything — published pages, draft previews, admin-only fields — and the app needs only a curated subset with custom fields like author_bio, estimated_read_time, and related_articles. The default pagination returns 20 results per page with no way for users to request more, and the app needs to filter articles by category, tag, and publish date range.
Wagtail's built-in API is powerful out of the box, but real-world applications demand custom serializers, filtered endpoints, role-based access, and pagination strategies that match the client's UX. This lesson teaches you to extend Wagtail's API framework — not just use it as-is — so you can serve exactly the data your frontend needs, no more and no less.
3. Core Concepts
Wagtail API v2 — a DRF-powered API framework built into Wagtail. Provides /api/v2/pages/, /api/v2/images/, /api/v2/documents/, and /api/v2/snippets/ endpoints with automatic serialization, filtering, and search. Extensible via WagtailAPIRouter.
WagtailAPIRouter — the central routing class that registers API endpoints. Mounted in urls.py like a DRF router. You subclass or instantiate it, register your endpoints, and plug in custom serializers.
PageSerializer — the default serializer for page models. Override meta_fields and field_aliases to control which fields the API exposes.
APIViewSet subclasses — PagesAPIViewSet, ImagesAPIViewSet, DocumentsAPIViewSet, SnippetsAPIViewSet. Subclass these to customise behaviour (filtering, ordering, search) without rewriting the entire view.
DRF Pagination — three built-in classes: PageNumberPagination (page/page_size), LimitOffsetPagination (limit/offset), and CursorPagination (opaque cursor, best for real-time feeds).
Token Authentication — DRF's TokenAuthentication provides per-user API tokens. Combine with IsAuthenticated or custom permissions to gate endpoints.
4. Hands-On Practice: Building a Custom Wagtail API
4.1 Setting Up the API Router
Start by mounting the Wagtail API router in your project's URL configuration. This provides the default endpoints that you will extend in later steps.
from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.images.api.v2.views import ImagesAPIViewSet
from wagtail.documents.api.v2.views import DocumentsAPIViewSet
# Create the router
api_router = WagtailAPIRouter('wagtailapi')
# Register the default endpoints
api_router.register_endpoint('pages', PagesAPIViewSet)
api_router.register_endpoint('images', ImagesAPIViewSet)
api_router.register_endpoint('documents', DocumentsAPIViewSet)
# urls.py
from django.urls import path, include
from .wagtail_api import api_router
urlpatterns = [
# Wagtail API at /api/v2/
path('api/v2/', api_router.urls),
# ... other URL patterns
]
$ python manage.py runserver 0.0.0.0:8000
$ curl -s http://localhost:8000/api/v2/pages/ | python3 -m json.tool | head -20
{
"meta": {
"total_count": 42,
"limit": 20,
"offset": 0
},
"items": [
{
"id": 1,
"meta": {
"type": "blog.BlogPage",
"detail_url": "http://localhost:8000/api/v2/pages/1/"
},
"title": "Hello World"
},
...
]
}
4.2 Custom Page Serializer with Extra Fields
To expose custom fields (like author_bio or estimated_read_time), subclass PageSerializer and define a custom PagesAPIViewSet. Override meta_fields to list additional fields from the page model or related models.
# serializers.py
from wagtail.api.v2.serializers import PageSerializer
from rest_framework.fields import Field
from wagtail.images.api.fields import ImageRenditionField
class BlogPageSerializer(PageSerializer):
"""Custom serializer that exposes extra fields for BlogPage."""
# Add a custom field computed from the page model
estimated_read_time = Field(source='get_read_time', read_only=True)
# Add an image rendition field
hero_image = ImageRenditionField('fill-1200x630', source='hero_image', read_only=True)
class Meta:
# Include parent fields PLUS our custom ones
meta_fields = PageSerializer.Meta.meta_fields + [
'estimated_read_time',
'hero_image',
'author_name',
'published_date',
'tags_list',
]
# views.py
from wagtail.api.v2.views import PagesAPIViewSet
from .serializers import BlogPageSerializer
class BlogPagesAPIViewSet(PagesAPIViewSet):
"""Custom pages viewset that uses our serializer."""
serializer_class = BlogPageSerializer
# Only expose BlogPage type
def get_base_queryset(self):
queryset = super().get_base_queryset()
from blog.models import BlogPage
return queryset.type(BlogPage).live().public()
# Exclude draft pages always
def get_queryset(self):
return self.get_base_queryset()
4.3 Registering Custom Endpoints
Replace the default pages endpoint with your custom one in the API router. You can also add entirely new endpoint groups using register_endpoint.
# wagtail_api.py
from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.images.api.v2.views import ImagesAPIViewSet
from wagtail.documents.api.v2.views import DocumentsAPIViewSet
from .views import BlogPagesAPIViewSet
from .category_views import CategoryAPIViewSet
api_router = WagtailAPIRouter('wagtailapi')
# Use our custom pages viewset instead of the default
api_router.register_endpoint('pages', BlogPagesAPIViewSet)
api_router.register_endpoint('images', ImagesAPIViewSet)
api_router.register_endpoint('documents', DocumentsAPIViewSet)
# Add a brand-new endpoint for categories
api_router.register_endpoint('categories', CategoryAPIViewSet)
4.4 Adding Custom Filtering and Search
Wagtail's API supports filtering by any field in meta_fields. To enable advanced filtering (e.g. category slug, date range), subclass the viewset and override filter_backends or add DRF's django-filter integration.
# Install django-filter:
# pip install django-filter
# Add 'django_filters' to INSTALLED_APPS
# filters.py
import django_filters
from wagtail.models import Page
class BlogPageFilter(django_filters.FilterSet):
"""Custom filters for BlogPage API."""
category = django_filters.CharFilter(
field_name='category__slug', lookup_expr='exact'
)
published_after = django_filters.DateFilter(
field_name='first_published_at', lookup_expr='gte'
)
published_before = django_filters.DateFilter(
field_name='first_published_at', lookup_expr='lte'
)
tags = django_filters.CharFilter(
field_name='tags__slug', lookup_expr='in', distinct=True
)
class Meta:
model = Page
fields = ['category', 'published_after', 'published_before', 'tags']
# Updated views.py with filtering + search
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.api.v2.filters import (
LocaleFilter, FieldsFilter, OrderingFilter, SearchFilter
)
from rest_framework.filters import OrderingFilter as DRFOrderingFilter
from django_filters.rest_framework import DjangoFilterBackend
from .serializers import BlogPageSerializer
from .filters import BlogPageFilter
class BlogPagesAPIViewSet(PagesAPIViewSet):
serializer_class = BlogPageSerializer
# Add django-filter backend for advanced filtering
filter_backends = [
DjangoFilterBackend,
SearchFilter,
FieldsFilter,
LocaleFilter,
DRFOrderingFilter,
]
# Link to our custom FilterSet
filterset_class = BlogPageFilter
# Fields that can be searched via ?search=
search_fields = ['title', 'search_description', 'body']
# Allowed ordering fields
ordering_fields = ['first_published_at', 'last_published_at', 'title']
ordering = ['-first_published_at'] # default: newest first
def get_base_queryset(self):
qs = super().get_base_queryset()
return qs.type(BlogPage).live().public()
$ curl -s 'http://localhost:8000/api/v2/pages/?category=technology&published_after=2025-01-01&search=django&ordering=-first_published_at' | python3 -m json.tool
{
"meta": {
"total_count": 12,
...
},
"items": [
...
]
}
4.5 Custom Pagination Strategies
Wagtail's default API uses offset/limit pagination (from DRF's LimitOffsetPagination). You can switch to page-based pagination for predictable numbered pages, or cursor-based pagination for real-time feeds where new items shouldn't shift existing pages.
# pagination.py
from rest_framework.pagination import (
PageNumberPagination,
CursorPagination,
LimitOffsetPagination,
)
class StandardPagePagination(PageNumberPagination):
"""Page-based pagination with configurable page size."""
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100
class ArticleCursorPagination(CursorPagination):
"""Cursor-based pagination — stable ordering even as new items are added."""
page_size = 20
ordering = '-first_published_at'
cursor_query_param = 'cursor'
class FlexiblePagination(LimitOffsetPagination):
"""Default-style pagination with generous limits."""
default_limit = 20
limit_query_param = 'limit'
offset_query_param = 'offset'
max_limit = 200
# Apply pagination to your viewset
class BlogPagesAPIViewSet(PagesAPIViewSet):
serializer_class = BlogPageSerializer
pagination_class = StandardPagePagination # <-- switch here
# ... rest of config
$ # Page-based pagination
$ curl -s 'http://localhost:8000/api/v2/pages/?page=2&page_size=5'
$ # Limit-offset pagination (default)
$ curl -s 'http://localhost:8000/api/v2/pages/?limit=10&offset=20'
$ # Cursor-based pagination
$ curl -s 'http://localhost:8000/api/v2/pages/?cursor=cD0yMDI1LTA3LTE1KzAwJTNBMDAlM0EwMA=='
4.6 API Authentication and Permissions
Secure your custom endpoints with DRF's authentication classes. Wagtail API v2 supports TokenAuthentication, SessionAuthentication, and custom permission classes. Add rest_framework.authtoken to INSTALLED_APPS and run migrations first.
# settings.py
INSTALLED_APPS += [
'rest_framework',
'rest_framework.authtoken',
'django_filters',
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
}
python manage.py migrate # creates authtoken tables
python manage.py drf_create_token your_username
# Output: Generated token a1b2c3d4e5f6... for user your_username
# permissions.py
from rest_framework.permissions import BasePermission, SAFE_METHODS
class IsEditorOrReadOnly(BasePermission):
"""Allow read-only for everyone; write only for editors."""
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return True
return request.user.is_authenticated and request.user.groups.filter(
name='Editors'
).exists()
class IsStaffOrReadOnly(BasePermission):
"""Allow read-only for everyone; write only for staff."""
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return True
return request.user.is_authenticated and request.user.is_staff
# Apply permission to viewset
from .permissions import IsEditorOrReadOnly
class BlogPagesAPIViewSet(PagesAPIViewSet):
serializer_class = BlogPageSerializer
permission_classes = [IsEditorOrReadOnly]
# ...
$ curl -s http://localhost:8000/api/v2/pages/ # OK — public read
$ curl -X PATCH http://localhost:8000/api/v2/pages/1/ -H 'Authorization: Token a1b2c3d4e5f6...' -H 'Content-Type: application/json' -d '{"title": "New Title"}'
{"title": "New Title"}
$ curl -X PATCH http://localhost:8000/api/v2/pages/1/ -H 'Content-Type: application/json' -d '{"title": "Nope"}'
{"detail": "Authentication credentials were not provided."}
4.7 Building a Completely Custom Endpoint
Sometimes you need an endpoint that doesn't return pages at all — e.g. a site-wide search endpoint, a stats dashboard, or a content export. Use DRF's APIView and register it outside the Wagtail API router.
# custom_views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from wagtail.models import Page, Site
from django.db.models import Count
class SiteStatsView(APIView):
"""Custom endpoint: return site-wide content statistics."""
permission_classes = [IsAuthenticated]
def get(self, request, format=None):
current_site = Site.find_for_request(request)
total_pages = Page.objects.live().count()
pages_by_type = (
Page.objects.live()
.values('content_type__model')
.annotate(count=Count('id'))
.order_by('-count')
)
return Response({
'site_name': current_site.site_name,
'total_pages': total_pages,
'pages_by_type': list(pages_by_type),
'languages': list(current_site.root_page.get_translations(
inclusive=True
).values_list('locale__language_code', flat=True).distinct()),
})
# urls.py
from django.urls import path, include
from .wagtail_api import api_router
from .custom_views import SiteStatsView
urlpatterns = [
path('api/v2/', api_router.urls),
path('api/v2/stats/', SiteStatsView.as_view(), name='site-stats'),
]
$ curl -s http://localhost:8000/api/v2/stats/ -H 'Authorization: Token a1b2c3d4e5f6...' | python3 -m json.tool
{
"site_name": "My Wagtail Site",
"total_pages": 156,
"pages_by_type": [
{"content_type__model": "blogpage", "count": 87},
{"content_type__model": "standardpage", "count": 42},
{"content_type__model": "formpage", "count": 27}
],
"languages": ["en", "fr", "de"]
}
5. Common Errors & Solutions
1. "Field does not exist" on custom serializer fields
The field name in meta_fields must match either a model field, a property on the page model, or a declared serializer field. If your model has def get_read_time(self):, the serializer field estimated_read_time must use source='get_read_time' or be declared as an explicit field.
2. 403 Forbidden when trying to access custom endpoint
Check your permission classes. IsAuthenticatedOrReadOnly blocks anonymous POST/PUT/PATCH but allows GET. If you get 403 on a GET, the permission class may be too strict, or you forgot to pass the Authorization header. Use curl -v to inspect response headers.
3. Pagination changes have no effect
Wagtail's PagesAPIViewSet has a pagination_class default. If you subclass and set pagination_class = MyPagination, verify the import is correct and the class is not shadowed by a parent default. Check the response meta block — if it still shows limit and offset when you expect page and page_size, your override isn't being applied.
4. Custom endpoint returns 404 even though URL is registered
Wagtail's API router uses a lookup_field regex for registered endpoints. Custom endpoints registered directly on api_router.urls must not collide with existing endpoint patterns. Register distinct endpoints (e.g. /api/v2/stats/) rather than trying to nest under an existing prefix. If using DRF routers instead, ensure you call .as_view() and match the trailing slash convention.
5. Filtering by a related field returns empty results
Related field filtering via django-filter often requires distinct=True on the FilterSet field to avoid duplicate rows from JOINs. For example, tags = django_filters.CharFilter(field_name='tags__slug', lookup_expr='in', distinct=True). Without distinct, a page tagged with both "python" and "django" appears twice in the results when you filter by one of them because the JOIN produces multiple rows.
6. Summary Checklist
- Wagtail API v2 is DRF-powered and extensible via WagtailAPIRouter, custom serializers, and custom viewsets
- Override
serializer_classon a subclass ofPagesAPIViewSetto control which fields the API exposes - Register custom endpoints with
api_router.register_endpoint()— they can use existing Wagtail base classes or be entirely custom DRFAPIViewsubclasses - Add
django-filterfor advanced filtering by date range, category, tags, and custom model fields - Choose between
PageNumberPagination,LimitOffsetPagination, andCursorPaginationdepending on your frontend's needs - Secure endpoints with
TokenAuthentication+ custom permission classes (IsEditorOrReadOnly,IsStaffOrReadOnly) - Use
distinct=Trueon M2M filter fields to prevent duplicate results - Build completely custom endpoints with DRF's
APIViewfor aggregated stats, search, or content export - Always test your endpoints with
curlor httpie, inspecting both the response body and HTTP status code
7. Practice Exercise
Challenge: Build a tagging analytics dashboard endpoint
Your task is to create a custom API endpoint at /api/v2/tag-analytics/ that:
- Returns a JSON object with the top 10 most-used tags across all live pages
- Includes the count of pages per tag and the most recently published page for each tag
- Is accessible only to staff users (use a custom permission class)
- Supports a
?min_count=5query parameter to filter tags with at least N pages - Returns a 403 with a helpful error message if an unauthenticated user tries to access it
Hints: Use Page.tags (via taggit), annotate() with Count, and a DRF APIView. Define a custom permission class that returns a custom error message in message attribute.
from taggit.models import Tag
from django.db.models import Count, Max
# Starting point for your solution:
Tag.objects.filter(
wagtail_taggedpage_items__page__live=True
).annotate(
page_count=Count('wagtail_taggedpage_items'),
last_used=Max('wagtail_taggedpage_items__page__first_published_at')
).filter(page_count__gte=REDACTED).order_by('-page_count')[:10]
8. Next Steps
You have learned to extend Wagtail's API framework with custom serializers, viewsets, filtering, pagination, and authentication. Your Wagtail site can now serve precisely the data your frontend or mobile app needs, with the right authentication, pagination strategy, and filtering logic.
The next topic in the Wagtail CMS series is Wagtail Admin Customization & Branding, where you will learn to customize the Wagtail admin interface — adding custom CSS and JavaScript, creating branded login pages, building custom admin views, and tailoring the editor experience for your content team.
Further resources:
- Wagtail API v2 Configuration Guide — official documentation for all API settings
- DRF Serializer Documentation — full serializer reference
- django-filter Documentation — advanced filtering options
- DRF Pagination Guide — pagination classes and parameters
- DRF Authentication Guide — token, session, JWT, and custom auth
Common pitfalls to avoid:
- Do not expose raw page model fields in the API without first considering security — draft content, admin-only notes, and
SECRET_KEY-adjacent fields should never be serialized - Avoid using default
PagesAPIViewSetfor mobile APIs — it returns all page types and admin-only fields; always subclass and filter - Do not use
LimitOffsetPaginationfor infinite-scroll UIs — cursor pagination is more stable when new content is added frequently - Never forget to add
distinct=Trueon M2M filter fields — duplicate results are silent bugs that corrupt pagination counts - Do not register custom endpoints that collide with Wagtail's internal routing (e.g.
/api/v2/pages/custom/may matchpages/{pk}/) — use distinct prefixes
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment