By Devport Team | Last updated: 2025-07-12 | 25 min read

Building a Blazing-Fast Blog with Headless WordPress and Next.js

Combining WordPress's powerful content management capabilities with Next.js's modern React framework creates an unbeatable combination for building performant, scalable blogs. This comprehensive tutorial walks you through building a complete blog from scratch, implementing best practices for performance, SEO, and user experience.

By the end of this guide, you'll have a production-ready blog that loads instantly, ranks well in search engines, and provides an exceptional editing experience for content creators.

Table of Contents

  1. Project Setup and Structure
  2. WordPress Configuration
  3. Next.js App Creation
  4. Data Fetching with SWR/React Query
  5. Static Generation Setup
  6. Dynamic Routing
  7. Deployment Guide
  8. Performance Optimization

Project Setup and Structure

Project Architecture Overview

Our headless blog architecture separates concerns for maximum flexibility:

headless-blog/
├── wordpress/          # WordPress backend (subdomain or separate hosting)
│   ├── wp-content/
│   │   ├── plugins/   # WPGraphQL, ACF, etc.
│   │   └── themes/    # Minimal theme for admin
│   └── wp-config.php
│
└── nextjs-frontend/   # Next.js application
    ├── src/
    │   ├── app/       # App router files
    │   ├── components/
    │   ├── lib/       # Utilities and API clients
    │   ├── hooks/     # Custom React hooks
    │   └── types/     # TypeScript definitions
    ├── public/
    └── package.json

Prerequisites and Initial Setup

# Required tools
node --version  # 18.17 or higher
npm --version   # 9.0 or higher

# Create Next.js project with TypeScript and Tailwind
npx create-next-app@latest nextjs-blog --typescript --tailwind --app --src-dir

# Navigate to project
cd nextjs-blog

# Install additional dependencies
npm install @apollo/client graphql
npm install swr graphql-request
npm install reading-time gray-matter
npm install next-seo
npm install --save-dev @types/node

Environment Configuration

Create environment files for different stages:

# .env.local (development)
WORDPRESS_API_URL=http://localhost:8080
WORDPRESS_GRAPHQL_URL=http://localhost:8080/graphql
NEXT_PUBLIC_SITE_URL=http://localhost:3000

# .env.production
WORDPRESS_API_URL=https://api.yourblog.com
WORDPRESS_GRAPHQL_URL=https://api.yourblog.com/graphql
NEXT_PUBLIC_SITE_URL=https://yourblog.com

# .env.example (commit this)
WORDPRESS_API_URL=
WORDPRESS_GRAPHQL_URL=
NEXT_PUBLIC_SITE_URL=

TypeScript Configuration

// tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/lib/*": ["./src/lib/*"],
      "@/hooks/*": ["./src/hooks/*"],
      "@/types/*": ["./src/types/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

WordPress Configuration

Essential Plugins Installation

# Using WP-CLI
wp plugin install --activate \
  wp-graphql \
  wp-graphql-acf \
  wp-gatsby \
  wordpress-seo \
  custom-post-type-ui \
  advanced-custom-fields

WPGraphQL Configuration

// functions.php - Extend GraphQL schema
add_action('graphql_register_types', function() {
    // Add custom fields to Post type
    register_graphql_field('Post', 'readingTime', [
        'type' => 'String',
        'description' => 'Estimated reading time',
        'resolve' => function($post) {
            $content = get_post_field('post_content', $post->ID);
            $word_count = str_word_count(strip_tags($content));
            $reading_time = ceil($word_count / 200); // 200 words per minute
            return $reading_time . ' min read';
        }
    ]);

    // Add view count
    register_graphql_field('Post', 'viewCount', [
        'type' => 'Int',
        'description' => 'Number of views',
        'resolve' => function($post) {
            return (int) get_post_meta($post->ID, 'view_count', true) ?: 0;
        }
    ]);

    // Add related posts
    register_graphql_field('Post', 'relatedPosts', [
        'type' => ['list_of' => 'Post'],
        'description' => 'Related posts based on categories',
        'args' => [
            'limit' => [
                'type' => 'Int',
                'defaultValue' => 3
            ]
        ],
        'resolve' => function($post, $args) {
            $categories = wp_get_post_categories($post->ID);

            $related = get_posts([
                'category__in' => $categories,
                'numberposts' => $args['limit'],
                'post__not_in' => [$post->ID],
                'orderby' => 'rand'
            ]);

            return $related;
        }
    ]);
});

// Enable preview support
add_action('init', function() {
    add_filter('preview_post_link', function($link, $post) {
        $frontend_url = 'https://yourblog.com';
        return add_query_arg([
            'secret' => 'your-preview-secret',
            'id' => $post->ID,
            'slug' => $post->post_name,
            'status' => $post->post_status
        ], $frontend_url . '/api/preview');
    }, 10, 2);
});

// CORS configuration for development
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: Content-Type, Authorization');

    if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
        exit(0);
    }
});

