'methods' => 'GET',
'callback' => 'fetch_curated_article_summaries',
'permission_callback' => function () {
return current_user_can('read') || !is_user_logged_in();
},
'args' => [
'page' => [
'default' => 1,
'sanitize_callback' => 'absint',
],
'limit' => [
'default' => 12,
'sanitize_callback' => 'absint',
'validate_callback' => function ($param) {
return $param > 0 && $param <= 50;
},
],
],
]);
});
**Architecture decision:** We validate and sanitize query parameters at the route level using `args`. This prevents malformed requests from reaching the callback and enforces pagination boundaries before database execution. The permission callback allows public read access while respecting WordPress capability checks for authenticated contexts.
### Step 2: Server-Side Data Projection
The callback executes a targeted query and maps only the required fields. Using `WP_Query` with `fields => 'ids'` minimizes memory allocation during the initial fetch. We then hydrate only the necessary metadata and post properties.
```php
function fetch_curated_article_summaries(WP_REST_Request $request) {
$page = $request->get_param('page');
$limit = $request->get_param('limit');
$query_args = [
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $limit,
'paged' => $page,
'fields' => 'ids',
'no_found_rows' => false,
];
$query = new WP_Query($query_args);
$post_ids = $query->posts;
if (empty($post_ids)) {
return rest_ensure_response([]);
}
$projection = [];
foreach ($post_ids as $post_id) {
$projection[] = [
'id' => $post_id,
'slug' => get_post_field('post_name', $post_id),
'title' => get_the_title($post_id),
'excerpt' => wp_trim_words(get_post_field('post_excerpt', $post_id), 20),
'published' => get_post_field('post_date_gmt', $post_id),
'metrics' => [
'views' => (int) get_post_meta($post_id, '_article_view_count', true),
'category' => get_the_category($post_id)[0]->name ?? 'Uncategorized',
],
];
}
$response = rest_ensure_response($projection);
$response->header('X-Total-Results', (string) $query->found_posts);
$response->header('X-Total-Pages', (string) $query->max_num_pages);
return $response;
}
Architecture decision: We separate ID fetching from metadata hydration to leverage WordPress's internal object cache. If wp_cache_get() is active, repeated meta calls resolve in-memory rather than hitting the database. Custom headers (X-Total-Results, X-Total-Pages) enable frontend pagination without parsing response bodies. The rest_ensure_response() wrapper guarantees consistent HTTP status codes and header injection.
Step 3: Typed Frontend Consumption
The frontend consumes the endpoint using a strict TypeScript contract. A lightweight fetch wrapper handles serialization, error mapping, and pagination state.
interface ArticleMetrics {
views: number;
category: string;
}
interface ArticleSummary {
id: number;
slug: string;
title: string;
excerpt: string;
published: string;
metrics: ArticleMetrics;
}
interface PaginatedResponse<T> {
data: T[];
total: number;
totalPages: number;
}
class WPDataClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/$/, '');
}
async fetchArticles(page = 1, limit = 12): Promise<PaginatedResponse<ArticleSummary>> {
const url = `${this.baseUrl}/wp-json/datahub/v2/curated-articles?page=${page}&limit=${limit}`;
const res = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error(`WP API Error: ${res.status} ${res.statusText}`);
}
const data: ArticleSummary[] = await res.json();
const total = parseInt(res.headers.get('X-Total-Results') || '0', 10);
const totalPages = parseInt(res.headers.get('X-Total-Pages') || '0', 10);
return { data, total, totalPages };
}
}
export const wpClient = new WPDataClient(process.env.NEXT_PUBLIC_WP_API_URL || '');
Architecture decision: We extract pagination metadata from response headers rather than embedding it in the JSON body. This keeps the payload lean and aligns with REST conventions. The client class encapsulates base URL resolution, header injection, and error normalization, preventing scattered fetch calls across components. Environment-driven base URL configuration supports multi-environment deployments without code changes.
Pitfall Guide
1. Unrestricted Public Access
Explanation: Leaving permission_callback as __return_true exposes endpoints to unauthenticated scraping, DDoS amplification, and data leakage. WordPress REST API routes default to public access unless explicitly restricted.
Fix: Implement capability checks or nonce validation. For public data, rate-limit via server configuration or plugin middleware. For authenticated data, verify is_user_logged_in() and map to specific roles.
Explanation: Calling get_post_meta() inside a loop without object caching triggers individual database queries per post. With 50 posts and 3 meta fields, this generates 150+ queries, degrading TTFB.
Fix: Enable persistent object caching (Redis/Memcached). Use update_meta_cache() to bulk-load metadata before iteration, or switch to WP_Query with meta_query when filtering is required.
3. Route Collision & Versioning Neglect
Explanation: Registering routes under generic namespaces like api/v1 conflicts with plugins or core updates. Future schema changes break frontend consumers without version isolation.
Fix: Prefix namespaces with project identifiers (datahub/v2). Maintain backward compatibility by deprecating old routes rather than deleting them. Document breaking changes in a changelog.
4. Bypassing WordPress Authentication Context
Explanation: Custom endpoints that ignore wp_verify_nonce() or cookie authentication fail to respect user sessions. This creates inconsistent permission models across the application.
Fix: Use rest_cookie_check_nonce() for browser-based requests or JWT/OAuth for mobile/SPA clients. Align endpoint permissions with WordPress capabilities (edit_posts, read_private_posts).
5. Over-Optimizing at the Expense of Extensibility
Explanation: Stripping fields to minimize payload size can break future features. Hardcoded projections require backend deployments for frontend-driven schema changes.
Fix: Expose optional query parameters (?fields=title,slug,metrics) to allow client-side field selection. Maintain a baseline contract and extend it incrementally rather than rebuilding endpoints.
6. Silent Frontend Failures
Explanation: Frontend consumers that swallow HTTP errors or assume successful JSON parsing cause UI degradation without visibility. Network timeouts or malformed responses break hydration silently.
Fix: Implement explicit error boundaries, retry logic with exponential backoff, and logging for non-2xx responses. Validate response shapes using runtime type checkers (Zod, io-ts) before rendering.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small marketing site with static content | Default WP REST API | Low complexity, sufficient payload size, zero maintenance | $0 |
| Enterprise SPA requiring strict typing & low latency | Native Custom Endpoint | Server-side projection, zero dependencies, full control | $0 (dev time only) |
| Multi-tenant SaaS with complex relational data | Third-Party Headless Plugin (WPGraphQL) | Advanced filtering, nested resolvers, ecosystem tooling | Plugin licensing + hosting overhead |
| Legacy WP migration with gradual frontend rollout | Native Custom Endpoint | Incremental decoupling, preserves editorial workflow, reduces risk | Low (phased implementation) |
Configuration Template
<?php
/**
* Production-ready native REST endpoint configuration
* Place in mu-plugins or theme functions.php
*/
add_action('rest_api_init', function () {
register_rest_route('projecthub/v1', '/content-feed', [
'methods' => 'GET',
'callback' => 'projecthub_fetch_content_feed',
'permission_callback' => function () {
return current_user_can('read');
},
'args' => [
'status' => [
'default' => 'publish',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => function ($param) {
return in_array($param, ['publish', 'draft', 'private'], true);
},
],
'per_page' => [
'default' => 20,
'sanitize_callback' => 'absint',
'validate_callback' => function ($param) {
return $param > 0 && $param <= 100;
},
],
],
]);
});
function projecthub_fetch_content_feed(WP_REST_Request $request) {
$status = $request->get_param('status');
$per_page = $request->get_param('per_page');
$page = max(1, (int) $request->get_param('page'));
$query = new WP_Query([
'post_type' => 'post',
'post_status' => $status,
'posts_per_page' => $per_page,
'paged' => $page,
'fields' => 'ids',
'no_found_rows' => true,
]);
$ids = $query->posts;
if (empty($ids)) {
return rest_ensure_response([]);
}
// Bulk cache meta to prevent N+1
update_meta_cache('post', $ids);
$feed = array_map(function ($id) {
return [
'id' => $id,
'slug' => get_post_field('post_name', $id),
'title' => get_the_title($id),
'date' => get_post_field('post_date_gmt', $id),
'modified' => get_post_field('post_modified_gmt', $id),
];
}, $ids);
$response = rest_ensure_response($feed);
$response->header('X-Total-Count', (string) count($ids));
return $response;
}
Quick Start Guide
- Create the endpoint file: Add the configuration template to
wp-content/mu-plugins/native-endpoints.php to ensure activation across all sites and themes.
- Verify route registration: Visit
https://your-domain.com/wp-json/projecthub/v1/content-feed in a browser or use curl to confirm a 200 OK response with JSON payload.
- Initialize the frontend client: Install the TypeScript client class in your SPA, configure
NEXT_PUBLIC_WP_API_URL or equivalent environment variable, and call wpClient.fetchArticles() within your data-fetching layer.
- Enable object caching: Deploy Redis or Memcached via your hosting provider or
wp-config.php to activate persistent caching and eliminate meta query overhead.
- Test pagination & error states: Simulate network failures, invalid parameters, and empty result sets to validate frontend error boundaries and header parsing before production deployment.