Traditional CMS platforms lock your content into a single frontend. Wagtail's headless capabilities break that lock, serving structured JSON through a powerful REST API that any frontend - React, Vue, Next.js, SwiftUI, or mobile - can consume. In this lesson, you'll learn how to enable, customize, and secure Wagtail's API v2, build custom viewsets that expose StreamField content, and connect a modern JavaScript frontend for a true decoupled architecture.

1. Learning Objectives

By the end of this lesson, you will be able to:

2. Why This Matters (Real-World Scenario)

Imagine you're building a high-traffic news website that needs to publish content to a web app, an iOS app, and an Android app simultaneously. A traditional CMS that couples content management with frontend rendering forces you to rebuild or duplicate logic for each platform. Your editorial team needs a single place to write articles, upload images, and manage categories—while your frontend developers want the freedom to use React, SwiftUI, or Jetpack Compose.

A headless CMS decouples the content management backend from the presentation layer. Wagtail's built-in REST API turns your Wagtail site into a pure content API, serving structured JSON to any client. This is exactly how major media outlets and enterprise sites power their omnichannel strategies.

3. Core Concepts

Headless CMS: A content management system that provides content via API (headless = no built-in frontend \"head\"). The backend handles content creation, storage, and delivery; the frontend is a completely separate application.

Wagtail API: Wagtail ships with a RESTful API package (wagtail.api.v2) that exposes pages, images, and documents as JSON endpoints. It supports filtering, field selection, pagination, and preview.

API v1 vs v2: Wagtail 2.x introduced v2 of the API, which is the recommended version. v1 is deprecated but available for backward compatibility. v2 offers richer filtering, better performance, and cleaner response structure.

StreamField serialization: When Wagtail serves a page with StreamField content via the API, each block type is serialized to JSON automatically. Custom blocks need their own serialization logic.

Private API vs Public API: The API can be configured to require authentication (private) or be open to the internet (public). For production headless setups, you typically authenticate with API tokens or JWT.

4. Hands-On Practice: Setting Up Wagtail Headless API

We'll walk through enabling the API, creating custom endpoints, and querying content from a frontend application.

Step 1: Enable the Wagtail API

First, ensure wagtail.api.v2 is in your INSTALLED_APPS:

# wagtail_project/settings/base.py\nINSTALLED_APPS = [\n    'wagtail.api.v2',\n    'wagtail.admin',\n    'wagtail',\n]
Enabling the Wagtail REST API module

Next, add the API URL routes to your main urls.py:

# wagtail_project/urls.py\nfrom wagtail.api.v2.router import WagtailAPIRouter\nfrom wagtail.api.v2.views import PagesAPIViewSet\nfrom wagtail.images.api.v2.views import ImagesAPIViewSet\nfrom wagtail.documents.api.v2.views import DocumentsAPIViewSet\n\napi_router = WagtailAPIRouter('wagtailapi')\napi_router.register_endpoint('pages', PagesAPIViewSet)\napi_router.register_endpoint('images', ImagesAPIViewSet)\napi_router.register_endpoint('documents', DocumentsAPIViewSet)\n\nurlpatterns = [\n    path('api/v2/', api_router.urls),\n]
Registering the Wagtail API v2 router

Step 2: Test the API Endpoints

Restart your server and test the endpoints:

# List all published pages\ncurl http://localhost:8000/api/v2/pages/\n\n# Get a single page by ID\ncurl http://localhost:8000/api/v2/pages/1/\n\n# List images\ncurl http://localhost:8000/api/v2/images/\n\n# List documents\ncurl http://localhost:8000/api/v2/documents/
Testing Wagtail API v2 endpoints with curl

The pages endpoint returns JSON with pagination metadata and results:

kubectl get pods -w
{\n \"meta\": {\n \"total_count\": 5,\n \"limit\": 20,\n \"offset\": 0\n },\n \"items\": [\n {\n \"id\": 1,\n \"meta\": {\n \"type\": \"home.HomePage\",\n \"detail_url\": \"http://localhost:8000/api/v2/pages/1/\"\n },\n \"title\": \"Home\",\n \"slug\": \"home\"\n }\n ]\n}
Sample API v2 response showing paginated page list

Step 3: Filtering and Selecting Fields

The API supports powerful query parameters:

# Select specific fields\ncurl \"http://localhost:8000/api/v2/pages/?fields=title,slug,first_published_at\"\n\n# Filter by type\ncurl \"http://localhost:8000/api/v2/pages/?type=blog.BlogPage\"\n\n# Search\ncurl \"http://localhost:8000/api/v2/pages/?search=wagtail\"\n\n# Ordering\ncurl \"http://localhost:8000/api/v2/pages/?order=-first_published_at\"\n\n# Pagination\ncurl \"http://localhost:8000/api/v2/pages/?limit=5&offset=10\"
Query parameters for filtering, field selection, search, and pagination

Step 4: Customize API Output with Serializers

To expose custom page fields (like StreamField body content), create a custom API viewset:

# blog/api.py\nfrom wagtail.api.v2.views import PagesAPIViewSet\nfrom wagtail.api.v2.filters import (\n    FieldsFilter, OrderingFilter, SearchFilter, LocaleFilter\n)\n\nclass BlogPagesAPIViewSet(PagesAPIViewSet):\n    body_fields = PagesAPIViewSet.body_fields + [\n        'body', 'excerpt', 'date_published', 'categories',\n    ]\n\n    def get_queryset(self):\n        return super().get_queryset().live().public().type('blog.BlogPage')\n\n    filter_backends = [\n        FieldsFilter, OrderingFilter, SearchFilter, LocaleFilter,\n    ]
Custom API viewset exposing StreamField body content
# urls.py\nfrom blog.api import BlogPagesAPIViewSet\napi_router.register_endpoint('blog', BlogPagesAPIViewSet)\n# Now available at: /api/v2/blog/
Registering a custom blog endpoint

Step 5: Authentication & Permissions for the API

For private APIs or draft previews, configure authentication:

# settings.py\nREST_FRAMEWORK = {\n    'DEFAULT_AUTHENTICATION_CLASSES': [\n        'rest_framework.authentication.TokenAuthentication',\n        'rest_framework.authentication.SessionAuthentication',\n    ],\n    'DEFAULT_PERMISSION_CLASSES': [\n        'rest_framework.permissions.IsAuthenticatedOrReadOnly',\n    ],\n    'DEFAULT_THROTTLE_RATES': {\n        'anon': '100/day',\n        'user': '1000/hour',\n    },\n}\n\nWAGTAILAPI_BASE_URL = 'https://yourdomain.com'
Configuring API authentication, permissions, and throttling

To preview unpublished pages via the API (useful for headless previews):

# Preview mode requires authentication\n# Get the latest draft of a page\ncurl -H \"Authorization: Token <your_token>\" \\\n  \"http://localhost:8000/api/v2/pages/5/?show_draft=true\"\n\n# Or by token-based preview URL\ncurl \"http://localhost:8000/api/v2/pages/5/?token=abc123def456\"
Accessing draft/preview content through the API

Step 6: Connecting a React Frontend

Here's a minimal React component that fetches and displays blog posts from Wagtail's API:

// BlogList.jsx\nimport { useState, useEffect } from 'react';\n\nconst API_URL = 'https://yourdomain.com/api/v2/blog/';\n\nfunction BlogList() {\n  const [posts, setPosts] = useState([]);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    fetch(API_URL)\n      .then(res => res.json())\n      .then(data => {\n        setPosts(data.items);\n        setLoading(false);\n      })\n      .catch(err => {\n        console.error('API Error:', err);\n        setLoading(false);\n      });\n  }, []);\n\n  if (loading) return <div>Loading...</div>;\n\n  return (\n    <div className=\"blog-list\">\n      {posts.map(post => (\n        <article key={post.id}>\n          <h2>{post.title}</h2>\n          <p>{post.excerpt}</p>\n          <time>{post.first_published_at}</time>\n        </article>\n      ))}\n    </div>\n  );\n}\n\nexport default BlogList;
React component consuming Wagtail's headless API

For Next.js (SSR/SSG), fetch during build or at request time:

// pages/blog.js (Next.js Pages Router)\nexport async function getStaticProps() {\n  const res = await fetch('https://yourdomain.com/api/v2/blog/?fields=title,slug,body,excerpt');\n  const data = await res.json();\n  return { props: { posts: data.items }, revalidate: 60 };\n}\n\nexport default function Blog({ posts }) {\n  return (\n    <main>\n      {posts.map(post => (\n        <article key={post.id}>\n          <h1>{post.title}</h1>\n          <div dangerouslySetInnerHTML={{ __html: post.body }} />\n        </article>\n      ))}\n    </main>\n  );\n}
Next.js static site generation with Wagtail headless API

5. Common Errors & Solutions

6. Summary Checklist

7. Practice Exercise

Challenge: Build a Headless Blog API with Wagtail & Next.js

Create a Wagtail project with a custom BlogPage model that includes:

Then:

Test that your frontend correctly renders the body StreamField content and that images load properly.

8. Next Steps

Congratulations! You've mastered using Wagtail as a headless CMS backend. This pattern is used in production by organisations that need to deliver content across web, mobile, and IoT platforms from a single Wagtail instance.

Coming up in Lesson 4: Wagtail SEO & Performance — We'll dive deep into meta tags, XML sitemaps, caching strategies, and performance optimization techniques to make your Wagtail site lightning-fast and search-engine-friendly.

Key resources for further learning:

Common pitfalls to avoid:

Gataya Med

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

Comments (0)

Sarah Chen July 6, 2026

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

Reply

Leave a Comment