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(); }
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.