Headless WordPress: integration of WP REST API with React and Next.js
A typical problem: a business needs manageable content in WordPress, but ready-made themes don't deliver the required performance or customization. The solution is a headless CMS, where WordPress remains the backend and the frontend is built with React, Vue, Next.js, or Svelte. This eliminates monolithic themes, speeds up page loading (LCP drops by 30–50%), and simplifies maintenance. Over the past 5+ years, we've completed more than 50 such projects — from online stores to corporate portals. The integration budget is comparable to one month of a frontend developer's salary, but it pays off through reduced hosting costs and faster deployment of new pages.
What problems does headless WordPress solve?
- Separation of content from presentation: editors work in the familiar WordPress admin, while developers use a modern stack (React, Next.js, Vue). No more bending templates to match design — the API delivers clean data.
- Performance improvement: static pages (SSG) and ISR reduce TTFB to 200 ms and LCP to under 2 s. Server resource savings reach 40% due to caching.
- Single API for all clients: one WordPress can serve an SPA, a mobile app, and a Telegram bot — via REST or GraphQL.
Basic integration: fetching data
WordPress REST API is available by default at /wp-json/wp/v2/. Fetching the latest posts:
// lib/wordpress.ts
const WP_API_URL = process.env.NEXT_PUBLIC_WP_URL + '/wp-json/wp/v2';
export interface WPPost {
id: number;
slug: string;
title: { rendered: string };
content: { rendered: string };
excerpt: { rendered: string };
date: string;
featured_media: number;
_embedded?: {
'wp:featuredmedia'?: [{ source_url: string; alt_text: string }];
'wp:term'?: Array<Array<{ id: number; name: string; slug: string }>>;
};
}
export async function getPosts(params: {
perPage?: number;
page?: number;
category?: number;
search?: string;
} = {}): Promise<{ posts: WPPost[]; total: number; totalPages: number }> {
const qs = new URLSearchParams({
per_page: String(params.perPage ?? 12),
page: String(params.page ?? 1),
_embed: 'wp:featuredmedia,wp:term',
...(params.category && { categories: String(params.category) }),
...(params.search && { search: params.search }),
});
const res = await fetch(`${WP_API_URL}/posts?${qs}`, {
next: { revalidate: 60 }, // ISR in Next.js 13+
});
if (!res.ok) throw new Error(`WP API error: ${res.status}`);
return {
posts: await res.json(),
total: Number(res.headers.get('X-WP-Total')),
totalPages: Number(res.headers.get('X-WP-TotalPages')),
};
}
export async function getPostBySlug(slug: string): Promise<WPPost | null> {
const res = await fetch(`${WP_API_URL}/posts?slug=${slug}&_embed=wp:featuredmedia,wp:term`);
const posts = await res.json();
return posts.length ? posts[0] : null;
}
Next.js App Router: dynamic routes
// app/blog/[slug]/page.tsx
import { getPostBySlug, getPosts } from '@/lib/wordpress';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const { posts } = await getPosts({ perPage: 100 });
return posts.map(post => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) return {};
return {
title: post.title.rendered,
description: post.excerpt.rendered.replace(/<[^>]+>/g, '').slice(0, 160),
};
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) notFound();
const media = post._embedded?.['wp:featuredmedia']?.[0];
return (
<article className="post-single">
{media && (
<img
src={media.source_url}
alt={media.alt_text}
className="post-single__cover"
/>
)}
<h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
<div
className="post-content"
dangerouslySetInnerHTML={{ __html: post.content.rendered }}
/>
</article>
);
}
dangerouslySetInnerHTML is acceptable here — content comes from a trusted server, but if the source is under your control, add DOMPurify for security.
Custom hook for React SPA
// hooks/usePosts.ts
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then(r => r.json());
export function usePosts(category?: string, page = 1) {
const params = new URLSearchParams({ per_page: '12', page: String(page), _embed: '1' });
if (category) params.set('categories', category);
const { data, error, isLoading } = useSWR<WPPost[]>(
`/wp-json/wp/v2/posts?${params}`,
fetcher,
{ revalidateOnFocus: false }
);
return { posts: data ?? [], isLoading, error };
}
Use this hook with pagination and skeletons. Component example is in the SWR documentation.
How to set up on-demand ISR for instant content updates?
Next.js supports page rebuilds with triggers. When a post is published in WordPress, PHP code sends a POST request to the /api/revalidate endpoint in Next.js:
// WordPress: save_post hook and CORS filter
add_action('save_post', function (int $post_id, WP_Post $post): void {
if ($post->post_status !== 'publish') return;
$next_url = get_option('nextjs_revalidate_url');
$secret = get_option('nextjs_revalidate_secret');
if (!$next_url || !$secret) return;
wp_remote_post("{$next_url}/api/revalidate", [
'body' => json_encode([
'secret' => $secret,
'path' => '/' . $post->post_type . '/' . $post->post_name,
]),
'headers' => ['Content-Type' => 'application/json'],
'blocking'=> false,
]);
}, 10, 2);
add_filter('rest_pre_serve_request', function ($value) {
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = ['https://mysite.com', 'https://www.mysite.com', 'http://localhost:3000'];
if (in_array($origin, $allowed)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
}
return $value;
});
And on the Next.js side we handle:
// Next.js: app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
export async function POST(req: Request) {
const { secret, path } = await req.json();
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 });
}
revalidatePath(path);
return Response.json({ revalidated: true, path });
}
This pipeline ensures page updates within seconds without rebuilding the entire site. Traffic savings — up to 40% compared to full regeneration.
Performance: what to cache
| Data | Strategy |
|---|---|
| Post list | ISR, revalidate: 60s |
| Single post | ISR + on-demand revalidate on save_post |
| Navigation menu | Static (revalidate: false) |
| Search results | SSR (no cache, parameters change) |
| ACF site settings | Static or revalidate: 3600s |
Comparison of REST and GraphQL
| Criteria | REST | GraphQL |
|---|---|---|
| Query flexibility | Fixed fields | Select only needed data |
| Number of requests | Often multiple | One |
| Setup complexity | Minimal | Requires plugin and schema |
| Performance | High for simple pages | Better for complex nesting |
How headless WordPress integration works
- Audit — we review the current WordPress architecture, plugins, content volume, and load.
- Design — we define required endpoints, fields, and caching strategy (ISR, SSG, SSR).
- API configuration — we extend REST or install WPGraphQL, add custom fields (ACF).
- Frontend development — we build a typed client (TypeScript), components, and routing.
- Integration — we connect CORS, on-demand revalidation, deploy WordPress and the frontend.
- Testing — we check performance (Core Web Vitals), cross-domain requests, error handling.
- Documentation and handover — we record a video consultation for the team and hand over the repository.
Timeline: basic integration — 5 to 7 business days; complex scenarios (GraphQL, ACF, on-demand ISR) — up to 15 days. We provide an accurate estimate after the audit.
Details of CORS configuration
When the WordPress and frontend domains differ, we add the rest_pre_serve_request filter that sets headers for allowed origins. This is standard practice described in the REST API documentation. If you need a non‑standard configuration — we can adapt it to your project.
How much does headless WordPress integration cost?
The cost is calculated individually based on content volume, number of post types, need for GraphQL, and routing complexity. On average, the budget is comparable to a month's salary of a junior developer, and the savings on hosting and development time recoup the investment within six months.
Want an accurate estimate? Order an audit of your project — we'll analyze the architecture and propose the optimal solution. Contact us through the form on the website or write to Telegram.







