Building a Headless CMS with
Wagtail and Next.js

The architecture that gives you editorial-grade content management and sub-second page loads. Here's the exact setup we use in production.

Headless CMS architecture has matured significantly. What used to require significant custom infrastructure now has well-trodden patterns, and Wagtail's built-in API support makes it an excellent headless backend. This article walks through exactly how we set up a Wagtail + Next.js stack in production — the parts that aren't obvious, and the decisions we've made after doing this many times.

Why go headless?

The short answer: front-end performance. A Wagtail-rendered page is fast, but a Next.js page with ISR is faster — and for content-heavy sites competing for organic search traffic, that difference matters. Sub-200ms response times, edge-cached pages, and perfect Lighthouse scores are more achievable with a dedicated front-end framework.

The longer answer: it also separates concerns cleanly. Your content team works in Wagtail's excellent editorial interface. Your front-end developers work in a modern JavaScript stack. Neither team is blocked by the other's choices.

Wagtail API setup

First, enable Wagtail's API. Add wagtail.api.v2 to your installed apps and configure the router in urls.py:

INSTALLED_APPS = [
    ...
    'wagtail.api.v2',
    'rest_framework',
]
# urls.py
from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.images.api.v2.views import ImagesAPIViewSet

api_router = WagtailAPIRouter()
api_router.register_endpoint('pages', PagesAPIViewSet)
api_router.register_endpoint('images', ImagesAPIViewSet)

urlpatterns = [
    path('api/v2/', api_router.urls),
    ...
]

This gives you a basic API. But you'll want custom serializers to expose the fields your front-end actually needs. Override api_fields on your page models:

from wagtail.api import APIField

class ArticlePage(Page):
    introduction = models.TextField()
    body = StreamField(StoryBlock())

    api_fields = [
        APIField('introduction'),
        APIField('body'),
        APIField('listing_image',
            serializer=ImageRenditionField('fill-1200x630')),
        APIField('first_published_at'),
    ]

Handling image renditions

Image renditions are the part most tutorials skip over. Wagtail generates renditions on demand, but in a headless setup you need to request specific renditions via the API and handle responsive images on the front-end.

We use a custom serializer that returns multiple renditions per image:

class MultiRenditionImageSerializer(Serializer):
    def to_representation(self, image):
        request = self.context.get('request')
        renditions = {
            'full': image.get_rendition('width-2000'),
            'medium': image.get_rendition('width-1000'),
            'thumb': image.get_rendition('fill-400x300'),
        }
        return {
            key: {
                'url': r.full_url,
                'width': r.width,
                'height': r.height,
                'alt': image.title,
            }
            for key, r in renditions.items()
        }

Next.js setup

Fetch pages by slug using Next.js's App Router and server components. We create a typed API client:

// lib/wagtail.ts
const WAGTAIL_API = process.env.WAGTAIL_API_URL;

export async function getPageByPath(path: string) {
  const url = `${WAGTAIL_API}/pages/find/?html_path=${path}&format=json`;
  const res = await fetch(url, {
    next: { revalidate: 60 }  // ISR: revalidate every 60s
  });
  if (!res.ok) return null;
  return res.json();
}
Important Set CORS_ALLOWED_ORIGINS in your Wagtail settings to include your Next.js deployment URL. Without this, browser requests from the front-end will fail. For ISR fetches happening server-side, CORS isn't required, but it becomes relevant for client-side data fetching.

Preview mode

Preview is the hardest part of headless Wagtail to get right. Editors need to see draft content in the actual front-end UI before publishing. Here's the pattern we use:

In Wagtail, create a preview URL that generates a signed token and redirects to the Next.js preview endpoint:

# In your base page model
def get_preview_url(self, token):
    frontend_url = settings.FRONTEND_URL
    return f"{frontend_url}/api/preview?token={token}&page_id={self.pk}"

In Next.js, create the preview route handler that validates the token and enables draft mode:

// app/api/preview/route.ts
import { draftMode } from 'next/headers';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const token = searchParams.get('token');
  const pageId = searchParams.get('page_id');

  // Validate token with Wagtail backend
  const valid = await validatePreviewToken(token, pageId);
  if (!valid) return new Response('Invalid token', { status: 401 });

  (await draftMode()).enable();
  return Response.redirect(new URL(`/preview/${pageId}`, request.url));
}

ISR and cache invalidation

Incremental Static Regeneration is what makes this architecture genuinely fast. Pages are statically generated at build time and re-generated in the background when Wagtail publishes new content.

Set up on-demand revalidation using Wagtail hooks:

# wagtail_hooks.py
from wagtail import hooks
import requests

@hooks.register('after_publish_page')
def invalidate_frontend_cache(request, page):
    try:
        revalidate_url = (
            f"{settings.FRONTEND_URL}/api/revalidate"
            f"?path={page.url}"
            f"&secret={settings.REVALIDATION_SECRET}"
        )
        requests.post(revalidate_url, timeout=5)
    except Exception:
        pass  # Don't block publish on cache failures

Deployment considerations

Run Wagtail on any Python-capable host — we typically use Fly.io or AWS ECS. Next.js deploys cleanly to Vercel, Netlify, or any Node host. The two services communicate only via the API.

Key environment variables to manage carefully: WAGTAIL_API_URL (the Wagtail API base URL for server-side fetches), NEXT_PUBLIC_WAGTAIL_URL (for client-side, if needed), REVALIDATION_SECRET (shared between both services), and FRONTEND_URL (Wagtail needs to know where to redirect previews).

The result is a system where content changes in Wagtail propagate to a statically-served Next.js site within seconds, with no cold starts, no server load on the front-end, and full editorial preview support.

Need help?

Building a headless Wagtail project?

We've architected this stack for publishers, universities, and SaaS companies. Get in touch and we'll help you avoid the common pitfalls.