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 APIView and ModelViewSet alongside 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-filter and 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)
Register endpoints in wagtail_api.py
# 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
]
Mount the router in urls.py
kubectl get pods -w
$ 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"
},
...
]
}
Default Wagtail API response showing paginated results

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',
        ]
Custom serializer with computed fields and image renditions
# 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()
Custom viewset scoped to BlogPage only

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)
Register both overridden and new endpoints

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
Dependency setup
# 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']
Custom filter set for date ranges and category
# 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()
Viewset with filtering, search, and ordering
kubectl get pods -w
$ 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": [
...
]
}
Combined filtering, search, and ordering in action

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
Three pagination strategies
# Apply pagination to your viewset
class BlogPagesAPIViewSet(PagesAPIViewSet):
    serializer_class = BlogPageSerializer
    pagination_class = StandardPagePagination  # <-- switch here
    # ... rest of config
Apply custom pagination
kubectl get pods -w
$ # 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=='
Three pagination styles in use

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',
    ],
}
DRF configuration for auth
python manage.py migrate          # creates authtoken tables
python manage.py drf_create_token your_username
# Output: Generated token a1b2c3d4e5f6... for user your_username
Create API tokens
# 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
Custom permission classes
# Apply permission to viewset
from .permissions import IsEditorOrReadOnly

class BlogPagesAPIViewSet(PagesAPIViewSet):
    serializer_class = BlogPageSerializer
    permission_classes = [IsEditorOrReadOnly]
    # ...
Attach permissions
kubectl get pods -w
$ 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."}
Authenticated vs anonymous access

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()),
        })
Custom APIView for aggregated stats
# 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'),
]
Register custom view outside the router
kubectl get pods -w
$ 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"]
}
Custom stats endpoint response

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_class on a subclass of PagesAPIViewSet to 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 DRF APIView subclasses
  • Add django-filter for advanced filtering by date range, category, tags, and custom model fields
  • Choose between PageNumberPagination, LimitOffsetPagination, and CursorPagination depending on your frontend's needs
  • Secure endpoints with TokenAuthentication + custom permission classes (IsEditorOrReadOnly, IsStaffOrReadOnly)
  • Use distinct=True on M2M filter fields to prevent duplicate results
  • Build completely custom endpoints with DRF's APIView for aggregated stats, search, or content export
  • Always test your endpoints with curl or 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:

  1. Returns a JSON object with the top 10 most-used tags across all live pages
  2. Includes the count of pages per tag and the most recently published page for each tag
  3. Is accessible only to staff users (use a custom permission class)
  4. Supports a ?min_count=5 query parameter to filter tags with at least N pages
  5. 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]
Starting point query (replace REDACTED with your parameter)

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:

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 PagesAPIViewSet for mobile APIs — it returns all page types and admin-only fields; always subclass and filter
  • Do not use LimitOffsetPagination for infinite-scroll UIs — cursor pagination is more stable when new content is added frequently
  • Never forget to add distinct=True on 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 match pages/{pk}/) — use distinct prefixes

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen July 16, 2026

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment