WordPress as a Headless CMS with Next.js - Complete Guide
WordPress as Headless
backendWordPress as a Headless CMS with Next.js
WordPress powers over 40% of all websites on the internet. However, the traditional monolithic architecture, where WordPress handles both content management and presentation, is increasingly giving way to the modern headless approach. In this article, we will show you how to combine the power of WordPress as a content management system with the performance and flexibility of Next.js on the frontend.
What Is a Headless CMS?#
A headless CMS is a content management system that separates the backend layer (content creation and storage) from the frontend layer (content presentation). Instead of generating HTML pages directly, a headless CMS exposes content through an API -- typically REST or GraphQL.
Why Use WordPress as a Headless CMS?#
- Familiar interface -- editors already know the WordPress admin panel
- Rich plugin ecosystem -- thousands of plugins to extend functionality
- Platform maturity -- over 20 years of development and stability
- Large community -- easy access to support and documentation
- Full frontend control -- use any technology for content presentation
- Better performance -- static pages generated by Next.js are blazingly fast
- Security -- the frontend is separated from the admin panel
Headless vs Traditional WordPress#
| Feature | Traditional WordPress | Headless WordPress | |---------|----------------------|-------------------| | Rendering | Server-side PHP | SSG/SSR/ISR in Next.js | | Performance | Depends on server and plugins | Static pages, CDN | | Frontend flexibility | Limited to PHP templates | Full freedom (React, Vue, etc.) | | SEO | Requires plugins (Yoast) | Built into Next.js | | Security | Admin panel exposed | Backend hidden, static frontend | | Scalability | Requires caching and optimization | Naturally scalable via CDN |
WordPress REST API#
Since version 4.7, WordPress includes a built-in REST API that exposes all core content types. The API is available at /wp-json/wp/v2/.
Core Endpoints#
# Fetch posts
GET /wp-json/wp/v2/posts
# Fetch a single post
GET /wp-json/wp/v2/posts/123
# Fetch pages
GET /wp-json/wp/v2/pages
# Fetch categories
GET /wp-json/wp/v2/categories
# Fetch media
GET /wp-json/wp/v2/media
# Fetch users
GET /wp-json/wp/v2/users
Filtering and Pagination#
The REST API offers extensive filtering capabilities:
# Posts from a specific category
GET /wp-json/wp/v2/posts?categories=5
# Search
GET /wp-json/wp/v2/posts?search=nextjs
# Pagination (10 posts per page)
GET /wp-json/wp/v2/posts?per_page=10&page=2
# Sorting
GET /wp-json/wp/v2/posts?orderby=date&order=desc
# Embedding related data (author, media, categories)
GET /wp-json/wp/v2/posts?_embed
Registering Custom Endpoints#
You can extend the REST API with custom endpoints:
// functions.php
add_action('rest_api_init', function () {
register_rest_route('custom/v1', '/featured-posts', [
'methods' => 'GET',
'callback' => 'get_featured_posts',
'permission_callback' => '__return_true',
]);
});
function get_featured_posts($request) {
$posts = get_posts([
'meta_key' => 'is_featured',
'meta_value' => '1',
'numberposts' => 6,
]);
$data = [];
foreach ($posts as $post) {
$data[] = [
'id' => $post->ID,
'title' => $post->post_title,
'excerpt' => get_the_excerpt($post),
'slug' => $post->post_name,
'thumbnail' => get_the_post_thumbnail_url($post, 'large'),
];
}
return rest_ensure_response($data);
}
WPGraphQL -- GraphQL for WordPress#
While the REST API is functional, GraphQL offers much more flexible data querying. The WPGraphQL plugin adds full GraphQL support to WordPress.
Installing WPGraphQL#
# Via WP-CLI
wp plugin install wp-graphql --activate
# Or download from https://www.wpgraphql.com/
After installation, the GraphQL endpoint is available at /graphql.
Example GraphQL Queries#
# Fetch posts with author and categories
query GetPosts {
posts(first: 10, where: { orderby: { field: DATE, order: DESC } }) {
nodes {
id
databaseId
title
slug
excerpt
date
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
author {
node {
name
avatar {
url
}
}
}
categories {
nodes {
name
slug
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
# Fetch a single post by slug
query GetPostBySlug($slug: ID!) {
post(id: $slug, idType: SLUG) {
title
content
date
modified
seo {
title
metaDesc
opengraphImage {
sourceUrl
}
}
}
}
Advantages of GraphQL over REST API#
- Fetch exactly the data you need -- no over-fetching
- Single query instead of multiple -- fewer HTTP requests
- Strong typing -- better validation and IDE autocompletion
- Introspection -- automatic schema documentation
Setting Up Next.js with WordPress#
Project Initialization#
npx create-next-app@latest wordpress-frontend --typescript --app
cd wordpress-frontend
npm install graphql-request graphql
Environment Variables Configuration#
# .env.local
NEXT_PUBLIC_WORDPRESS_URL=https://cms.yourdomain.com
WORDPRESS_GRAPHQL_URL=https://cms.yourdomain.com/graphql
WORDPRESS_AUTH_REFRESH_TOKEN=your-refresh-token
WORDPRESS_PREVIEW_SECRET=your-preview-secret
REVALIDATION_SECRET=your-revalidation-secret
GraphQL Client#
// lib/wordpress.ts
import { GraphQLClient, gql } from 'graphql-request';
const client = new GraphQLClient(
process.env.WORDPRESS_GRAPHQL_URL!,
{
headers: {
'Content-Type': 'application/json',
},
}
);
// Types
export interface WPPost {
id: string;
databaseId: number;
title: string;
slug: string;
excerpt: string;
content: string;
date: string;
modified: string;
featuredImage: {
node: {
sourceUrl: string;
altText: string;
mediaDetails: {
width: number;
height: number;
};
};
} | null;
author: {
node: {
name: string;
avatar: { url: string };
};
};
categories: {
nodes: Array<{ name: string; slug: string }>;
};
seo?: {
title: string;
metaDesc: string;
opengraphImage?: { sourceUrl: string };
};
}
// Fetch all posts
export async function getAllPosts(first = 20): Promise<WPPost[]> {
const query = gql`
query GetAllPosts($first: Int!) {
posts(first: $first, where: { orderby: { field: DATE, order: DESC } }) {
nodes {
id
databaseId
title
slug
excerpt
date
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
author {
node {
name
avatar {
url
}
}
}
categories {
nodes {
name
slug
}
}
}
}
}
`;
const data = await client.request<{ posts: { nodes: WPPost[] } }>(
query,
{ first }
);
return data.posts.nodes;
}
// Fetch post by slug
export async function getPostBySlug(slug: string): Promise<WPPost | null> {
const query = gql`
query GetPostBySlug($slug: ID!) {
post(id: $slug, idType: SLUG) {
id
databaseId
title
slug
content
excerpt
date
modified
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
author {
node {
name
avatar {
url
}
}
}
categories {
nodes {
name
slug
}
}
seo {
title
metaDesc
opengraphImage {
sourceUrl
}
}
}
}
`;
const data = await client.request<{ post: WPPost | null }>(
query,
{ slug }
);
return data.post;
}
// Fetch all post slugs (for generateStaticParams)
export async function getAllPostSlugs(): Promise<string[]> {
const query = gql`
query GetAllSlugs {
posts(first: 1000) {
nodes {
slug
}
}
}
`;
const data = await client.request<{
posts: { nodes: Array<{ slug: string }> };
}>(query);
return data.posts.nodes.map((node) => node.slug);
}
Blog List Page#
// app/blog/page.tsx
import { getAllPosts } from '@/lib/wordpress';
import Image from 'next/image';
import Link from 'next/link';
export const revalidate = 3600; // ISR: revalidate every hour
export default async function BlogPage() {
const posts = await getAllPosts();
return (
<main className="container mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => (
<article key={post.id} className="bg-white rounded-lg shadow-md overflow-hidden">
{post.featuredImage && (
<Image
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText || post.title}
width={post.featuredImage.node.mediaDetails.width}
height={post.featuredImage.node.mediaDetails.height}
className="w-full h-48 object-cover"
/>
)}
<div className="p-6">
<div className="flex gap-2 mb-3">
{post.categories.nodes.map((cat) => (
<span key={cat.slug} className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
{cat.name}
</span>
))}
</div>
<h2 className="text-xl font-semibold mb-2">
<Link href={`/blog/${post.slug}`} className="hover:text-blue-600">
{post.title}
</Link>
</h2>
<div
className="text-gray-600 text-sm mb-4"
dangerouslySetInnerHTML={{ __html: post.excerpt }}
/>
<div className="flex items-center text-sm text-gray-500">
<span>{post.author.node.name}</span>
<span className="mx-2">|</span>
<time dateTime={post.date}>
{new Date(post.date).toLocaleDateString('en-US')}
</time>
</div>
</div>
</article>
))}
</div>
</main>
);
}
Single Post Page#
// app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPostSlugs } from '@/lib/wordpress';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { Metadata } from 'next';
export const revalidate = 3600;
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const slugs = await getAllPostSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return {};
return {
title: post.seo?.title || post.title,
description: post.seo?.metaDesc || post.excerpt.replace(/<[^>]*>/g, ''),
openGraph: {
title: post.seo?.title || post.title,
description: post.seo?.metaDesc || post.excerpt.replace(/<[^>]*>/g, ''),
images: post.seo?.opengraphImage
? [{ url: post.seo.opengraphImage.sourceUrl }]
: post.featuredImage
? [{ url: post.featuredImage.node.sourceUrl }]
: [],
},
};
}
export default async function PostPage({ params }: PageProps) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
return (
<article className="container mx-auto px-4 py-12 max-w-3xl">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center text-gray-600 mb-6">
<span>{post.author.node.name}</span>
<span className="mx-2">|</span>
<time dateTime={post.date}>
{new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</div>
{post.featuredImage && (
<Image
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText || post.title}
width={post.featuredImage.node.mediaDetails.width}
height={post.featuredImage.node.mediaDetails.height}
className="w-full rounded-lg"
priority
/>
)}
</header>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
);
}
Custom Post Types and Advanced Custom Fields (ACF)#
One of WordPress's strongest features is custom post types combined with ACF fields. To expose them via GraphQL, you need the additional WPGraphQL for ACF plugin.
Registering a Custom Post Type#
// functions.php
add_action('init', function () {
register_post_type('portfolio', [
'labels' => [
'name' => 'Portfolio',
'singular_name' => 'Project',
],
'public' => true,
'has_archive' => true,
'show_in_rest' => true, // Required for REST API
'show_in_graphql' => true, // Required for WPGraphQL
'graphql_single_name' => 'project',
'graphql_plural_name' => 'projects',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
]);
});
ACF Configuration with GraphQL#
After installing WPGraphQL for ACF, ACF fields are automatically available in the GraphQL schema:
query GetProjects {
projects(first: 12) {
nodes {
title
slug
excerpt
featuredImage {
node {
sourceUrl
altText
}
}
projectFields {
clientName
projectUrl
technologies
completionDate
testimonial
}
}
}
}
Fetching ACF Data in Next.js#
// lib/wordpress.ts
export interface Project {
title: string;
slug: string;
excerpt: string;
featuredImage: {
node: {
sourceUrl: string;
altText: string;
};
} | null;
projectFields: {
clientName: string;
projectUrl: string;
technologies: string[];
completionDate: string;
testimonial: string;
};
}
export async function getProjects(): Promise<Project[]> {
const query = gql`
query GetProjects {
projects(first: 50) {
nodes {
title
slug
excerpt
featuredImage {
node {
sourceUrl
altText
}
}
projectFields {
clientName
projectUrl
technologies
completionDate
testimonial
}
}
}
}
`;
const data = await client.request<{
projects: { nodes: Project[] };
}>(query);
return data.projects.nodes;
}
Image Optimization with Next.js Image#
WordPress stores images on its own server, but Next.js can optimize them on the fly using the Image component.
next.config.js Configuration#
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cms.yourdomain.com',
pathname: '/wp-content/uploads/**',
},
],
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
};
module.exports = nextConfig;
WordPress Image Component#
// components/WordPressImage.tsx
import Image from 'next/image';
interface WordPressImageProps {
src: string;
alt: string;
width: number;
height: number;
priority?: boolean;
className?: string;
sizes?: string;
}
export function WordPressImage({
src,
alt,
width,
height,
priority = false,
className,
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw',
}: WordPressImageProps) {
return (
<Image
src={src}
alt={alt}
width={width}
height={height}
priority={priority}
className={className}
sizes={sizes}
quality={85}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD..."
/>
);
}
Incremental Static Regeneration (ISR)#
ISR is a key Next.js feature that allows you to update static pages without a full rebuild. It is the ideal solution for WordPress content that changes regularly.
Time-Based Revalidation#
// app/blog/page.tsx
export const revalidate = 3600; // Revalidate every 1 hour
On-Demand Revalidation#
A better approach is revalidation triggered by a webhook from WordPress:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidation-secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
const body = await request.json();
const { post_type, slug } = body;
try {
// Revalidate specific post
if (slug) {
revalidatePath(`/blog/${slug}`);
}
// Revalidate blog listing
revalidatePath('/blog');
// Revalidate by tag
revalidateTag('wordpress-posts');
return NextResponse.json({
revalidated: true,
date: new Date().toISOString(),
});
} catch (error) {
return NextResponse.json(
{ message: 'Error revalidating' },
{ status: 500 }
);
}
}
WordPress Webhook#
// functions.php
add_action('save_post', function ($post_id, $post) {
if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
return;
}
if ($post->post_status !== 'publish') {
return;
}
$webhook_url = 'https://yourdomain.com/api/revalidate';
wp_remote_post($webhook_url, [
'headers' => [
'Content-Type' => 'application/json',
'x-revalidation-secret' => defined('REVALIDATION_SECRET')
? REVALIDATION_SECRET
: '',
],
'body' => json_encode([
'post_type' => $post->post_type,
'slug' => $post->post_name,
'post_id' => $post_id,
]),
'timeout' => 10,
]);
}, 10, 2);
Authentication and Preview Mode#
Preview mode allows editors to see unpublished content changes directly on the Next.js frontend.
Preview Endpoint in Next.js#
// app/api/preview/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret');
const slug = request.nextUrl.searchParams.get('slug');
const postType = request.nextUrl.searchParams.get('post_type') || 'post';
if (secret !== process.env.WORDPRESS_PREVIEW_SECRET || !slug) {
return new Response('Invalid token', { status: 401 });
}
const draft = await draftMode();
draft.enable();
const path = postType === 'page' ? `/${slug}` : `/blog/${slug}`;
redirect(path);
}
// app/api/exit-preview/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET() {
const draft = await draftMode();
draft.disable();
redirect('/');
}
Fetching Drafts from WordPress#
// lib/wordpress.ts
export async function getPreviewPost(
id: number,
authToken: string
): Promise<WPPost | null> {
const authenticatedClient = new GraphQLClient(
process.env.WORDPRESS_GRAPHQL_URL!,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
}
);
const query = gql`
query GetPreviewPost($id: ID!) {
post(id: $id, idType: DATABASE_ID, asPreview: true) {
title
content
slug
date
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
}
}
`;
const data = await authenticatedClient.request<{ post: WPPost | null }>(
query,
{ id }
);
return data.post;
}
Configuring Preview in WordPress#
// functions.php
add_filter('preview_post_link', function ($link, $post) {
$frontend_url = 'https://yourdomain.com';
$secret = defined('PREVIEW_SECRET') ? PREVIEW_SECRET : '';
$slug = $post->post_name ?: $post->ID;
return sprintf(
'%s/api/preview?secret=%s&slug=%s&post_type=%s',
$frontend_url,
$secret,
$slug,
$post->post_type
);
}, 10, 2);
SEO in Headless WordPress#
SEO is one of the main reasons businesses choose WordPress. In a headless architecture, you do not lose these capabilities -- you actually enhance them.
Yoast SEO Integration#
The WPGraphQL Yoast SEO plugin exposes Yoast SEO data through GraphQL:
query GetPostSEO($slug: ID!) {
post(id: $slug, idType: SLUG) {
seo {
title
metaDesc
canonical
opengraphTitle
opengraphDescription
opengraphImage {
sourceUrl
mediaDetails {
width
height
}
}
twitterTitle
twitterDescription
twitterImage {
sourceUrl
}
schema {
raw
}
}
}
}
Generating Metadata in Next.js#
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post?.seo) return {};
return {
title: post.seo.title,
description: post.seo.metaDesc,
alternates: {
canonical: post.seo.canonical || `/blog/${slug}`,
},
openGraph: {
title: post.seo.opengraphTitle || post.seo.title,
description: post.seo.opengraphDescription || post.seo.metaDesc,
type: 'article',
publishedTime: post.date,
modifiedTime: post.modified,
images: post.seo.opengraphImage
? [{
url: post.seo.opengraphImage.sourceUrl,
width: post.seo.opengraphImage.mediaDetails?.width,
height: post.seo.opengraphImage.mediaDetails?.height,
}]
: [],
},
twitter: {
card: 'summary_large_image',
title: post.seo.twitterTitle || post.seo.title,
description: post.seo.twitterDescription || post.seo.metaDesc,
},
};
}
Generating a Sitemap#
// app/sitemap.ts
import { getAllPosts } from '@/lib/wordpress';
export default async function sitemap() {
const posts = await getAllPosts(1000);
const blogEntries = posts.map((post) => ({
url: `https://yourdomain.com/blog/${post.slug}`,
lastModified: new Date(post.modified || post.date),
changeFrequency: 'weekly' as const,
priority: 0.7,
}));
return [
{
url: 'https://yourdomain.com',
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 1,
},
{
url: 'https://yourdomain.com/blog',
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.9,
},
...blogEntries,
];
}
Performance Comparison: Headless vs Traditional#
Tests conducted on a real project with 500 posts show significant differences:
| Metric | Traditional WordPress | Headless (Next.js + WP) | |--------|----------------------|------------------------| | TTFB | 800-1500 ms | 50-150 ms | | LCP | 2.5-4.0 s | 0.8-1.5 s | | FID / INP | 150-300 ms | 30-80 ms | | CLS | 0.1-0.25 | 0.01-0.05 | | Lighthouse Score | 55-75 | 90-100 | | Build time (500 posts) | N/A | ~3-5 min | | Hosting cost | $20-50/mo (quality hosting) | $0-20/mo (Vercel free/pro) |
Key factors driving performance improvements:
- Static pages -- HTML pre-generated, served from CDN
- Image optimization -- automatic conversion to WebP/AVIF, lazy loading
- Code splitting -- only essential JavaScript loaded per page
- Edge caching -- content served from the nearest CDN node
Deployment Strategies#
Target Architecture#
[Editor] --> [WordPress CMS] --> [Webhook]
| |
v v
[WPGraphQL API] [On-Demand ISR]
| |
v v
[Next.js Build] [Vercel Edge CDN]
| |
v v
[Static Pages] <----------+
|
v
[End User]
WordPress Hosting#
Recommended hosting options for WordPress as a headless CMS:
- WordPress.com Business -- managed hosting, automatic updates
- Kinsta -- high performance, PHP 8.x support, staging environments
- WP Engine -- dedicated WordPress hosting, advanced tooling
- DigitalOcean Droplet -- full control, low cost
Deploying Next.js on Vercel#
# Install Vercel CLI
npm install -g vercel
# Deploy
vercel --prod
# Configure environment variables
vercel env add WORDPRESS_GRAPHQL_URL
vercel env add REVALIDATION_SECRET
vercel env add WORDPRESS_PREVIEW_SECRET
Docker Compose for Local Development#
# docker-compose.yml
services:
wordpress:
image: wordpress:6.4-php8.2-apache
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: secret
WORDPRESS_DB_NAME: wordpress
volumes:
- wp_data:/var/www/html
- ./wp-plugins:/var/www/html/wp-content/plugins
depends_on:
- db
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: rootsecret
volumes:
- db_data:/var/lib/mysql
nextjs:
build: ./frontend
ports:
- "3000:3000"
environment:
WORDPRESS_GRAPHQL_URL: http://wordpress:80/graphql
depends_on:
- wordpress
volumes:
wp_data:
db_data:
Conclusion#
WordPress as a headless CMS combined with Next.js is a powerful combination that unites a familiar content management interface with a modern, high-performance presentation layer. The key benefits include:
- Performance -- static pages served from CDN, sub-second load times
- SEO -- full control over metadata, server-side rendering, automatic sitemaps
- Flexibility -- any frontend technology, multi-channel content distribution
- Security -- admin panel separated from the public site
- Scalability -- content on CDN, backend used only for editing
At MDS Software Solutions Group, we specialize in building modern web solutions based on headless architecture. We help businesses migrate from traditional WordPress to headless architecture, design and implement Next.js frontends, and integrate CMS systems with mobile applications and other distribution channels.
Need a high-performance website powered by WordPress and Next.js? Contact us -- our experts will help you choose the best architecture and deliver a solution tailored to your needs.
Team of programming experts specializing in modern web technologies.