Wagtail Next.js React Headless

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.

Wagtail CMS Page models + admin API v2 (JSON) REST JSON /api/v2/pages/ /api/v2/images/ fetch() Next.js Frontend ISR / SSG / SSR React components
Wagtail as a pure backend: the admin manages content, the API v2 exposes it as JSON, and Next.js fetches and renders it. No Django templates involved.

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.