Advertisement
[Responsive Ad - 728x90]
#Headless #Next.js #WPGraphQL #React WordPress 6.9

🚀 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.

WordPress 6.9 Next.js 15 PHP 8.3 WPGraphQL 2.0+ Apollo Client GraphQL Tailwind CSS Vercel

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?

200-300%

Faster page loads

🔒 99.9%

Better security

📱 Omnichannel

Web, mobile, apps

🚀 Jamstack

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.

Advertisement
[Responsive Medium Rectangle - 300x250]

⚙️ 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.

Advertisement
[Responsive Leaderboard]

⚛️ 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
          }
        }
      }
    }
  }
`;
Advertisement
[Responsive Large Rectangle]

🏗️ 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>
  );
}
Advertisement
[Responsive Leaderboard]

👁️ 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',
        },
      ],
    },
  ],
});
Advertisement
[Responsive Large Rectangle]

⚡ 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