
The traditional WordPress theme architecture — PHP templates rendering HTML on the server, jQuery animations in the front-end — served the web well for two decades. But in 2025, the expectations for web experiences have fundamentally changed. Users expect app-like interactivity, instant page transitions, and real-time updates. This is where the headless WordPress + React stack shines.
In this guide, we’ll build a production-grade headless WordPress application using Next.js 15, the WordPress REST API, and React 19. You’ll learn authentication, data fetching patterns, ISR caching, and the trade-offs you need to understand before choosing headless.
In a headless setup, WordPress serves exclusively as a content management system (CMS) and API backend. The frontend is a completely separate application — typically built with React, Next.js, Nuxt, Astro, or any other framework. WordPress exposes its data through:
/wp-json/wp/v2//graphqlThe benefits of going headless are significant:
Your Next.js frontend will make requests from a different domain (e.g., mysite.com) to your WordPress API (e.g., api.mysite.com). You need to configure CORS in WordPress to allow this:
// In your theme's functions.php or a mu-plugin
add_action('rest_api_init', function() {
remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
add_filter('rest_pre_serve_request', function($served) {
$allowed_origins = [
'https://mysite.com',
'https://www.mysite.com',
'http://localhost:3000', // Development
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed_origins, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Headers: Content-Type, X-WP-Nonce, Authorization');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
status_header(200);
exit();
}
return $served;
});
}, 15);
The built-in REST API is powerful but sometimes you need custom endpoints for specific use cases — like returning aggregated data, triggering custom logic, or exposing data from custom tables:
add_action('rest_api_init', function() {
register_rest_route('myapp/v1', '/featured-content', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'get_featured_content',
'permission_callback' => '__return_true',
'schema' => [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'featured-content',
'type' => 'object',
'properties' => [
'posts' => ['type' => 'array'],
'projects' => ['type' => 'array'],
],
],
]);
});
function get_featured_content(WP_REST_Request $request): WP_REST_Response {
$posts = get_posts([
'post_type' => 'post',
'posts_per_page' => 3,
'orderby' => 'date',
'order' => 'DESC',
]);
$response = [
'posts' => array_map('format_post_for_api', $posts),
'projects' => get_featured_projects(),
];
return new WP_REST_Response($response, 200, [
'Cache-Control' => 'public, max-age=3600',
]);
}
npx create-next-app@latest my-portfolio \
--typescript \
--tailwind \
--app \
--src-dir
cd my-portfolio
npm install graphql-request graphql
Create a typed API client that wraps all WordPress REST API calls. This centralizes your data fetching logic and makes it easy to swap from REST to GraphQL later:
// src/lib/wordpress.ts
const WP_API_BASE = process.env.WORDPRESS_API_URL || 'https://api.mysite.com/wp-json/wp/v2';
interface WPPost {
id: number;
slug: string;
title: { rendered: string };
content: { rendered: string };
excerpt: { rendered: string };
date: string;
modified: string;
featured_media: number;
_embedded?: {
'wp:featuredmedia'?: Array<{
source_url: string;
alt_text: string;
media_details: { width: number; height: number };
}>;
};
categories: number[];
tags: number[];
}
async function wpFetch(path: string, options?: RequestInit): Promise {
const url = `${WP_API_BASE}${path}`;
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
next: { revalidate: 3600 },
});
if (!res.ok) {
throw new Error(`WordPress API error: ${res.status} ${res.statusText} for ${url}`);
}
return res.json();
}
export async function getPosts(params?: {
perPage?: number;
page?: number;
category?: number;
search?: string;
}) {
const searchParams = new URLSearchParams({
per_page: String(params?.perPage ?? 10),
page: String(params?.page ?? 1),
_embed: 'wp:featuredmedia,author',
...(params?.category && { categories: String(params.category) }),
...(params?.search && { search: params.search }),
});
const posts = await wpFetch(`/posts?${searchParams}`);
return posts.map(transformPost);
}
export async function getPostBySlug(slug: string) {
const posts = await wpFetch(
`/posts?slug=${slug}&_embed=wp:featuredmedia,author`
);
return posts[0] ? transformPost(posts[0]) : null;
}
function transformPost(post: WPPost) {
return {
id: post.id,
slug: post.slug,
title: post.title.rendered,
content: post.content.rendered,
excerpt: post.excerpt.rendered.replace(/<[^>]+>/g, ''),
date: post.date,
modified: post.modified,
featuredImage: post._embedded?.['wp:featuredmedia']?.[0] ?? null,
};
}
// src/app/blog/page.tsx
import { getPosts } from '@/lib/wordpress';
import { PostCard } from '@/components/blog/post-card';
import type { Metadata } from 'next';
export const revalidate = 3600; // Revalidate every hour
export const metadata: Metadata = {
title: 'Blog – My Portfolio',
description: 'Articles on WordPress development, React, and modern web engineering.',
};
interface BlogPageProps {
searchParams: Promise<{ page?: string }>;
}
export default async function BlogPage({ searchParams }: BlogPageProps) {
const { page: pageParam } = await searchParams;
const page = parseInt(pageParam ?? '1', 10);
const posts = await getPosts({ perPage: 9, page });
return (
<main className="container mx-auto px-4 py-16">
<h1 className="text-4xl font-bold mb-12">Blog</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</main>
);
}
// src/app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPostSlugs } from '@/lib/wordpress';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
export const revalidate = 86400; // Revalidate every 24 hours
interface PostPageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const slugs = await getAllPostSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: post.featuredImage ? [post.featuredImage.source_url] : [],
},
};
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
return (
<article className="container mx-auto px-4 py-16 max-w-3xl">
<h1 className="text-4xl font-bold mb-6">{post.title}</h1>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
);
}
If your WordPress site has membership content, user profiles, or private posts, you need authentication. WordPress supports two main approaches for headless setups:
Application passwords are 24-character tokens generated per user in the WordPress admin. They’re ideal for server-to-server API calls (e.g., your Next.js server fetching private data):
// Server-side only — never expose in client code
const WP_AUTH = Buffer.from(
`${process.env.WP_USERNAME}:${process.env.WP_APP_PASSWORD}`
).toString('base64');
const privatePost = await fetch(`${WP_API_BASE}/posts/123?status=private`, {
headers: {
Authorization: `Basic ${WP_AUTH}`,
},
cache: 'no-store', // Private content should never be cached
});
For user-facing authentication (e.g., members logging into your site), use the JWT Authentication for WP REST API plugin. This gives you a proper stateless auth flow:
// Login and get JWT token
async function loginUser(username: string, password: string) {
const res = await fetch(`${WP_API_BASE.replace('/wp/v2', '')}/jwt-auth/v1/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error('Login failed');
const { token } = await res.json();
// Store securely — httpOnly cookie via Next.js Server Action
return token;
}
// Authenticated request
async function getPrivateContent(token: string) {
return wpFetch('/posts?status=private', {
headers: { Authorization: `Bearer ${token}` },
});
}
ISR with a fixed revalidation interval means new content appears within an hour. For a better editorial experience, implement on-demand revalidation: when a post is published in WordPress, it immediately triggers a cache purge on your Next.js frontend.
// In functions.php or a mu-plugin
add_action('save_post', function(int $post_id, WP_Post $post) {
if ($post->post_status !== 'publish') return;
if (wp_is_post_revision($post_id)) return;
$frontend_url = get_option('headless_frontend_url', 'https://mysite.com');
$secret = get_option('revalidation_secret');
$post_type_map = [
'post' => '/blog',
'portfolio' => '/projects',
];
$base_path = $post_type_map[$post->post_type] ?? null;
if (!$base_path) return;
$paths = [$base_path, "$base_path/{$post->post_name}"];
wp_remote_post("$frontend_url/api/revalidate", [
'headers' => [
'Content-Type' => 'application/json',
'x-revalidate-secret' => $secret,
],
'body' => wp_json_encode(['paths' => $paths]),
'timeout' => 10,
]);
}, 10, 2);
// src/app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
const body = await request.json();
const paths: string[] = body.paths ?? [];
for (const path of paths) {
revalidatePath(path);
}
return NextResponse.json({ revalidated: true, paths });
}
For interactive features — search, filters, infinite scroll — you need client-side data fetching. React Query (TanStack Query) is the best solution for this:
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
interface Post {
id: number;
slug: string;
title: string;
excerpt: string;
}
async function fetchPosts({ pageParam = 1 }: { pageParam: number }) {
const res = await fetch(`/api/posts?page=${pageParam}&per_page=9`);
if (!res.ok) throw new Error('Failed to fetch posts');
const posts: Post[] = await res.json();
const totalPages = parseInt(res.headers.get('X-WP-TotalPages') ?? '1', 10);
return { posts, nextPage: pageParam < totalPages ? pageParam + 1 : undefined };
}
export function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
});
if (status === 'pending') return <PostsSkeleton />;
if (status === 'error') return <ErrorState />;
const allPosts = data.pages.flatMap((page) => page.posts);
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{allPosts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="mt-8 px-6 py-3 bg-primary-600 text-white rounded-lg"
>
{isFetchingNextPage ? 'Loading...' : 'Load More Posts'}
</button>
)}
</div>
);
}
Headless WordPress is powerful but it’s not always the right choice. Consider carefully:
The sweet spot for headless WordPress is: content-heavy sites, portfolios, blogs, marketing sites, and apps where performance and developer experience are top priorities.
The WordPress REST API + React/Next.js stack gives you the best of both worlds: WordPress’s unmatched content management experience for editors, and a modern, performant frontend for users. With ISR, on-demand revalidation, and React Query for interactive features, you can build sites that score 100/100 on Lighthouse while still giving content teams the WordPress experience they love.
The architectural patterns in this guide — typed API clients, transform functions, on-demand revalidation, JWT auth — are production-tested across dozens of projects. Start with a simple blog setup, get comfortable with the data flow, then layer in complexity as your requirements grow.