Building a headless CMS with Wagtail and Next.js
A practical walkthrough of building a headless Wagtail CMS with a Next.js frontend — API setup, image renditions, preview mode, ISR, and deployment.
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();
}
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.
x