Custom Post Types and Taxonomies

// Create 'Tutorial' post type for technical content
function create_tutorial_post_type() {
    register_post_type('tutorial', [
        'labels' => [
            'name' => 'Tutorials',
            'singular_name' => 'Tutorial',
            'add_new' => 'Add New Tutorial',
            'edit_item' => 'Edit Tutorial',
            'view_item' => 'View Tutorial',
        ],
        'public' => true,
        'has_archive' => true,
        'show_in_rest' => true,
        'show_in_graphql' => true,
        'graphql_single_name' => 'tutorial',
        'graphql_plural_name' => 'tutorials',
        'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
        'menu_icon' => 'dashicons-welcome-learn-more',
    ]);

    // Tutorial difficulty taxonomy
    register_taxonomy('difficulty', 'tutorial', [
        'labels' => [
            'name' => 'Difficulty Levels',
            'singular_name' => 'Difficulty',
        ],
        'hierarchical' => true,
        'show_in_rest' => true,
        'show_in_graphql' => true,
        'graphql_single_name' => 'difficulty',
        'graphql_plural_name' => 'difficulties',
    ]);
}
add_action('init', 'create_tutorial_post_type');

Next.js App Creation

GraphQL Client Setup

// src/lib/apollo-client.ts
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';

const httpLink = createHttpLink({
  uri: process.env.WORDPRESS_GRAPHQL_URL,
});

const authLink = setContext((_, { headers }) => {
  return {
    headers: {
      ...headers,
      authorization: process.env.WORDPRESS_AUTH_TOKEN
        ? `Bearer ${process.env.WORDPRESS_AUTH_TOKEN}`
        : '',
    },
  };
});

export const apolloClient = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache(),
  defaultOptions: {
    query: {
      fetchPolicy: 'no-cache',
    },
  },
});

// Alternative: Using graphql-request for simpler needs
import { GraphQLClient } from 'graphql-request';

export const graphqlClient = new GraphQLClient(process.env.WORDPRESS_GRAPHQL_URL!, {
  headers: {
    authorization: process.env.WORDPRESS_AUTH_TOKEN
      ? `Bearer ${process.env.WORDPRESS_AUTH_TOKEN}`
      : '',
  },
});

Type Definitions

// src/types/wordpress.ts
export interface Post {
  id: string;
  databaseId: number;
  title: string;
  slug: string;
  content: string;
  excerpt: string;
  date: string;
  modified: string;
  readingTime: string;
  viewCount: number;
  featuredImage?: {
    node: {
      sourceUrl: string;
      altText: string;
      mediaDetails: {
        width: number;
        height: number;
      };
    };
  };
  author: {
    node: {
      name: string;
      description: string;
      avatar: {
        url: string;
      };
    };
  };
  categories: {
    nodes: Array<{
      id: string;
      name: string;
      slug: string;
    }>;
  };
  tags: {
    nodes: Array<{
      id: string;
      name: string;
      slug: string;
    }>;
  };
  seo: {
    title: string;
    metaDesc: string;
    canonical: string;
    opengraphTitle: string;
    opengraphDescription: string;
    opengraphImage?: {
      sourceUrl: string;
    };
  };
}

