Wagtail Headless CMS with Next.js: A Practical Guide
Wagtail's built-in API v2 makes it straightforward to use as a headless CMS — enable it, expose your page models, and any frontend can consume clean JSON. This guide pairs Wagtail with Next.js: CORS, custom API fields, typed StreamField responses, incremental static regeneration, and preview mode for draft content.
1. Architecture Overview
In a headless Wagtail setup, Wagtail handles content management only — no templates, no server-rendered HTML for the frontend. The Next.js app fetches content from the Wagtail API at build time (SSG) or request time (SSR / ISR) and renders it.
2. Enable the Wagtail API v2
# settings/base.py
INSTALLED_APPS += ['wagtail.api.v2']
# mysite/api.py
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.images.api.v2.views import ImagesAPIViewSet
from wagtail.documents.api.v2.views import DocumentsAPIViewSet
api_router = WagtailAPIRouter('wagtailapi')
api_router.register_endpoint('pages', PagesAPIViewSet)
api_router.register_endpoint('images', ImagesAPIViewSet)
api_router.register_endpoint('documents', DocumentsAPIViewSet)
# mysite/urls.py
from .api import api_router
urlpatterns = [
path('api/v2/', api_router.urls),
# ...
]
Visit /api/v2/pages/?format=json to verify — you should see all live pages as JSON.
Add ?fields=* to include all fields, or ?type=blog.BlogPage&fields=*
to filter by page type.
3. Configure CORS
Your Next.js app runs on a different origin, so you need CORS headers on the Wagtail API.
pip install django-cors-headers
# settings/base.py
INSTALLED_APPS += ['corsheaders']
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # must be before CommonMiddleware
'django.middleware.common.CommonMiddleware',
# ...
]
# Allow your Next.js dev server and production domain
CORS_ALLOWED_ORIGINS = [
'http://localhost:3000',
'https://www.yourdomain.com',
]
# Or restrict to API paths only:
CORS_URLS_REGEX = r'^/api/.*$'
4. Custom API Fields
By default the API exposes a minimal field set. Override api_fields on each
page model to expose what the frontend needs:
from wagtail.api import APIField
from wagtail.images.api.fields import ImageRenditionField
class BlogPage(Page):
date = models.DateField()
intro = models.CharField(max_length=250)
body = StreamField(BlogStreamBlock, use_json_field=True)
hero_image = models.ForeignKey(
'wagtailimages.Image', null=True, blank=True,
on_delete=models.SET_NULL, related_name='+'
)
api_fields = [
APIField('date'),
APIField('intro'),
APIField('body'),
# Expose a 1200×630 WebP rendition of the hero image
APIField('hero_image_url', serializer=ImageRenditionField('fill-1200x630|format-webp')),
]
@property
def hero_image_url(self):
return self.hero_image
The ImageRenditionField generates the rendition on the server and returns its URL —
the React component never needs to know about Wagtail's image format spec.
5. Next.js Setup
npx create-next-app@latest frontend --typescript --app
cd frontend
Add a typed Wagtail client in lib/wagtail.ts:
const BASE = process.env.WAGTAIL_API_URL ?? 'http://localhost:8000/api/v2';
export async function getPages<T = WagtailPage>(params: Record<string, string> = {}): Promise<T[]> {
const qs = new URLSearchParams({ limit: '100', ...params }).toString();
const res = await fetch(`${BASE}/pages/?${qs}`, { next: { revalidate: 60 } });
if (!res.ok) throw new Error(`Wagtail API error: ${res.status}`);
const data = await res.json();
return data.items;
}
export async function getPage<T = WagtailPage>(id: number): Promise<T> {
const res = await fetch(`${BASE}/pages/${id}/?fields=*`, { next: { revalidate: 60 } });
if (!res.ok) throw new Error(`Page ${id} not found`);
return res.json();
}
export async function getPageBySlug<T = WagtailPage>(slug: string): Promise<T | null> {
const pages = await getPages<T>({ slug, fields: '*' });
return pages[0] ?? null;
}
export interface WagtailPage {
id: number;
title: string;
slug: string;
meta: { type: string; html_url: string; };
}
export interface BlogPage extends WagtailPage {
date: string;
intro: string;
body: StreamFieldBlock[];
hero_image_url: string | null;
}
export type StreamFieldBlock =
| { type: 'heading'; value: { heading_text: string; size: string } }
| { type: 'paragraph'; value: string }
| { type: 'image'; value: { image: number; caption: string } }
| { type: 'code'; value: { language: string; code: string } };
6. Fetching Pages & Routing
Use Next.js App Router dynamic segments. The key is fetching the Wagtail page tree to generate static paths:
// app/blog/[slug]/page.tsx
import { getPages, getPageBySlug, BlogPage } from '@/lib/wagtail';
export async function generateStaticParams() {
const posts = await getPages({ type: 'blog.BlogPage' });
return posts.map(p => ({ slug: p.slug }));
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await getPageBySlug<BlogPage>(params.slug);
if (!post) return <div>Not found</div>;
return (
<article>
<h1>{post.title}</h1>
<p className="meta">{post.date}</p>
<StreamFieldRenderer blocks={post.body} />
</article>
);
}
7. Rendering StreamField in React
The Wagtail API serialises StreamField as a typed block array. A dispatcher component handles each block type:
// components/StreamFieldRenderer.tsx
import type { StreamFieldBlock } from '@/lib/wagtail';
export function StreamFieldRenderer({ blocks }: { blocks: StreamFieldBlock[] }) {
return (
<>
{blocks.map((block, i) => (
<Block key={i} block={block} />
))}
</>
);
}
function Block({ block }: { block: StreamFieldBlock }) {
switch (block.type) {
case 'heading':
const Tag = (block.value.size || 'h2') as 'h2' | 'h3' | 'h4';
return <Tag>{block.value.heading_text}</Tag>;
case 'paragraph':
// Wagtail RichTextBlock returns HTML — render safely via dangerouslySetInnerHTML
return <div className="richtext" dangerouslySetInnerHTML={{ __html: block.value }} />;
case 'image':
// Image id — fetch rendition via Wagtail images endpoint or use pre-resolved URL
return <figure><img src={`/api/v2/images/${block.value.image}/?fields=*`} alt="" /></figure>;
case 'code':
return (
<pre data-language={block.value.language}>
<code>{block.value.code}</code>
</pre>
);
default:
return null;
}
}
For the paragraph block, Wagtail returns sanitised HTML from its rich text renderer.
It is safe to pass to dangerouslySetInnerHTML — Wagtail strips disallowed tags
server-side based on the features list you declared on the RichTextBlock.
8. Incremental Static Regeneration
Pass revalidate to the fetch call (Next.js 13+ App Router):
// Revalidate cached page data every 60 seconds
const res = await fetch(`${BASE}/pages/${id}/?fields=*`, {
next: { revalidate: 60 },
});
For on-demand revalidation triggered by Wagtail page publishes, use a Wagtail hook to call Next.js's revalidation endpoint:
# hooks.py
import requests
from wagtail import hooks
NEXT_REVALIDATE_URL = 'https://www.yourdomain.com/api/revalidate'
NEXT_REVALIDATE_SECRET = 'my-secret-token'
@hooks.register('after_publish_page')
def revalidate_nextjs(request, page):
try:
requests.post(NEXT_REVALIDATE_URL, json={
'secret': NEXT_REVALIDATE_SECRET,
'slug': page.slug,
}, timeout=5)
except requests.RequestException:
pass # Never let a failed revalidation block publication
9. Images & Renditions
Use the ImageRenditionField on the Wagtail side to pre-generate URLs rather than
fetching image metadata separately on the frontend:
# models.py — expose multiple sizes for srcset
from wagtail.images.api.fields import ImageRenditionField
class BlogPage(Page):
api_fields = [
APIField('hero_400', serializer=ImageRenditionField('width-400|format-webp')),
APIField('hero_800', serializer=ImageRenditionField('width-800|format-webp')),
APIField('hero_1200', serializer=ImageRenditionField('width-1200|format-webp')),
]
// React component with srcset
<img
src={post.hero_400?.url}
srcSet={`${post.hero_400?.url} 400w, ${post.hero_800?.url} 800w, ${post.hero_1200?.url} 1200w`}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 800px, 1200px"
width={post.hero_1200?.width}
height={post.hero_1200?.height}
alt={post.title}
/>
10. Draft Preview Mode
Wagtail editors can preview draft content before publishing. Install the headless preview package:
pip install wagtail-headless-preview
# settings/base.py
INSTALLED_APPS += ['wagtail_headless_preview']
WAGTAIL_HEADLESS_PREVIEW = {
'CLIENT_URLS': {
'default': 'http://localhost:3000/api/preview',
}
}
Add the HeadlessPreviewMixin to your page models so the Wagtail preview button
redirects to Next.js:
from wagtail_headless_preview.models import HeadlessPreviewMixin
class BlogPage(HeadlessPreviewMixin, Page):
# ...
On the Next.js side, create app/api/preview/route.ts that calls
draftMode().enable() and redirects to the post slug. Full integration details are
in the wagtail-headless-preview
docs.
With this setup, editors click Preview in the Wagtail admin and land on the live Next.js site showing their unpublished draft — exactly what they will see when they hit Publish.