🚀 Headless WordPress 6.9 with Next.js 15: Complete Developer Guide 2026
Build modern headless WordPress sites with Next.js 15 and PHP 8.3. Learn WPGraphQL, Apollo Client, static generation, and deployment for lightning-fast experiences. Full-stack tutorial with production-ready code.
Blogs Team
Full-Stack Developers • 2026 Edition
🔰 What is Headless WordPress? (Architecture Overview)
Traditional WordPress
[WordPress]
├── PHP Templates
├── MySQL Database
├── Theme (PHP/CSS/JS)
└── Renders HTML on server
→ Monolithic architecture
→ Coupled frontend/backend
→ Slower, less flexible
Headless WordPress + Next.js
[WordPress (Backend)]
├── Admin Dashboard
├── MySQL Database
└── GraphQL/REST API
↓
[Next.js (Frontend)]
├── React Components
├── Static Generation
├── Client-side Rendering
└── Deployed on Vercel
→ Decoupled architecture
→ Blazing fast frontend
→ Better scalability
Why Headless in 2026?
Faster page loads
Better security
Web, mobile, apps
Modern architecture
Key Insight: Headless WordPress combines the best CMS with the best frontend framework. You get WordPress's admin experience with Next.js performance.
⚙️ WordPress 6.9 Headless Configuration
Step 1: Fresh WordPress Installation
# Download WordPress 6.9
wget https://wordpress.org/latest.tar.gz
tar -xzf latest.tar.gz
# Configure wp-config.php
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
# Enable CORS for Next.js
add_action('init', function() {
header("Access-Control-Allow-Origin: http://localhost:3000");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Authorization, Content-Type");
});
Step 2: Install Required Plugins
| Plugin | Purpose | Version |
|---|---|---|
| WPGraphQL | GraphQL API for WordPress | 2.0+ |
| WPGraphQL CORS | Cross-origin support | Latest |
| Advanced Custom Fields | Custom fields with GraphQL support | 6.3+ |
| WPGraphQL for ACF | Expose ACF fields in GraphQL | Latest |
| Yoast SEO (with GraphQL) | SEO metadata via GraphQL | 22.0+ |
Step 3: Configure Permalinks
# Go to Settings → Permalinks # Choose "Post name" for clean URLs # This affects GraphQL endpoints structure
Step 4: Create Custom Post Types (Optional)
// functions.php - Register Custom Post Type
add_action('init', function() {
register_post_type('project', [
'labels' => [
'name' => 'Projects',
'singular_name' => 'Project'
],
'public' => true,
'has_archive' => true,
'show_in_graphql' => true,
'graphql_single_name' => 'project',
'graphql_plural_name' => 'projects',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt']
]);
});
Test GraphQL: Visit https://yoursite.com/graphql to use the GraphiQL IDE.
🔌 WPGraphQL: Querying WordPress with GraphQL
Sample GraphQL Queries
Get Posts with Featured Images
{
posts(first: 10) {
nodes {
id
title
slug
date
excerpt
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
categories {
nodes {
name
slug
}
}
seo {
metaDesc
title
}
}
}
}
Get Single Post by Slug
query GetPost($slug: ID!) {
post(id: $slug, idType: SLUG) {
title
content
date
modified
author {
node {
name
description
avatar {
url
}
}
}
featuredImage {
node {
sourceUrl
srcSet
}
}
seo {
metaDesc
metaKeywords
}
tags {
nodes {
name
slug
}
}
}
}
Advanced Queries with ACF
{
projects(first: 10) {
nodes {
title
projectFields {
client
completionDate
technologies
demoUrl
githubUrl
gallery {
sourceUrl
}
}
}
}
}
Using GraphQL Variables
# Query with variables
query GetPostsByCategory($categoryName: String!, $first: Int) {
posts(first: $first, where: {categoryName: $categoryName}) {
nodes {
title
slug
}
}
}
# Variables
{
"categoryName": "tutorials",
"first": 5
}
Pro Tip: Use idType: SLUG for human-readable URLs, idType: DATABASE_ID for numeric IDs.
⚛️ Next.js 15 Project Setup
Create Next.js App
# Create new Next.js 15 project npx create-next-app@latest headless-wordpress --typescript --tailwind --app cd headless-wordpress # Install dependencies npm install @apollo/client graphql @graphql-typed-document-node/core npm install @types/node --save-dev # Install additional utilities npm install date-fns html-react-parser npm install framer-motion # for animations
Project Structure
headless-wordpress/ ├── src/ │ ├── app/ │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── posts/ │ │ │ ├── [slug]/ │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── categories/ │ │ └── [slug]/ │ │ └── page.tsx │ ├── components/ │ │ ├── Header.tsx │ │ ├── Footer.tsx │ │ ├── PostCard.tsx │ │ └── PostContent.tsx │ ├── lib/ │ │ ├── apollo-client.ts │ │ ├── graphql-queries.ts │ │ └── wordpress.ts │ ├── types/ │ │ └── wordpress.d.ts │ └── utils/ │ └── date.ts ├── .env.local └── next.config.js
Environment Variables
# .env.local WORDPRESS_API_URL=https://your-wordpress-site.com/graphql WORDPRESS_PREVIEW_SECRET=your-secret-key-here NEXT_PUBLIC_SITE_URL=http://localhost:3000
next.config.js Configuration
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['your-wordpress-site.com', 'secure.gravatar.com'],
formats: ['image/avif', 'image/webp'],
},
experimental: {
optimizeCss: true,
optimizePackageImports: ['@apollo/client'],
},
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
};
module.exports = nextConfig;
📡 Apollo Client Configuration
Create Apollo Client Instance
// src/lib/apollo-client.ts
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
const httpLink = new HttpLink({
uri: process.env.WORDPRESS_API_URL,
credentials: 'same-origin',
});
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) => {
console.error(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
);
});
}
if (networkError) {
console.error(`[Network error]: ${networkError}`);
}
});
export const client = new ApolloClient({
link: from([errorLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
Post: {
fields: {
featuredImage: {
merge: true,
},
},
},
},
}),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
},
},
});
Apollo Provider Wrapper
// src/app/ApolloProvider.tsx
'use client';
import { ApolloProvider as Provider } from '@apollo/client';
import { client } from '@/lib/apollo-client';
export function ApolloProvider({ children }: { children: React.ReactNode }) {
return <Provider client={client}>{children}</Provider>;
}
// src/app/layout.tsx
import { ApolloProvider } from './ApolloProvider';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ApolloProvider>
{children}
</ApolloProvider>
</body>
</html>
);
}
GraphQL Queries Definition
// src/lib/graphql-queries.ts
import { gql } from '@apollo/client';
export const GET_ALL_POSTS = gql`
query GetAllPosts($first: Int = 10) {
posts(first: $first) {
nodes {
id
title
slug
date
excerpt
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
categories {
nodes {
name
slug
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
export const GET_POST_BY_SLUG = gql`
query GetPostBySlug($slug: ID!) {
post(id: $slug, idType: SLUG) {
id
title
content
date
modified
slug
excerpt
featuredImage {
node {
sourceUrl
srcSet
altText
}
}
author {
node {
name
description
avatar {
url
}
}
}
categories {
nodes {
name
slug
}
}
tags {
nodes {
name
slug
}
}
seo {
metaDesc
metaKeywords
opengraphTitle
opengraphDescription
opengraphImage {
sourceUrl
}
}
}
}
`;
export const GET_ALL_POST_SLUGS = gql`
query GetAllPostSlugs {
posts(first: 10000) {
nodes {
slug
modified
}
}
}
`;
export const GET_CATEGORIES = gql`
query GetCategories {
categories {
nodes {
id
name
slug
count
description
seo {
metaDesc
}
}
}
}
`;
export const GET_POSTS_BY_CATEGORY = gql`
query GetPostsByCategory($categoryName: String!, $first: Int = 10) {
posts(first: $first, where: { categoryName: $categoryName }) {
nodes {
id
title
slug
date
excerpt
featuredImage {
node {
sourceUrl
altText
}
}
}
}
}
`;
🏗️ Static Generation with Next.js 15
Homepage with getStaticProps
// src/app/page.tsx
import { client } from '@/lib/apollo-client';
import { GET_ALL_POSTS } from '@/lib/graphql-queries';
import PostCard from '@/components/PostCard';
async function getPosts() {
const { data } = await client.query({
query: GET_ALL_POSTS,
variables: { first: 10 },
});
return data?.posts?.nodes || [];
}
export default async function Home() {
const posts = await getPosts();
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Latest Posts</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
);
}
// Optional: Revalidate every hour
export const revalidate = 3600;
PostCard Component
// src/components/PostCard.tsx
import Link from 'next/link';
import Image from 'next/image';
import { formatDate } from '@/utils/date';
interface PostCardProps {
post: {
title: string;
slug: string;
date: string;
excerpt: string;
featuredImage?: {
node: {
sourceUrl: string;
altText: string;
mediaDetails: {
width: number;
height: number;
};
};
};
categories?: {
nodes: Array<{
name: string;
slug: string;
}>;
};
};
}
export default function PostCard({ post }: PostCardProps) {
return (
<article className="bg-white rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow">
{post.featuredImage && (
<Link href={`/posts/${post.slug}`}>
<div className="relative h-48 w-full">
<Image
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText || post.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
</Link>
)}
<div className="p-6">
{post.categories && post.categories.nodes.length > 0 && (
<div className="flex gap-2 mb-3">
{post.categories.nodes.map((cat) => (
<Link
key={cat.slug}
href={`/categories/${cat.slug}`}
className="text-sm text-primary hover:underline"
>
{cat.name}
</Link>
))}
</div>
)}
<Link href={`/posts/${post.slug}`}>
<h2 className="text-xl font-bold mb-2 hover:text-primary transition-colors">
{post.title}
</h2>
</Link>
<div
className="text-gray-600 mb-4 prose-sm"
dangerouslySetInnerHTML={{ __html: post.excerpt }}
/>
<div className="flex justify-between items-center text-sm text-gray-500">
<time dateTime={post.date}>{formatDate(post.date)}</time>
<Link
href={`/posts/${post.slug}`}
className="text-primary font-medium hover:underline"
>
Read More →
</Link>
</div>
</div>
</article>
);
}
Date Utility
// src/utils/date.ts
import { format, parseISO } from 'date-fns';
export function formatDate(date: string) {
return format(parseISO(date), 'MMMM dd, yyyy');
}
export function formatDateTime(date: string) {
return format(parseISO(date), 'MMMM dd, yyyy - h:mm a');
}
🔄 Dynamic Routes for Posts
generateStaticParams for Static Generation
// src/app/posts/[slug]/page.tsx
import { client } from '@/lib/apollo-client';
import { GET_POST_BY_SLUG, GET_ALL_POST_SLUGS } from '@/lib/graphql-queries';
import PostContent from '@/components/PostContent';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
interface PostPageProps {
params: {
slug: string;
};
}
// Generate static paths at build time
export async function generateStaticParams() {
const { data } = await client.query({
query: GET_ALL_POST_SLUGS,
});
return data?.posts?.nodes.map((post: { slug: string }) => ({
slug: post.slug,
})) || [];
}
// Fetch post data
async function getPost(slug: string) {
try {
const { data } = await client.query({
query: GET_POST_BY_SLUG,
variables: { slug },
});
return data?.post;
} catch (error) {
console.error('Error fetching post:', error);
return null;
}
}
// Generate metadata for SEO
export async function generateMetadata({ params }: PostPageProps): Promise {
const post = await getPost(params.slug);
if (!post) {
return {
title: 'Post Not Found',
};
}
return {
title: post.seo?.title || post.title,
description: post.seo?.metaDesc || post.excerpt,
openGraph: {
title: post.seo?.opengraphTitle || post.title,
description: post.seo?.opengraphDescription || post.excerpt,
images: post.seo?.opengraphImage ? [post.seo.opengraphImage.sourceUrl] : [],
type: 'article',
publishedTime: post.date,
modifiedTime: post.modified,
authors: post.author?.node?.name ? [post.author.node.name] : [],
},
twitter: {
card: 'summary_large_image',
title: post.seo?.title || post.title,
description: post.seo?.metaDesc || post.excerpt,
},
};
}
export default async function PostPage({ params }: PostPageProps) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
<article className="container mx-auto px-4 py-8 max-w-4xl">
{/* Post Header */}
<header className="mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-4">{post.title}</h1>
{post.categories && post.categories.nodes.length > 0 && (
<div className="flex gap-2 mb-4">
{post.categories.nodes.map((cat: any) => (
<a
key={cat.slug}
href={`/categories/${cat.slug}`}
className="text-sm bg-gray-100 px-3 py-1 rounded-full hover:bg-gray-200"
>
{cat.name}
</a>
))}
</div>
)}
{post.author && (
<div className="flex items-center gap-3 text-gray-600">
{post.author.node.avatar && (
<img
src={post.author.node.avatar.url}
alt={post.author.node.name}
className="w-10 h-10 rounded-full"
/>
)}
<div>
<span className="font-medium">{post.author.node.name}</span>
<time dateTime={post.date} className="block text-sm">
{formatDate(post.date)}
</time>
</div>
</div>
)}
</header>
{/* Featured Image */}
{post.featuredImage && (
<div className="relative h-[400px] w-full mb-8 rounded-lg overflow-hidden">
<Image
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText || post.title}
fill
className="object-cover"
priority
sizes="(max-width: 1200px) 100vw, 1200px"
/>
</div>
)}
{/* Post Content */}
<PostContent content={post.content} />
{/* Tags */}
{post.tags && post.tags.nodes.length > 0 && (
<footer className="mt-12 pt-6 border-t">
<h3 className="text-lg font-semibold mb-3">Tags</h3>
<div className="flex flex-wrap gap-2">
{post.tags.nodes.map((tag: any) => (
<a
key={tag.slug}
href={`/tags/${tag.slug}`}
className="bg-gray-100 px-3 py-1 rounded-full text-sm hover:bg-gray-200"
>
#{tag.name}
</a>
))}
</div>
</footer>
)}
</article>
);
}
// Revalidate every hour
export const revalidate = 3600;
PostContent Component with HTML Parsing
// src/components/PostContent.tsx
'use client';
import parse from 'html-react-parser';
import Image from 'next/image';
import Link from 'next/link';
interface PostContentProps {
content: string;
}
export default function PostContent({ content }: PostContentProps) {
const options = {
replace: (node: any) => {
// Replace img tags with Next.js Image
if (node.name === 'img') {
return (
<Image
src={node.attribs.src}
alt={node.attribs.alt || ''}
width={800}
height={400}
className="rounded-lg my-4"
/>
);
}
// Replace a tags with Next.js Link
if (node.name === 'a' && node.attribs.href?.startsWith('/')) {
return (
<Link href={node.attribs.href} className="text-primary hover:underline">
{node.children[0]?.data}
</Link>
);
}
// Add classes to headings
if (node.name === 'h2') {
node.attribs = { className: 'text-2xl font-bold mt-6 mb-4' };
}
if (node.name === 'h3') {
node.attribs = { className: 'text-xl font-semibold mt-5 mb-3' };
}
if (node.name === 'p') {
node.attribs = { className: 'mb-4 leading-relaxed' };
}
},
};
return (
<div className="prose prose-lg max-w-none">
{parse(content, options)}
</div>
);
}
👁️ Implementing Preview Mode for Drafts
1. WordPress Preview Hook
// functions.php - Add preview endpoint
add_action('rest_api_init', function() {
register_rest_route('headless/v1', '/preview', [
'methods' => 'GET',
'callback' => 'handle_preview_request',
'permission_callback' => function() {
return current_user_can('edit_posts');
}
]);
});
function handle_preview_request($request) {
$post_id = $request->get_param('post_id');
$post = get_post($post_id);
if (!$post) {
return new WP_Error('no_post', 'Post not found', ['status' => 404]);
}
$preview_url = add_query_arg([
'secret' => PREVIEW_SECRET,
'id' => $post_id,
'slug' => $post->post_name,
'type' => $post->post_type,
], 'http://localhost:3000/api/preview');
return rest_ensure_response([
'preview_url' => $preview_url,
]);
}
2. Next.js Preview API Route
// src/app/api/preview/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { client } from '@/lib/apollo-client';
import { GET_POST_BY_SLUG } from '@/lib/graphql-queries';
import { draftMode } from 'next/headers';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const secret = searchParams.get('secret');
const id = searchParams.get('id');
const slug = searchParams.get('slug');
// Check secret
if (secret !== process.env.WORDPRESS_PREVIEW_SECRET) {
return new NextResponse('Invalid token', { status: 401 });
}
// Fetch post to verify it exists
try {
const { data } = await client.query({
query: GET_POST_BY_SLUG,
variables: { slug },
});
if (!data?.post) {
return new NextResponse('Post not found', { status: 404 });
}
// Enable Draft Mode
draftMode().enable();
// Redirect to the post page
return NextResponse.redirect(
new URL(`/posts/${slug}?preview=true`, request.url)
);
} catch (error) {
console.error('Preview error:', error);
return new NextResponse('Preview failed', { status: 500 });
}
}
3. Update Post Page for Preview
// src/app/posts/[slug]/page.tsx (updated)
import { draftMode } from 'next/headers';
export default async function PostPage({ params }: PostPageProps) {
const { isEnabled } = draftMode();
// Fetch with or without cache based on preview mode
const post = await getPost(params.slug, isEnabled);
// ... rest of the component
}
// Update getPost function
async function getPost(slug: string, isPreview = false) {
try {
const { data } = await client.query({
query: GET_POST_BY_SLUG,
variables: { slug },
fetchPolicy: isPreview ? 'no-cache' : 'cache-first',
});
return data?.post;
} catch (error) {
console.error('Error fetching post:', error);
return null;
}
}
🔐 Authentication & Mutations (Comments, Forms)
1. Comment Mutation
// GraphQL mutation for comments
export const CREATE_COMMENT = gql`
mutation CreateComment(
$postId: Int!
$author: String!
$authorEmail: String!
$content: String!
) {
createComment(
input: {
commentOn: $postId
author: $author
authorEmail: $authorEmail
content: $content
}
) {
success
comment {
id
content
date
author {
node {
name
}
}
}
}
}
`;
// src/components/CommentForm.tsx
'use client';
import { useState } from 'react';
import { useMutation } from '@apollo/client';
import { CREATE_COMMENT } from '@/lib/graphql-queries';
interface CommentFormProps {
postId: number;
onCommentAdded: () => void;
}
export default function CommentForm({ postId, onCommentAdded }: CommentFormProps) {
const [formData, setFormData] = useState({
author: '',
email: '',
content: '',
});
const [createComment, { loading, error }] = useMutation(CREATE_COMMENT, {
onCompleted: () => {
setFormData({ author: '', email: '', content: '' });
onCommentAdded();
},
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createComment({
variables: {
postId,
author: formData.author,
authorEmail: formData.email,
content: formData.content,
},
});
} catch (err) {
console.error('Error posting comment:', err);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="author" className="block text-sm font-medium mb-1">
Name *
</label>
<input
type="text"
id="author"
required
value={formData.author}
onChange={(e) => setFormData({ ...formData, author: e.target.value })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email *
</label>
<input
type="email"
id="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium mb-1">
Comment *
</label>
<textarea
id="content"
required
rows={4}
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary"
/>
</div>
{error && (
<div className="text-red-500 text-sm">
Error posting comment. Please try again.
</div>
)}
<button
type="submit"
disabled={loading}
className="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary-dark disabled:opacity-50"
>
{loading ? 'Posting...' : 'Post Comment'}
</button>
</form>
);
}
2. Contact Form with GraphQL
// Add GraphQL mutation for contact form
export const SUBMIT_CONTACT_FORM = gql`
mutation SubmitContactForm(
$name: String!
$email: String!
$message: String!
) {
submitContactForm(
input: {
name: $name
email: $email
message: $message
}
) {
success
message
}
}
`;
// WordPress side: Add custom mutation
add_action('graphql_register_types', function() {
register_graphql_mutation('submitContactForm', [
'inputFields' => [
'name' => ['type' => 'String'],
'email' => ['type' => 'String'],
'message' => ['type' => 'String'],
],
'outputFields' => [
'success' => ['type' => 'Boolean'],
'message' => ['type' => 'String'],
],
'mutateAndGetPayload' => function($input) {
$name = sanitize_text_field($input['name']);
$email = sanitize_email($input['email']);
$message = sanitize_textarea_field($input['message']);
// Send email
$to = get_option('admin_email');
$subject = "Contact form submission from $name";
$headers = ['Content-Type: text/html; charset=UTF-8'];
$email_sent = wp_mail($to, $subject, $message, $headers);
return [
'success' => $email_sent,
'message' => $email_sent ? 'Email sent successfully' : 'Failed to send email',
];
}
]);
});
🚀 Deploying to Vercel & Production
1. Vercel Configuration
// vercel.json
{
"buildCommand": "next build",
"devCommand": "next dev",
"installCommand": "npm install",
"framework": "nextjs",
"regions": ["iad1"],
"crons": [
{
"path": "/api/revalidate",
"schedule": "0 * * * *"
}
]
}
2. Environment Variables on Vercel
- WORDPRESS_API_URL - Production WordPress GraphQL endpoint
- WORDPRESS_PREVIEW_SECRET - Secret for preview mode
- NEXT_PUBLIC_SITE_URL - Your production URL
3. On-Demand Revalidation
// src/app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
try {
const body = await request.json();
const { slug, type } = body;
if (type === 'post') {
revalidatePath(`/posts/${slug}`);
revalidateTag('posts');
} else if (type === 'home') {
revalidatePath('/');
}
return NextResponse.json({ revalidated: true });
} catch (error) {
return NextResponse.json({ error: 'Revalidation failed' }, { status: 500 });
}
}
4. WordPress Webhook for Auto-Revalidation
// functions.php - Trigger revalidation on post save
add_action('save_post', function($post_id, $post, $update) {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
$revalidate_url = 'https://your-nextjs-site.com/api/revalidate';
$secret = 'your-revalidate-secret';
wp_remote_post($revalidate_url, [
'headers' => [
'x-revalidate-secret' => $secret,
'Content-Type' => 'application/json',
],
'body' => json_encode([
'slug' => $post->post_name,
'type' => 'post',
]),
]);
}, 10, 3);
5. Performance Optimizations
// next.config.js - Additional optimizations
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// ... existing config
compress: true,
poweredByHeader: false,
generateEtags: true,
productionBrowserSourceMaps: false,
swcMinify: true,
compiler: {
removeConsole: {
exclude: ['error'],
},
},
headers: async () => [
{
source: '/(.*)',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
],
},
],
});
⚡ Advanced Optimizations & Patterns
1. Incremental Static Regeneration (ISR)
// src/app/posts/[slug]/page.tsx
export const revalidate = 60; // Revalidate every 60 seconds
// For on-demand revalidation
export async function generateMetadata({ params }: PostPageProps) {
const post = await getPost(params.slug);
if (!post) {
// This will trigger 404
return {};
}
return {
title: post.title,
};
}
2. Image Optimization with Next.js Image
// src/components/OptimizedImage.tsx
'use client';
import Image from 'next/image';
import { useState } from 'react';
interface OptimizedImageProps {
src: string;
alt: string;
width?: number;
height?: number;
className?: string;
priority?: boolean;
}
export default function OptimizedImage({
src,
alt,
width,
height,
className,
priority = false,
}: OptimizedImageProps) {
const [isLoading, setIsLoading] = useState(true);
return (
<div className={`relative overflow-hidden ${className}`}>
<Image
src={src}
alt={alt}
width={width || 800}
height={height || 400}
priority={priority}
className={`
duration-700 ease-in-out
${isLoading ? 'scale-110 blur-lg' : 'scale-100 blur-0'}
`}
onLoad={() => setIsLoading(false)}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
);
}
3. GraphQL Fragment Colocation
// src/lib/fragments.ts
import { gql } from '@apollo/client';
export const POST_FRAGMENT = gql`
fragment PostFields on Post {
id
title
slug
date
excerpt
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
categories {
nodes {
name
slug
}
}
}
`;
export const GET_ALL_POSTS = gql`
${POST_FRAGMENT}
query GetAllPosts($first: Int = 10) {
posts(first: $first) {
nodes {
...PostFields
}
}
}
`;
4. Caching Strategy with Apollo
// src/lib/apollo-client.ts (enhanced)
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
const httpLink = createHttpLink({
uri: process.env.WORDPRESS_API_URL,
});
const authLink = setContext((_, { headers }) => {
// Add authentication if needed
return {
headers: {
...headers,
// 'Authorization': `Bearer ${token}`,
},
};
});
export const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: false,
merge(existing = { nodes: [] }, incoming) {
return {
...incoming,
nodes: [...existing.nodes, ...incoming.nodes],
};
},
},
},
},
},
}),
ssrMode: typeof window === 'undefined',
});
5. TypeScript Types for WordPress
// src/types/wordpress.d.ts
export interface Post {
id: string;
title: string;
slug: string;
date: string;
modified: string;
content?: string;
excerpt?: string;
featuredImage?: {
node: {
sourceUrl: string;
altText: string;
mediaDetails: {
width: number;
height: number;
};
};
};
categories?: {
nodes: Category[];
};
tags?: {
nodes: Tag[];
};
author?: {
node: Author;
};
seo?: SEO;
}
export interface Category {
id: string;
name: string;
slug: string;
count?: number;
description?: string;
}
export interface Tag {
id: string;
name: string;
slug: string;
}
export interface Author {
name: string;
description?: string;
avatar?: {
url: string;
};
}
export interface SEO {
title?: string;
metaDesc?: string;
metaKeywords?: string;
opengraphTitle?: string;
opengraphDescription?: string;
opengraphImage?: {
sourceUrl: string;
};
}
6. Performance Monitoring
// src/components/PerformanceMonitor.tsx
'use client';
import { useEffect } from 'react';
import { useReportWebVitals } from 'next/web-vitals';
export function PerformanceMonitor() {
useReportWebVitals((metric) => {
console.log(metric);
// Send to analytics
if (process.env.NODE_ENV === 'production') {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
page: window.location.pathname,
});
navigator.sendBeacon('/api/metrics', body);
}
});
return null;
}
🎯 Complete Example: Blog with Categories & Search
Features Implemented
- Static generation with ISR
- Category pages
- Search functionality
- Comment system
- Preview mode
- SEO optimization
- Responsive design
- Dark mode support
Tech Stack
- WordPress 6.9 (backend)
- Next.js 15 (frontend)
- WPGraphQL 2.0+
- Apollo Client
- Tailwind CSS
- TypeScript
- Vercel hosting
❓ Headless WordPress FAQ
Is headless WordPress SEO friendly?
Yes! Next.js provides excellent SEO with SSR/SSG, and WPGraphQL can expose Yoast SEO data.
Do I lose WordPress plugins?
Backend plugins still work. Frontend plugins need to be rebuilt in React.
Can I use ACF with headless?
Yes! WPGraphQL for ACF exposes all custom fields in GraphQL.
Is it worth the complexity?
For large sites needing performance and modern frontend, absolutely.
📬 Get Headless WordPress Tutorials
Weekly updates on headless architecture, Next.js, and GraphQL.
📢 Share this headless guide with fellow developers