export interface PageInfo {
  hasNextPage: boolean;
  hasPreviousPage: boolean;
  startCursor: string;
  endCursor: string;
}

export interface PostEdge {
  cursor: string;
  node: Post;
}

export interface PostsResponse {
  posts: {
    edges: PostEdge[];
    pageInfo: PageInfo;
  };
}

GraphQL Queries

// src/lib/queries.ts
import { gql } from '@apollo/client';

export const POST_FIELDS = gql`
  fragment PostFields on Post {
    id
    databaseId
    title
    slug
    excerpt
    date
    modified
    readingTime
    viewCount
    featuredImage {
      node {
        sourceUrl
        altText
        mediaDetails {
          width
          height
        }
      }
    }
    author {
      node {
        name
        description
        avatar {
          url
        }
      }
    }
    categories {
      nodes {
        id
        name
        slug
      }
    }
  }
`;

export const GET_ALL_POSTS = gql`
  ${POST_FIELDS}
  query GetAllPosts($first: Int!, $after: String) {
    posts(first: $first, after: $after, where: { status: PUBLISH, orderby: { field: DATE, order: DESC } }) {
      edges {
        cursor
        node {
          ...PostFields
        }
      }
      pageInfo {
        hasNextPage
        hasPreviousPage
        startCursor
        endCursor
      }
    }
  }
`;

export const GET_POST_BY_SLUG = gql`
  query GetPostBySlug($slug: ID!) {
    post(id: $slug, idType: SLUG) {
      id
      databaseId
      title
      content
      slug
      date
      modified
      readingTime
      viewCount
      featuredImage {
        node {
          sourceUrl
          altText
          mediaDetails {
            width
            height
          }
        }
      }
      author {
        node {
          name
          description
          avatar {
            url
          }
        }
      }
      categories {
        nodes {
          id
          name
          slug
        }
      }
      tags {
        nodes {
          id
          name
          slug
        }
      }
      seo {
        title
        metaDesc
        canonical
        opengraphTitle
        opengraphDescription
        opengraphImage {
          sourceUrl
        }
      }
      relatedPosts {
        id
        title
        slug
        featuredImage {
          node {
            sourceUrl
          }
        }
      }
    }
  }
`;

