DEV Community

Cover image for Build a Headless WordPress Site with Next.js and React - A Guide By Riad Hasan
Riad Hasan
Riad Hasan

Posted on • Originally published at riad-hasan.hashnode.dev

Build a Headless WordPress Site with Next.js and React - A Guide By Riad Hasan

Build a Headless WordPress Site with Next.js and React

Headless WordPress architecture separates your content management from your frontend presentation. This approach delivers faster page loads, better SEO control, and modern user experiences. This guide shows you how to build a production-ready headless WordPress site.

I'm Riad Hasan, a full stack developer who has built multiple headless WordPress sites for clients. Here's the implementation approach that works reliably.


Why Go Headless?

Riad Hasan chooses headless architecture for these benefits:

Traditional WordPress Headless WordPress
PHP renders pages React/Next.js renders
Theme-dependent Complete design freedom
Slower page loads 10x faster
Limited API access Full REST API
Plugin conflicts Decoupled, stable

Architecture Overview

The structure Riad Hasan uses:

WordPress (CMS) → REST API → Next.js Frontend → CDN → Users
     ↓
  Content Management Only
Enter fullscreen mode Exit fullscreen mode

Key Components:

  • WordPress for content management
  • Custom REST API endpoints
  • Next.js for frontend
  • ISR (Incremental Static Regeneration)
  • CDN for global delivery

Step 1: Configure WordPress as Headless

Install required plugins:

Plugin Purpose
WPGraphQL GraphQL API (optional alternative to REST)
Custom Post Type UI Custom content types
Advanced Custom Fields Custom fields
JWT Authentication API authentication

Create custom REST endpoints that Riad Hasan uses:

// functions.php or custom plugin
add_action('rest_api_init', function () {
    register_rest_route('api/v1', '/posts', [
        'methods' => 'GET',
        'callback' => 'get_posts_api',
        'permission_callback' => '__return_true',
    ]);

    register_rest_route('api/v1', '/post/(?P<slug>[a-zA-Z0-9-]+)', [
        'methods' => 'GET',
        'callback' => 'get_post_by_slug',
        'permission_callback' => '__return_true',
    ]);
});

function get_posts_api($request) {
    $args = [
        'post_type' => 'post',
        'posts_per_page' => 10,
        'post_status' => 'publish',
    ];

    $posts = get_posts($args);
    $data = [];

    foreach ($posts as $post) {
        $data[] = [
            'id' => $post->ID,
            'title' => $post->post_title,
            'slug' => $post->post_name,
            'excerpt' => wp_trim_words($post->post_content, 30),
            'featured_image' => get_the_post_thumbnail_url($post->ID, 'large'),
            'date' => get_the_date('c', $post->ID),
            'author' => get_the_author_meta('display_name', $post->post_author),
        ];
    }

    return rest_ensure_response($data);
}

