Headless WordPress Integration (WP REST API + Frontend)
Headless WordPress is architecture where WordPress manages content only and provides it via API. Frontend completely separate: Next.js, Nuxt, React SPA or any other stack. Editors work in familiar WordPress interface, developers have technology freedom on frontend.
When It's Justified
Headless adds complexity. Justified when:
- existing React/Next.js frontend needs CMS;
- content delivery to multiple platforms (site + mobile app + newsletter);
- ISR/SSG on Next.js with update without full deploy required;
- frontend team doesn't want to work with PHP templates.
Not needed if building site from scratch without strict frontend stack requirement — regular WordPress simpler.
WP REST API: Basic Endpoints
WordPress REST API included out-of-box since 4.7. Base URL: https://site.com/wp-json/wp/v2/.
# List posts
GET /wp-json/wp/v2/posts?per_page=10&page=1&_fields=id,title,slug,date,excerpt
# Single post by slug
GET /wp-json/wp/v2/posts?slug=my-post-slug
# Pages
GET /wp-json/wp/v2/pages?slug=about
# Taxonomies
GET /wp-json/wp/v2/categories
GET /wp-json/wp/v2/tags?post=123
# Custom Post Type (must register with show_in_rest=true)
GET /wp-json/wp/v2/portfolio?per_page=6&acf_format=standard
Parameter _fields critically important — by default response contains dozens of fields, most unnecessary:
GET /wp-json/wp/v2/posts?_fields=id,title,slug,date,featured_media,excerpt,_links
WordPress Headless Configuration
Disable theme (frontend not needed):
// functions.php
add_action('template_redirect', function () {
if (!is_admin() && !is_user_logged_in()) {
// Redirect all frontend requests to frontend domain
if (!str_starts_with($_SERVER['REQUEST_URI'], '/wp-json')) {
wp_redirect('https://frontend.site.com' . $_SERVER['REQUEST_URI'], 301);
exit;
}
}
});
CORS for frontend requests:
add_action('rest_api_init', function () {
remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
add_filter('rest_pre_serve_request', function ($value) {
$allowed_origins = [
'https://frontend.site.com',
'http://localhost:3000',
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed_origins, true)) {
header("Access-Control-Allow-Origin: {$origin}");
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
header('Access-Control-Allow-Credentials: true');
}
return $value;
});
}, 15);
Extending REST API with Custom Data
ACF fields in REST API via acf-to-rest-api plugin or manually:
// Add ACF fields to REST API response for portfolio
add_action('rest_api_init', function () {
register_rest_field('portfolio', 'acf', [
'get_callback' => function ($post) {
return get_fields($post['id']);
},
'schema' => ['type' => 'object'],
]);
// Add featured image URL directly to response
register_rest_field('post', 'featured_image_url', [
'get_callback' => function ($post) {
$id = $post['featured_media'];
if (!$id) return null;
$img = wp_get_attachment_image_src($id, 'large');
return $img ? $img[0] : null;
},
'schema' => ['type' => ['string', 'null']],
]);
});
Custom endpoint for non-standard requests:
add_action('rest_api_init', function () {
register_rest_route('app/v1', '/home', [
'methods' => 'GET',
'callback' => function (WP_REST_Request $request) {
return rest_ensure_response([
'hero' => get_fields(get_option('home_hero_page_id')),
'featured' => array_map(
fn($post) => [
'id' => $post->ID,
'title' => get_the_title($post),
'slug' => $post->post_name,
'image' => get_the_post_thumbnail_url($post, 'medium'),
],
get_posts(['post_type' => 'portfolio', 'posts_per_page' => 3])
),
]);
},
'permission_callback' => '__return_true',
]);
});
Next.js Integration
// lib/wordpress.ts
const WP_API = process.env.WP_API_URL; // https://cms.site.com/wp-json/wp/v2
export interface WpPost {
id: number;
slug: string;
title: { rendered: string };
excerpt: { rendered: string };
date: string;
featured_image_url: string | null;
acf?: Record<string, unknown>;
}
export async function getPosts(params: {
perPage?: number;
page?: number;
category?: number;
} = {}): Promise<{ posts: WpPost[]; total: number; totalPages: number }> {
const url = new URL(`${WP_API}/posts`);
url.searchParams.set('per_page', String(params.perPage ?? 10));
url.searchParams.set('page', String(params.page ?? 1));
url.searchParams.set('_fields', 'id,slug,title,excerpt,date,featured_image_url,acf');
if (params.category) {
url.searchParams.set('categories', String(params.category));
}
const res = await fetch(url.toString(), {
next: { revalidate: 60 }, // ISR: update no more than once per minute
});
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}/posts?slug=${slug}&_fields=id,slug,title,content,date,featured_image_url,acf`,
{ next: { revalidate: 300 } }
);
const data = await res.json();
return data[0] ?? null;
}
Timeline
WordPress headless setup (CORS, ACF in API, custom endpoints) — 6–8 hours. Next.js integration (API client, ISR, preview mode) — 1–1.5 working days. Webhooks on-demand revalidation — 3–4 hours.