export const GET_POSTS_BY_CATEGORY = gql`
  ${POST_FIELDS}
  query GetPostsByCategory($slug: String!, $first: Int!, $after: String) {
    posts(
      first: $first
      after: $after
      where: { 
        status: PUBLISH, 
        categoryName: $slug,
        orderby: { field: DATE, order: DESC }
      }
    ) {
      edges {
        cursor
        node {
          ...PostFields
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

Data Fetching with SWR/React Query

SWR Implementation

// src/hooks/usePosts.ts
import useSWR from 'swr';
import { graphqlClient } from '@/lib/apollo-client';
import { GET_ALL_POSTS } from '@/lib/queries';
import { PostsResponse } from '@/types/wordpress';

const fetcher = (query: string, variables?: any) =>
  graphqlClient.request(query, variables);

export function usePosts(pageSize = 10) {
  const { data, error, isLoading, mutate } = useSWR<PostsResponse>(
    ['posts', pageSize],
    () => fetcher(GET_ALL_POSTS, { first: pageSize }),
    {
      revalidateOnFocus: false,
      revalidateOnReconnect: false,
    }
  );

  return {
    posts: data?.posts.edges.map(edge => edge.node) || [],
    pageInfo: data?.posts.pageInfo,
    isLoading,
    isError: error,
    mutate,
  };
}

// Infinite scrolling hook
import useSWRInfinite from 'swr/infinite';

export function useInfinitePosts(pageSize = 10) {
  const getKey = (pageIndex: number, previousPageData: PostsResponse | null) => {
    if (previousPageData && !previousPageData.posts.pageInfo.hasNextPage) {
      return null;
    }

    if (pageIndex === 0) {
      return [GET_ALL_POSTS, { first: pageSize }];
    }

    return [
      GET_ALL_POSTS,
      {
        first: pageSize,
        after: previousPageData?.posts.pageInfo.endCursor,
      },
    ];
  };

  const { data, error, size, setSize, isValidating } = useSWRInfinite<PostsResponse>(
    getKey,
    ([query, variables]) => fetcher(query, variables),
    {
      revalidateFirstPage: false,
    }
  );

  const posts = data?.flatMap(page => page.posts.edges.map(edge => edge.node)) || [];
  const isLoadingInitialData = !data && !error;
  const isLoadingMore =
    isLoadingInitialData ||
    (size > 0 && data && typeof data[size - 1] === 'undefined');
  const isEmpty = data?.[0]?.posts.edges.length === 0;
  const isReachingEnd =
    isEmpty || (data && !data[data.length - 1]?.posts.pageInfo.hasNextPage);

  return {
    posts,
    error,
    isLoadingMore,
    size,
    setSize,
    isReachingEnd,
    isValidating,
  };
}

React Query Alternative

// src/hooks/usePostsQuery.ts
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { apolloClient } from '@/lib/apollo-client';
import { GET_ALL_POSTS, GET_POST_BY_SLUG } from '@/lib/queries';

export function usePostsQuery(pageSize = 10) {
  return useQuery({
    queryKey: ['posts', pageSize],
    queryFn: async () => {
      const { data } = await apolloClient.query({
        query: GET_ALL_POSTS,
        variables: { first: pageSize },
      });
      return data.posts;
    },
    staleTime: 5 * 60 * 1000, // 5 minutes
    cacheTime: 10 * 60 * 1000, // 10 minutes
  });
}

export function useInfinitePostsQuery(pageSize = 10) {
  return useInfiniteQuery({
    queryKey: ['posts', 'infinite', pageSize],
    queryFn: async ({ pageParam = null }) => {
      const { data } = await apolloClient.query({
        query: GET_ALL_POSTS,
        variables: {
          first: pageSize,
          after: pageParam,
        },
      });
      return data.posts;
    },
    getNextPageParam: (lastPage) =>
      lastPage.pageInfo.hasNextPage ? lastPage.pageInfo.endCursor : undefined,
    staleTime: 5 * 60 * 1000,
  });
}

export function usePostQuery(slug: string) {
  return useQuery({
    queryKey: ['post', slug],
    queryFn: async () => {
      const { data } = await apolloClient.query({
        query: GET_POST_BY_SLUG,
        variables: { slug },
      });
      return data.post;
    },
    enabled: !!slug,
    staleTime: 5 * 60 * 1000,
  });
}

Static Generation Setup

Homepage with ISR

// src/app/page.tsx
import { Metadata } from 'next';
import { apolloClient } from '@/lib/apollo-client';
import { GET_ALL_POSTS } from '@/lib/queries';
import { PostGrid } from '@/components/PostGrid';
import { Hero } from '@/components/Hero';
import { Newsletter } from '@/components/Newsletter';

export const metadata: Metadata = {
  title: 'My Tech Blog - Latest Articles on Web Development',
  description: 'Discover the latest tutorials, tips, and insights on web development, JavaScript, React, and more.',
  openGraph: {
    title: 'My Tech Blog',
    description: 'Latest articles on web development',
    type: 'website',
  },
};

async function getFeaturedPosts() {
  const { data } = await apolloClient.query({
    query: GET_ALL_POSTS,
    variables: { first: 6 },
  });

  return data.posts.edges.map((edge: any) => edge.node);
}

export const revalidate = 3600; // Revalidate every hour

export default async function HomePage() {
  const posts = await getFeaturedPosts();

  return (
    <>
      <Hero />

      <section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
        <div className="text-center mb-12">
          <h2 className="text-3xl font-bold text-gray-900 sm:text-4xl">
            Latest Articles
          </h2>
          <p className="mt-3 text-xl text-gray-600">
            Thoughts, tutorials, and insights on modern web development
          </p>
        </div>

        <PostGrid posts={posts} />

        <div className="mt-12 text-center">
          <a
            href="/blog"
            className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
          >
            View All Posts
            <svg className="ml-2 -mr-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
              <path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" />
            </svg>
          </a>
        </div>
      </section>

      <Newsletter />
    </>
  );
}

Blog Archive Page

// src/app/blog/page.tsx
import { Metadata } from 'next';
import { apolloClient } from '@/lib/apollo-client';
import { GET_ALL_POSTS } from '@/lib/queries';
import { PostList } from '@/components/PostList';
import { Pagination } from '@/components/Pagination';

export const metadata: Metadata = {
  title: 'Blog - All Articles',
  description: 'Browse all articles on web development, JavaScript, React, and more.',
};

interface BlogPageProps {
  searchParams: {
    page?: string;
  };
}

const POSTS_PER_PAGE = 12;

async function getPosts(page: number) {
  const { data } = await apolloClient.query({
    query: GET_ALL_POSTS,
    variables: {
      first: POSTS_PER_PAGE,
      after: page > 1 ? btoa(`arrayconnection:${(page - 1) * POSTS_PER_PAGE - 1}`) : null,
    },
  });

  return {
    posts: data.posts.edges.map((edge: any) => edge.node),
    pageInfo: data.posts.pageInfo,
  };
}

export default async function BlogPage({ searchParams }: BlogPageProps) {
  const currentPage = Number(searchParams.page) || 1;
  const { posts, pageInfo } = await getPosts(currentPage);

  return (
    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
      <div className="text-center mb-12">
        <h1 className="text-4xl font-bold text-gray-900 sm:text-5xl">
          All Articles
        </h1>
        <p className="mt-3 text-xl text-gray-600">
          Explore our complete collection of tutorials and insights
        </p>
      </div>

      <PostList posts={posts} />

      {(pageInfo.hasNextPage || currentPage > 1) && (
        <Pagination
          currentPage={currentPage}
          hasNextPage={pageInfo.hasNextPage}
          basePath="/blog"
        />
      )}
    </div>
  );
}

Dynamic Routing

Individual Post Pages

// src/app/blog/[slug]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { apolloClient } from '@/lib/apollo-client';
import { GET_POST_BY_SLUG, GET_ALL_POSTS } from '@/lib/queries';
import { PostContent } from '@/components/PostContent';
import { PostSidebar } from '@/components/PostSidebar';
import { RelatedPosts } from '@/components/RelatedPosts';
import { Comments } from '@/components/Comments';

interface PostPageProps {
  params: {
    slug: string;
  };
}

// Generate static params for all posts
export async function generateStaticParams() {
  const { data } = await apolloClient.query({
    query: GET_ALL_POSTS,
    variables: { first: 100 }, // Adjust based on your needs
  });

  return data.posts.edges.map((edge: any) => ({
    slug: edge.node.slug,
  }));
}

// Generate metadata for SEO
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
  const { data } = await apolloClient.query({
    query: GET_POST_BY_SLUG,
    variables: { slug: params.slug },
  });

  const post = data?.post;

  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,
      type: 'article',
      publishedTime: post.date,
      modifiedTime: post.modified,
      authors: [post.author.node.name],
      images: post.seo?.opengraphImage?.sourceUrl
        ? [{ url: post.seo.opengraphImage.sourceUrl }]
        : post.featuredImage
        ? [{ url: post.featuredImage.node.sourceUrl }]
        : [],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.seo?.title || post.title,
      description: post.seo?.metaDesc || post.excerpt,
    },
  };
}

export const revalidate = 3600; // Revalidate every hour

export default async function PostPage({ params }: PostPageProps) {
  const { data } = await apolloClient.query({
    query: GET_POST_BY_SLUG,
    variables: { slug: params.slug },
  });

  const post = data?.post;

  if (!post) {
    notFound();
  }

  // Track view count
  await fetch(`${process.env.WORDPRESS_API_URL}/wp-json/custom/v1/track-view`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ postId: post.databaseId }),
  });

  return (
    <>
      <article className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
        <div className="lg:grid lg:grid-cols-3 lg:gap-8">
          <div className="lg:col-span-2">
            <PostContent post={post} />
            <RelatedPosts posts={post.relatedPosts} />
            <Comments postId={post.databaseId} />
          </div>

          <aside className="mt-8 lg:mt-0">
            <PostSidebar post={post} />
          </aside>
        </div>
      </article>
    </>
  );
}

Category Archive Pages

// src/app/category/[slug]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { apolloClient } from '@/lib/apollo-client';
import { GET_POSTS_BY_CATEGORY, GET_CATEGORY } from '@/lib/queries';
import { PostGrid } from '@/components/PostGrid';

interface CategoryPageProps {
  params: {
    slug: string;
  };
}

export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> {
  const { data } = await apolloClient.query({
    query: GET_CATEGORY,
    variables: { slug: params.slug },
  });

  const category = data?.category;

  if (!category) {
    return {
      title: 'Category Not Found',
    };
  }

  return {
    title: `${category.name} - Articles and Tutorials`,
    description: category.description || `Browse all ${category.name} articles`,
  };
}

export default async function CategoryPage({ params }: CategoryPageProps) {
  const [categoryData, postsData] = await Promise.all([
    apolloClient.query({
      query: GET_CATEGORY,
      variables: { slug: params.slug },
    }),
    apolloClient.query({
      query: GET_POSTS_BY_CATEGORY,
      variables: { slug: params.slug, first: 20 },
    }),
  ]);

  const category = categoryData.data?.category;
  const posts = postsData.data?.posts.edges.map((edge: any) => edge.node) || [];

  if (!category) {
    notFound();
  }

  return (
    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
      <div className="text-center mb-12">
        <h1 className="text-4xl font-bold text-gray-900">
          {category.name}
        </h1>
        {category.description && (
          <p className="mt-3 text-xl text-gray-600">
            {category.description}
          </p>
        )}
      </div>

      <PostGrid posts={posts} />
    </div>
  );
}

Deployment Guide

Vercel Deployment

// vercel.json
{
  "functions": {
    "app/api/preview.ts": {
      "maxDuration": 10
    },
    "app/api/revalidate.ts": {
      "maxDuration": 10
    }
  },
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        },
        {
          "key": "X-Frame-Options",
          "value": "DENY"
        },
        {
          "key": "X-XSS-Protection",
          "value": "1; mode=block"
        }
      ]
    },
    {
      "source": "/api/:path*",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "no-store, max-age=0"
        }
      ]
    }
  ],
  "rewrites": [
    {
      "source": "/sitemap.xml",
      "destination": "/api/sitemap"
    },
    {
      "source": "/feed.xml",
      "destination": "/api/feed"
    }
  ]
}

Environment Variables on Vercel

# Set via Vercel CLI
vercel env add WORDPRESS_API_URL
vercel env add WORDPRESS_GRAPHQL_URL
vercel env add NEXT_PUBLIC_SITE_URL
vercel env add REVALIDATE_SECRET

# Or via Vercel Dashboard
# Project Settings > Environment Variables

Preview Mode Implementation

// src/app/api/preview/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);

  const secret = searchParams.get('secret');
  const id = searchParams.get('id');
  const slug = searchParams.get('slug');
  const postType = searchParams.get('post_type') || 'post';

  // Validate secret
  if (secret !== process.env.PREVIEW_SECRET) {
    return NextResponse.json({ message: 'Invalid token' }, { status: 401 });
  }

  // Validate required params
  if (!id || !slug) {
    return NextResponse.json({ message: 'Missing parameters' }, { status: 400 });
  }

  // Enable Draft Mode
  draftMode().enable();

  // Redirect to the post
  const path = postType === 'page' ? `/${slug}` : `/blog/${slug}`;
  redirect(path);
}

// src/app/api/exit-preview/route.ts
export async function GET() {
  draftMode().disable();
  redirect('/');
}

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 body = await request.json();
  const { secret, path, tag } = body;

  // Validate secret
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
  }

  try {
    if (path) {
      revalidatePath(path);
      return NextResponse.json({ revalidated: true, path });
    }

    if (tag) {
      revalidateTag(tag);
      return NextResponse.json({ revalidated: true, tag });
    }

    return NextResponse.json({ message: 'Missing path or tag' }, { status: 400 });
  } catch (err) {
    return NextResponse.json({ message: 'Error revalidating' }, { status: 500 });
  }
}

// WordPress webhook on post update
add_action('save_post', function($post_id, $post) {
  if ($post->post_status !== 'publish') {
    return;
  }

  $revalidate_url = 'https://yourblog.com/api/revalidate';

  wp_remote_post($revalidate_url, [
    'body' => json_encode([
      'secret' => 'your-revalidate-secret',
      'path' => '/blog/' . $post->post_name,
    ]),
    'headers' => [
      'Content-Type' => 'application/json',
    ],
  ]);
}, 10, 2);

Performance Optimization

Image Optimization

// src/components/OptimizedImage.tsx
import Image from 'next/image';
import { useState } from 'react';

interface OptimizedImageProps {
  src: string;
  alt: string;
  width: number;
  height: number;
  priority?: boolean;
  className?: string;
  sizes?: string;
}

export function OptimizedImage({
  src,
  alt,
  width,
  height,
  priority = false,
  className = '',
  sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw',
}: OptimizedImageProps) {
  const [isLoading, setLoading] = useState(true);

  return (
    <div className={`relative overflow-hidden ${className}`}>
      <Image
        src={src}
        alt={alt}
        width={width}
        height={height}
        priority={priority}
        sizes={sizes}
        quality={85}
        placeholder="blur"
        blurDataURL={`/_next/image?url=${encodeURIComponent(src)}&w=16&q=1`}
        className={`
          duration-700 ease-in-out
          ${isLoading ? 'scale-110 blur-2xl grayscale' : 'scale-100 blur-0 grayscale-0'}
        `}
        onLoadingComplete={() => setLoading(false)}
      />
    </div>
  );
}

// Usage in PostCard
<OptimizedImage
  src={post.featuredImage.node.sourceUrl}
  alt={post.featuredImage.node.altText || post.title}
  width={post.featuredImage.node.mediaDetails.width}
  height={post.featuredImage.node.mediaDetails.height}
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>

Code Splitting and Lazy Loading

// src/components/Comments.tsx
import dynamic from 'next/dynamic';
import { useState } from 'react';

const CommentForm = dynamic(() => import('./CommentForm'), {
  loading: () => <div className="animate-pulse h-32 bg-gray-200 rounded" />,
});

const CommentList = dynamic(() => import('./CommentList'), {
  loading: () => <div className="animate-pulse space-y-4">
    {[...Array(3)].map((_, i) => (
      <div key={i} className="h-20 bg-gray-200 rounded" />
    ))}
  </div>,
});

export function Comments({ postId }: { postId: number }) {
  const [showComments, setShowComments] = useState(false);

  return (
    <section className="mt-12">
      <h2 className="text-2xl font-bold mb-6">Comments</h2>

      {!showComments ? (
        <button
          onClick={() => setShowComments(true)}
          className="w-full py-4 bg-gray-100 hover:bg-gray-200 rounded-lg"
        >
          Load Comments
        </button>
      ) : (
        <>
          <CommentForm postId={postId} />
          <CommentList postId={postId} />
        </>
      )}
    </section>
  );
}

SEO Component

// src/components/SEO.tsx
import { NextSeo, ArticleJsonLd, BreadcrumbJsonLd } from 'next-seo';

interface SEOProps {
  post: Post;
  url: string;
}

export function SEO({ post, url }: SEOProps) {
  const publishedTime = new Date(post.date).toISOString();
  const modifiedTime = new Date(post.modified).toISOString();

  return (
    <>
      <NextSeo
        title={post.seo?.title || post.title}
        description={post.seo?.metaDesc || post.excerpt}
        canonical={post.seo?.canonical || url}
        openGraph={{
          title: post.seo?.opengraphTitle || post.title,
          description: post.seo?.opengraphDescription || post.excerpt,
          url,
          type: 'article',
          article: {
            publishedTime,
            modifiedTime,
            authors: [post.author.node.name],
            tags: post.tags.nodes.map(tag => tag.name),
          },
          images: post.featuredImage
            ? [
                {
                  url: post.featuredImage.node.sourceUrl,
                  width: post.featuredImage.node.mediaDetails.width,
                  height: post.featuredImage.node.mediaDetails.height,
                  alt: post.featuredImage.node.altText || post.title,
                },
              ]
            : [],
        }}
      />

      <ArticleJsonLd
        type="BlogPosting"
        url={url}
        title={post.title}
        images={post.featuredImage ? [post.featuredImage.node.sourceUrl] : []}
        datePublished={publishedTime}
        dateModified={modifiedTime}
        authorName={post.author.node.name}
        description={post.excerpt}
      />

      <BreadcrumbJsonLd
        itemListElements={[
          {
            position: 1,
            name: 'Home',
            item: 'https://yourblog.com',
          },
          {
            position: 2,
            name: 'Blog',
            item: 'https://yourblog.com/blog',
          },
          {
            position: 3,
            name: post.title,
            item: url,
          },
        ]}
      />
    </>
  );
}

Performance Monitoring

// src/app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics />
        <SpeedInsights />
      </body>
    </html>
  );
}

// Custom performance monitoring
// src/hooks/usePerformance.ts
import { useEffect } from 'react';

export function usePerformance() {
  useEffect(() => {
    // Core Web Vitals
    if ('web-vitals' in window) {
      import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
        getCLS(console.log);
        getFID(console.log);
        getFCP(console.log);
        getLCP(console.log);
        getTTFB(console.log);
      });
    }

    // Navigation timing
    window.addEventListener('load', () => {
      const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;

      const metrics = {
        dns: navigation.domainLookupEnd - navigation.domainLookupStart,
        tcp: navigation.connectEnd - navigation.connectStart,
        ttfb: navigation.responseStart - navigation.requestStart,
        download: navigation.responseEnd - navigation.responseStart,
        domComplete: navigation.domComplete - navigation.fetchStart,
      };

      console.log('Performance metrics:', metrics);

      // Send to analytics
      if (window.gtag) {
        window.gtag('event', 'page_timing', {
          event_category: 'Performance',
          event_label: 'Page Load',
          value: Math.round(metrics.domComplete),
        });
      }
    });
  }, []);
}

Production Checklist

Pre-Launch Checklist

## Technical
- [ ] All environment variables set in production
- [ ] HTTPS enabled on WordPress backend
- [ ] CORS properly configured
- [ ] API rate limiting implemented
- [ ] Error tracking (Sentry) configured
- [ ] Analytics installed
- [ ] Performance monitoring active

## SEO
- [ ] Sitemap generated and submitted
- [ ] Robots.txt configured
- [ ] Meta tags on all pages
- [ ] Structured data implemented
- [ ] Open Graph images optimized
- [ ] Canonical URLs set

## Performance
- [ ] Images optimized and lazy loaded
- [ ] Code splitting implemented
- [ ] Static generation for all possible pages
- [ ] CDN configured
- [ ] Caching headers set
- [ ] Bundle size optimized

## Content
- [ ] 404 page designed
- [ ] Error boundary implemented
- [ ] Loading states for all components
- [ ] Accessibility tested
- [ ] Mobile responsiveness verified
- [ ] Cross-browser testing complete

Conclusion

Building a headless WordPress blog with Next.js combines the best of both worlds: WordPress's powerful content management with Next.js's modern development experience and blazing-fast performance. The architecture we've built provides:

Key takeaways: - Use static generation wherever possible for optimal performance - Implement proper caching strategies at all levels - Optimize images and implement lazy loading - Monitor performance metrics continuously - Keep the WordPress backend secure and optimized

This foundation can be extended with features like: - User authentication and personalization - Advanced search with Algolia - Newsletter integration - E-commerce capabilities - Multi-language support

The headless approach gives you the freedom to create exactly the experience you want while maintaining the familiar WordPress editing experience your content team loves.


Ready to explore more headless WordPress possibilities? Check out our comprehensive Headless WordPress Guide for advanced patterns and integrations.