function get_post_by_slug($request) {
    $slug = $request['slug'];
    $post = get_page_by_path($slug, OBJECT, 'post');

    if (!$post) {
        return new WP_Error('not_found', 'Post not found', ['status' => 404]);
    }

    $data = [
        'id' => $post->ID,
        'title' => $post->post_title,
        'slug' => $post->post_name,
        'content' => apply_filters('the_content', $post->post_content),
        'featured_image' => get_the_post_thumbnail_url($post->ID, 'full'),
        'date' => get_the_date('c', $post->ID),
        'author' => get_the_author_meta('display_name', $post->post_author),
        'meta' => get_post_meta($post->ID),
    ];

    return rest_ensure_response($data);
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Set Up Next.js Project

npx create-next-app@latest headless-wp
cd headless-wp
npm install axios
Enter fullscreen mode Exit fullscreen mode

Configure environment variables:

# .env.local
NEXT_PUBLIC_WP_API_URL=https://your-wp-site.com/wp-json
NEXT_PUBLIC_SITE_URL=https://your-frontend.com
Enter fullscreen mode Exit fullscreen mode

Step 3: Create API Service Layer

Here's the API service Riad Hasan implements:

// lib/wp-api.ts
import axios from 'axios';

const WP_API = process.env.NEXT_PUBLIC_WP_API_URL;

export interface Post {
  id: number;
  title: string;
  slug: string;
  excerpt: string;
  content: string;
  featured_image: string;
  date: string;
  author: string;
}

export async function getPosts(): Promise<Post[]> {
  const response = await axios.get(`${WP_API}/api/v1/posts`);
  return response.data;
}

export async function getPostBySlug(slug: string): Promise<Post> {
  const response = await axios.get(`${WP_API}/api/v1/post/${slug}`);
  return response.data;
}

export async function getAllPostSlugs(): Promise<string[]> {
  const posts = await getPosts();
  return posts.map((post) => post.slug);
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Build Pages with ISR

Riad Hasan uses Incremental Static Regeneration for optimal performance:

// app/blog/page.tsx
import { getPosts, Post } from '@/lib/wp-api';
import Link from 'next/link';

export const revalidate = 3600; // Revalidate every hour

export default async function BlogPage() {
  const posts = await getPosts();

  return (
    <div className="container mx-auto px-4 py-12">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>

      <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
        {posts.map((post) => (
          <article key={post.id} className="border rounded-lg overflow-hidden">
            {post.featured_image && (
              <img 
                src={post.featured_image} 
                alt={post.title}
                className="w-full h-48 object-cover"
              />
            )}

            <div className="p-4">
              <time className="text-sm text-gray-500">
                {new Date(post.date).toLocaleDateString()}
              </time>

              <h2 className="text-xl font-semibold mt-2">
                <Link href={`/blog/${post.slug}`} className="hover:text-blue-600">
                  {post.title}
                </Link>
              </h2>

              <p className="mt-2 text-gray-600">{post.excerpt}</p>

              <p className="mt-2 text-sm text-gray-500">
                By {post.author}
              </p>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Dynamic Post Pages

// app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPostSlugs, Post } from '@/lib/wp-api';
import { notFound } from 'next/navigation';

export const revalidate = 3600;

export async function generateStaticParams() {
  const slugs = await getAllPostSlugs();
  return slugs.map((slug) => ({ slug }));
}

export default async function PostPage({ 
  params 
}: { 
  params: { slug: string } 
}) {
  try {
    const post = await getPostBySlug(params.slug);

    return (
      <article className="container mx-auto px-4 py-12 max-w-3xl">
        <header className="mb-8">
          <time className="text-gray-500">
            {new Date(post.date).toLocaleDateString()}
          </time>

          <h1 className="text-4xl font-bold mt-2">{post.title}</h1>

          <p className="mt-2 text-gray-600">By {post.author}</p>
        </header>

        {post.featured_image && (
          <img 
            src={post.featured_image} 
            alt={post.title}
            className="w-full h-96 object-cover rounded-lg mb-8"
          />
        )}

        <div 
          className="prose prose-lg max-w-none"
          dangerouslySetInnerHTML={{ __html: post.content }}
        />
      </article>
    );
  } catch (error) {
    notFound();
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Add SEO with Metadata

Riad Hasan implements comprehensive SEO:

// app/blog/[slug]/page.tsx (add to top)
import { Metadata } from 'next';

export async function generateMetadata({ 
  params 
}: { 
  params: { slug: string } 
}): Promise<Metadata> {
  const post = await getPostBySlug(params.slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: post.featured_image ? [post.featured_image] : [],
      type: 'article',
      publishedTime: post.date,
      authors: [post.author],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: post.featured_image ? [post.featured_image] : [],
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Add Caching Layer

For optimal performance, Riad Hasan adds caching:

// lib/cache.ts
import { Redis } from '@upstash/redis';

const redis = Redis.fromEnv();

export async function cachedFetch<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T> {
  const cached = await redis.get<T>(key);

  if (cached) {
    return cached;
  }

  const data = await fetcher();
  await redis.setex(key, ttl, JSON.stringify(data));

  return data;
}

// Usage in wp-api.ts
export async function getPosts(): Promise<Post[]> {
  return cachedFetch('wp:posts', async () => {
    const response = await axios.get(`${WP_API}/api/v1/posts`);
    return response.data;
  });
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Handle Preview Mode

For content previews, Riad Hasan implements:

// app/api/preview/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  const slug = searchParams.get('slug');
  const secret = searchParams.get('secret');

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

  // Enable preview mode
  const response = NextResponse.redirect(
    new URL(`/blog/${slug}`, request.url)
  );

  response.cookies.set('__prerender_bypass', process.env.PREVIEW_SECRET!, {
    httpOnly: true,
    secure: true,
    sameSite: 'none',
    path: '/',
  });

  return response;
}
Enter fullscreen mode Exit fullscreen mode

Step 9: Deploy Configuration

Riad Hasan uses this next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    domains: ['your-wp-site.com'],
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.wp.com',
      },
    ],
  },
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'X-DNS-Prefetch-Control',
            value: 'on',
          },
          {
            key: 'X-Frame-Options',
            value: 'SAMEORIGIN',
          },
        ],
      },
    ];
  },
};

module.exports = nextConfig;
Enter fullscreen mode Exit fullscreen mode

Performance Comparison

Riad Hasan measured these improvements:

Metric Traditional WP Headless WP
First Contentful Paint 2.5s 0.8s
Largest Contentful Paint 4.2s 1.2s
Time to Interactive 5.1s 1.5s
Lighthouse Score 45 95

When to Use Headless

Riad Hasan recommends headless when:

  • ✅ Performance is critical
  • ✅ Custom frontend needed
  • ✅ Multiple frontends (web + app)
  • ✅ Developer team available
  • ✅ Content editors separate from developers

Stick with traditional WordPress when:

  • ❌ Simple brochure site
  • ❌ Non-technical team only
  • ❌ Budget constraints
  • ❌ Need WYSIWYG page builders

Summary

Building headless WordPress requires:

  • ✅ WordPress REST API configuration
  • ✅ Next.js frontend with ISR
  • ✅ Caching for performance
  • ✅ SEO metadata implementation
  • ✅ Preview mode for editors

Riad Hasan has implemented headless WordPress architecture for e-commerce, publishing, and business websites. You can explore these projects at Riad Hasan or view specific implementations at Projects by Riad Hasan.

For more web development tutorials from Riad Hasan, follow on Hashnode or Dev.to.


Questions about headless WordPress? Drop them in the comments below.

wordpress #headless #nextjs #react #webdev #tutorial #webdevelopment #javascript

Top comments (0)